Field Frame
betaThe frame every field wears: a label above, the control, and a hint or an error under it, wired up for screen readers. Also exports FieldButton, the field-shaped trigger for lists and pickers.
Preview
size
Shirts run small.
states
control
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/field-frame
Use it for
- Frame any field with it, so every form reads, sizes, and announces the same way.
- Give the frame the control's id. The label gets <id>-label and the hint or error gets <id>-note.
- Spread fieldIds(id, { label, hint, error, control }) on the control. It sets aria-describedby, aria-invalid, and, for a button or a group, aria-labelledby. A button cannot take aria-invalid, so it gets data-invalid, which draws the red edge.
- fieldIds lives in a client file, so call it from a client component. In a server component, write the same ids by hand: aria-describedby="<id>-note", and aria-labelledby="<id>-label <id>" for a button.
- control="input" for a native input or textarea. The label is a real <label> that points at it.
- control="button" for a trigger whose text is its value, like a picker. It reads as the label and the value: "Room, Boardroom". Pressing the label focuses the button and does not open it.
- control="custom" for a group or an editor. The label names it. Pass onLabel to say what pressing the label focuses.
- Use FieldButton for anything that opens a list or a picker from a field. It has the field's box and states, and a chevron that turns over while open.
- Put a saving status in status, and a counter like "12/80" in aside.
Avoid it for
- A label you hide. Every field needs a name people can see.
- Putting the error only in the red edge. Pass it as error, so it is said and shown in words.
- A form section with several fields. Use a heading or a fieldset for the section, and a frame for each field.
Props
- id
Required. The control's id. The label and the note take ids built from it.
string
- label
Names the control. Pressing it focuses the control.
ReactNode
- hint
A short note under the control that describes it.
ReactNode
- error
Replaces the hint while set, in red. Announced once each time a new message appears.
ReactNode
- size
Label and note text size, and the gaps. Match the control's size.
"sm" | "md" | "lg" · default "md"
- control
What the frame holds, which decides how the label names it.
"input" | "button" | "custom" · default "input"
- onLabel
What pressing a label that is not a native label does. By default it focuses the element with the id.
() => void
- aside
Ends the note line, like a counter. Muted, with figures that keep their width.
ReactNode
- status
Ends the label line, like a saving status. Kept to the label's height.
ReactNode
- disabled
Dims the label to match a disabled control.
boolean · default false
- className
Extra classes for the frame. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| id | string | - | Required. The control's id. The label and the note take ids built from it. |
| label | ReactNode | - | Names the control. Pressing it focuses the control. |
| hint | ReactNode | - | A short note under the control that describes it. |
| error | ReactNode | - | Replaces the hint while set, in red. Announced once each time a new message appears. |
| size | "sm" | "md" | "lg" | "md" | Label and note text size, and the gaps. Match the control's size. |
| control | "input" | "button" | "custom" | "input" | What the frame holds, which decides how the label names it. |
| onLabel | () => void | - | What pressing a label that is not a native label does. By default it focuses the element with the id. |
| aside | ReactNode | - | Ends the note line, like a counter. Muted, with figures that keep their width. |
| status | ReactNode | - | Ends the label line, like a saving status. Kept to the label's height. |
| disabled | boolean | false | Dims the label to match a disabled control. |
| className | string | - | Extra classes for the frame. Later classes win. |
Accessibility
- For control="input" the label is a native <label htmlFor>, so clicking it focuses the input and screen readers read it as the name.
- For a button or a group the label is plain text with an id, named through aria-labelledby. A native label would press the button.
- The hint or error has the id <id>-note. fieldIds points the control's aria-describedby at it.
- The error has role="alert". It mounts fresh for each new message, so screen readers say it once when it appears.
- The error color is the danger tone's soft text, which the theme tests hold to 4.5:1. The words carry the meaning, not only the color.
- FieldButton sets aria-haspopup, aria-expanded, and aria-controls while open. A popup trigger, like PopoverTrigger, sets them itself instead.
Examples
A text field with a hint
<FieldFrame id="shirt" label="Shirt size" hint="Shirts run small.">
<Input id="shirt" aria-describedby="shirt-note" />
</FieldFrame>An error in place of the hint
<FieldFrame id="handle" label="Username" hint="Letters and numbers." error="That name is taken.">
<Input id="handle" defaultValue="admin" {...fieldIds("handle", { hint: "Letters and numbers.", error: "That name is taken." })} />
</FieldFrame>A picker trigger
<FieldFrame id="room" label="Room" control="button">
<FieldButton id="room" filled {...fieldIds("room", { label: "Room", control: "button" })}>
Boardroom
</FieldButton>
</FieldFrame>