Notification stack
Notifications fanned into a stack — the newest in front, the rest peeking out scaled back — that springs apart into a list of openable, dismissable cards on hover or click.
- Lee joined the workspace2mAccepted your invitation as a Member.Unread
pnpm exec shadcn add @intelligo/notification-stackInside an app made by intelligo create, or any shadcn project that names the registry.
used by these pages
Source
What shadcn add writes to components/ui/notification-stack.tsx. It is yours from then on.
"use client";
/*
* Notifications fanned into a stack: the newest card in front, the next
* ones peeking out below it, scaled back. Hovering or clicking the stack
* springs the cards apart into a list; each card then opens and dismisses
* on its own. Fits a notification popover's width.
*/
import * as React from "react";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { BellOffIcon, ChevronDownIcon, XIcon } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
} from "motion/react";
import { EASE_OUT, SPRING_LAYOUT, SwapText } from "@/components/ui/ai-motion";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface NotificationStackItem {
id: string;
title: React.ReactNode;
description?: React.ReactNode;
/** A relative time or date, shown beside the title. */
time?: React.ReactNode;
/** An icon or avatar node, shown at the start of the card. */
icon?: React.ReactNode;
unread?: boolean;
}
export interface NotificationStackLabels {
/** The toggle while the stack is folded. */
expand: string;
/** The toggle while the stack is fanned out. */
collapse: string;
/** Shown in place of the stack when there is nothing to show. */
empty: string;
/** Accessible name of each card's dismiss button. */
dismiss: string;
/** Accessible name of the list, given the number of notifications. */
list: (count: number) => string;
/** Read out beside the unread marker. */
unread: string;
}
const DEFAULT_LABELS: NotificationStackLabels = {
expand: "Show all",
collapse: "Show less",
empty: "You're all caught up",
dismiss: "Dismiss notification",
list: (count) => `${count} ${count === 1 ? "notification" : "notifications"}`,
unread: "Unread",
};
export interface NotificationStackProps
extends Omit<React.ComponentProps<"div">, "children"> {
items: NotificationStackItem[];
/** Fanned out (controlled). */
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
/** Called when a card is chosen; without it cards are not interactive. */
onItemClick?: (item: NotificationStackItem) => void;
/** Called from a card's dismiss button; without it there is none. */
onDismiss?: (item: NotificationStackItem) => void;
/** How many cards show in the folded stack, the front one included. */
maxVisible?: number;
/** Fan out while a mouse hovers the stack. */
expandOnHover?: boolean;
labels?: Partial<NotificationStackLabels>;
}
/** How far each card behind the front one peeks out, in px. */
const PEEK = 8;
/** How much smaller each card behind the front one is. */
const SCALE_STEP = 0.05;
function useExpanded(
expanded: boolean | undefined,
defaultExpanded: boolean,
onExpandedChange: ((expanded: boolean) => void) | undefined
) {
const [uncontrolled, setUncontrolled] = React.useState(defaultExpanded);
const controlled = expanded !== undefined;
const setExpanded = React.useCallback(
(next: boolean) => {
if (!controlled) setUncontrolled(next);
onExpandedChange?.(next);
},
[controlled, onExpandedChange]
);
return [controlled ? expanded : uncontrolled, setExpanded] as const;
}
function NotificationCardBody({
item,
labels,
}: {
item: NotificationStackItem;
labels: NotificationStackLabels;
}) {
return (
<>
{item.icon ? (
<span className="grid size-8 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-muted-foreground [&_svg:not([class*='size-'])]:size-4">
{item.icon}
</span>
) : null}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-baseline gap-2">
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5 text-foreground",
item.unread ? "font-semibold" : "font-medium"
)}
>
{item.title}
</span>
{item.time ? (
<span className="shrink-0 text-xs text-muted-foreground">
{item.time}
</span>
) : null}
</span>
{item.description ? (
<span className="line-clamp-2 text-xs leading-4 text-muted-foreground">
{item.description}
</span>
) : null}
</span>
{item.unread ? (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary">
<span className="sr-only">{labels.unread}</span>
</span>
) : null}
</>
);
}
function NotificationStack({
items,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
onItemClick,
onDismiss,
maxVisible = 3,
expandOnHover = true,
labels: labelOverrides,
className,
onPointerEnter,
onPointerLeave,
...props
}: NotificationStackProps) {
const reduced = useReducedMotion() ?? false;
const labels = React.useMemo(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
[labelOverrides]
);
const listId = React.useId();
const rootRef = React.useRef<HTMLDivElement>(null);
// Set when a hovering mouse fanned the stack out: only then does the
// mouse leaving fold it back. A click that opened it keeps it open.
const openedByHover = React.useRef(false);
const [expanded, setExpanded] = useExpanded(
expandedProp,
defaultExpanded,
onExpandedChange
);
const canStack = items.length > 1;
const stacked = canStack && !expanded;
const peeking = Math.min(items.length, Math.max(1, maxVisible)) - 1;
const cardTransition: Transition = reduced
? { duration: 0 }
: { ...SPRING_LAYOUT, opacity: { duration: 0.2, ease: EASE_OUT } };
if (items.length === 0) {
return (
<div
data-slot="notification-stack"
className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg bg-muted px-4 py-8 text-sm font-medium text-muted-foreground",
className
)}
{...props}
>
<BellOffIcon className="size-4" aria-hidden />
{labels.empty}
</div>
);
}
const toggle = () => {
openedByHover.current = false;
setExpanded(!expanded);
};
return (
<div
ref={rootRef}
data-slot="notification-stack"
data-state={stacked ? "stacked" : "expanded"}
className={cn("flex w-full flex-col gap-1.5", className)}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (!expandOnHover || event.pointerType !== "mouse") return;
if (stacked) {
openedByHover.current = true;
setExpanded(true);
}
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
if (event.pointerType !== "mouse" || !openedByHover.current) return;
// Keyboard focus inside the list keeps it open.
if (rootRef.current?.contains(document.activeElement)) return;
openedByHover.current = false;
setExpanded(false);
}}
{...props}
>
<motion.ul
id={listId}
aria-label={labels.list(items.length)}
initial={false}
animate={{ marginBottom: stacked ? peeking * PEEK : 0 }}
transition={cardTransition}
// Folded, the whole stack is one target that fans it out; the
// toggle below is its keyboard and screen-reader route.
onClick={stacked ? () => setExpanded(true) : undefined}
className={cn(
"relative flex flex-col gap-2",
stacked && "cursor-pointer"
)}
>
<AnimatePresence initial={false}>
{items.map((item, index) => {
const behind = stacked && index > 0;
const depth = stacked ? Math.min(index, peeking) : 0;
const hidden = stacked && index > peeking;
const interactive = !stacked;
return (
<motion.li
key={item.id}
layout={!reduced}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: -8 }}
animate={{
opacity: hidden ? 0 : 1,
y: depth * PEEK,
scale: 1 - depth * SCALE_STEP,
}}
exit={
reduced
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
x: 24,
transition: { duration: 0.16, ease: EASE_OUT },
}
}
transition={cardTransition}
inert={behind}
aria-hidden={behind || undefined}
data-unread={item.unread || undefined}
className={cn(
"group/notification relative flex overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-xs",
behind && "absolute inset-0"
)}
style={{
zIndex: items.length - index,
transformOrigin: "bottom center",
}}
>
<div
className={cn(
"flex min-w-0 flex-1",
behind && "invisible"
)}
>
{interactive && onItemClick ? (
<ButtonPrimitive
className="flex min-w-0 flex-1 items-start gap-3 rounded-xl p-3 text-left outline-none transition-colors duration-fast hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
onClick={() => onItemClick(item)}
>
<NotificationCardBody item={item} labels={labels} />
</ButtonPrimitive>
) : (
<div className="flex min-w-0 flex-1 items-start gap-3 p-3">
<NotificationCardBody item={item} labels={labels} />
</div>
)}
{interactive && onDismiss ? (
<Button
variant="ghost"
size="icon-xs"
aria-label={labels.dismiss}
className="absolute top-2 right-2 bg-card opacity-0 group-focus-within/notification:opacity-100 group-hover/notification:opacity-100 pointer-coarse:opacity-100"
onClick={() => onDismiss(item)}
>
<XIcon />
</Button>
) : null}
</div>
</motion.li>
);
})}
</AnimatePresence>
</motion.ul>
{canStack ? (
<Button
variant="ghost"
size="sm"
aria-expanded={!stacked}
aria-controls={listId}
onClick={toggle}
className="self-start"
>
<span className="grid h-5 min-w-5 place-items-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground tabular-nums">
{items.length}
</span>
<SwapText value={stacked ? "expand" : "collapse"}>
{stacked ? labels.expand : labels.collapse}
</SwapText>
<motion.span
aria-hidden
className="inline-flex"
initial={false}
animate={{ rotate: stacked ? 0 : 180 }}
transition={reduced ? { duration: 0 } : SPRING_LAYOUT}
>
<ChevronDownIcon className="size-3.5" />
</motion.span>
</Button>
) : null}
</div>
);
}
export { NotificationStack };Ready to try it? Three commands and the app runs against a stub model.