Skip to content

Preferences

Immediate switches alongside saved selects, with the distinction made explicit.

Profilestarterpreferencessettingslocaleinterface

Live preview

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

Source

This exact file renders the preview above.

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Radio, Switch } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { Panel } from '@/components/ui/card'
import { useDemoForm } from '@/hooks/use-demo-form'
import { FormShell } from './_shell'

/**
 * Preferences
 *
 * Every control here is a preference with an immediate meaning, so switches
 * are correct — a switch promises the change applies now. The Save button
 * exists for the select and radio settings, which do not.
 */
export default function PreferencesForm() {
  const form = useDemoForm({
    schema: {
      language: { initial: 'en-GB' },
      startPage: { initial: 'overview' },
      dateFormat: { initial: 'dmy' },
      reduceMotion: { initial: 'false' },
      compactTables: { initial: 'true' },
      keyboardHints: { initial: 'true' },
    },
    latency: 600,
  })

  const bool = (name: string) => form.values[name] === 'true'

  return (
    <FormShell
      title="Preferences"
      description="How the product behaves for you. Nothing here affects other members."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Save preferences"
      submittingLabel="Saving"
      onReset={form.reset}
      successTitle="Preferences saved"
      width="lg"
    >
      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="language" label="Language">
          {(field) => (
            <Select
              {...field}
              {...form.field('language')}
              options={[
                { value: 'en-GB', label: 'English (United Kingdom)' },
                { value: 'en-US', label: 'English (United States)' },
                { value: 'de-DE', label: 'Deutsch' },
                { value: 'fr-FR', label: 'Français' },
                { value: 'ja-JP', label: '日本語' },
              ]}
            />
          )}
        </Field>
        <Field name="startPage" label="Start page" hint="Where you land after signing in.">
          {(field) => (
            <Select
              {...field}
              {...form.field('startPage')}
              options={[
                { value: 'overview', label: 'Overview' },
                { value: 'projects', label: 'Projects' },
                { value: 'activity', label: 'Activity' },
              ]}
            />
          )}
        </Field>
      </div>

      <Fieldset legend="Date format" name="dateFormat">
        <Radio
          id="date-dmy"
          name="dateFormat"
          value="dmy"
          label="14 March 2026"
          checked={form.values.dateFormat === 'dmy'}
          onChange={() => form.setValue('dateFormat', 'dmy')}
        />
        <Radio
          id="date-mdy"
          name="dateFormat"
          value="mdy"
          label="March 14, 2026"
          checked={form.values.dateFormat === 'mdy'}
          onChange={() => form.setValue('dateFormat', 'mdy')}
        />
        <Radio
          id="date-iso"
          name="dateFormat"
          value="iso"
          label="2026-03-14"
          checked={form.values.dateFormat === 'iso'}
          onChange={() => form.setValue('dateFormat', 'iso')}
        />
      </Fieldset>

      <Panel
        title="Interface"
        description="These apply as soon as you change them."
        headingLevel="h4"
        flush
      >
        <div className="divide-y divide-[var(--color-border-subtle)]">
          <div className="px-4">
            <Switch
              id="pref-motion"
              name="reduceMotion"
              align="trailing"
              checked={bool('reduceMotion')}
              onChange={(event) => form.setValue('reduceMotion', String(event.target.checked))}
              label="Reduce motion"
              description="Overrides animation regardless of your system setting."
            />
          </div>
          <div className="px-4">
            <Switch
              id="pref-tables"
              name="compactTables"
              align="trailing"
              checked={bool('compactTables')}
              onChange={(event) => form.setValue('compactTables', String(event.target.checked))}
              label="Compact tables"
              description="More rows per screen, smaller row height."
            />
          </div>
          <div className="px-4">
            <Switch
              id="pref-hints"
              name="keyboardHints"
              align="trailing"
              checked={bool('keyboardHints')}
              onChange={(event) => form.setValue('keyboardHints', String(event.target.checked))}
              label="Show keyboard hints"
              description="Displays shortcut keys next to menu items."
            />
          </div>
        </div>
      </Panel>
    </FormShell>
  )
}

