Components · Field
Field
Field is where LUC decides label semantics: it generates the control's id and wires the description, the pending check and the message to it. The rest of the page is the parts a form is assembled from — one field shell with addons, a standalone label for third-party controls, the platform's own picker, and the keyboard hints that make a shortcut discoverable.
import {
Field,
FieldGroup,
Fieldset,
} from "@luc/ui/forms";Examples
What Field owns — and what a bare Label costs you
Both fields below look alike. Only the left one has a generated id, an `aria-describedby` relationship and `aria-invalid` when the value is rejected; the right one leaves all three to you.
Bars used to warm up indicators.
Bars used to warm up indicators — and the wiring is yours.
A Field already renders the label for a LUC control. Use Label for a third-party or composite widget, where you assemble the ARIA yourself.
<Field label="Lookback" description="Bars used to warm up indicators." required>
<Input defaultValue="200" />
</Field>
<div className="flex flex-col gap-1.5">
<Label htmlFor="hand-rolled">Lookback</Label>
<Input id="hand-rolled" defaultValue="200" aria-describedby="hand-rolled-hint" />
<Text variant="caption" tone="muted" id="hand-rolled-hint">
Bars used to warm up indicators.
</Text>
</div>Fieldset names a question, FieldGroup is the grid
A `<fieldset>` with a legend is announced with every control inside it, which a heading over a grid is not. `FieldGroup` adds no semantics at all — it is only the columns.
<Fieldset legend="Delivery" description="Where the daily report is sent.">
<FieldGroup columns={2}>
<Field label="Channel">
<Select defaultValue="email">…</Select>
</Field>
<Field label="Address">
<Input placeholder="research@acme.dev" />
</Field>
</FieldGroup>
</Fieldset>One message slot
Description, pending check, confirmation and error share one row: only the most specific one renders, so a field never stacks four lines of text.
Lowercase, no spaces.
Checking…
Handle is available
Already taken
<Field label="Handle" description="Lowercase, no spaces.">
<Input defaultValue="acme" />
</Field>
<Field label="Handle" validating>
<Input defaultValue="acme" />
</Field>
<Field label="Handle" success="Handle is available">
<Input defaultValue="acme-research" />
</Field>
<Field label="Handle" error="Already taken">
<Input defaultValue="acme" />
</Field>One shell, many addons
InputGroup is the same shell every other text control uses, exposed as composition: a prefix, a separator, a suffix, an icon — with the focus ring, the invalid state and the sunk surface already correct.
<InputGroup>
<InputGroupAddon>https://</InputGroupAddon>
<InputGroupInput placeholder="example.com" />
</InputGroup>Labels and keycaps
Label exists for the control LUC does not own, and its required asterisk is aria-hidden so the name is not read twice. KbdGroup joins a shortcut into one announced chord.
<Label htmlFor="workspace-name">Workspace name</Label>
<Label htmlFor="slug" required>Slug</Label>
<Kbd variant="raised" size="sm">Esc</Kbd>
<KbdGroup keys={["⌘", "K"]} />When native is better
Select is right nine times out of ten. The tenth is a mobile form, where the platform picker is a wheel the user already knows and the custom popup is worse than either.
<NativeSelect
aria-label="Timezone"
defaultValue="utc"
options={[
{ value: "utc", label: "UTC" },
{ value: "sg", label: "Asia/Singapore" },
]}
/>
<NativeSelect aria-label="Granularity" defaultValue="" placeholder="Choose a granularity" size="lg">
<option value="1h">1 hour</option>
<option value="1d">1 day</option>
</NativeSelect>HoverCard is not a Tooltip
A tooltip labels a control; a HoverCard previews something with content and actions, and follows focus as well as the pointer.
Never the only way to reach the content: the trigger is focusable and the same information belongs on the page it previews.
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Button variant="ghost">@ada</Button>
</HoverCardTrigger>
<HoverCardContent width="md">
<Avatar name="Ada Lovelace" status="online" />
<Text variant="label">Ada Lovelace</Text>
<Text variant="caption" tone="muted">Research · Singapore</Text>
</HoverCardContent>
</HoverCard>Usage
import { Field, FieldGroup, Fieldset } from "@luc/ui/forms";<Field label="Lookback" description="Bars used to warm up indicators.">
<Input defaultValue="200" />
</Field>`Field` is the one place the library decides `<label>` semantics: the control inside it inherits the id, the description and the message, so nothing is written by hand.
API
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
| Export | Kind | Signature |
|---|---|---|
Field | 13 documented props | ({ children, id, label, description, error, validating, success, required, disabled, readOnly, labelHint, className, orientation, }: FieldProps) => import("@types/react").JSX.Element |
FieldGroup | 1 documented props | ({ columns, className, ...props }: FieldGroupProps) => import("@types/react").JSX.Element |
Fieldset | 4 documented props | ({ legend, description, children, className, disabled, legendHint, ...props }: FieldsetProps) => import("@types/react").JSX.Element |
useField | export | () => { id: string; labelId: string | undefined; describedBy: string | undefined; hasLabel: boolean; invalid: boolean; disabled: boolean; readOnly: boolean; busy: boolean; required: boolean; groupLab… |
Field props
Type: FieldProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
children | ReactNode | — | |
className? | string | undefined | — | |
description? | ReactNode | — | Helper text rendered under the control. Replaced by `error` when present. |
disabled? | boolean | undefined | — | |
error? | ReactNode | — | Error message. Presence sets `aria-invalid` on the control. |
id? | string | undefined | — | Explicit id for the control. Generated when omitted. |
label? | ReactNode | — | |
labelHint? | ReactNode | — | Optional content rendered next to the label, e.g. a "Why?" hint button. |
orientation? | "horizontal" | "vertical" | undefined | — | Compact layouts drop the label row spacing. |
readOnly? | boolean | undefined | — | |
required? | boolean | undefined | — | |
success? | ReactNode | — | Confirmation of a *checked* value (an availability probe, a verified handle). Deliberately not rendered for ordinary "field is filled in" — see DESIGN_SYSTEM.md §12. |
validating? | ReactNode | — | Validation in flight. Renders a quiet inline status row and sets `aria-busy` on the control. Pass a node to override "Checking…". |
FieldGroup props1 props
FieldGroup props
Type: FieldGroupProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
columns? | 1 | 2 | 3 | undefined | — | Column count from the `sm` breakpoint up. A single column on mobile always. |
Fieldset props4 props
Fieldset props
Type: FieldsetProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
description? | ReactNode | — | |
disabled? | boolean | undefined | — | |
legend | ReactNode | — | The group's accessible name. Always rendered — a fieldset without a legend is a container, not a form group. |
legendHint? | ReactNode | — | Optional content in the legend row, e.g. "Select all". |
Guidelines
Do
- Wrap every control in a `Field` and let it own the id, the description and the message rather than writing `htmlFor` by hand.
- Reach for `Fieldset` with a legend when several controls answer one question, and `FieldGroup` only for the columns.
- Use `validating` and `success` where they mean something — a check in flight, a value that was actually confirmed.
Don't
- Stack two messages on one field: description, pending check, success and error share one slot, and the error wins.
- Read `success` as “the reader filled this in” — it is for a checked value, such as an availability probe.
- Put a `Label` inside a `Field`: the field already renders one, and two labels for one control make the name ambiguous.
Accessibility
The field generates the control's id and hands down the `aria-describedby` list and the invalid, disabled and read-only states through context, so the message the reader sees is the one the control is described by — an error never stacks on top of stale helper text. The error row is a live `role="alert"`, and `required` reaches assistive technology through the control's own attribute while the asterisk stays hidden from it.
Related