← ui

field

ui

one control and what is said about it: label, description, error, required

npx shadcn add @ag/field
Already installed? Preview an update
npx shadcn add @ag/field --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • A glass FieldSet is a glass Panel drawn as the fieldset, named by its legend (aria-labelledby).
Updated 2026-10-14 · release notes

also called form field

shelf controls

comes with field-context

uses label, material, panel

used by components, form

the demo above, verbatim

field.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Field, FieldSet } from "@/registry/base-nova/ui/field"
import { Input } from "@/registry/base-nova/ui/input"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { NumberField, SECONDS } from "@/registry/base-nova/ui/number"
import { Switch } from "@/registry/base-nova/ui/switch"
import { Textarea } from "@/registry/base-nova/ui/textarea"

// #region profile
// A field on its own: the page keeps the value and says what is wrong; the field lays
// it out and wires it (the render prop's `wiring` on the control).
function Profile() {
  const [handle, setHandle] = React.useState("adrian galilea")
  const [bio, setBio] = React.useState("")
  const [wait, setWait] = React.useState(1500)
  const [public_, setPublic] = React.useState(true)
  return (
    <FieldSet
      legend="profile"
      description="what other people see next to your name"
      className="w-full max-w-md"
    >
      <Field
        label="handle"
        required
        description="lowercase letters, digits and dashes"
        error={
          /^[a-z0-9-]+$/.test(handle)
            ? undefined
            : "only lowercase letters, digits and dashes"
        }
      >
        {(wiring) => (
          <Input
            {...wiring}
            value={handle}
            onChange={(e) => setHandle(e.currentTarget.value)}
          />
        )}
      </Field>
      <Field label="bio" aside={`${bio.length} / 160`}>
        {(wiring) => (
          <Textarea
            {...wiring}
            rows={2}
            maxRows={6}
            maxLength={160}
            value={bio}
            onChange={(e) => setBio(e.currentTarget.value)}
          />
        )}
      </Field>
      <Field
        label="wait before playing"
        description="a control that names itself takes the label as words"
      >
        <NumberField
          label="wait before playing"
          unit={SECONDS}
          min={0}
          max={10_000}
          step={100}
          value={wait}
          onValueChange={setWait}
        />
      </Field>
      <Field label="public" orientation="horizontal">
        {(wiring) => (
          <Switch {...wiring} checked={public_} onCheckedChange={setPublic} />
        )}
      </Field>
    </FieldSet>
  )
}
// #endregion

// #region handle
function Handle() {
  const [handle, setHandle] = React.useState("Adrian")
  return (
    <Field
      label="handle"
      required
      description="lowercase letters, digits and dashes"
      error={
        /^[a-z0-9-]+$/.test(handle)
          ? undefined
          : "only lowercase letters, digits and dashes"
      }
      className="w-full max-w-xs"
    >
      {(wiring) => (
        <Input
          {...wiring}
          value={handle}
          onChange={(e) => setHandle(e.currentTarget.value)}
        />
      )}
    </Field>
  )
}
// #endregion

export default function FieldDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="handle"
        glance
        title="one field, and what is wrong"
        with="handle"
      >
        <Handle />
      </Sample>
      <Sample
        name="profile"
        title="fields, alone"
        description="label, description, error, required, an aside at the label's end, a row beside the label; the error opens in place and the control is described by it."
        with="profile"
      >
        <Profile />
      </Sample>
      <Sample
        name="glass"
        title="the same, in glass"
        description="MaterialProvider round it: the label and lines catch the rim's light, the fieldset is a pane of glass."
        with="profile"
      >
        <MaterialProvider material="glass">
          <div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6">
            <Profile />
          </div>
        </MaterialProvider>
      </Sample>
    </div>
  )
}

the source, every file an install writes

field.tsx
"use client"

import { CircleAlert } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  FieldContext,
  type FieldContextValue,
  type FieldWiring,
} from "@/registry/base-nova/ui/field-context"
import { Label } from "@/registry/base-nova/ui/label"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassWords, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Panel } from "@/registry/base-nova/ui/panel"

// ONE CONTROL AND WHAT IS SAID ABOUT IT: its label, a line describing it, what is
// wrong with it, whether it is required, laid out the same way for every control
// and wired for a screen reader: the label names it (`htmlFor`), the description and
// the error describe it (`aria-describedby`), the error marks it (`aria-invalid`).
//
// THE CONTRACT a control meets to sit in a field is plain props: the field hands its
// children the wiring (`id`, `aria-describedby`, `aria-invalid`, `aria-required`,
// `disabled`), and a control spreads it on its focusable element:
//
//   <Field label="name" error={error}>
//     {(wiring) => <Input {...wiring} value={name} onChange={…} />}
//   </Field>
//
// A VALUE CONTROL READS `useField()` itself, so binding it is only its value: inside a
// field it spreads `wiring` on its own focusable element (id, the description and the
// error, invalid, required, disabled), holds still while `readOnly` (a form sending:
// it takes no change, keeps its focus, is never disabled), and may draw `invalid`.
// That is the whole contract a control (a number, a slider, a select, a switch) meets
// to sit in a field or a form, with no field-specific props of its own. Alone, a field
// is layout and accessibility; `ui/form` binds it to a form by name (value, change,
// leaving, the error said when it is due, read-only while it sends).
//
// The error comes and goes in place, its row opening from nothing and closing back,
// never a jump of everything under it. `material="glass"`: the label and lines catch
// the rim's light, and a fieldset is a pane of glass.