components/blocks/forms/preferences.tsx

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Radio, Switch } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { Panel } from '@/components/ui/card'
import { useDemoForm } from '@/hooks/use-demo-form'
import { FormShell } from './_shell'

/**
 * Preferences
 *
 * Every control here is a preference with an immediate meaning, so switches
 * are correct — a switch promises the change applies now. The Save button
 * exists for the select and radio settings, which do not.
 */
export default function PreferencesForm() {
  const form = useDemoForm({
    schema: {
      language: { initial: 'en-GB' },
      startPage: { initial: 'overview' },
      dateFormat: { initial: 'dmy' },
      reduceMotion: { initial: 'false' },
      compactTables: { initial: 'true' },
      keyboardHints: { initial: 'true' },
    },
    latency: 600,
  })

  const bool = (name: string) => form.values[name] === 'true'

  return (
    <FormShell
      title="Preferences"
      description="How the product behaves for you. Nothing here affects other members."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Save preferences"
      submittingLabel="Saving"
      onReset={form.reset}
      successTitle="Preferences saved"
      width="lg"
    >
      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="language" label="Language">
          {(field) => (
            <Select
              {...field}
              {...form.field('language')}
              options={[
                { value: 'en-GB', label: 'English (United Kingdom)' },
                { value: 'en-US', label: 'English (United States)' },
                { value: 'de-DE', label: 'Deutsch' },
                { value: 'fr-FR', label: 'Français' },
                { value: 'ja-JP', label: '日本語' },
              ]}
            />
          )}
        </Field>
        <Field name="startPage" label="Start page" hint="Where you land after signing in.">
          {(field) => (
            <Select
              {...field}
              {...form.field('startPage')}
              options={[
                { value: 'overview', label: 'Overview' },
                { value: 'projects', label: 'Projects' },
                { value: 'activity', label: 'Activity' },
              ]}
            />
          )}
        </Field>
      </div>

      <Fieldset legend="Date format" name="dateFormat">
        <Radio
          id="date-dmy"
          name="dateFormat"
          value="dmy"
          label="14 March 2026"
          checked={form.values.dateFormat === 'dmy'}
          onChange={() => form.setValue('dateFormat', 'dmy')}
        />
        <Radio
          id="date-mdy"
          name="dateFormat"
          value="mdy"
          label="March 14, 2026"
          checked={form.values.dateFormat === 'mdy'}
          onChange={() => form.setValue('dateFormat', 'mdy')}
        />
        <Radio
          id="date-iso"
          name="dateFormat"
          value="iso"
          label="2026-03-14"
          checked={form.values.dateFormat === 'iso'}
          onChange={() => form.setValue('dateFormat', 'iso')}
        />
      </Fieldset>

      <Panel
        title="Interface"
        description="These apply as soon as you change them."
        headingLevel="h4"
        flush
      >
        <div className="divide-y divide-[var(--color-border-subtle)]">
          <div className="px-4">
            <Switch
              id="pref-motion"
              name="reduceMotion"
              align="trailing"
              checked={bool('reduceMotion')}
              onChange={(event) => form.setValue('reduceMotion', String(event.target.checked))}
              label="Reduce motion"
              description="Overrides animation regardless of your system setting."
            />
          </div>
          <div className="px-4">
            <Switch
              id="pref-tables"
              name="compactTables"
              align="trailing"
              checked={bool('compactTables')}
              onChange={(event) => form.setValue('compactTables', String(event.target.checked))}
              label="Compact tables"
              description="More rows per screen, smaller row height."
            />
          </div>
          <div className="px-4">
            <Switch
              id="pref-hints"
              name="keyboardHints"
              align="trailing"
              checked={bool('keyboardHints')}
              onChange={(event) => form.setValue('keyboardHints', String(event.target.checked))}
              label="Show keyboard hints"
              description="Displays shortcut keys next to menu items."
            />
          </div>
        </div>
      </Panel>
    </FormShell>
  )
}

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/card.tsx

import type { HTMLAttributes, ReactNode, ElementType } from 'react'
import { cn } from '@/lib/cn'

/**
 * Card & Panel
 *
 * Two containment primitives with deliberately different jobs:
 *
 *   Card  — a discrete, often interactive record in a collection.
 *   Panel — a titled region of a page, with an optional header action row.
 *
 * Keeping them separate is what stops the library from degenerating into
 * "everything is a rounded box with a shadow".
 */

