Skip to content

Notification settings

An event-by-channel matrix behind a master switch that disables rather than hides.

Profileintermediatenotificationsmatrixchannelsdigest

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 { 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.

All forms