Field
The anatomy around a control: its label, a description and an error, stacked or side by side, and fieldsets that group them. The vendored shadcn parts, used as they come but for the description and the error, set small at text-xs; the wiring stays explicit, so it works the same for every control.
Playground
The end of your chat link: withokami.com/c/barber-kamzik.
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<Field>
<FieldLabel htmlFor="slug">Slug</FieldLabel>
<Input id="slug" defaultValue="barber-kamzik" aria-describedby="slug-hint" />
<FieldDescription id="slug-hint">The end of your chat link: withokami.com/c/barber-kamzik.</FieldDescription>
</Field>Label and control
The label names the control: point htmlFor at the control's id, and a click on the label focuses it.
import { Field, FieldLabel } from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<Field>
<FieldLabel htmlFor="venue-name">Venue name</FieldLabel>
<Input id="venue-name" placeholder="Barber Kamzík" required />
</Field>Optional
Mark the fields that can stay empty, not the ones that can't: give FieldLabel or FieldLegend optional, the word in the page's language, and it follows the name in muted brackets at the label's own gap — the same in every form.
import { Field, FieldLabel } from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<Field>
<FieldLabel htmlFor="st-address" optional="optional">Address</FieldLabel>
<Input id="st-address" placeholder="Street, city" />
</Field>Description
Say what the value is for, or its format, under the control. Give it an id and list it in the control's aria-describedby so it's read with the label.
The end of your chat link: withokami.com/c/barber-kamzik.
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<Field>
<FieldLabel htmlFor="st-slug">Slug</FieldLabel>
<Input id="st-slug" defaultValue="barber-kamzik" aria-describedby="st-slug-hint" />
<FieldDescription id="st-slug-hint">
The end of your chat link: withokami.com/c/barber-kamzik.
</FieldDescription>
</Field>Errors
FieldError says what's wrong and how to fix it, and is announced as it appears. Mark the control aria-invalid and point its aria-describedby at the error. Pass errors — a validator's { message } list — to show each problem once; with nothing to say it renders nothing.
- Price can't be below zero.
- Use steps of 50 cents.
import { Field, FieldError, FieldLabel } from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" defaultValue="jana@studio-lumen" aria-invalid aria-describedby="email-error" />
<FieldError id="email-error">Add the part after the dot, like .sk or .com.</FieldError>
</Field>
<Field>
<FieldLabel htmlFor="sv-price">Price (€)</FieldLabel>
<Input id="sv-price" type="number" defaultValue={-2.25} aria-invalid aria-describedby="sv-price-error" />
<FieldError id="sv-price-error" errors={issues} />
</Field>Side by side
orientation="horizontal" puts the control beside its label — for short values in settings. Stack the label and description in FieldContent. responsive stacks until its FieldGroup is wide enough.
How soon before a slot clients can still book it.
How far ahead the calendar opens.
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<FieldGroup>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="st-lead">Lead time (min)</FieldLabel>
<FieldDescription id="st-lead-hint">How soon before a slot clients can still book it.</FieldDescription>
</FieldContent>
<Input id="st-lead" type="number" min={0} defaultValue={60} aria-describedby="st-lead-hint" className="w-24" />
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="st-horizon">Booking horizon (days)</FieldLabel>
<FieldDescription id="st-horizon-hint">How far ahead the calendar opens.</FieldDescription>
</FieldContent>
<Input id="st-horizon" type="number" min={1} defaultValue={30} aria-describedby="st-horizon-hint" className="w-24" />
</Field>
</FieldGroup>Fieldsets and groups
Fields that answer one question together go in a FieldSet, captioned by a FieldLegend; FieldGroup spaces them evenly, and a grid lines short ones up in a row. When the legend names a single choice among plain fields — a group of radio cards — give it variant="label" so it sits at a label's size.
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<FieldSet>
<FieldLegend>Customer</FieldLegend>
<FieldGroup>
<Field>
<FieldLabel htmlFor="bk-name">Name</FieldLabel>
<Input id="bk-name" autoComplete="name" />
</Field>
<Field>
<FieldLabel htmlFor="bk-phone">Phone</FieldLabel>
<Input id="bk-phone" type="tel" autoComplete="tel" />
</Field>
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>Skin fade</FieldLegend>
<div className="grid grid-cols-3 gap-3">
<Field>
<FieldLabel htmlFor="sv-duration">Duration (min)</FieldLabel>
<Input id="sv-duration" type="number" min={5} step={5} defaultValue={30} />
</Field>
{/* Buffer (min), Price (€) */}
</div>
</FieldSet>A bare label
Label is the same label outside a Field — for rows where the control sits in a grid, like a day of opening hours.
import { Label } from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<div className="grid grid-cols-2 items-center gap-3 sm:grid-cols-3">
<Label htmlFor="day-1-open" className="col-span-2 sm:col-span-1">Monday</Label>
<Input id="day-1-open" type="time" defaultValue="09:00" />
<Input type="time" defaultValue="18:00" aria-label="Monday closes" />
</div>API reference
Field
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "vertical" | "horizontal" | "responsive" | "vertical" | Label above the control, beside it, or above until the FieldGroup is wide enough. |
| data-disabled | boolean | — | Dims the label along with a disabled control. Disable the control itself too. |
FieldLabel · Label
| Prop | Type | Default | Description |
|---|---|---|---|
| htmlFor | string | — | The id of the control it names. Always set it — a click on the label then focuses the control. |
| optional | string | — | FieldLabel only: the word for optional, shown after the name in muted brackets. |
FieldDescription
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Give one and list it in the control's aria-describedby, so it's read with the label. |
FieldError
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The message. Wins over errors. |
| errors | Array<{ message?: string } | undefined> | — | A validator's issues, each shown once — one as text, several as a list. |
| id | string | — | For the control's aria-describedby. |
FieldSet · FieldLegend · FieldGroup · FieldContent
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "legend" | "label" | "legend" | FieldLegend only: a section caption, or the size of a FieldLabel. |
| optional | string | — | FieldLegend only: the word for optional, after the caption as FieldLabel shows it. |
Every part passes the rest through to its element: Field, FieldGroup and FieldContent to a <div>, FieldLabel and Label to a <label>, FieldDescription to a <p>, FieldSet and FieldLegend to <fieldset> and <legend>.
Guidelines
- Every control gets a visible label above or beside it; placeholders show an example, never the name. Labels are nouns in sentence case, with the unit when there is one: “Duration (min)”, “Price (€)”.
- Wire it by hand, every time:
htmlForto the control'sid; description and error ids into itsaria-describedby. Ids must be unique on the page — prefix them by form (bk-,st-). - Errors say what to do, not just what failed: “Use letters, digits and dashes — no spaces or accents”, not “Invalid slug”. Show them after the person leaves the field or submits, never while they're still typing.
- Keep descriptions to one line where you can. If every field needs one, the labels aren't clear enough.
- Stack fields by default; go side by side only for short values in a settings list, where the label carries the explanation.