Form
A transaction scaffold that groups labeled fields, validation, section context, and commit actions.
- Family
- Transaction
- Available import
@/components/ui/form- Status
- Thorbis wrapper and live specimen ship here.
form- Use
- Use for durable data entry where submit, error recovery, and keyboard order must remain explicit.
- Avoid
- Do not invent page-local field geometry, hide validation until the end, or use a form for immediate settings.
Use this component
GitHub source links require access to the private Thorbis design repository.
Wrapper sourceRepository and dependencies
- Value exports
FormActionsFormErrorFormFieldFormHelpFormLabelFormRowFormSectionFormSectionDescriptionFormSectionFieldsFormSectionInfoFormSectionTitle
- Type exports
- None.
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:
./form.stylex@stylexjs/stylexreact
Form validation and recovery
Submit an empty address, then correct it, optionally add a unit, and choose a priority. FormRow wraps paired fields to the available width; the error replaces street help. Controls become available after client hydration so the local preview cannot send a native form request before it is ready. Nothing is saved. Form exports layout pieces; NativeSelect takes native option elements and native change events.
The live specimen above runs this exact example.
Read the complete example
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import {
FormActions,
FormError,
FormField,
FormHelp,
FormLabel,
FormRow,
FormSection,
FormSectionDescription,
FormSectionFields,
FormSectionInfo,
FormSectionTitle,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
export function JobFormExample() {
const id = useId();
const street = useRef<HTMLInputElement>(null);
const [ready, setReady] = useState(false);
const [error, setError] = useState(false);
const [preview, setPreview] = useState("");
useEffect(() => setReady(true), []);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const address = String(data.get("street") ?? "").trim();
const unit = String(data.get("unit") ?? "").trim();
setError(!address);
setPreview("");
if (!address) {
street.current?.focus();
return;
}
setPreview(
`Preview ready: ${address}${unit ? `, unit ${unit}` : ""}, ${data.get("priority")}. Nothing was saved.`,
);
}
return (
<form data-form-pilot="form" aria-labelledby={`${id}-title`} aria-busy={!ready} onSubmit={submit} noValidate>
<FormSection>
<FormSectionInfo>
<FormSectionTitle id={`${id}-title`}>Preview a job</FormSectionTitle>
<FormSectionDescription>
Local example. Your entry stays in this page and is not saved.
</FormSectionDescription>
{!ready && (
<FormHelp>
This preview needs JavaScript. If the fields stay disabled, reload the page.
</FormHelp>
)}
</FormSectionInfo>
<FormSectionFields>
<FormField>
<FormLabel htmlFor={`${id}-street`}>Street address (required)</FormLabel>
<Input
ref={street}
id={`${id}-street`}
name="street"
autoComplete="off"
required
disabled={!ready}
aria-invalid={error || undefined}
aria-describedby={`${id}-street-message`}
onChange={() => {
setError(false);
setPreview("");
}}
/>
{error ? (
<FormError id={`${id}-street-message`}>Enter a street address.</FormError>
) : (
<FormHelp id={`${id}-street-message`}>Use a sample address to try the form.</FormHelp>
)}
</FormField>
<FormRow>
<FormField>
<FormLabel htmlFor={`${id}-unit`}>Unit or suite (optional)</FormLabel>
<Input
id={`${id}-unit`}
name="unit"
autoComplete="off"
disabled={!ready}
aria-describedby={`${id}-unit-help`}
onChange={() => setPreview("")}
/>
<FormHelp id={`${id}-unit-help`}>Suite, apartment, or unit if needed.</FormHelp>
</FormField>
<FormField>
<FormLabel htmlFor={`${id}-priority`}>Priority</FormLabel>
<NativeSelect
id={`${id}-priority`}
name="priority"
defaultValue="routine"
disabled={!ready}
onChange={() => setPreview("")}
>
<option value="routine">Routine</option>
<option value="urgent">Urgent</option>
</NativeSelect>
</FormField>
</FormRow>
<FormActions>
<Button type="submit" disabled={!ready}>
Preview job
</Button>
</FormActions>
<FormHelp role="status" aria-live="polite">
{preview}
</FormHelp>
</FormSectionFields>
</FormSection>
</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.