// What a control reads (the wiring, `useField()`, `heldStill`) is `field-context`, its
// own part, so a value control installs it without this file's layout.
export {
  type FieldContextValue,
  type FieldWiring,
  heldStill,
  useField,
} from "@/registry/base-nova/ui/field-context"

export type FieldOrientation = "vertical" | "horizontal"

export interface FieldProps
  extends Omit<React.ComponentProps<"div">, "children" | "id"> {
  label?: React.ReactNode
  /** One line saying what it is for, under the control. */
  description?: React.ReactNode
  /** What is wrong with it, said under the control (and to a screen reader). */
  error?: React.ReactNode
  required?: boolean
  disabled?: boolean
  /** The control holds still (a form sending); read by the control through
   *  `useField()`, never spread as an attribute. */
  readOnly?: boolean
  /** At the label's end: "optional", a count, a hint. */
  aside?: React.ReactNode
  /** Label above (default), or beside it on one row (a switch's, a checkbox's). */
  orientation?: FieldOrientation
  /** The control's id; made up when not given. */
  id?: string
  material?: Material
  /** The control, or a function given its wiring (and the label's id, for a group of
   *  controls named by `aria-labelledby`). */
  children:
    | React.ReactNode
    | ((wiring: FieldWiring, named: { labelId: string }) => React.ReactNode)
}

/** The quiet lines under a control, in either material. */
const LINE = "text-xs leading-5"
const DESCRIPTION: Record<Material, string> = {
  plain: "text-muted-foreground",
  glass: "text-current/65",
}
const ERROR: Record<Material, string> = {
  plain: "text-tone-bad",
  glass: `text-tone-bad ${glassWords}`,
}

export function Field({
  label,
  description,
  error,
  required = false,
  disabled = false,
  readOnly = false,
  aside,
  orientation = "vertical",
  id: idProp,
  material,
  className,
  children,
  ...props
}: FieldProps) {
  const m = useMaterial(material)
  const made = React.useId()
  const id = idProp ?? `${made}-control`
  const labelId = `${made}-label`
  const descriptionId = `${made}-description`
  const errorId = `${made}-error`
  const invalid = error !== undefined && error !== null && error !== false
  const describedBy =
    [description ? descriptionId : null, invalid ? errorId : null]
      .filter(Boolean)
      .join(" ") || undefined
  const wiring: FieldWiring = {
    id,
    ...(describedBy && { "aria-describedby": describedBy }),
    ...(invalid && { "aria-invalid": true as const }),
    ...(required && { "aria-required": true as const }),
    ...(disabled && { disabled: true }),
  }
  const context: FieldContextValue = {
    wiring,
    labelId,
    ...(typeof label === "string" && { label }),
    invalid,
    readOnly,
    material: m,
  }
  const control = (
    <FieldContext.Provider value={context}>
      {typeof children === "function"
        ? children(wiring, { labelId })
        : children}
    </FieldContext.Provider>
  )
  const name = label !== undefined && (
    <span
      className={cn(
        "flex min-w-0 items-baseline justify-between gap-3",
        orientation === "horizontal" && "flex-1",
      )}
    >
      <Label id={labelId} htmlFor={id} material={m}>
        {label}
        {required && (
          // The words say it to a screen reader (aria-required on the control); the
          // mark is for the eye.
          <span aria-hidden className="-ml-1 text-tone-bad/80">
            *
          </span>
        )}
      </Label>
      {aside !== undefined && (
        <span className={cn(LINE, DESCRIPTION[m])}>{aside}</span>
      )}
    </span>
  )
  return (
    <div
      data-slot="field"
      data-invalid={invalid || undefined}
      data-disabled={disabled || undefined}
      data-readonly={readOnly || undefined}
      data-orientation={orientation}
      data-material={m}
      className={cn("grid min-w-0", disabled && "opacity-60", className)}
      {...props}
    >
      {/* Two rows, always: the name and the control, then what it says. A row of
          fields (`fieldRow`) lines its fields' rows up, so a field that speaks
          moves no other field's control. */}
      {orientation === "horizontal" ? (
        <div className="flex items-center gap-3">
          {name}
          {control}
        </div>
      ) : (
        <div className="flex flex-col gap-2">
          {name}
          {control}
        </div>
      )}
      <div>
        {description && (
          <p
            id={descriptionId}
            data-slot="field-description"
            className={cn("mt-1.5", LINE, DESCRIPTION[m])}
          >
            {description}
          </p>
        )}
        <Said id={errorId} material={m}>
          {invalid ? error : null}
        </Said>
      </div>
    </div>
  )
}

