Notification deck
Booking notifications stacked with depth; every few seconds the front one lifts away and the rest step forward.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
type Kind = "booked" | "moved" | "paid" | "soon" | "joined";
const NOTES: { kind: Kind; title: string; body: string; time: string }[] = [
{ kind: "booked", title: "New booking", body: "Priya Raman, Intro call, Tue 10:30", time: "now" },
{ kind: "paid", title: "Payment received", body: "$120 for Strategy session", time: "2m" },
{ kind: "moved", title: "Rescheduled", body: "Design review moved to Thu 14:00", time: "8m" },
{ kind: "soon", title: "Starting in 10 min", body: "Weekly sync with Marco and Lea", time: "12m" },
{ kind: "joined", title: "Guest added", body: "Tom Becker joined Product demo", time: "20m" },
];
const ICONS: Record<Kind, ReactNode> = {
booked: <><rect x="2.5" y="3.5" width="11" height="10" rx="2" /><path d="M2.5 6.5h11M5.5 2v3M10.5 2v3M6 10l1.5 1.5L10.5 8.5" /></>,
paid: <><rect x="2" y="4" width="12" height="8.5" rx="2" /><path d="M2 7h12M4.5 10.2h2.5" /></>,
moved: <path d="M12.5 6A4.8 4.8 0 0 0 3.6 5M3.5 2.5V5H6M3.5 10a4.8 4.8 0 0 0 8.9 1M12.5 13.5V11H10" />,
soon: <><circle cx="8" cy="8.5" r="5.5" /><path d="M8 5.5v3l2 1.3" /></>,
joined: <><circle cx="6.5" cy="5.5" r="2.5" /><path d="M2 13c.5-2.4 2.3-3.6 4.5-3.6S10.5 10.6 11 13M12.5 5.5v4M10.5 7.5h4" /></>,
};
const VISIBLE = 3; // cards drawn with depth; the rest wait invisibly at the back
const EVERY = 2600;
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [step, setStep] = useState(0);
const [leaving, setLeaving] = useState<number | null>(null);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const leaveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const front = step % NOTES.length;
const advance = () => {
// The front card flies off; the others step forward in the same beat.
setLeaving(step % NOTES.length);
setStep((s) => s + 1);
clearTimeout(leaveTimer.current);
leaveTimer.current = setTimeout(() => setLeaving(null), 520);
};
useEffect(() => () => clearTimeout(leaveTimer.current), []);
const paused = hovered || focused || reduced || !inView;
useEffect(() => {
if (paused) return;
const timer = setTimeout(advance, EVERY);
return () => clearTimeout(timer);
}, [step, paused]); // eslint-disable-line react-hooks/exhaustive-deps
const note = NOTES[front];
return (
<div
ref={root}
className={styles.root}
onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
onPointerLeave={() => setHovered(false)}
>
<div className={styles.wrap}>
<button
type="button"
className={styles.deck}
onClick={advance}
onFocus={(e) => setFocused(e.currentTarget.matches(":focus-visible"))}
onBlur={() => setFocused(false)}
aria-label={`${note.title}: ${note.body}, ${note.time}. Show next notification`}
>
{NOTES.map((n, i) => {
const depth = (i - front + NOTES.length) % NOTES.length;
return (
<span
key={n.title}
className={styles.card}
data-leaving={leaving === i || undefined}
data-hidden={depth >= VISIBLE || undefined}
style={{ "--d": Math.min(depth, VISIBLE), zIndex: leaving === i ? 10 : NOTES.length - depth } as React.CSSProperties}
aria-hidden="true"
>
<span className={styles.icon} data-kind={n.kind}>
<svg viewBox="0 0 16 16">{ICONS[n.kind]}</svg>
</span>
<span className={styles.text}>
<span className={styles.title}>{n.title}</span>
<span className={styles.body}>{n.body}</span>
</span>
<span className={styles.time}>{n.time}</span>
</span>
);
})}
</button>
<div className={styles.foot} aria-hidden="true">
<span className={styles.dots}>
{NOTES.map((n, i) => <span key={n.title} data-on={i === front || undefined} />)}
</span>
<span className={styles.hint}>
{hovered || focused ? "Paused, " : ""}
<span className={styles.click}>{hovered || focused ? "click" : "Click"}</span>
<span className={styles.tap}>{hovered || focused ? "tap" : "Tap"}</span> for next
</span>
</div>
</div>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.wrap { width: min(380px, 100%); display: grid; gap: 14px; }
/* The deck is one button; cards sit on the same spot and depth moves them up, smaller and dimmer. */
.deck {
position: relative; display: block; width: 100%; height: 100px; padding: 0; border: 0; background: none;
font: inherit; color: inherit; text-align: left; cursor: pointer; border-radius: 16px;
}
.deck:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 4px; }
.card {
--d: 0;
position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 12px; height: 68px; padding: 0 14px;
box-sizing: border-box; border-radius: 16px; border: 1px solid var(--line); background: var(--white);
box-shadow: 0 12px 28px -18px color-mix(in srgb, var(--ink) 45%, transparent);
transform-origin: 50% 0;
transform: translateY(calc(var(--d) * -15px)) scale(calc(1 - var(--d) * .06));
transition: transform .55s var(--out), opacity .4s var(--out);
}
/* Dimming with an overlay keeps cards opaque, so text behind never shows through. */
.card::after {
content: ""; position: absolute; inset: -1px; border-radius: inherit; background: var(--surface); pointer-events: none;
opacity: calc(var(--d) * .3); transition: opacity .55s var(--out);
}
.card[data-hidden] { opacity: 0; }
.card[data-leaving] { opacity: 0; transform: translateY(-34px) scale(1.04); transition: transform .5s var(--out), opacity .32s var(--out); }
.deck:hover .card:not([data-hidden]):not([data-leaving]) { box-shadow: 0 16px 32px -18px color-mix(in srgb, var(--ink) 55%, transparent); }
.icon { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 11px; background: var(--accent-pale); color: var(--accent-text); }
.icon[data-kind="paid"] { background: var(--sand); color: var(--ink); }
.icon[data-kind="moved"], .icon[data-kind="joined"] { background: var(--sky); color: var(--ink); }
.icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.text { flex: 1; min-width: 0; display: grid; gap: 2px; }
.title { font-size: 13px; font-weight: 650; }
.body { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.time { flex: none; align-self: flex-start; margin-top: 14px; font-size: 11px; font-weight: 600; color: var(--muted); }
.foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 4px; }
.dots { display: flex; gap: 5px; }
.dots span { width: 6px; height: 6px; border-radius: 3px; background: var(--line); transition: width .4s var(--out), background-color .3s; }
.dots span[data-on] { width: 16px; background: var(--accent-text); }
.hint { font-size: 11px; font-weight: 600; color: var(--muted); }
@container (max-width: 340px) {
.card { gap: 10px; padding: 0 12px; }
.icon { width: 32px; height: 32px; }
}
@container (max-height: 200px) {
.wrap { gap: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.card, .card::after, .card[data-leaving], .dots span { transition: none; }
}
@container (min-width: 640px) and (min-height: 360px) {
.wrap { width: 420px; gap: 18px; }
.deck { height: 112px; }
.card { height: 78px; padding: 0 18px; gap: 14px; }
.icon { width: 42px; height: 42px; border-radius: 12px; }
.title { font-size: 14px; }
.body { font-size: 13px; }
}
.tap { display: none; }
@media (hover: none) {
.click { display: none; }
.tap { display: inline; }
}
Use it for
- Hero sections that show a product's activity at a glance
- Scheduling, payments or inbox products with a steady stream of events
- Small widgets where one event at a time is enough
Avoid
- Real notification centres, where people need every item at once
- Alerts that need action, which must not rotate out of view
How it works
Each card's depth is its distance from the front index, written to a --d custom property that drives translateY, scale and a dimming overlay, so one state change moves the whole deck through CSS transitions. A single timeout advances the index every 2.6 seconds; the outgoing card gets a leaving flag that lifts and fades it, then it slides invisibly to the back. Hover, focus and being off screen stop the timer.
Accessibility
The deck is one button named after the front notification, so a click, tap, Enter or Space shows the next one. Hover and keyboard focus pause the rotation. Reduced motion stops the autoplay and transitions, leaving a still stack that still advances on click.