Notification Matrix
A notification preferences grid: events down the side, channels across the top, with whole-column toggles.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/notification-matrix.json'use client'
import { useEffect, useRef, useState } from 'react'
import './notification-matrix.css'
export interface NotificationMatrixProps {
events: { id: string; label: string; hint?: string }[]
channels: { id: string; label: string }[]
value?: Record<string, string[]>
defaultValue?: Record<string, string[]>
onValueChange?: (v: Record<string, string[]>) => void
showHints?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Events × channels notification preferences with autosave status. */
export function NotificationMatrix({ events, channels, value, defaultValue = {}, onValueChange, showHints = true, fill = 'dark', className }: NotificationMatrixProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const [status, setStatus] = useState<'' | 'saving' | 'saved'>('')
const t = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => () => { if (t.current) clearTimeout(t.current) }, [])
const commit = (n: Record<string, string[]>) => {
setInner(n); onValueChange?.(n); setStatus('saving')
if (t.current) clearTimeout(t.current)
t.current = setTimeout(() => setStatus('saved'), 700)
}
const has = (e: string, c: string) => (v[e] ?? []).includes(c)
const toggle = (e: string, c: string) => commit({ ...v, [e]: has(e, c) ? v[e].filter((x) => x !== c) : [...(v[e] ?? []), c] })
const col = (c: string) => {
const all = events.every((e) => has(e.id, c))
const n: Record<string, string[]> = {}
for (const e of events) n[e.id] = all ? (v[e.id] ?? []).filter((x) => x !== c) : [...new Set([...(v[e.id] ?? []), c])]
commit(n)
}
return (
<div className={['sd-nmx', className].filter(Boolean).join(' ')} data-fill={fill}>
<table>
<thead>
<tr>
<th scope="col">Notify me about</th>
{channels.map((c) => {
const on = events.filter((e) => has(e.id, c.id)).length
return (
<th key={c.id} scope="col">
<button type="button" className="sd-nmx__col" aria-pressed={on === events.length} aria-label={`${c.label}: ${on === events.length ? 'all on' : on ? 'some on' : 'all off'}. Toggle all.`} onClick={() => col(c.id)}>{c.label}</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<th scope="row">{e.label}{showHints && e.hint && <small>{e.hint}</small>}</th>
{channels.map((c) => (
<td key={c.id}><input type="checkbox" className="sd-nmx__cb" aria-label={`${e.label} via ${c.label}`} checked={has(e.id, c.id)} onChange={() => toggle(e.id, c.id)} /></td>
))}
</tr>
))}
</tbody>
</table>
<div className="sd-nmx__st" aria-live="polite">{status && <span key={status}>{status === 'saving' ? 'Saving…' : 'Saved'}</span>}</div>
</div>
)
}.sd-nmx { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-nmx[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-nmx button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-nmx :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-nmx .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-nmx { width: min(100%, 340px); padding: 6px 12px 10px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-nmx table { width: 100%; border-collapse: collapse; }
.sd-nmx th { font-weight: 500; }
.sd-nmx thead th { padding: 10px 0 8px; font-size: 12px; color: var(--mute); text-align: center; }
.sd-nmx thead th:first-child { text-align: left; }
.sd-nmx__col { padding: 3px 7px; border-radius: 7px; font-size: 12px; transition: color 150ms, background-color 150ms; }
.sd-nmx__col:hover { color: var(--fg); background: var(--soft) !important; }
.sd-nmx tbody tr + tr td, .sd-nmx tbody tr + tr th { border-top: 1px solid var(--line); }
.sd-nmx tbody th { text-align: left; padding: 10px 6px 10px 0; font-size: 13.5px; }
.sd-nmx tbody th small { display: block; font-size: 11.5px; color: var(--mute); font-weight: 400; margin-top: 1px; }
.sd-nmx td { text-align: center; width: 54px; }
.sd-nmx__cb { appearance: none; margin: 0; width: 20px; height: 20px; border-radius: 6px; box-shadow: inset 0 0 0 1.5px var(--mute); cursor: pointer; display: inline-grid; place-items: center; transition: background-color 150ms, box-shadow 150ms, transform 140ms; vertical-align: middle; }
.sd-nmx__cb:active { transform: scale(0.85); }
.sd-nmx__cb:checked { background: var(--strong); box-shadow: none; }
.sd-nmx__cb::after { content: ''; width: 9px; height: 5px; border-left: 2px solid var(--on); border-bottom: 2px solid var(--on); transform: rotate(-45deg) translate(1px, -1px) scale(0); transition: transform 220ms cubic-bezier(0.3, 1.7, 0.4, 1); }
.sd-nmx__cb:checked::after { transform: rotate(-45deg) translate(1px, -1px) scale(1); }
.sd-nmx__cb:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-nmx__st { height: 18px; margin-top: 6px; font-size: 12px; color: var(--mute); text-align: right; }
.sd-nmx__st span { animation: sd-nmx-in 200ms ease; display: inline-block; }
@keyframes sd-nmx-in { from { opacity: 0; transform: translateY(3px); } }
@media (prefers-reduced-motion: reduce) { .sd-nmx *, .sd-nmx *::before, .sd-nmx *::after { animation: none !important; transition: none !important; } }Overview
Each cell is a compact checkbox. Column headers toggle a whole channel, so “turn off all email” is one click, and their state shows mixed when only some rows are on.
Changes show a small “Saved” confirmation after a short pause instead of a save button.
When to use
- Account and workspace notification settings.
When not to use
- More than four channels on mobile: split by channel instead.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/notification-matrix.json$
npx shadcn@latest add https://sundaeui.dev/r/notification-matrix.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/notification-matrix.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/notification-matrix.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy notification-matrix.tsx and notification-matrix.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { NotificationMatrix } from '@/components/sundae/notification-matrix'
<NotificationMatrix />Props
| Prop | Type | Default |
|---|---|---|
events*Rows. | { id: string; label: string; hint?: string }[] | — |
channels*Columns. | { id: string; label: string }[] | — |
value / defaultValueChannel ids enabled per event. | Record<string, string[]> | — |
onValueChangeCalled on change. | (v: Record<string, string[]>) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A real table: column headers are toggle buttons with aria-pressed (mixed shown in text); cells are checkboxes labelled "Event via Channel".
- Autosave status is a polite live region.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-nmx; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Notification Matrix free?
- Yes. Notification Matrix is free for personal and commercial projects, with full source on this page.
- How do I install Notification Matrix?
- Run npx shadcn@latest add https://sundaeui.dev/r/notification-matrix.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Notification Matrix support keyboard and screen readers?
- A real table: column headers are toggle buttons with aria-pressed (mixed shown in text); cells are checkboxes labelled "Event via Channel". Autosave status is a polite live region.
- Does Notification Matrix respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Settings List
Forms · RefinedDisplay Settings
Forms · RefinedCheckbox Tree
Forms · Refined- Pro
Shortcut Recorder
Forms · Refined Multi Select
Forms · RefinedInvite Members
Forms · Refined