Events by channels, column select-alls that ripple, and chip cards on phones.
01Preview
Notification preferences
Notify me aboutAll events
Push · Blocked in this browser.
Activity
MentionsSomeone @mentions you in an issue, comment or doc
Assigned to youAn issue or review request lands on your plate
Deploys
Production deploy failedIncludes the failing step and a link to the logs
Preview ready
Email not available
Account
Security alertsNew sign-ins, password and 2FA changesSecurity alerts always go to your email
Product updatesRelease notes, about twice a month
Push not available
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react motion03Usage
import { NotificationMatrix } from "@/components/ui/notification-matrix";
<NotificationMatrix
groups={[
{
id: "activity",
label: "Activity",
events: [
{ id: "mentions", label: "Mentions", description: "Someone @mentions you" },
{ id: "security", label: "Security alerts", required: ["email"] },
],
},
]}
defaultValue={{ mentions: ["email", "in-app"] }}
onValueChange={(value) => save(value)}
/>04Source
"use client";
import { Checkbox } from "@base-ui/react/checkbox";
import { Tooltip } from "@base-ui/react/tooltip";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useState } from "react";
import { cn } from "@/lib/cn";
import { Bell, Lock, Mail } from "@/lib/icons";
import { ease } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type NotificationChannel = {
id: string;
label: string;
/** A 14px glyph beside the label in the header. */
icon?: React.ReactNode;
/** The whole channel can't be changed right now, e.g. push is blocked in this browser. Values are kept. */
disabled?: boolean;
/** A short line under the channel: why it's off, or a button to turn it on. */
hint?: React.ReactNode;
};
export type NotificationEvent = {
id: string;
label: string;
description?: string;
/** Channels this event is never sent on. Drawn as a dash, skipped by select-all. */
unsupported?: string[];
/** Channels that are always on for this event, like security alerts by email. */
required?: string[];
/** Why the required channels can't be turned off. */
requiredReason?: string;
};
export type NotificationGroup = { id: string; label: string; events: NotificationEvent[] };
/** Event id → the channel ids it's delivered on. Required channels are always included. */
export type NotificationMatrixValue = Record<string, string[]>;
function Phone({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden focusable="false">
<rect x="4.25" y="1.75" width="7.5" height="12.5" rx="1.75" />
<path d="M7 11.75h2" />
</svg>
);
}
export const defaultNotificationChannels: NotificationChannel[] = [
{ id: "email", label: "Email", icon: <Mail size={14} /> },
{ id: "push", label: "Push", icon: <Phone size={14} /> },
{ id: "in-app", label: "In-app", icon: <Bell size={14} /> },
];
/* ------------------------------------------------------------------ */
/* NotificationMatrix */
/* ------------------------------------------------------------------ */
export type NotificationMatrixProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
groups: NotificationGroup[];
channels?: NotificationChannel[];
value?: NotificationMatrixValue;
defaultValue?: NotificationMatrixValue;
onValueChange?: (value: NotificationMatrixValue) => void;
disabled?: boolean;
/** Heading over the event column on wide layouts. */
eventsLabel?: string;
};
type Wave = { channel: string; on: boolean; order: Map<string, number> } | null;
export function NotificationMatrix({
groups,
channels = defaultNotificationChannels,
value: valueProp,
defaultValue = {},
onValueChange,
disabled = false,
eventsLabel = "Notify me about",
className,
style,
onKeyDown,
...rest
}: NotificationMatrixProps) {
const uid = useId();
const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
const [wave, setWave] = useState<Wave>(null);
const [previewCol, setPreviewCol] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState("");
// A column toggle ripples down the column once, then later single toggles are instant again.
useEffect(() => {
if (!wave) return;
const t = window.setTimeout(() => setWave(null), 500);
return () => window.clearTimeout(t);
}, [wave]);
const events = groups.flatMap((g) => g.events);
const channelById = new Map(channels.map((c) => [c.id, c]));
const supports = (ev: NotificationEvent, ch: string) => !ev.unsupported?.includes(ch);
const isRequired = (ev: NotificationEvent, ch: string) => !!ev.required?.includes(ch);
// A cell select-all speaks for: supported and not locked. A blocked channel still reports its state.
const choosable = (ev: NotificationEvent, ch: string) => supports(ev, ch) && !isRequired(ev, ch);
const isOn = (ev: NotificationEvent, ch: string) => isRequired(ev, ch) || !!value[ev.id]?.includes(ch);
const withChannel = (ev: NotificationEvent, ch: string, on: boolean, from: NotificationMatrixValue) => {
const set = new Set(from[ev.id] ?? []);
ev.required?.forEach((r) => set.add(r));
if (on) set.add(ch);
else set.delete(ch);
// Keep channel order stable so the value diffs cleanly.
return channels.map((c) => c.id).filter((id) => set.has(id));
};
const toggleCell = (ev: NotificationEvent, ch: string, on: boolean) => {
setWave(null);
setValue({ ...value, [ev.id]: withChannel(ev, ch, on, value) });
};
const column = (ch: string) => {
const cells = events.filter((ev) => choosable(ev, ch));
const on = cells.filter((ev) => isOn(ev, ch)).length;
return { cells, on, total: cells.length, state: on === 0 ? "off" : on === cells.length ? "on" : "mixed" } as const;
};
const toggleColumn = (ch: string) => {
const { cells, state } = column(ch);
const on = state !== "on";
const next = { ...value };
const order = new Map<string, number>();
let i = 0;
for (const ev of cells) {
if (isOn(ev, ch) !== on) order.set(ev.id, i++);
next[ev.id] = withChannel(ev, ch, on, next);
}
setWave({ channel: ch, on, order });
setValue(next);
const label = channelById.get(ch)?.label ?? ch;
setAnnouncement(`${label} ${on ? "on" : "off"} for ${cells.length} ${cells.length === 1 ? "event" : "events"}`);
};
// Arrow keys move between checkboxes like a grid: up and down within a channel, left and right within an event.
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
const dirs: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
const d = dirs[e.key];
const from = (e.target as HTMLElement).closest<HTMLElement>("[data-matrix-cell]");
if (!d || !from) return;
const cells = Array.from(e.currentTarget.querySelectorAll<HTMLElement>("[data-matrix-cell]:not([data-disabled])")).filter((el) => el.offsetParent !== null);
const r = Number(from.dataset.row);
const c = Number(from.dataset.col);
const candidates = cells
.map((el) => ({ el, r: Number(el.dataset.row), c: Number(el.dataset.col) }))
.filter((p) => (d[0] ? p.c === c && Math.sign(p.r - r) === d[0] : p.r === r && Math.sign(p.c - c) === d[1]))
.sort((a, b) => Math.abs(a.r - r) + Math.abs(a.c - c) - (Math.abs(b.r - r) + Math.abs(b.c - c)));
if (candidates[0]) {
e.preventDefault();
candidates[0].el.focus();
}
};
const cols = `minmax(0,1fr) repeat(${channels.length}, 4.75rem)`;
let row = 0;
// One DOM for both layouts: below 30rem of container width (the @max-[30rem] classes)
// each event stacks over a row of channel chips and the header becomes chips too.
return (
<Tooltip.Provider delay={400}>
<div
role="group"
aria-labelledby={`${uid}-label`}
data-disabled={disabled || undefined}
className={cn("@container w-full min-w-0", className)}
style={{ ...style, ["--matrix-cols" as string]: cols }}
onKeyDown={handleKeyDown}
{...rest}
>
<span id={`${uid}-label`} className="sr-only">
Notification preferences
</span>
{/* overflow-clip rounds the corners without becoming a scroll container, so the header can still stick. */}
<div className="overflow-clip rounded-xl border border-line bg-raised shadow-[var(--shadow)]">
{/* Header: one select-all per channel, stacked over its column. */}
<div
className={cn(
"sticky top-(--matrix-sticky-top,0px) z-(--z-sticky) grid border-b border-line bg-raised pr-2 [grid-template-columns:var(--matrix-cols)]",
"@max-[30rem]:flex @max-[30rem]:flex-col @max-[30rem]:gap-2 @max-[30rem]:px-4 @max-[30rem]:py-3",
)}
>
<div className={cn("flex items-end px-4 pb-2.5 pt-3", "@max-[30rem]:p-0")}>
<span className="font-mono text-2xs uppercase tracking-[0.08em] text-fg-3">
<span className={"@max-[30rem]:hidden"}>{eventsLabel}</span>
<span className={"hidden @max-[30rem]:inline"}>All events</span>
</span>
</div>
<div className={cn("contents", "@max-[30rem]:flex @max-[30rem]:flex-wrap @max-[30rem]:gap-1.5")}>
{channels.map((ch, c) => {
const col = column(ch.id);
const off = disabled || ch.disabled || col.total === 0;
return (
<label
key={ch.id}
data-preview={previewCol === ch.id || undefined}
onPointerEnter={(e) => e.pointerType === "mouse" && !off && setPreviewCol(ch.id)}
onPointerLeave={() => setPreviewCol(null)}
className={cn(
"group/cell relative flex select-none flex-col items-center justify-end gap-2 px-1 pb-2.5 pt-3",
"transition-colors duration-150 data-preview:bg-fg/[0.03]",
"@max-[30rem]:h-8 @max-[30rem]:flex-row-reverse @max-[30rem]:justify-end @max-[30rem]:gap-2 @max-[30rem]:rounded-full @max-[30rem]:border @max-[30rem]:border-line-2 @max-[30rem]:px-2.5 @max-[30rem]:py-0",
"@max-[30rem]:before:absolute @max-[30rem]:before:-inset-y-1.5 @max-[30rem]:before:inset-x-0 @max-[30rem]:before:content-['']",
off ? "opacity-50" : "cursor-pointer",
)}
>
<span className="flex items-center gap-1.5 text-[12.5px] font-medium leading-4 text-fg-2">
{ch.icon && <span className={cn("flex text-fg-3", "@max-[30rem]:hidden")}>{ch.icon}</span>}
{ch.label}
</span>
<Box
checked={col.state === "on"}
indeterminate={col.state === "mixed"}
disabled={off}
row={-1}
col={c}
aria-label={`${ch.label} for all events`}
onCheckedChange={() => toggleColumn(ch.id)}
onFocus={() => setPreviewCol(ch.id)}
onBlur={() => setPreviewCol(null)}
/>
</label>
);
})}
</div>
{channels.some((ch) => ch.hint) && (
<div className={cn("col-span-full -mt-1 flex flex-col gap-0.5 px-4 pb-2.5 text-right", "@max-[30rem]:m-0 @max-[30rem]:p-0 @max-[30rem]:text-left")}>
{channels
.filter((ch) => ch.hint)
.map((ch) => (
<p key={ch.id} className="text-[12px] leading-4 text-fg-3">
<span className="text-fg-2">{ch.label}</span> · {ch.hint}
</p>
))}
</div>
)}
</div>
{groups.map((group) => (
<div key={group.id} role="group" aria-labelledby={`${uid}-${group.id}`} className="border-b border-line last:border-b-0">
{/* Empty cells under each channel keep the column preview continuous through the group label. */}
<div className={cn("grid pr-2 [grid-template-columns:var(--matrix-cols)]", "@max-[30rem]:block")}>
<h3 id={`${uid}-${group.id}`} className="px-4 pb-1 pt-3 font-mono text-2xs uppercase tracking-[0.08em] text-fg-3">
{group.label}
</h3>
{channels.map((ch) => (
<span key={ch.id} aria-hidden data-preview={previewCol === ch.id || undefined} className={cn("transition-colors duration-150 data-preview:bg-fg/[0.03]", "@max-[30rem]:hidden")} />
))}
</div>
<div>
{group.events.map((ev) => {
const r = row++;
const reasonId = `${uid}-${ev.id}-reason`;
return (
<div
key={ev.id}
role="group"
aria-labelledby={`${uid}-${ev.id}`}
aria-describedby={ev.description ? `${uid}-${ev.id}-desc` : undefined}
className={cn(
"grid border-t border-line/60 pr-2 first:border-t-0 [grid-template-columns:var(--matrix-cols)]",
"@max-[30rem]:flex @max-[30rem]:flex-col @max-[30rem]:gap-2.5 @max-[30rem]:px-4 @max-[30rem]:py-3",
)}
>
<div className={cn("flex min-w-0 flex-col justify-center gap-0.5 py-2 pl-4 pr-3", "@max-[30rem]:p-0")}>
<span id={`${uid}-${ev.id}`} className="text-[13px] font-medium leading-5 text-fg">
{ev.label}
</span>
{ev.description && (
<span id={`${uid}-${ev.id}-desc`} className="line-clamp-2 text-[12px] leading-4 text-pretty text-fg-3">
{ev.description}
</span>
)}
{ev.required?.length ? (
<span id={reasonId} className="sr-only">
{ev.requiredReason ?? `${ev.required.map((id) => channelById.get(id)?.label ?? id).join(", ")} always on`}
</span>
) : null}
</div>
<div className={cn("contents", "@max-[30rem]:flex @max-[30rem]:flex-wrap @max-[30rem]:gap-1.5")}>
{channels.map((ch, c) => {
if (!supports(ev, ch.id))
return (
<span
key={ch.id}
data-preview={previewCol === ch.id || undefined}
className={cn("grid place-items-center text-fg-4 transition-colors duration-150 data-preview:bg-fg/[0.03]", "@max-[30rem]:hidden")}
>
<span aria-hidden className="h-px w-2.5 bg-current" />
<span className="sr-only">{ch.label} not available</span>
</span>
);
const required = isRequired(ev, ch.id);
const on = isOn(ev, ch.id);
const off = disabled || !!ch.disabled || required;
const w = wave?.channel === ch.id ? wave.order.get(ev.id) : undefined;
const cell = (
<label
key={ch.id}
data-preview={previewCol === ch.id || undefined}
data-on={on || undefined}
className={cn(
"group/cell relative flex select-none items-center justify-center",
"transition-[background-color,border-color] duration-150 data-preview:bg-fg/[0.03]",
"@max-[30rem]:h-8 @max-[30rem]:flex-row @max-[30rem]:justify-start @max-[30rem]:gap-2 @max-[30rem]:rounded-full @max-[30rem]:border @max-[30rem]:border-line-2 @max-[30rem]:px-2.5",
"@max-[30rem]:data-on:border-fg-4 @max-[30rem]:data-on:bg-fg/[0.05]",
"@max-[30rem]:before:absolute @max-[30rem]:before:-inset-y-1.5 @max-[30rem]:before:inset-x-0 @max-[30rem]:before:content-['']",
!off && "cursor-pointer @max-[30rem]:active:scale-[0.97] @max-[30rem]:transition-[background-color,border-color,scale]",
off && !required && "opacity-50",
)}
>
<Box
checked={on}
disabled={off}
locked={required}
row={r}
col={c}
delay={w === undefined ? 0 : Math.min(w, 8) * 0.022}
aria-describedby={required ? reasonId : undefined}
onCheckedChange={(next) => toggleCell(ev, ch.id, next)}
/>
<span className={cn("sr-only text-[12.5px] leading-4 text-fg-2", "@max-[30rem]:not-sr-only", "@max-[30rem]:group-data-on/cell:text-fg")}>
{ch.label}
</span>
{required && (
// Beside the box on wide layouts, after the label on chips.
<span aria-hidden className={cn("absolute left-[calc(50%+13px)] flex text-fg-4", "@max-[30rem]:static @max-[30rem]:text-fg-3")}>
<Lock size={12} />
</span>
)}
</label>
);
if (!required) return cell;
return (
<Tooltip.Root key={ch.id}>
<Tooltip.Trigger render={cell} />
<Tooltip.Portal>
<Tooltip.Positioner side="top" sideOffset={2} className="z-(--z-tooltip)">
<Tooltip.Popup
className={cn(
"flex w-max max-w-64 origin-(--transform-origin) items-center gap-1.5 rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 text-fg-2 shadow-pop",
"transition-[opacity,scale] duration-150 ease-out-expo data-starting-style:scale-96 data-starting-style:opacity-0",
"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none motion-reduce:data-starting-style:scale-100",
)}
>
<Lock size={12} className="shrink-0 text-fg-3" />
{ev.requiredReason ?? "Always on for this event"}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
);
})}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
</div>
</Tooltip.Provider>
);
}
/* ------------------------------------------------------------------ */
/* The box */
/* ------------------------------------------------------------------ */
const CHECK = "M3.75 8.25 6.75 11.25 12.25 4.75";
const DASH = "M4.5 8h7";
type BoxProps = Omit<Checkbox.Root.Props, "className" | "children"> & {
locked?: boolean;
/** Seconds to wait before drawing: set per row by a column toggle so the change ripples down. */
delay?: number;
row: number;
col: number;
};
function Box({ checked, indeterminate, locked, delay = 0, row, col, disabled, ...rest }: BoxProps) {
const reduce = useReducedMotion();
const on = !!checked && !indeterminate;
const d = reduce ? 0 : delay;
return (
<Checkbox.Root
checked={checked}
indeterminate={indeterminate}
disabled={disabled}
data-matrix-cell=""
data-row={row}
data-col={col}
style={{ transitionDelay: d ? `${d}s` : undefined }}
className={(state) =>
cn(
"relative inline-grid size-4 shrink-0 place-items-center rounded-[4.5px] border outline-none",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,color,scale] duration-150 ease-out-expo motion-reduce:transition-none",
!state.disabled && "motion-safe:active:scale-[0.86] motion-safe:group-active/cell:scale-[0.86] active:duration-100 group-active/cell:duration-100",
locked
? "border-transparent bg-fg-4 text-frame"
: state.checked || state.indeterminate
? "border-fg bg-fg text-frame"
: cn("border-fg-4 bg-raised", !state.disabled && "group-hover/cell:border-fg-3"),
)
}
{...rest}
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden focusable="false" className="size-full">
<motion.path
d={CHECK}
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
transition={
reduce
? { duration: 0.12 }
: on
? { pathLength: { duration: 0.24, ease: ease.out, delay: d + 0.03 }, opacity: { duration: 0.04, delay: d + 0.03 } }
: { pathLength: { duration: 0.1, ease: ease.in, delay: d }, opacity: { duration: 0.08, delay: d + 0.04 } }
}
/>
<motion.path
d={DASH}
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
initial={false}
animate={{ scaleX: indeterminate ? 1 : 0.2, opacity: indeterminate ? 1 : 0 }}
transition={reduce ? { duration: 0.12, scaleX: { duration: 0 } } : { duration: 0.18, ease: ease.out }}
/>
</svg>
</Checkbox.Root>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| groups* | NotificationGroup[] | — | Titled groups of events. Each event can list unsupported channels (drawn as a dash) and required ones (locked on, with a reason). |
| channels | NotificationChannel[] | Email, Push, In-app | The columns. A channel can be disabled with a hint, e.g. push blocked in this browser, and keeps its values. |
| value | Record<string, string[]> | — | Event id to the channel ids it's delivered on. Required channels are always included. |
| defaultValue | Record<string, string[]> | {} | Starting value when uncontrolled. |
| onValueChange | (value: Record<string, string[]>) => void | — | Called on every cell or column change with the whole matrix. |
| disabled | boolean | false | Shows the current preferences without letting them change. |
| eventsLabel | string | "Notify me about" | The label over the event column on wide layouts. |
06Notes
Behavior
- A column select-all goes mixed when some events are on; pressing it turns the whole column on, or off when it's already all on. Locked and unsupported cells are left alone.
- Below 30rem of container width the same DOM restacks: each event becomes a card with a row of channel chips, and the header becomes chips that select all.
- A disabled channel keeps its values and still reports its column state, so allowing push later restores exactly what was chosen.
- The header sticks while the list scrolls; set --matrix-sticky-top to clear a sticky app bar. overflow-clip rounds the corners without breaking the stick.
Motion
- Ticks draw their path in 240ms on the expo ease-out and erase in 100ms. A column toggle staggers each changed cell by 22ms down the column, capped after eight rows, so the change reads as one sweep.
- Hovering or focusing a column's select-all washes that column at 3% foreground, through group labels, so you see what it will change before you press it.
- Boxes squash to 0.86 on press. Reduced motion drops the stagger, the squash and the path draw for a 120ms fade.
Accessibility
- Base UI checkboxes. Each event is a labelled group, so a cell reads as “Mentions, Email, checkbox, checked”; select-alls are named “Email for all events” and go mixed.
- Tab visits every checkbox; arrow keys move like a grid, up and down a channel and across an event, skipping locked and blocked cells.
- Locked cells are described by their reason, which sighted users also get in a tooltip. Column toggles announce the result in a polite live region.