Components
ComponentsTier 3 · Components
Field & Label
Label, hint, error and the aria wiring between them.
bunx --bun shadcn@latest add @woohl/fieldPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/fieldregistry/tier-3-components/field.tsx
import { useId, type ComponentProps, type ReactNode } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { Label as LabelPrimitive } from "radix-ui";
import { AlertGlyph } from "@/registry/tier-2-primitives/glyphs";
/**
* Field — label, hint, error, and the wiring between them.
*
* Not in the architecture's component list, and here anyway, because the three
* accessibility rules a form has to get right are all relational:
*
* · the label is associated with the control (`htmlFor` / `id`)
* · the hint and the error are announced with it (`aria-describedby`)
* · an invalid control says so (`aria-invalid`)
*
* Those cannot live inside `Input` — they span the label, the control and the
* message. Without a Field, every form re-implements them and about a third of
* them get it wrong.
*
* Render-prop rather than context so the ids are visible at the call site and
* the control stays an ordinary input you can pass anything to.
*
* <Field label="Delivery address" hint="We'll only use this for the order.">
* {(field) => <Input placeholder="Flat, street, landmark" {...field} />}
* </Field>
*/
export type FieldRenderProps = {
id: string;
"aria-describedby": string | undefined;
"aria-invalid": boolean | undefined;
required: boolean | undefined;
};
export type FieldProps = {
label: string;
/** Helper text. Hidden while an error is showing — two messages compete. */
hint?: string;
/**
* The error message. Name the field and say what to do:
* "Enter a 6-digit PIN code", not "Invalid input".
*/
error?: string;
required?: boolean;
/** Hide the label visually but keep it for screen readers. Rare, and a smell. */
labelHidden?: boolean;
id?: string;
className?: string;
children: (field: FieldRenderProps) => ReactNode;
};
export function Field({
label,
hint,
error,
required,
labelHidden = false,
id,
className,
children,
}: FieldProps) {
const generated = useId();
const fieldId = id ?? generated;
const hintId = `${fieldId}-hint`;
const errorId = `${fieldId}-error`;
const describedBy =
[hint && !error ? hintId : null, error ? errorId : null]
.filter(Boolean)
.join(" ") || undefined;
return (
<div data-slot="field" className={cn("font-ui", className)}>
<Label
htmlFor={fieldId}
required={required}
className={labelHidden ? "sr-only" : undefined}
>
{label}
</Label>
{children({
id: fieldId,
"aria-describedby": describedBy,
"aria-invalid": error ? true : undefined,
required: required || undefined,
})}
{hint && !error && (
<p
id={hintId}
data-slot="field-hint"
className="text-caption text-text-muted mt-[7px]"
>
{hint}
</p>
)}
{error && (
<p
id={errorId}
data-slot="field-error"
/* role=alert so the message is announced when it appears, not only
when the field is next focused. */
role="alert"
className="text-caption text-danger mt-[7px] flex items-center gap-1.5"
>
<AlertGlyph size={13} />
{error}
</p>
)}
</div>
);
}
export function Label({
className,
required,
children,
...props
}: ComponentProps<typeof LabelPrimitive.Root> & { required?: boolean }) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn("text-label text-text-primary mb-[7px] block", className)}
{...props}
>
{children}
{required && (
<span className="text-danger ml-0.5" aria-hidden>
*
</span>
)}
</LabelPrimitive.Root>
);
}
/**
* A group of related controls — a set of radios, a set of checkboxes.
*
* `fieldset` + `legend` is the only construction a screen reader reads as
* "these five options belong to one question". A div with a heading is not.
*/
export function FieldSet({
legend,
hint,
className,
children,
...props
}: ComponentProps<"fieldset"> & { legend: string; hint?: string }) {
return (
<fieldset
data-slot="fieldset"
className={cn("font-ui", className)}
{...props}
>
<legend className="text-label text-text-primary mb-[7px]">
{legend}
</legend>
{hint && <p className="text-caption text-text-muted mb-3">{hint}</p>}
{children}
</fieldset>
);
}
FieldRenderProps
| Prop | Type | Default |
|---|---|---|
aria-describedby* | string | undefined | — |
aria-invalid* | boolean | undefined | — |
id* | string | — |
required* | boolean | undefined | — |
FieldProps
| Prop | Type | Default |
|---|---|---|
children* | (field: FieldRenderProps) => ReactNode | — |
label* | string | — |
className | string | — |
errorThe error message. Name the field and say what to do: "Enter a 6-digit PIN code", not "Invalid input". | string | — |
hintHelper text. Hidden while an error is showing — two messages compete. | string | — |
id | string | — |
labelHiddenHide the label visually but keep it for screen readers. Rare, and a smell. | boolean | — |
required | boolean | — |
Registry items
npm packages
- radix-ui