Build a notification preferences matrix
By the end of this recipe you'll have a settings page that lets a user pick which event types reach them on which channels (Email · Push · In-app · SMS), with a master "Pause for X hours" toggle, quiet-hours schedule, and a matrix where rows are events and columns are channels — saved per change without a Save button.
Overview
A grid of switches. Rows are events ("Order placed", "Refund issued"). Columns are channels. Each cell is one boolean.
Notification settings are a tax on the user; the goal is to keep that tax small. A flat matrix beats a wizard because the user can see every choice at once and find their event without scrolling-and-toggling. The recipe persists each switch as you click — no Save button — and offers a master "Pause all" plus quiet hours for the common cases ("I'm asleep" / "I'm in the field"). Granularity is the win; channel-level defaults are an opt-in opinion you make, not a switch the user has to find.
Notification settings should NEVER have a "Resubscribe to marketing emails" trap. Granular by default; subscribing is opt-in only. The dark pattern of pre-ticking marketing categories is the single fastest way to lose trust.
Skip this recipe if your app has one channel and three notifications. A single switch beats a matrix; matrices are for apps that have grown enough to feel noisy.
What you'll build
Three screens: the matrix, the master pause, and quiet hours.
Required pieces
A small surface. Most of the work is the data model.
@corelithzw/react exports used here: NotificationMatrix, usePreferences, Switch, Button, Input, Stack, Card, Toast, useToast.
Step-by-step build
Model preferences as one normalised record
Events × channels is a 2D matrix; store it as a flat Record<`${eventId}:${channel}`, boolean>. That key shape makes diffing trivial ("which event:channel pairs changed?") and the API surface is one field name per row in the audit table. Don't model the matrix as nested objects — you'll re-write the persistence code three times before you get it right.
pauseUntil: ISO string or null
Flat keys diff cleanly. Audit logs one row per toggle.
Switch to the Code tab to see the snippet.
Render the matrix as a real table
A real <table> with <th scope="col"> for the channels and <th scope="row"> for the events gives you free a11y. Each cell holds a Switch with an aria-label like "Email when order is placed". The toggle dispatches and persists in one go.
Add the master pause with quick durations
The most-requested feature on a notification page is "shut up for the next hour". Provide preset durations (1h, 2h, 4h, 24h) and a "Until I turn it back on". Compute the resume time once on click; don't store a duration and recompute every render.
Pause notifications
All channels go quiet for the chosen window. Critical alerts still ring.
Persist per toggle — no Save button
Each toggle calls PATCH /me/preferences with the single delta. Show a tiny "Saved" toast on success; rollback the optimistic value on failure. No Save button means the user never wonders "did this take effect?" — closing the tab is always safe.
Final composition
The whole settings page — matrix, pause, quiet hours, optimistic persistence.
Notifications
Choose what reaches you and how. Changes save as you flip them.
Variations
Three forks. Each is a small diff from the final composition.
Per-role defaults
Admins should hear about refunds; cashiers should not. Seed the matrix from the user's role before merging their personal overrides.
const role = currentUser.role;
const seeded = {
...ROLE_DEFAULTS[role],
...storedPrefs.matrix,
};
return <NotificationPreferences
initial={{ ...storedPrefs,
matrix: seeded }} />;
Channel ordering by preference
If a user never picks SMS, push it to the rightmost column. Reorder the CHANNELS array by usage count.
const usage = useChannelUsage();
const ordered = [...CHANNELS]
.sort((a, b) =>
(usage[b.id] ?? 0) - (usage[a.id] ?? 0));
// Pass `ordered` into the table head.
Bulk "Off for this column"
Click a column header to turn that channel off for every event. Single dispatch, batched patch.
const offAll = (c: Channel) => {
EVENTS.forEach((e) => dispatch({
type: 'TOGGLE',
eventId: e.id, channel: c,
value: false,
}));
};
Accessibility
A matrix is the worst-case for screen readers if you build it as a div-grid. Use a real table and the work is done.
- Real
<table>with scope.<th scope="col">on channels,<th scope="row">on events — screen readers read "Email, Order placed, on" without you naming each cell. - Switches carry
aria-label. Each label combines the channel and event so the announcement is unambiguous: "SMS when refund issued, off". - Critical events are marked. A visible tag and an SR-only "Critical event" string — colour is never the only signal.
- "Saved" toast is live but quiet. A polite live region announces "Saved" after each toggle without stealing focus.
- Pause state is announced. The pause card carries
aria-live="polite"so flipping to paused is read. - Quiet-hours inputs have real labels. "Quiet hours start" and "Quiet hours end" — never relying on a visible "to" between two unlabelled inputs.
- Caption explains the grid. A visually-hidden
<caption>says "Rows are events, columns are channels" so SR users know the structure on enter.