Skip to content

Checkbox

Multi-select control with inline and card appearances, plus a genuine indeterminate state for bulk selection.

Formsstarterformmulti-selectindeterminatebulk

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 { Fieldset } from '@/components/ui/field'
import { DemoColumn, DemoStage } from './_kit'

export default function CheckboxDemo() {
  return (
    <DemoStage>
      <DemoColumn>
        <Fieldset
          legend="Notifications"
          name="notifications"
          hint="Applies to this workspace only."
        >
          <Checkbox
            id="notify-deploys"
            name="notify"
            value="deploys"
            defaultChecked
            label="Deployments"
            description="Every production deploy, success or failure."
          />
          <Checkbox
            id="notify-incidents"
            name="notify"
            value="incidents"
            defaultChecked
            label="Incidents"
            description="Paging events and status changes."
          />
          <Checkbox id="notify-digest" name="notify" value="digest" label="Weekly digest" />
          <Checkbox
            id="notify-sms"
            name="notify"
            value="sms"
            disabled
            label="SMS alerts"
            description="Requires a verified phone number."
          />
        </Fieldset>

        <Fieldset legend="Bulk selection" name="bulk">
          <Checkbox
            id="select-all"
            name="select-all"
            indeterminate
            label="12 of 40 rows selected"
          />
        </Fieldset>

        <Fieldset legend="Data regions" name="regions" required error="Select at least one region.">
          <Checkbox
            id="region-us"
            name="regions"
            value="us"
            appearance="card"
            label="United States"
            description="us-east-1, us-west-2"
          />
          <Checkbox
            id="region-eu"
            name="regions"
            value="eu"
            appearance="card"
            label="European Union"
            description="eu-west-1, eu-central-1"
          />
        </Fieldset>
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/checkbox.tsx

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

export default function CheckboxDemo() {
  return (
    <DemoStage>
      <DemoColumn>
        <Fieldset
          legend="Notifications"
          name="notifications"
          hint="Applies to this workspace only."
        >
          <Checkbox
            id="notify-deploys"
            name="notify"
            value="deploys"
            defaultChecked
            label="Deployments"
            description="Every production deploy, success or failure."
          />
          <Checkbox
            id="notify-incidents"
            name="notify"
            value="incidents"
            defaultChecked
            label="Incidents"
            description="Paging events and status changes."
          />
          <Checkbox id="notify-digest" name="notify" value="digest" label="Weekly digest" />
          <Checkbox
            id="notify-sms"
            name="notify"
            value="sms"
            disabled
            label="SMS alerts"
            description="Requires a verified phone number."
          />
        </Fieldset>

        <Fieldset legend="Bulk selection" name="bulk">
          <Checkbox
            id="select-all"
            name="select-all"
            indeterminate
            label="12 of 40 rows selected"
          />
        </Fieldset>

        <Fieldset legend="Data regions" name="regions" required error="Select at least one region.">
          <Checkbox
            id="region-us"
            name="regions"
            value="us"
            appearance="card"
            label="United States"
            description="us-east-1, us-west-2"
          />
          <Checkbox
            id="region-eu"
            name="regions"
            value="eu"
            appearance="card"
            label="European Union"
            description="eu-west-1, eu-central-1"
          />
        </Fieldset>
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/choice.tsx

import type { InputHTMLAttributes, ReactNode } from 'react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Checkbox / Radio / Switch
 *
 * All three keep a real, focusable native input in the DOM and paint the
 * visible control with a sibling element. That keeps `aria-checked`, form
 * submission, `:checked`, `:disabled` and keyboard behaviour native, while
 * still allowing a token-driven appearance.
 *
 * The native input is positioned over the visual control rather than hidden
 * with `display:none`, so the tap target is the full 44px row on touch.
 */

const controlBox = cn(
  'pointer-events-none flex shrink-0 items-center justify-center border transition-colors duration-150 ease-standard',
  'border-line-strong bg-surface text-accent-ink',
  'peer-hover:border-accent',
  'peer-checked:border-accent peer-checked:bg-accent',
  'peer-disabled:opacity-50',
  'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
  'peer-aria-[invalid=true]:border-danger',
  // The tick/dot is a *descendant* of this box, not a sibling of the input, so
  // the peer variant has to reach through with a child selector.
  '[&>*]:opacity-0 peer-checked:[&>*]:opacity-100',
)

const nativeInput = cn(
  'peer absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed',
)

export interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
  label: ReactNode
  description?: ReactNode
  /** `card` turns the whole row into a bordered, selectable surface. */
  appearance?: 'inline' | 'card'
}

export function Checkbox({
  label,
  description,
  appearance = 'inline',
  className,
  id,
  indeterminate,
  ...props
}: ChoiceProps & { indeterminate?: boolean }) {
  return (
    <label
      className={cn(
        'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
        appearance === 'card' &&
          'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        appearance === 'inline' && 'py-1.5',
        className,
      )}
      htmlFor={id}
    >
      <span className="relative flex size-4.5 shrink-0 items-center justify-center">
        <input
          type="checkbox"
          id={id}
          className={nativeInput}
          aria-checked={indeterminate ? 'mixed' : undefined}
          {...props}
        />
        <span className={cn(controlBox, 'size-4.5 rounded-sm')} aria-hidden="true">
          {indeterminate ? (
            <Minus className="size-3" strokeWidth={3} />
          ) : (
            <Check className="size-3" strokeWidth={3} />
          )}
        </span>
      </span>
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
    </label>
  )
}

export function Radio({
  label,
  description,
  appearance = 'inline',
  className,
  id,
  ...props
}: ChoiceProps) {
  return (
    <label
      className={cn(
        'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
        appearance === 'card' &&
          'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        appearance === 'inline' && 'py-1.5',
        className,
      )}
      htmlFor={id}
    >
      <span className="relative flex size-4.5 shrink-0 items-center justify-center">
        <input type="radio" id={id} className={nativeInput} {...props} />
        <span className={cn(controlBox, 'size-4.5 rounded-full')} aria-hidden="true">
          <span className="size-1.5 rounded-full bg-current" />
        </span>
      </span>
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
    </label>
  )
}

export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
  label: ReactNode
  description?: ReactNode
  /** Places the switch on the trailing edge — the settings-row convention. */
  align?: 'leading' | 'trailing'
}

export function Switch({
  label,
  description,
  align = 'leading',
  className,
  id,
  ...props
}: SwitchProps) {
  const control = (
    <span className="relative inline-flex h-5 w-9 shrink-0 items-center">
      <input type="checkbox" role="switch" id={id} className={nativeInput} {...props} />
      <span
        className={cn(
          'pointer-events-none h-5 w-9 rounded-full border border-line-strong bg-surface-sunken transition-colors duration-150 ease-standard',
          'peer-checked:border-accent peer-checked:bg-accent',
          'peer-disabled:opacity-50',
          'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
        )}
        aria-hidden="true"
      />
      <span
        className={cn(
          'pointer-events-none absolute left-0.5 size-4 rounded-full bg-surface shadow-sm transition-transform duration-150 ease-standard',
          'border border-line peer-checked:translate-x-4 peer-checked:border-transparent',
        )}
        aria-hidden="true"
      />
    </span>
  )

  return (
    <label
      className={cn(
        'flex min-h-9 cursor-pointer items-start gap-3 py-1.5 text-sm has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        align === 'trailing' && 'justify-between',
        className,
      )}
      htmlFor={id}
    >
      {align === 'leading' ? control : null}
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
      {align === 'trailing' ? control : null}
    </label>
  )
}

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

For independent options where any number may be chosen. The card appearance turns the whole row into a target, which suits onboarding choices and plan pickers.

  • The native input stays in the DOM and covers the full row, so the tap target is the row, not the 18px box.
  • Indeterminate expresses "some but not all" in a bulk-selection header — it is not a third value to submit.
  • Group related checkboxes in a Fieldset so the group, not each box, carries the error.

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.

  • Inline
  • Card — selectable surface
  • Indeterminate
  • Disabled
  • Invalid via Fieldset

Accessibility

Native control
A real `<input type="checkbox">` provides `:checked`, form submission and screen-reader semantics; only the visuals are custom.
Mixed state
`aria-checked="mixed"` is set for the indeterminate case.
Target size
Rows are at least 36px, and 44px in card appearance.

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.