Skip to content

Newsletter with topics

Topic selection, because choice reduces unsubscribes more than frequency does.

Marketingintermediatenewslettertopicspreferencessubscribe

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 { useState, type FormEvent } from 'react'
import { Mail } from 'lucide-react'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Checkbox } from '@/components/ui/choice'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'

/**
 * Newsletter panel with topic selection
 *
 * Lets people choose what they hear about, which is the single most effective
 * way to reduce unsubscribes. Topics are a checkbox group inside a Fieldset,
 * so the question is announced with the options.
 */
const topics = [
  { id: 'releases', label: 'Releases', description: 'New components and breaking changes.' },
  { id: 'writing', label: 'Writing', description: 'Articles on systems, craft and accessibility.' },
  { id: 'events', label: 'Events', description: 'Workshops and office hours.' },
]

export default function PanelNewsletter() {
  const [email, setEmail] = useState('')
  const [selected, setSelected] = useState<string[]>(['releases'])
  const [error, setError] = useState<string | null>(null)
  const [groupError, setGroupError] = useState<string | null>(null)
  const [done, setDone] = useState(false)

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const value = email.trim()
    let ok = true
    if (!value) {
      setError('Enter an email address.')
      ok = false
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
      setError('Enter a valid email address, for example name@company.com.')
      ok = false
    } else {
      setError(null)
    }
    if (selected.length === 0) {
      setGroupError('Choose at least one topic.')
      ok = false
    } else {
      setGroupError(null)
    }
    if (ok) setDone(true)
  }

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container size="narrow">
        <div className="rounded-2xl border border-line bg-surface p-6 sm:p-10">
          <div className="flex items-center gap-3">
            <span className="flex size-10 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-ink">
              <Mail className="size-5" aria-hidden="true" />
            </span>
            <div>
              <h2 className="display-type text-xl font-semibold text-ink-strong">
                Stay in the loop
              </h2>
              <p className="text-sm text-ink-muted">Choose what you hear about.</p>
            </div>
          </div>

          {done ? (
            <Alert tone="success" title="Subscribed" className="mt-6">
              This is a template demo. No submission was sent.
            </Alert>
          ) : (
            <form onSubmit={submit} noValidate className="mt-6 flex flex-col gap-stack">
              <Field name="newsletter-panel-email" label="Email address" required error={error}>
                {(field) => (
                  <Input
                    {...field}
                    type="email"
                    autoComplete="email"
                    placeholder="you@company.com"
                    value={email}
                    onChange={(event) => {
                      setEmail(event.target.value)
                      if (error) setError(null)
                    }}
                  />
                )}
              </Field>

              <Fieldset legend="Topics" name="newsletter-topics" required error={groupError}>
                {topics.map((topic) => (
                  <Checkbox
                    key={topic.id}
                    id={`topic-${topic.id}`}
                    name="topics"
                    value={topic.id}
                    label={topic.label}
                    description={topic.description}
                    checked={selected.includes(topic.id)}
                    onChange={(event) => {
                      setSelected((current) =>
                        event.target.checked
                          ? [...current, topic.id]
                          : current.filter((item) => item !== topic.id),
                      )
                      setGroupError(null)
                    }}
                  />
                ))}
              </Fieldset>

              <Button type="submit" className="self-start">
                Subscribe
              </Button>
            </form>
          )}
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/newsletter/panel.tsx

'use client'

import { useState, type FormEvent } from 'react'
import { Mail } from 'lucide-react'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Checkbox } from '@/components/ui/choice'
import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'

/**
 * Newsletter panel with topic selection
 *
 * Lets people choose what they hear about, which is the single most effective
 * way to reduce unsubscribes. Topics are a checkbox group inside a Fieldset,
 * so the question is announced with the options.
 */
const topics = [
  { id: 'releases', label: 'Releases', description: 'New components and breaking changes.' },
  { id: 'writing', label: 'Writing', description: 'Articles on systems, craft and accessibility.' },
  { id: 'events', label: 'Events', description: 'Workshops and office hours.' },
]

export default function PanelNewsletter() {
  const [email, setEmail] = useState('')
  const [selected, setSelected] = useState<string[]>(['releases'])
  const [error, setError] = useState<string | null>(null)
  const [groupError, setGroupError] = useState<string | null>(null)
  const [done, setDone] = useState(false)

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const value = email.trim()
    let ok = true
    if (!value) {
      setError('Enter an email address.')
      ok = false
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) {
      setError('Enter a valid email address, for example name@company.com.')
      ok = false
    } else {
      setError(null)
    }
    if (selected.length === 0) {
      setGroupError('Choose at least one topic.')
      ok = false
    } else {
      setGroupError(null)
    }
    if (ok) setDone(true)
  }

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container size="narrow">
        <div className="rounded-2xl border border-line bg-surface p-6 sm:p-10">
          <div className="flex items-center gap-3">
            <span className="flex size-10 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-ink">
              <Mail className="size-5" aria-hidden="true" />
            </span>
            <div>
              <h2 className="display-type text-xl font-semibold text-ink-strong">
                Stay in the loop
              </h2>
              <p className="text-sm text-ink-muted">Choose what you hear about.</p>
            </div>
          </div>

          {done ? (
            <Alert tone="success" title="Subscribed" className="mt-6">
              This is a template demo. No submission was sent.
            </Alert>
          ) : (
            <form onSubmit={submit} noValidate className="mt-6 flex flex-col gap-stack">
              <Field name="newsletter-panel-email" label="Email address" required error={error}>
                {(field) => (
                  <Input
                    {...field}
                    type="email"
                    autoComplete="email"
                    placeholder="you@company.com"
                    value={email}
                    onChange={(event) => {
                      setEmail(event.target.value)
                      if (error) setError(null)
                    }}
                  />
                )}
              </Field>

              <Fieldset legend="Topics" name="newsletter-topics" required error={groupError}>
                {topics.map((topic) => (
                  <Checkbox
                    key={topic.id}
                    id={`topic-${topic.id}`}
                    name="topics"
                    value={topic.id}
                    label={topic.label}
                    description={topic.description}
                    checked={selected.includes(topic.id)}
                    onChange={(event) => {
                      setSelected((current) =>
                        event.target.checked
                          ? [...current, topic.id]
                          : current.filter((item) => item !== topic.id),
                      )
                      setGroupError(null)
                    }}
                  />
                ))}
              </Fieldset>

              <Button type="submit" className="self-start">
                Subscribe
              </Button>
            </form>
          )}
        </div>
      </Container>
    </section>
  )
}

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

Letting people choose what they hear about is the single most effective way to reduce unsubscribes. Topics are a checkbox group inside a Fieldset, so the question is announced with the options.

  • Pre-select the most common topic; an empty form is a decision people postpone.
  • Validate the group, not each checkbox.

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.

  • Topic checkbox group
  • Group validation
  • Success alert

Accessibility

Grouped choices
Topics sit in a Fieldset whose legend is the question.
Group errors
The empty-selection error is announced against the group.

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.