Field
The way to build forms: a label, a control, a description and an error, linked for assistive technology without passing ids.
npx prodular-ui add fieldSettings form
A complete workspace settings form: text, URL prefix, select, switches and a textarea.
"use client"
import { Button } from "@/components/ui/button"import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card"import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet,} from "@/components/ui/field"import { Input } from "@/components/ui/input"import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText,} from "@/components/ui/input-group"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"import { Switch } from "@/components/ui/switch"import { Textarea } from "@/components/ui/textarea"
const timezones = [ { label: "Pacific Time (GMT-8)", value: "America/Los_Angeles" }, { label: "Eastern Time (GMT-5)", value: "America/New_York" }, { label: "London (GMT+0)", value: "Europe/London" }, { label: "Berlin (GMT+1)", value: "Europe/Berlin" }, { label: "Singapore (GMT+8)", value: "Asia/Singapore" },]
const notifications = [ { id: "summary", label: "Weekly summary", description: "Every Monday at 9:00, a recap of what shipped and what slipped.", defaultChecked: true, }, { id: "mentions", label: "Mentions and replies", description: "When someone mentions you or replies to your comment.", defaultChecked: true, }, { id: "billing", label: "Failed payment alerts", description: "Sent to workspace owners when a card is declined.", defaultChecked: false, },]
export default function FieldSettingsForm() { return ( <Card className="mx-auto w-full max-w-2xl"> <CardHeader> <CardTitle>Workspace</CardTitle> <CardDescription> Settings for Northbeam Studio. Changes apply to all 24 members. </CardDescription> </CardHeader> <CardContent> <form id="workspace-settings" onSubmit={(e) => e.preventDefault()}> <FieldGroup> <Field> <FieldLabel>Workspace name</FieldLabel> <Input name="name" defaultValue="Northbeam Studio" required /> <FieldDescription>Shown in the sidebar, in emails and on invoices.</FieldDescription> </Field> <Field> <FieldLabel>Workspace URL</FieldLabel> <InputGroup> <InputGroupAddon> <InputGroupText>tidewater.app/</InputGroupText> </InputGroupAddon> <InputGroupInput name="slug" defaultValue="northbeam" className="pl-0!" /> </InputGroup> <FieldDescription> Changing it breaks existing links. Old links redirect for 30 days. </FieldDescription> </Field> <Field> <FieldLabel>Timezone</FieldLabel> <Select name="timezone" items={timezones} defaultValue="Europe/London"> <SelectTrigger className="w-full sm:w-72"> <SelectValue /> </SelectTrigger> <SelectContent> {timezones.map((tz) => ( <SelectItem key={tz.value} value={tz.value}> {tz.label} </SelectItem> ))} </SelectContent> </Select> <FieldDescription>Used for due dates, digests and reports.</FieldDescription> </Field> <FieldSeparator /> <FieldSet> <FieldLegend variant="label">Email notifications</FieldLegend> <FieldGroup className="gap-4"> {notifications.map((n) => ( <Field key={n.id} orientation="horizontal"> <FieldContent> <FieldLabel>{n.label}</FieldLabel> <FieldDescription>{n.description}</FieldDescription> </FieldContent> <Switch name={n.id} defaultChecked={n.defaultChecked} /> </Field> ))} </FieldGroup> </FieldSet> <FieldSeparator /> <Field> <FieldLabel>Welcome message</FieldLabel> <Textarea name="welcome" defaultValue="Welcome to Northbeam. Start with the Q4 roadmap board and say hi in #studio." /> <FieldDescription>New members see this the first time they sign in.</FieldDescription> </Field> </FieldGroup> </form> </CardContent> <CardFooter className="justify-end gap-2"> <Button variant="outline" type="reset" form="workspace-settings"> Cancel </Button> <Button type="submit" form="workspace-settings"> Save changes </Button> </CardFooter> </Card> )}Validation
Set invalid on Field and swap the description for a FieldError.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel,} from "@/components/ui/field"import { Input } from "@/components/ui/input"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
const roles = [ { label: "Admin", value: "admin" }, { label: "Member", value: "member" }, { label: "Viewer", value: "viewer" },]
export default function FieldValidation() { const [email, setEmail] = React.useState("daniel.reyes@") const [role, setRole] = React.useState<string | null>(null) const [submitted, setSubmitted] = React.useState(true) const emailError = !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) const roleError = !role
return ( <form className="w-full max-w-sm" noValidate onSubmit={(e) => { e.preventDefault() setSubmitted(true) }} > <FieldGroup> <Field invalid={submitted && emailError}> <FieldLabel>Email</FieldLabel> <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> {submitted && emailError ? ( <FieldError>Enter a full address, like daniel@northbeam.studio.</FieldError> ) : ( <FieldDescription>They get an invite that expires in 7 days.</FieldDescription> )} </Field> <Field invalid={submitted && roleError}> <FieldLabel>Role</FieldLabel> <Select items={roles} value={role} onValueChange={(v) => setRole(v as string | null)}> <SelectTrigger className="w-full"> <SelectValue placeholder="Choose a role" /> </SelectTrigger> <SelectContent> {roles.map((r) => ( <SelectItem key={r.value} value={r.value}> {r.label} </SelectItem> ))} </SelectContent> </Select> {submitted && roleError && <FieldError>Pick a role before sending the invite.</FieldError>} </Field> <Button type="submit" className="self-start"> Send invite </Button> </FieldGroup> </form> )}Responsive
Label on the left from medium container widths, stacked below.
How you appear in comments and mentions.
Used to estimate project cost in reports.
import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldSeparator } from "@/components/ui/field"import { Input } from "@/components/ui/input"import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"
export default function FieldResponsive() { return ( <FieldGroup className="max-w-2xl"> <Field orientation="responsive"> <FieldContent> <FieldLabel>Display name</FieldLabel> <FieldDescription>How you appear in comments and mentions.</FieldDescription> </FieldContent> <Input defaultValue="Maya Okafor" className="sm:w-72" /> </Field> <FieldSeparator /> <Field orientation="responsive"> <FieldContent> <FieldLabel>Hourly rate</FieldLabel> <FieldDescription>Used to estimate project cost in reports.</FieldDescription> </FieldContent> <InputGroup className="sm:w-72"> <InputGroupAddon> <InputGroupText>$</InputGroupText> </InputGroupAddon> <InputGroupInput defaultValue="145" className="tabular-nums" /> <InputGroupAddon align="inline-end"> <InputGroupText>per hour</InputGroupText> </InputGroupAddon> </InputGroup> </Field> </FieldGroup> )}Anatomy
<Field> <FieldLabel>Workspace name</FieldLabel> <Input defaultValue="Northbeam Studio" /> <FieldDescription>Shown in the sidebar and on invoices.</FieldDescription> <FieldError>Enter a name.</FieldError></Field>Guidance
Use it when
- Every form control that has a visible label.
- FieldSet and FieldLegend for a group of related controls, such as notification switches.
- orientation="responsive" for settings pages with the label column on the left.
Avoid
- Wrapping a Field in another bordered box inside a card. Use FieldSeparator between sections.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Build every form control as Field > FieldLabel + control + FieldDescription or FieldError. Field links them, so skip id, htmlFor and aria-describedby for Base UI controls (Input, Textarea, Select, Combobox, Checkbox, RadioGroup, Switch, Slider).
- NativeSelect and InputOTP are not Base UI controls: give them an id and a matching FieldLabel htmlFor. Explicit ids also keep working for every other control.
- Show errors by setting invalid on Field and rendering FieldError with a sentence that says how to fix it. Replace the description with the error rather than showing both.
- Use orientation="horizontal" for checkboxes and switches. Put switches last and checkboxes first.
- Group fields with FieldGroup and separate sections with FieldSeparator, not with nested cards.
- Write descriptions as one short sentence about consequences (Old links redirect for 30 days), not a restatement of the label.
API
Field
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "vertical" | "horizontal" | "responsive" | "vertical" | Stacked, inline, or inline from the @md container width. |
| invalid | boolean | Marks the control invalid and shows FieldError. data-invalid also works. | |
| disabled | boolean | Disables the control and dims the label. data-disabled also works. | |
| validate | (value) => string | null | Base UI validation. Return an error message or null. |
FieldError
| Prop | Type | Default | Description |
|---|---|---|---|
| errors | { message?: string }[] | Messages from a form library. Duplicates are removed. |
FieldLegend
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "legend" | "label" | "legend" | Section heading or label sized legend. |
FieldLabel
| Prop | Type | Default | Description |
|---|---|---|---|
| htmlFor | string | Only needed for controls Field cannot link, such as NativeSelect. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add field# or, with the registry in components.jsonnpx shadcn@latest add @prodular/field