Skip to content

Field

The accessibility contract shared by every form control — label, help text, error, success and the ARIA wiring that connects them.

FormsintermediateFeaturedformlabelerrorvalidationaria-describedby

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Checkbox } from '@/components/ui/choice'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { DemoColumn, DemoStage } from './_kit'

export default function FieldDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Field name="plain" label="Plain field">
          {(field) => <Input {...field} placeholder="No hint, no validation" />}
        </Field>

        <Field
          name="hinted"
          label="With help text"
          hint="Help text is wired through aria-describedby."
        >
          {(field) => <Input {...field} placeholder="Describe the change" />}
        </Field>

        <Field
          name="required-field"
          label="Required"
          required
          hint="Marked with an asterisk and aria-required."
        >
          {(field) => <Input {...field} />}
        </Field>

        <Field
          name="optional-field"
          label="Optional"
          showOptional
          hint="Mark the exception, not the rule."
        >
          {(field) => <Input {...field} />}
        </Field>

        <Field
          name="invalid-field"
          label="Invalid"
          error="Enter a value between 1 and 64 characters."
          hint="The error joins the description, it does not replace the hint."
        >
          {(field) => <Input {...field} defaultValue="" />}
        </Field>

        <Field name="valid-field" label="Valid" success="Looks good.">
          {(field) => <Input {...field} defaultValue="foundry" />}
        </Field>

        <Field name="hidden-label" label="Search everything" hideLabel>
          {(field) => <Input {...field} placeholder="Label is present but visually hidden" />}
        </Field>

        <Fieldset
          legend="Grouped controls"
          name="grouped"
          hint="Fieldset carries the group's description."
        >
          <Checkbox id="grouped-a" name="grouped" value="a" label="First option" />
          <Checkbox id="grouped-b" name="grouped" value="b" label="Second option" />
        </Fieldset>
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/field.tsx

import { Checkbox } from '@/components/ui/choice'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { DemoColumn, DemoStage } from './_kit'

export default function FieldDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Field name="plain" label="Plain field">
          {(field) => <Input {...field} placeholder="No hint, no validation" />}
        </Field>

        <Field
          name="hinted"
          label="With help text"
          hint="Help text is wired through aria-describedby."
        >
          {(field) => <Input {...field} placeholder="Describe the change" />}
        </Field>

        <Field
          name="required-field"
          label="Required"
          required
          hint="Marked with an asterisk and aria-required."
        >
          {(field) => <Input {...field} />}
        </Field>

        <Field
          name="optional-field"
          label="Optional"
          showOptional
          hint="Mark the exception, not the rule."
        >
          {(field) => <Input {...field} />}
        </Field>

        <Field
          name="invalid-field"
          label="Invalid"
          error="Enter a value between 1 and 64 characters."
          hint="The error joins the description, it does not replace the hint."
        >
          {(field) => <Input {...field} defaultValue="" />}
        </Field>

        <Field name="valid-field" label="Valid" success="Looks good.">
          {(field) => <Input {...field} defaultValue="foundry" />}
        </Field>

        <Field name="hidden-label" label="Search everything" hideLabel>
          {(field) => <Input {...field} placeholder="Label is present but visually hidden" />}
        </Field>

        <Fieldset
          legend="Grouped controls"
          name="grouped"
          hint="Fieldset carries the group's description."
        >
          <Checkbox id="grouped-a" name="grouped" value="a" label="First option" />
          <Checkbox id="grouped-b" name="grouped" value="b" label="Second option" />
        </Fieldset>
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/field.tsx

import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Field
 *
 * The accessibility contract for every form control in Foundry lives here, in
 * one place, rather than being re-implemented per input.
 *
 * Ids are derived deterministically from `name` instead of `useId`, for two
 * reasons: the component stays renderable from a Server Component (no hooks),
 * and the markup is byte-identical between server and client, so a form works
 * before hydration — see `/docs/accessibility`.
 *
 * The render prop hands back the exact wiring a control needs, which makes it
 * impossible to forget `aria-describedby` on a field that has help text.
 */