/** Fields side by side on a grid (a sign-up in one line): every field's name and
 *  control on one row, what each says on the next (CSS subgrid), so one field's
 *  error opens under it without moving the controls beside it. On the grid with its
 *  columns; something after the fields that is no field (a submit) flows into the
 *  control row's next free column by itself (`self-end` to sit on the controls' line;
 *  a row-start of its own would place it before them). The grid's row gap is also
 *  the gap between a control and its words, so a row of fields sets none.
 *
 *  The words' row keeps one line from the start (the error's `mt-1.5` and its
 *  `leading-5`, 1.625rem), so the first error to open fills room already there and
 *  nothing around the row grows: aligned fields alone still let the first error push
 *  the whole card taller. Only words longer than a line add to it. */
export const fieldRow =
  "[grid-auto-rows:auto_minmax(1.625rem,auto)] [&>[data-slot=field]]:row-span-2 [&>[data-slot=field]]:grid-rows-subgrid"

/** The error's row: it opens from nothing when there is something to say and closes
 *  back, keeping the words while it closes, so nothing under it jumps. */
function Said({
  id,
  material,
  children,
}: {
  id: string
  material: Material
  children: React.ReactNode
}) {
  const open = children !== null
  // The last words, kept while the row closes.
  const last = React.useRef<React.ReactNode>(children)
  if (open) last.current = children
  const kept = last.current
  return (
    <div
      className={cn(
        "grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
        open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
      )}
    >
      <div className="min-h-0 overflow-hidden">
        <p
          id={open ? id : undefined}
          data-slot="field-error"
          className={cn(
            "mt-1.5 flex items-start gap-1.5 font-medium",
            LINE,
            ERROR[material],
          )}
        >
          <CircleAlert aria-hidden className="mt-[3px] size-3.5 shrink-0" />
          {open ? children : kept}
        </p>
      </div>
    </div>
  )
}

export interface FieldSetProps
  extends Omit<React.ComponentProps<"fieldset">, "children"> {
  /** What the group is: the fieldset's legend. */
  legend?: React.ReactNode
  description?: React.ReactNode
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  children: React.ReactNode
}

/** Fields that belong together, under one name (a fieldset and its legend): a panel
 *  of the page's surface, or a pane of glass. */
export function FieldSet({
  legend,
  description,
  material,
  scheme,
  className,
  children,
  ref,
  ...props
}: FieldSetProps) {
  const m = useMaterial(material)
  const [s, here] = useSurfaceScheme<HTMLFieldSetElement>(scheme, ref)
  const legendId = `${React.useId()}-legend`
  // What is inside stands on the set: it wears the set's scheme, whatever decided
  // it (a picture under the set included).
  const body = (
    <MaterialProvider scheme={s}>
      {legend !== undefined && (
        // Floated: a legend otherwise sits on the fieldset's border and cuts it.
        <legend
          id={legendId}
          data-slot="field-legend"
          className="float-left mb-1 w-full font-medium text-sm"
        >
          {legend}
        </legend>
      )}
      {description && (
        <p
          data-slot="fieldset-description"
          className={cn("clear-both -mt-0.5 mb-1", LINE, DESCRIPTION[m])}
        >
          {description}
        </p>
      )}
      <div className="clear-both flex flex-col gap-5">{children}</div>
    </MaterialProvider>
  )
  const shape = "m-0 min-w-0 flex flex-col gap-3 p-5"
  return m === "glass" ? (
    // The one glass container (Panel: body, spill, edge) drawn as the fieldset. What
    // it holds is wrapped (it stands on glass), so the legend is not the set's own
    // child: the set is named by it.
    <Panel
      render={<fieldset />}
      ref={here}
      material="glass"
      scheme={s}
      radius="1rem"
      data-slot="fieldset"
      aria-labelledby={legend === undefined ? undefined : legendId}
      bodyClassName={shape}
      className={className}
      {...props}
    >
      {body}
    </Panel>
  ) : (
    <fieldset
      ref={here}
      data-slot="fieldset"
      data-material={m}
      className={cn(
        shape,
        schemeOf(s),
        "rounded-xl border-0 bg-foreground/[0.025] ring-1 ring-foreground/8 ring-inset",
        className,
      )}
      {...props}
    >
      {body}
    </fieldset>
  )
}