Newsletter with topics
Topic selection, because choice reduces unsubscribes more than frequency does.
Live preview
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.
Related
All sectionsInline newsletter
One row: heading, field and button, with the privacy line in the pattern.
starter3 variantsNotification settings
An event-by-channel matrix behind a master switch that disables rather than hides.
intermediate4 variantsNewsletter with back issues
Three recent issues shown as real links beside the form.
starter3 variants