Home/ Cookbook/ Settings/ Notification preferences

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.

Settings 3 primitives · 1 block · 1 pattern ~35 min React · @corelithzw/react

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.

EventEmailPushAppSMS
Order placed
Refund issued
Low stock
Mention
Weekly report
01 Matrix — events × channels
Pause notifications
All channels go quiet for the next…
1 h
2 h
4 h
24 h
Paused until 15:42
02 Pause — quick durations
Quiet hours
Don't disturb between
22:00
06:00
Critical alerts still ring through.
03 Quiet hours — daily window

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

01

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.

Code-only step

pauseUntil: ISO string or null

Flat keys diff cleanly. Audit logs one row per toggle.

Switch to the Code tab to see the snippet.

Step 1 · Model@corelithzw/react
02

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.

Event
Email
Push
App
SMS
Order placed
When a sale rings up
Refund issuedCritical
Step 2 · Matrix table
03

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.

1 h
2 h
4 h
Tomorrow
Step 3 · Master pause
04

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.

Saved
Push notifications for "Low stock" turned on
Step 4 · Persist per toggle

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.

Pause notifications
All channels go quiet. Critical alerts still ring.
1 h
2 h
4 h
Until tomorrow
What sends to where
Event
Email
Push
App
SMS
Order placed
NotificationPreferences.tsx@corelithzw/react

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.