← Component library
Field
FormsLabel, control, hint and error as one unit, wired together so the error is announced rather than just coloured.
FormValidationaria-describedbyCSS only — no dependency
Preview
I reply to every message myself.
Controls
Notes
The error is bound with aria-describedby and aria-invalid, not signalled by turning the border red. Colour alone fails SC 1.4.1, and a screen reader gets nothing from it.
The label is a real <label> with htmlFor. Placeholder-as-label disappears exactly when the user needs it — the moment they start typing.
Every text input carries a maxLength. Uncapped free text is the input a form handler regrets.
Source
ShowHide
"use client";
import { useId } from "react";
import { cn } from "@/lib/cn";
const control =
"w-full rounded-md border bg-paper-raised px-4 py-3 text-[15px] text-ink placeholder:text-ink-faint transition-colors";
export function Field({
label,
type = "text",
hint,
error,
required = false,
maxLength = 200,
...rest
}: {
label: string;
type?: "text" | "email" | "textarea";
hint?: string;
/** Present means invalid. The string is shown AND announced. */
error?: string;
required?: boolean;
maxLength?: number;
} & Omit<React.ComponentProps<"input">, "type" | "required" | "maxLength">) {
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
// Colour alone would fail SC 1.4.1 and tells a screen reader nothing. The
// state travels on aria-invalid and the message via aria-describedby.
const describedBy = [hint ? hintId : null, error ? errorId : null]
.filter(Boolean)
.join(" ");
const shared = {
id,
name: rest.name ?? label.toLowerCase().replace(/\s+/g, "-"),
required,
maxLength,
"aria-invalid": error ? true : undefined,
"aria-describedby": describedBy || undefined,
className: cn(control, error ? "border-accent" : "border-control-border focus:border-accent"),
};
return (
<div className="w-full">
<label htmlFor={id} className="eyebrow block">
{label}
{!required && <span className="ml-2 normal-case tracking-normal">(optional)</span>}
</label>
<div className="mt-3">
{type === "textarea" ? (
<textarea {...shared} rows={4} className={cn(shared.className, "resize-y")} />
) : (
<input {...rest} {...shared} type={type} />
)}
</div>
{hint && !error && (
<p id={hintId} className="mt-2 text-[13px] text-ink-muted">
{hint}
</p>
)}
{error && (
<p id={errorId} role="alert" className="mt-2 text-[13px] text-accent">
{error}
</p>
)}
</div>
);
}
export default function Demo({
type = "email",
label = "Email",
hint = "I reply to every message myself.",
invalid = false,
required = true,
}: {
type?: "text" | "email" | "textarea";
label?: string;
hint?: string;
invalid?: boolean;
required?: boolean;
}) {
return (
<div className="w-full max-w-sm">
<Field
label={label}
type={type}
hint={hint}
required={required}
error={invalid ? "That does not look like an email address." : undefined}
placeholder={type === "email" ? "you@company.com" : undefined}
/>
</div>
);
}
Next
BadgeNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.