export interface FieldRenderProps {
  id: string
  name: string
  'aria-describedby': string | undefined
  'aria-invalid': true | undefined
  'aria-required': true | undefined
  required: boolean
  invalid: boolean
}

export interface FieldProps {
  name: string
  label: ReactNode
  hint?: ReactNode
  /** Validation failure. Presence flips the field into its invalid state. */
  error?: string | null
  /** Confirmation message shown once a value has validated. */
  success?: string | null
  required?: boolean
  /** Marks the field "Optional" instead of marking required fields. */
  showOptional?: boolean
  /** Visually hides the label while keeping it available to screen readers. */
  hideLabel?: boolean
  className?: string
  /** Disambiguates ids when the same field name appears twice on one page. */
  idPrefix?: string
  children: (props: FieldRenderProps) => ReactNode
}

export function Field({
  name,
  label,
  hint,
  error,
  success,
  required = false,
  showOptional = false,
  hideLabel = false,
  className,
  idPrefix = 'field',
  children,
}: FieldProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const successId = success && !error ? `${id}-success` : undefined
  const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined

  return (
    <div className={cn('flex flex-col gap-1.5', className)}>
      <label
        htmlFor={id}
        className={cn(
          'flex items-baseline gap-1.5 text-sm font-medium text-ink',
          hideLabel && 'sr-only',
        )}
      >
        <span>{label}</span>
        {required && !showOptional ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
        {showOptional && !required ? (
          <span className="text-xs font-normal text-ink-subtle">Optional</span>
        ) : null}
      </label>

      {hint ? (
        <p id={hintId} className="text-xs leading-normal text-ink-muted">
          {hint}
        </p>
      ) : null}

      {children({
        id,
        name,
        'aria-describedby': describedBy,
        'aria-invalid': error ? true : undefined,
        'aria-required': required || undefined,
        required,
        invalid: Boolean(error),
      })}

      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}

      {success && !error ? (
        <p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
          <CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{success}</span>
        </p>
      ) : null}
    </div>
  )
}

/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
  legend: ReactNode
  hint?: ReactNode
  error?: string | null
  name: string
  required?: boolean
  className?: string
  children: ReactNode
  idPrefix?: string
}

export function Fieldset({
  legend,
  hint,
  error,
  name,
  required = false,
  className,
  children,
  idPrefix = 'group',
}: FieldsetProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined

  return (
    <fieldset
      className={cn('flex min-w-0 flex-col gap-2', className)}
      aria-describedby={describedBy}
      aria-invalid={error ? true : undefined}
      aria-required={required || undefined}
    >
      <legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
        <span>{legend}</span>
        {required ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
      </legend>
      {hint ? (
        <p id={hintId} className="text-xs text-ink-muted">
          {hint}
        </p>
      ) : null}
      {children}
      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}
    </fieldset>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Wrap every control in a Field. The render prop hands back exactly the props the control needs, which makes it structurally impossible to ship a field whose help text is not associated with its input.

  • Ids are derived from `name` rather than `useId`, so a form renders identically on the server and works before hydration.
  • Mark the exception: if most fields are required, mark the optional ones with `showOptional` instead of starring everything.
  • Errors are strings, not booleans — "Required" is not an error message, "Enter your work email" is.
  • Use Fieldset for radio groups and checkbox groups; a group needs a legend, not a floating label.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Plain — label and control only
  • With help text — wired through aria-describedby
  • Required and Optional marking
  • Invalid — error joins the description, it does not replace the hint
  • Valid — confirmation after a successful check
  • Hidden label — visually hidden, still announced
  • Fieldset — legend and shared description for grouped controls

Accessibility

Association
The label uses `htmlFor`, and hint, error and success ids are joined into one `aria-describedby`.
Invalid state
`aria-invalid` is set from the presence of an error, never from a separate boolean that could drift.
Colour independence
Errors carry an icon and text; the red border is the third signal, not the only one.
Grouping
Fieldset applies the same description and invalid wiring at group level via `aria-describedby` on the fieldset.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.