Field
A form field composition: label, control, help text and validation error in one consistent layout. FieldControl wires id, aria-describedby and aria-invalid automatically.
Used only for deploy alerts.
Slugs cannot contain spaces.
import {
Field,
FieldControl,
FieldDescription,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
export default function FieldDemo() {
return (
<div className="flex max-w-sm flex-col gap-5">
<Field>
<FieldLabel>Work email</FieldLabel>
<FieldControl>
<Input type="email" placeholder="you@company.com" />
</FieldControl>
<FieldDescription>Used only for deploy alerts.</FieldDescription>
</Field>
<Field>
<FieldLabel>Project slug</FieldLabel>
<FieldControl>
<Input defaultValue="my project" />
</FieldControl>
<FieldError>Slugs cannot contain spaces.</FieldError>
</Field>
<Field orientation="horizontal">
<FieldLabel>Deploy alerts</FieldLabel>
<FieldControl>
<Switch defaultChecked />
</FieldControl>
</Field>
</div>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/fieldDependencies, the @ai2/tokens theme and the component file are installed together. Field depends on @ai2/label, which installs alongside it.
Add the Label component
Field imports the ai2 Label component and uses the radix Slot from radix-ui, so install the Label first when installing manually.
Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/field.tsx"use client"
import * as React from "react"
import { Slot } from "@/components/ui/primitives"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
interface FieldContextValue {
controlId: string
descriptionId: string
errorId: string
invalid: boolean
hasDescription: boolean
hasError: boolean
registerDescription: (present: boolean) => void
registerError: (present: boolean) => void
}
const FieldContext = React.createContext<FieldContextValue | null>(null)
function useFieldContext() {
return React.useContext(FieldContext)
}
interface FieldProps extends React.ComponentProps<"div"> {
orientation?: "vertical" | "horizontal"
/** Marks the whole field invalid: control gets aria-invalid, label turns danger. */
invalid?: boolean
/** Dims the label via the group chain; does not disable the control itself. */
disabled?: boolean
}
function Field({
className,
orientation = "vertical",
invalid = false,
disabled = false,
...props
}: FieldProps) {
const uid = React.useId()
const [hasDescription, setHasDescription] = React.useState(false)
const [hasError, setHasError] = React.useState(false)
const isInvalid = invalid || hasError
const ctx = React.useMemo<FieldContextValue>(
() => ({
controlId: `${uid}-control`,
descriptionId: `${uid}-description`,
errorId: `${uid}-error`,
invalid: isInvalid,
hasDescription,
hasError,
registerDescription: setHasDescription,
registerError: setHasError,
}),
[uid, isInvalid, hasDescription, hasError]
)
return (
<FieldContext.Provider value={ctx}>
<div
data-slot="field"
data-orientation={orientation}
data-invalid={isInvalid ? "true" : undefined}
data-disabled={disabled ? "true" : undefined}
className={cn(
"group flex gap-2",
orientation === "horizontal"
? "flex-row items-center justify-between"
: "flex-col",
className
)}
{...props}
/>
</FieldContext.Provider>
)
}
/** Wraps the form control (Input, Select trigger, Switch...) and wires it to
* the field: id for the label, aria-describedby to description/error,
* aria-invalid when the field is invalid. Renders no extra DOM (Slot). */
function FieldControl({
...props
}: React.ComponentProps<typeof Slot.Root>) {
const ctx = useFieldContext()
const describedBy =
[
ctx?.hasDescription ? ctx.descriptionId : null,
ctx?.hasError ? ctx.errorId : null,
]
.filter(Boolean)
.join(" ") || undefined
return (
<Slot.Root
data-slot="field-control"
id={ctx?.controlId}
aria-describedby={describedBy}
aria-invalid={ctx?.invalid || undefined}
{...props}
/>
)
}
function FieldLabel({
className,
htmlFor,
...props
}: React.ComponentProps<typeof Label>) {
const ctx = useFieldContext()
return (
<Label
data-slot="field-label"
htmlFor={htmlFor ?? ctx?.controlId}
className={cn("gap-1 group-data-[invalid=true]:text-danger", className)}
{...props}
/>
)
}
function FieldDescription({
className,
...props
}: React.ComponentProps<"p">) {
const ctx = useFieldContext()
const register = ctx?.registerDescription
React.useEffect(() => {
register?.(true)
return () => register?.(false)
}, [register])
return (
<p
data-slot="field-description"
id={ctx?.descriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function FieldError({
className,
children,
...props
}: React.ComponentProps<"p">) {
const ctx = useFieldContext()
const register = ctx?.registerError
const present = Boolean(children)
React.useEffect(() => {
register?.(present)
return () => register?.(false)
}, [register, present])
if (!children) return null
return (
<p
data-slot="field-error"
id={ctx?.errorId}
role="alert"
className={cn("text-sm font-medium text-danger", className)}
{...props}
>
{children}
</p>
)
}
export {
Field,
FieldControl,
FieldLabel,
FieldDescription,
FieldError,
type FieldProps,
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the danger color used by FieldError will be missing.
Usage
import {
Field,
FieldControl,
FieldDescription,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
<Field>
<FieldLabel>Work email</FieldLabel>
<FieldControl>
<Input type="email" placeholder="you@company.com" />
</FieldControl>
<FieldDescription>Used only for deploy alerts.</FieldDescription>
</Field>Wrap the control in FieldControl and the wiring is automatic: the label points at the control, the description and error are announced via aria-describedby, and aria-invalid follows the field state. No manual htmlFor / id pairs needed.
Examples
With description
Shown on the public dashboard.
The description is linked to the control through aria-describedby, so screen readers read it after the label.
With error
Slugs cannot contain spaces.
A rendered FieldError marks the whole field invalid: the control gets aria-invalid (which triggers the danger border on ai2 inputs) and the label turns danger. FieldError renders nothing when its children are empty, so you can pass your form library's error message directly.
Invalid and disabled
The invalid prop flips the field into the error look without a message. The disabled prop dims the label; pass disabled to the control as well to actually disable it.
Horizontal orientation
orientation="horizontal" puts the label and control on one row with space between, the layout used for switch settings rows. Clicking the label toggles the switch thanks to the automatic association.
Form composition
Props
Field adds three props of its own, listed below. FieldControl is a radix Slot: it renders no DOM, takes exactly one child and forwards every prop to it. FieldDescription and FieldError accept native <p> props, and FieldLabel accepts everything the ai2 Label does. FieldError renders role="alert" and returns null without children.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "vertical" | Layout direction: vertical stacks label, control and messages; horizontal puts label and control on one row with space between, for switch rows. |
invalid | boolean | false | Marks the whole field invalid: the control inside FieldControl gets aria-invalid and the label turns danger. A FieldError with content sets this automatically. |
disabled | boolean | false | Dims the label via the group chain. It does not disable the control itself; pass disabled to the control too. |
ai2 Field: form field anatomy for React, one consistent stack
The ai2 Field is a shadcn-compatible form field composition for React, styled with Tailwind CSS v4 on the shared ai2 tokens. It gives every form control the same anatomy: a label above the input, optional help text and a validation error below, all stacked in a single vertical layout with a consistent gap. An orientation prop switches to a horizontal row for switch-style settings. Wrap the control in FieldControl and the accessibility wiring, id, aria-describedby and aria-invalid, is handled for you.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Field?
It is a five-part composition: Field, FieldControl, FieldLabel, FieldDescription and FieldError. The root is a flex container, a column by default or a row with space between when orientation="horizontal". FieldControl is a Slot: it renders no extra DOM and injects a generated id, an aria-describedby pointing at the description and error, and aria-invalid when the field is invalid, straight onto your control. FieldLabel points its htmlFor at that same id automatically, and FieldError renders with role="alert" in the danger color.
Field is deliberately unopinionated about the control itself: put an input, textarea, select, switch or a custom widget inside FieldControl. The parts are plain elements with data-slot attributes, so form libraries like react-hook-form plug in without adapters, and styling stays on your token file in both light and dark mode.
Why use it
- One anatomy for every form: Label above the control, description and error below, always with the same gap. Forms across your app stop drifting apart visually.
- Accessibility wiring on autopilot: FieldControl injects a generated id, aria-describedby for the description and error, and aria-invalid when the field is invalid. FieldLabel picks up the id on its own; no manual htmlFor and id pairs.
- Errors announced to screen readers: FieldError renders role="alert", so assistive technology announces validation messages the moment they appear, and its presence marks the whole field invalid automatically.
- Form-library friendly: FieldError returns null when its children are empty, so you can pass errors.email?.message straight from react-hook-form or zod without conditional JSX. When the message appears, the control gets aria-invalid for free.
- Agent-readable metadata: The registry item describes the five parts and their intended order in plain words, so an MCP agent can compose correct forms without guessing.
Features
- shadcn registry install: One command adds Field, its @ai2/label dependency and the @ai2/tokens theme to your project.
- Zero-DOM control wiring: FieldControl is a radix Slot, so it adds no wrapper element. The id, aria-describedby and aria-invalid attributes land directly on your Input, Select trigger or Switch.
- Two orientations, one gap: The Field root is a flex column with a consistent gap, and orientation="horizontal" turns it into a label-left, control-right row for switch settings.
- Invalid and disabled field state: The invalid prop (or a rendered FieldError) sets data-invalid on the root, turns the label danger and puts aria-invalid on the control. The disabled prop dims the label through the group chain.
- Conditional error rendering: FieldError renders nothing without children: no empty paragraphs, no layout shift logic in your form code. Its presence alone flips the field into the invalid state.
- Data attributes for styling: Every part exposes data-slot (field, field-control, field-label, field-description, field-error), and the root carries data-orientation, data-invalid and data-disabled for targeted CSS overrides without forking.
Production tips
- Wrap the control in FieldControl: The label association, description announcement and invalid state all flow through FieldControl. Skip it and you are back to wiring htmlFor, id and aria-describedby by hand.
- Let FieldError drive the invalid state: Rendering a FieldError with content marks the field invalid on its own: the control gets aria-invalid and the label turns danger. Use the invalid prop only when there is no visible message.
- Disable the control and the field together: The disabled prop only dims the label. Pass disabled to the control inside FieldControl as well so the whole row reads and behaves as disabled.
- Keep descriptions short and stable: FieldDescription is for persistent hints like formats and limits. Do not swap it for the error text; render both parts and let FieldError appear below.
- Compose forms with a parent gap: Stack multiple Fields inside a form with flex flex-col gap-5 or similar; the inner gap-2 handles spacing within each field.
- Reserve role="alert" for real errors: FieldError announces immediately to assistive technology. Use it for validation failures only, not for success notes or hints.
Works with the rest of ai2
Field is the glue layer of the form kit. Put an ai2 Input or ai2 Textarea inside FieldControl for text entry, an ai2 Select for choices, and rely on the built-in ai2 Label that FieldLabel wraps.
For page-level composition, group related Fields inside an Card to give a settings section its own surface, or split long forms into collapsible sections. Everything shares one token source, so forms stay visually consistent in both modes.