Preferences
Immediate switches alongside saved selects, with the distinction made explicit.
Live preview
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.
Related
All formsNotification settings
An event-by-channel matrix behind a master switch that disables rather than hides.
intermediate4 variantsAccount profile
A pre-filled settings form with a live avatar and a character budget.
starter4 variantsSwitch
An immediate on/off setting, with leading alignment for forms and trailing alignment for settings rows.
starter4 variants