Field
The canonical label, control, help, error, and required-state relationship.
- Family
- Field
- Available import
@/components/ui/field- Status
- Thorbis wrapper and live specimen ship here.
field- Use
- Use around every form value so accessible naming and validation remain consistent.
- Avoid
- Do not substitute placeholder text for the label or separate the error from its control.
Use this component
GitHub source links require access to the private Thorbis design repository.
Wrapper sourceRepository and dependencies
- Value exports
FieldFieldContentFieldDescriptionFieldErrorFieldGroupFieldLabelFieldLegendFieldSeparatorFieldSetFieldTitle
- Type exports
FieldLabelPropsFieldPropsFieldSetProps
Setup, styles, and dependencies
These are React wrappers in the Thorbis design repository, not an installable npm UI package. In this repository, @/ resolves to src/ through tsconfig.json.
Use the repository's package.json and package-lock.json for dependencies. Load src/styles.css once at the application root for global tokens, typography, and themes. The wrappers compose colocated StyleX owners; include the generated token CSS and extracted StyleX output.
For another project, bring the wrapper and its transitive local imports, configure the alias, install the dependencies those files use, and provide the same token and style setup. Review the source before copying; the direct imports below are not a complete dependency closure.
Interactive examples require client-side React. This TanStack Start site server-renders and hydrates React; another host must hydrate or render the component on the client. Example code is the same TypeScript source used by its live preview.
Direct imports in this wrapper:
./field.stylex./label@stylexjs/stylexreact
Field anatomy, native validation and reset
Try native email validation, a local preview and reset. Inspect the same input's target, face, label, message and validity; measurements update on resize. Field supplies layout: connect htmlFor/id and aria-describedby yourself, and put aria-invalid on Input when reporting an error. Field invalid alone does not set the input's accessible state. Wrap a horizontal control and its message in FieldContent. FieldSet/FieldLegend keep native grouping. Input forwards native input attributes and its ref; read-only values submit, disabled values do not. Use defaultValue for native reset; controlled callers reset their own state. Controls wait for hydration. Nothing is saved or sent.
Read the complete example
import { exampleLayout } from "@/components/components/examples/example-layout.stylex";
import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormActions } from "@/components/ui/form";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
type FieldFacts = {
name: string;
description: string;
targetHeight: number;
faceHeight: number | null;
validity: string;
};
export function JobContactExample() {
const id = useId();
const email = useRef<HTMLInputElement>(null);
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
const [preview, setPreview] = useState("");
const [facts, setFacts] = useState<FieldFacts | null>(null);
const inspect = useCallback(() => {
const input = email.current;
const frame = input?.parentElement;
if (!input || !frame) return;
const face = getComputedStyle(frame, "::before");
const descriptionId = input.getAttribute("aria-describedby");
setFacts({
name: Array.from(input.labels ?? [])
.map((label) => label.textContent?.trim())
.join(" "),
description: descriptionId ? (document.getElementById(descriptionId)?.textContent ?? "") : "",
targetHeight: input.getBoundingClientRect().height,
faceHeight: face.display === "none" ? null : Number.parseFloat(face.height),
validity: !input.willValidate
? "Validation is inactive while disabled"
: input.validity.valid
? "Valid email"
: input.validity.valueMissing
? "Required value is empty"
: "Email is incomplete",
});
}, []);
useEffect(() => setReady(true), []);
useEffect(() => {
inspect();
const input = email.current;
if (!input) return;
const observer = new ResizeObserver(inspect);
observer.observe(input);
const forcedColors = matchMedia("(forced-colors: active)");
forcedColors.addEventListener("change", inspect);
return () => {
observer.disconnect();
forcedColors.removeEventListener("change", inspect);
};
}, [inspect, ready, error]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPreview("");
const input = email.current;
if (!input) return;
if (!input.validity.valid) {
setError(
input.validity.valueMissing ? "Enter a receipt email." : "Enter a complete email address.",
);
input.focus();
return;
}
setError("");
const data = new FormData(event.currentTarget);
setPreview(
`Preview ready: ${data.get("email")} for ${data.get("reference")}. Nothing was saved or sent.`,
);
}
const target = facts?.targetHeight ?? 0;
const face = facts?.faceHeight ?? target;
const drawingFaceHeight = target > 0 ? 64 * Math.min(face / target, 1) : 64;
return (
<form
aria-labelledby={`${id}-title`}
aria-busy={!ready}
noValidate
onSubmit={submit}
onReset={() => {
setError("");
setPreview("");
// Read on the next frame, after the browser's default reset action.
requestAnimationFrame(inspect);
}}
>
<FieldSet disabled={!ready}>
<FieldLegend id={`${id}-title`}>Preview a receipt</FieldLegend>
<FieldDescription>
Use a sample email. This example only changes its local preview.
</FieldDescription>
{!ready && (
<FieldDescription>
This preview needs JavaScript. If the fields stay disabled, reload the page.
</FieldDescription>
)}
<FieldGroup>
<Field orientation="horizontal" invalid={Boolean(error)}>
<FieldLabel htmlFor={`${id}-email`}>Receipt email (required)</FieldLabel>
<FieldContent>
<Input
ref={email}
id={`${id}-email`}
name="email"
type="email"
autoComplete="off"
required
defaultValue=""
placeholder="mike@example.com"
aria-invalid={Boolean(error) || undefined}
aria-describedby={`${id}-email-message`}
onChange={() => {
setError("");
setPreview("");
inspect();
}}
/>
{error ? (
<FieldError id={`${id}-email-message`}>{error}</FieldError>
) : (
<FieldDescription id={`${id}-email-message`}>
A receipt destination for this preview. Nothing is emailed.
</FieldDescription>
)}
</FieldContent>
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor={`${id}-reference`}>Job reference (read only)</FieldLabel>
<FieldContent>
<Input
id={`${id}-reference`}
name="reference"
defaultValue="JOB-1842"
readOnly
aria-describedby={`${id}-reference-help`}
/>
<FieldDescription id={`${id}-reference-help`}>
Read-only values remain selectable and appear in form data.
</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor={`${id}-phone`}>Customer phone (unavailable)</FieldLabel>
<FieldContent>
<Input
id={`${id}-phone`}
name="phone"
type="tel"
defaultValue="(555) 014-0184"
disabled
aria-describedby={`${id}-phone-help`}
/>
<FieldDescription id={`${id}-phone-help`}>
Phone editing is unavailable in this example. Disabled values are omitted from form
data.
</FieldDescription>
</FieldContent>
</Field>
<FormActions>
<Button type="submit">Preview receipt</Button>
<Button type="reset" variant="secondary">
Reset example
</Button>
</FormActions>
<FieldDescription role="status" aria-live="polite">
{preview || "Nothing has been saved or sent."}
</FieldDescription>
</FieldGroup>
</FieldSet>
<details {...exampleLayout.details} onToggle={inspect}>
<summary
{...exampleLayout.summary}
style={{ minHeight: "var(--ui-control-target)" }}
>
Inspect the email field
</summary>
<div {...exampleLayout.inspector}>
<FieldDescription>
The diagram compares heights, enlarged to fit. Values come from the actual input above
and update when it resizes.
</FieldDescription>
{facts ? (
<>
<svg viewBox="0 0 320 104" {...exampleLayout.full} aria-hidden="true" focusable="false">
<rect
x="32"
y="20"
width="256"
height="64"
rx="10"
fill="none"
stroke="var(--color-accent)"
strokeWidth="2"
strokeDasharray="4 4"
/>
<rect
x="32"
y={20 + (64 - drawingFaceHeight) / 2}
width="256"
height={drawingFaceHeight}
rx="10"
fill="var(--color-field)"
stroke="var(--color-marker)"
/>
<path d="M48 52h80" fill="none" stroke="var(--color-fg)" strokeWidth="2" />
</svg>
<dl {...exampleLayout.facts}>
<div>
<dt {...exampleLayout.emphasis}>Input target height</dt>
<dd {...exampleLayout.mono} data-field-fact="target">
{facts.targetHeight.toFixed(1)} CSS px
</dd>
</div>
<div>
<dt {...exampleLayout.emphasis}>Visible field height</dt>
<dd {...exampleLayout.mono} data-field-fact="face">
{facts.faceHeight === null
? "Native boundary in forced colors"
: `${facts.faceHeight.toFixed(1)} CSS px`}
</dd>
</div>
<div>
<dt {...exampleLayout.emphasis}>Associated label</dt>
<dd {...exampleLayout.body} data-field-fact="name">
{facts.name}
</dd>
</div>
<div>
<dt {...exampleLayout.emphasis}>Connected help or error</dt>
<dd {...exampleLayout.body} data-field-fact="description">
{facts.description}
</dd>
</div>
<div>
<dt {...exampleLayout.emphasis}>Native validity</dt>
<dd {...exampleLayout.body} data-field-fact="validity">
{facts.validity}
</dd>
</div>
</dl>
</>
) : (
<FieldDescription>
Live measurements become available when the example loads.
</FieldDescription>
)}
<div>
<Button
type="button"
variant="secondary"
disabled={!ready}
onClick={() => email.current?.focus()}
>
Focus receipt email
</Button>
</div>
<FieldDescription>
Field supplies layout. Its caller connects htmlFor, IDs, aria-describedby and
aria-invalid. Input keeps native input attributes and its ref points to the input.
</FieldDescription>
</div>
</details>
</form>
);
}
Adoption contract
- Start from the ShadCN accessible source primitive; keep upstream keyboard and focus behavior.
- Use Thorbis color, type, space, radius, shadow, and motion tokens only.
- Design hover, focus, selected, disabled, loading, empty, error, offline, and reduced-motion states where they apply.
- Verify desktop and 390px mobile, keyboard order, accessible names, and stable async geometry.