Field
The macOS 26 Form layout family — labelled rows with descriptions, errors, separators, and fieldsets, in vertical / horizontal / responsive orientations.
A macOS Form is a layout, not a control: each row pairs a label with a control on a shared baseline, rows stack with a uniform vertical rhythm, and 1px hairline separators divide them. The Field family is that layout, expressed as composable parts. Spacing and typography are lifted from the Apple macOS 26 UI Kit; colors flip light/dark via the Acrylic theme tokens. API-compatible with the shadcn Field.
Installation
npx shadcn add https://acrylic-ui.vercel.app/r/field.jsonUsage
macOS rows — label leading, control filling the right, caption below — stacked in
a FieldGroup, with the last one invalid showing a FieldError:
Used for sign-in and receipts.
"use client"import { Input } from "@/registry/acrylic/input"import { Field, FieldDescription, FieldGroup, FieldLabel,} from "@/registry/acrylic/field"// macOS aligned form (Mode 1): labels share one right-aligned column sized to the// widest ("Email address"), so every input starts at the same x and is equal width.export default function FieldDemo() { return ( <FieldGroup variant="aligned" className="w-full max-w-md text-foreground"> <Field> <FieldLabel htmlFor="fd-project">Project</FieldLabel> <Input id="fd-project" placeholder="acrylic-ui" /> </Field> <Field> <FieldLabel htmlFor="fd-email">Email address</FieldLabel> <Input id="fd-email" type="email" placeholder="you@example.com" /> <FieldDescription>Used for sign-in and receipts.</FieldDescription> </Field> </FieldGroup> )}import { Input } from "@/components/acrylic/input"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/acrylic/field"
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" placeholder="ada@example.com" />
<FieldDescription>We'll never share your email.</FieldDescription>
</Field>
</FieldGroup>Examples
The macOS "settings card" (Mode 2): a Card-wrapped FieldGroup of rows,
each label-left / trailing-control-right (grid-cols-[1fr_auto]) divided by
hairlines, with a range of trailing accessories:
Trailing accessory options
"use client"import * as React from "react"import { Info, MoreHorizontal } from "lucide-react"import { Input } from "@/registry/acrylic/input"import { Button } from "@/registry/acrylic/button"import { Combobox } from "@/registry/acrylic/combobox"import { Card } from "@/registry/acrylic/card"import { Switch } from "@/registry/acrylic/switch"import { Stepper } from "@/registry/acrylic/stepper"import { RadioGroup, RadioGroupItem } from "@/registry/acrylic/radio-group"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/registry/acrylic/select"import { Field, FieldGroup, FieldLabel } from "@/registry/acrylic/field"// Mode 2 — the macOS "Trailing accessory options" settings card: a Card-wrapped// FieldGroup of rows, each label-left / trailing-control-right (grid-cols-[1fr_auto])// with hairline dividers. Every trailing accessory is a real Acrylic component// (Switch · Stepper · Select pop-up · RadioGroup · Input · Combobox · Button);// Info / More are plain glyphs.// A settings row: label on the left, trailing control on the right.function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( <Field className="grid-cols-[1fr_auto] px-4 py-2.5"> <FieldLabel className="font-normal">{label}</FieldLabel> {children} </Field> )}export default function FieldShowcase() { const [combo, setCombo] = React.useState("value") return ( <div className="w-full max-w-md"> <p className="mb-3 px-1 text-[15px] font-semibold text-foreground">Trailing accessory options</p> <Card className="overflow-hidden bg-[var(--acr-control)]"> <FieldGroup className="gap-0 divide-y divide-[var(--acr-border-soft)]"> <Row label="Label"> <span className="text-[13px] text-muted-foreground">Label</span> </Row> <Row label="Info"> <Info className="size-[18px] shrink-0 text-muted-foreground" /> </Row> <Row label="Popup"> <Select defaultValue="label"> <SelectTrigger size="small"> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="label">Label</SelectItem> <SelectItem value="other">Other</SelectItem> </SelectContent> </Select> </Row> <Row label="Toggle"> <Switch size="small" defaultChecked /> </Row> <Row label="Info Button + Toggle"> <span className="flex items-center gap-2"> <Info className="size-[18px] shrink-0 text-muted-foreground" /> <Switch size="small" /> </span> </Row> <Row label="Label + Info Button"> <span className="flex items-center gap-2"> <span className="text-[13px] text-muted-foreground">Label</span> <Info className="size-[18px] shrink-0 text-muted-foreground" /> </span> </Row> <Row label="Stepper"> <Stepper size="small" defaultValue={3} /> </Row> <Row label="Stepper + Label"> <span className="flex items-center gap-2"> <Stepper size="small" defaultValue={30} min={0} step={5} /> <span className="text-[13px] text-muted-foreground">seconds</span> </span> </Row> <Row label="Text Field"> <Input defaultValue="Value" className="w-32" /> </Row> <Row label="Radio buttons"> <RadioGroup defaultValue="2" className="flex flex-row items-center gap-4"> <label className="flex items-center gap-1.5 text-[13px]"> <RadioGroupItem value="2" size="small" /> Option 2 </label> <label className="flex items-center gap-1.5 text-[13px]"> <RadioGroupItem value="1" size="small" /> Option 1 </label> </RadioGroup> </Row> <Row label="More button"> <MoreHorizontal className="size-4 text-muted-foreground" /> </Row> <Row label="Combo Box"> <Combobox options={[{ value: "value", label: "Value" }, { value: "other", label: "Other" }]} value={combo} onValueChange={setCombo} className="w-32" /> </Row> <Row label="Menu + Button"> <span className="flex items-center gap-2"> <Select defaultValue="full"> <SelectTrigger size="small"> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="full">Full Screen</SelectItem> <SelectItem value="window">Window</SelectItem> </SelectContent> </Select> <Button variant="neutral" size="small">Choose Display…</Button> </span> </Row> <Row label="Button"> <Button variant="neutral" size="small">Advanced…</Button> </Row> </FieldGroup> </Card> </div> )}Sizes — the label follows the control
Field takes the same size axis as the controls (mini · small · medium
(default) · large · xl), and the label reads it off the row. Declare it on
both halves — <Field size="xl"> + <Input size="xl"> — and the two agree.
Skipping it on the Field is what makes a 13px label sit next to a 17px value:
The description steps down with it, never up to the label.
The description steps down with it, never up to the label.
The description steps down with it, never up to the label.
The description steps down with it, never up to the label.
import { Field, FieldDescription, FieldGroup, FieldLabel,} from "@/registry/acrylic/field"import { Input } from "@/registry/acrylic/input"// One size per row, declared twice: on the Field (label) and on the control.// Both halves of a row always read at the same size.const rows = [ { size: "small" as const, label: "Small", value: "11px label · 11px field" }, { size: "medium" as const, label: "Medium", value: "13px label · 13px field" }, { size: "large" as const, label: "Large", value: "15px label · 15px field" }, { size: "xl" as const, label: "Extra large", value: "17px label · 17px field" },]export default function FieldSizes() { return ( <FieldGroup className="w-full max-w-md"> {rows.map((row) => ( <Field key={row.size} orientation="vertical" size={row.size}> <FieldLabel htmlFor={`field-${row.size}`}>{row.label}</FieldLabel> <Input id={`field-${row.size}`} size={row.size} defaultValue={row.value} /> <FieldDescription> The description steps down with it, never up to the label. </FieldDescription> </Field> ))} </FieldGroup> )}Notes
- Exports
Field,FieldLabel,FieldTitle,FieldDescription,FieldError,FieldContent,FieldGroup,FieldSet,FieldLegend,FieldSeparator. - Orientation —
Fieldtakesorientation:horizontal(default, the macOS row — label hugs the left, the control fills the right, and the description/error sit below the control, aligned under it),vertical(label over control — use for wide controls / textareas), orresponsive(vertical until the enclosingFieldGroupis ≥ ~28rem wide, then the macOS row — via container queries, so it adapts to its container, not the viewport). For a control row (title + subtitle on the left, control hugging the right) wrap the text inFieldContentand addclassName="grid-cols-[1fr_auto]"to theField. - Size —
Fieldtakessize:mini·small·medium(default) ·large·xl, named to matchInput/Select/Comboboxso a row declares one size on both halves.FieldLabel/FieldTitlescale with it (10/11/13/15/17, each with its tracking companion) andFieldDescriptionsteps up one notch onlarge/xlso it stays a subtitle. The label can't see its control, which is why the axis lives onField— pair anxlcontrol with a defaultFieldand the label renders 4px smaller than the value beside it. - Errors —
FieldErroraccepts anerrorsarray of{ message }objects or plainchildren, and renders nothing when there's neither. Setdata-invalidon theFieldto tint its label/control toward the destructive color. - Separators —
FieldSeparatoris a 1px hairline (--acr-field, the exact kit separator color); pass inline children to render a label centered over the line. - Grouping —
FieldContentstacks a control with its title/description for horizontal rows;FieldSet+FieldLegendgroup related fields (FieldLegendvariant:legenddefault, orlabelfor a smaller heading). - Label alignment —
FieldGrouptakesvariant:rows(default, macOS settings rows — each label hugs the left of its own row) oraligned(the classic macOS dialog form — one shared label column, labels right-aligned to the widest so every control starts at the same x and the inputs are equal width; implemented with a CSS subgrid).
Dropdown Menu
The macOS 26 click-triggered menu — frosted glass panel, accent-highlighted rows, shortcuts, and destructive variants.
File Picker
A backend-agnostic filesystem browser and picker — drill into folders, search, create a folder in place, and pick a file or a directory. No component knows what a "file" physically is; every byte arrives through host callbacks.