Notification settings
An event-by-channel matrix behind a master switch that disables rather than hides.
Live preview
Source
This exact file renders the preview above.
'use client'
import { Checkbox, Switch } from '@/components/ui/choice'
import { Fieldset } from '@/components/ui/field'
import { Panel } from '@/components/ui/card'
import { Select } from '@/components/ui/select'
import { Field } from '@/components/ui/field'
import { Alert } from '@/components/ui/alert'
import { useDemoForm } from '@/hooks/use-demo-form'
import { FormShell } from './_shell'
/**
* Notification settings
*
* A matrix of event × channel. The master switch disables the whole grid
* rather than hiding it, so a user can see what they have turned off — hiding
* the settings makes the state unrecoverable without toggling back.
*/
const events = [
{
id: 'deploys',
label: 'Deployments',
description: 'Every production deploy, success or failure.',
},
{ id: 'incidents', label: 'Incidents', description: 'Paging events and status changes.' },
{ id: 'mentions', label: 'Mentions', description: 'When someone mentions you in a comment.' },
{ id: 'billing', label: 'Billing', description: 'Invoices, failed payments and plan changes.' },
]
export default function NotificationsForm() {
const form = useDemoForm({
schema: {
enabled: { initial: 'true' },
digest: { initial: 'daily' },
emailEvents: { initial: 'deploys,incidents,billing' },
inAppEvents: { initial: 'deploys,incidents,mentions' },
},
latency: 600,
})
const enabled = form.values.enabled === 'true'
const emailSet = new Set((form.values.emailEvents ?? '').split(',').filter(Boolean))
const inAppSet = new Set((form.values.inAppEvents ?? '').split(',').filter(Boolean))
const toggle = (name: 'emailEvents' | 'inAppEvents', id: string, isChecked: boolean) => {
const set = new Set((form.values[name] ?? '').split(',').filter(Boolean))
if (isChecked) set.add(id)
else set.delete(id)
form.setValue(name, Array.from(set).join(','))
}
return (
<FormShell
title="Notifications"
description="Choose what reaches you, and where."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Save notification settings"
submittingLabel="Saving"
onReset={form.reset}
successTitle="Notification settings saved"
width="lg"
>
<Switch
id="notify-master"
name="enabled"
align="trailing"
checked={enabled}
onChange={(event) => form.setValue('enabled', String(event.target.checked))}
label="Send me notifications"
description="Turning this off silences everything below without losing your choices."
/>
{!enabled ? (
<Alert tone="warning" title="All notifications are paused">
Your per-event choices are kept and will apply again when you turn notifications back on.
</Alert>
) : null}
<Panel
title="By event"
description="Each event can go to email, the in-app inbox, or both."
headingLevel="h4"
flush
>
<div className="divide-y divide-[var(--color-border-subtle)]">
{events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium text-ink">{event.label}</p>
<p className="mt-0.5 text-xs text-ink-muted">{event.description}</p>
</div>
<Fieldset
legend={`${event.label} channels`}
name={`channels-${event.id}`}
className="shrink-0 sm:w-56"
>
<div className="flex gap-5">
<Checkbox
id={`email-${event.id}`}
name={`email-${event.id}`}
label="Email"
disabled={!enabled}
checked={emailSet.has(event.id)}
onChange={(e) => toggle('emailEvents', event.id, e.target.checked)}
/>
<Checkbox
id={`inapp-${event.id}`}
name={`inapp-${event.id}`}
label="In app"
disabled={!enabled}
checked={inAppSet.has(event.id)}
onChange={(e) => toggle('inAppEvents', event.id, e.target.checked)}
/>
</div>
</Fieldset>
</div>
))}
</div>
</Panel>
<Field
name="digest"
label="Email digest"
hint="Groups low-priority notifications into one message."
>
{(field) => (
<Select
{...field}
{...form.field('digest')}
disabled={!enabled}
options={[
{ value: 'off', label: 'Off — send each notification separately' },
{ value: 'daily', label: 'Daily at 09:00' },
{ value: 'weekly', label: 'Weekly on Monday' },
]}
/>
)}
</Field>
</FormShell>
)
}
components/blocks/forms/notifications.tsx
'use client'
import { Checkbox, Switch } from '@/components/ui/choice'
import { Fieldset } from '@/components/ui/field'
import { Panel } from '@/components/ui/card'
import { Select } from '@/components/ui/select'
import { Field } from '@/components/ui/field'
import { Alert } from '@/components/ui/alert'
import { useDemoForm } from '@/hooks/use-demo-form'
import { FormShell } from './_shell'
/**
* Notification settings
*
* A matrix of event × channel. The master switch disables the whole grid
* rather than hiding it, so a user can see what they have turned off — hiding
* the settings makes the state unrecoverable without toggling back.
*/
const events = [
{
id: 'deploys',
label: 'Deployments',
description: 'Every production deploy, success or failure.',
},
{ id: 'incidents', label: 'Incidents', description: 'Paging events and status changes.' },
{ id: 'mentions', label: 'Mentions', description: 'When someone mentions you in a comment.' },
{ id: 'billing', label: 'Billing', description: 'Invoices, failed payments and plan changes.' },
]
export default function NotificationsForm() {
const form = useDemoForm({
schema: {
enabled: { initial: 'true' },
digest: { initial: 'daily' },
emailEvents: { initial: 'deploys,incidents,billing' },
inAppEvents: { initial: 'deploys,incidents,mentions' },
},
latency: 600,
})
const enabled = form.values.enabled === 'true'
const emailSet = new Set((form.values.emailEvents ?? '').split(',').filter(Boolean))
const inAppSet = new Set((form.values.inAppEvents ?? '').split(',').filter(Boolean))
const toggle = (name: 'emailEvents' | 'inAppEvents', id: string, isChecked: boolean) => {
const set = new Set((form.values[name] ?? '').split(',').filter(Boolean))
if (isChecked) set.add(id)
else set.delete(id)
form.setValue(name, Array.from(set).join(','))
}
return (
<FormShell
title="Notifications"
description="Choose what reaches you, and where."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Save notification settings"
submittingLabel="Saving"
onReset={form.reset}
successTitle="Notification settings saved"
width="lg"
>
<Switch
id="notify-master"
name="enabled"
align="trailing"
checked={enabled}
onChange={(event) => form.setValue('enabled', String(event.target.checked))}
label="Send me notifications"
description="Turning this off silences everything below without losing your choices."
/>
{!enabled ? (
<Alert tone="warning" title="All notifications are paused">
Your per-event choices are kept and will apply again when you turn notifications back on.
</Alert>
) : null}
<Panel
title="By event"
description="Each event can go to email, the in-app inbox, or both."
headingLevel="h4"
flush
>
<div className="divide-y divide-[var(--color-border-subtle)]">
{events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium text-ink">{event.label}</p>
<p className="mt-0.5 text-xs text-ink-muted">{event.description}</p>
</div>
<Fieldset
legend={`${event.label} channels`}
name={`channels-${event.id}`}
className="shrink-0 sm:w-56"
>
<div className="flex gap-5">
<Checkbox
id={`email-${event.id}`}
name={`email-${event.id}`}
label="Email"
disabled={!enabled}
checked={emailSet.has(event.id)}
onChange={(e) => toggle('emailEvents', event.id, e.target.checked)}
/>
<Checkbox
id={`inapp-${event.id}`}
name={`inapp-${event.id}`}
label="In app"
disabled={!enabled}
checked={inAppSet.has(event.id)}
onChange={(e) => toggle('inAppEvents', event.id, e.target.checked)}
/>
</div>
</Fieldset>
</div>
))}
</div>
</Panel>
<Field
name="digest"
label="Email digest"
hint="Groups low-priority notifications into one message."
>
{(field) => (
<Select
{...field}
{...form.field('digest')}
disabled={!enabled}
options={[
{ value: 'off', label: 'Off — send each notification separately' },
{ value: 'daily', label: 'Daily at 09:00' },
{ value: 'weekly', label: 'Weekly on Monday' },
]}
/>
)}
</Field>
</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
The master switch disables the whole grid rather than hiding it, so a user can see what they have turned off. Hiding the settings makes the state unrecoverable without toggling back.
- Per-event choices are preserved while paused, and the copy says so.
- Each event row is a Fieldset, so “Email” and “In app” are announced against the right event.
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.
- Master switch
- Event by channel grid
- Paused state
- Digest select
Accessibility
- Grouped channels
- Every row’s checkboxes are wrapped in a Fieldset naming the event.
- Disabled not hidden
- Paused controls stay visible and are marked disabled.
- Paused explanation
- A warning Alert explains what the paused state means.
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 formsPreferences
Immediate switches alongside saved selects, with the distinction made explicit.
starter3 variantsSwitch
An immediate on/off setting, with leading alignment for forms and trailing alignment for settings rows.
starter4 variantsSecurity settings
Password change, two-factor management and a session list with a guarded bulk action.
advancedFeatured4 variants