export interface CardProps extends HTMLAttributes<HTMLDivElement> {
  as?: ElementType
  /** Forwarded when `as` renders a link. */
  href?: string
  /** `outline` is the default; `raised` adds elevation; `sunken` insets. */
  tone?: 'outline' | 'raised' | 'sunken' | 'ghost' | 'accent'
  /** Adds hover affordance. Only use when the whole card is a link/button. */
  interactive?: boolean
  padding?: 'none' | 'sm' | 'md' | 'lg'
}

const cardTones = {
  outline: 'bg-surface border border-line',
  raised: 'bg-surface-raised border border-line shadow-sm',
  sunken: 'bg-surface-sunken border border-line-subtle',
  ghost: 'bg-transparent border border-transparent',
  accent: 'bg-accent-soft border border-accent-line',
} as const

const cardPadding = {
  none: 'p-0',
  sm: 'p-3',
  md: 'p-card',
  lg: 'p-6 sm:p-8',
} as const

export function Card({
  as: Tag = 'div',
  tone = 'outline',
  interactive = false,
  padding = 'md',
  className,
  children,
  ...props
}: CardProps) {
  return (
    <Tag
      className={cn(
        'rounded-lg',
        cardTones[tone],
        cardPadding[padding],
        interactive &&
          'transition-[border-color,box-shadow,background-color] duration-150 ease-standard hover:border-line-strong hover:shadow-sm',
        className,
      )}
      {...props}
    >
      {children}
    </Tag>
  )
}

export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div className={cn('flex items-start justify-between gap-4', className)} {...props}>
      {children}
    </div>
  )
}

export function CardTitle({
  as: Tag = 'h3',
  className,
  children,
  ...props
}: HTMLAttributes<HTMLHeadingElement> & { as?: ElementType }) {
  return (
    <Tag className={cn('text-md leading-snug font-semibold text-ink-strong', className)} {...props}>
      {children}
    </Tag>
  )
}

export function CardDescription({
  className,
  children,
  ...props
}: HTMLAttributes<HTMLParagraphElement>) {
  return (
    <p className={cn('text-sm leading-normal text-ink-muted', className)} {...props}>
      {children}
    </p>
  )
}

export function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn(
        'mt-4 flex flex-wrap items-center gap-3 border-t border-line-subtle pt-4',
        className,
      )}
      {...props}
    >
      {children}
    </div>
  )
}

export interface PanelProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
  title: ReactNode
  description?: ReactNode
  /** Rendered on the right of the panel header. */
  action?: ReactNode
  /** Removes body padding — for tables and lists that manage their own. */
  flush?: boolean
  as?: ElementType
  headingLevel?: 'h2' | 'h3' | 'h4'
}

export function Panel({
  title,
  description,
  action,
  flush = false,
  as: Tag = 'section',
  headingLevel: Heading = 'h3',
  className,
  children,
  ...props
}: PanelProps) {
  return (
    <Tag
      className={cn('overflow-hidden rounded-lg border border-line bg-surface', className)}
      {...props}
    >
      <div className="flex flex-wrap items-start justify-between gap-3 border-b border-line-subtle bg-surface-sunken px-4 py-3">
        <div className="min-w-0">
          <Heading className="text-sm font-semibold text-ink-strong">{title}</Heading>
          {description ? <p className="mt-0.5 text-xs text-ink-muted">{description}</p> : null}
        </div>
        {action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
      </div>
      <div className={cn(flush ? '' : 'p-card')}>{children}</div>
    </Tag>
  )
}

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

Usage

Switches promise the change applies now; selects and radios do not. Mixing them on one page is fine as long as the Save button clearly belongs to the second group.

  • Interface toggles are grouped into a panel labelled “apply as soon as you change them”.
  • Date format options show the actual formatted date rather than naming the pattern.

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.

  • Language and start page
  • Date format radios
  • Interface switches

Accessibility

Switch semantics
Each toggle is a native checkbox with a switch role.
Format examples
Options display real examples, so the choice needs no explanation.

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.

All forms