notification deck.

A lock-screen notification stack that collapses into a tidy deck and fans out on tap, even when the cards are different heights.

react / motion / ios / resize observer

Loading component...

live component, try it

what to expect.

Tap the deck to fan it out
Tap again to collapse it
Note the two-line card behind the front one

how it works.

Cards in a notification deck are not the same height, and that is what breaks the obvious version. Collapsed, each card peeks a fixed distance below the one in front, and every card behind the front is clamped to the front card's height, so a notification that wraps to two lines can neither hang out below the deck nor hide the card behind it. Expanded, each offset comes from the measured height of the card above plus a gap, so the fan never overlaps. Heights are read by a resize observer and handed to a pure function as numbers, which keeps the stacking maths testable without a layout engine. The second card runs to two lines on purpose.

the component.

import { useEffect, useRef, useState, type CSSProperties } from "react";
import { stackOffsets, type Pose } from "./stack";

export interface Note {
  id: string;
  app: string;
  title: string;
  body: string;
}

export interface DeckProps {
  notes: Note[];
  peek: number;
  scaleStep: number;
  measured: boolean;
}

const GAP = 8;

export default function NotificationDeck({ notes, peek, scaleStep, measured }: DeckProps) {
  const [expanded, setExpanded] = useState(false);
  const [heights, setHeights] = useState<number[]>([]);
  const cardsRef = useRef<(HTMLElement | null)[]>([]);

  useEffect(() => {
    const nodes = cardsRef.current.slice(0, notes.length).filter(Boolean) as HTMLElement[];
    if (nodes.length === 0) return;
    const read = () => setHeights(nodes.map((n) => Math.max(n.scrollHeight, n.offsetHeight)));
    read();
    // Cards differ in height, so offsets come from measured sizes.
    const ro = new ResizeObserver(read);
    for (const n of nodes) ro.observe(n);
    return () => ro.disconnect();
  }, [notes.length]);

  const poses: Pose[] = stackOffsets(
    heights.length === notes.length ? heights : notes.map(() => 0),
    { peek, scaleStep, measured, expanded, gap: GAP },
  );

  const frontHeight = heights[0];

  return (
    <button
      type="button"
      className="deck"
      data-expanded={String(expanded)}
      aria-expanded={expanded}
      aria-label={expanded ? "collapse notifications" : "expand notifications"}
      onClick={() => setExpanded((e) => !e)}
    >
      {notes.map((note, i) => {
        const isBackground = !expanded && i > 0;
        return (
          <article
            key={note.id}
            className="deck-card"
            data-background={String(isBackground)}
            ref={(el) => {
              cardsRef.current[i] = el;
            }}
            style={
              {
                zIndex: notes.length - i,
                transform: `translateY(${poses[i]?.y ?? 0}px) scale(${poses[i]?.scale ?? 1})`,
                // Clamp cards behind the front one so a tall card cannot poke out below the deck.
                ...(isBackground && frontHeight ? { maxHeight: `${frontHeight}px` } : {}),
              } as CSSProperties
            }
          >
            <span className="deck-app">{note.app}</span>
            <strong>{note.title}</strong>
            <p>{note.body}</p>
          </article>
        );
      })}
    </button>
  );
}