Notification Stack
A deck of notification cards: collapsed to the top card with peeking edges behind it, fanning into a readable list on hover or focus.
Orders import failed42s · TimeoutError at Step 2
import { NotificationStack, type NotificationStackItem } from "@/components/motion/notification-stack";
const ITEMS: NotificationStackItem[] = [
{ id: "import-failed", title: "Orders import failed", description: "42s · TimeoutError at Step 2" },
{ id: "sla-breach", title: "SLA breach", description: "2m 11s · Data enrichment" },
{ id: "sync-fixed", title: "Product sync auto-fixed", description: "5m · 404 on GET /products" },
];
export function NotificationStackPreview() {
return (
<div className="w-full max-w-sm">
<NotificationStack items={ITEMS} />
</div>
);
}
"use client";
// easeui.dev/components/motion/notification-stack
import { AnimatePresence, motion } from "motion/react";
import { type FocusEvent, type ReactNode, useState } from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface NotificationStackItem {
id: string;
title: string;
description?: string;
/** A small trailing element, such as a retry count or a status icon. */
trailing?: ReactNode;
}
export interface NotificationStackProps {
items: NotificationStackItem[];
/** Controlled expanded state. */
expanded?: boolean;
/** Starting state when uncontrolled. Default false. */
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
/** How many cards peek out behind the top one when collapsed. Default 3. */
maxVisible?: number;
emptyLabel?: string;
className?: string;
}
function Card({ item }: { item: NotificationStackItem }) {
return (
<motion.div
layout
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15 } }}
transition={SPRING_LAYOUT}
style={{ willChange: "transform" }}
className="relative z-10 flex items-start gap-3 rounded-xl bg-background p-3 shadow-[0_0_0_1px_var(--border)]"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{item.title}</span>
{item.description ? (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{item.description}</span>
) : null}
</span>
{item.trailing ? <span className="shrink-0 text-xs font-medium">{item.trailing}</span> : null}
</motion.div>
);
}
/**
* A deck of notification cards: collapsed to the top card with peeking
* edges behind it, fanning into a readable list on hover or focus.
*/
export function NotificationStack({
items,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
maxVisible = 3,
emptyLabel = "All caught up",
className,
}: NotificationStackProps) {
const [uncontrolled, setUncontrolled] = useState(defaultExpanded);
const isControlled = expandedProp !== undefined;
const expanded = isControlled ? expandedProp : uncontrolled;
const setExpanded = (next: boolean) => {
if (!isControlled) setUncontrolled(next);
onExpandedChange?.(next);
};
const onBlur = (event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) setExpanded(false);
};
if (items.length === 0) {
return (
<p
className={cn(
"rounded-xl bg-background p-3 text-center text-xs text-muted-foreground shadow-[0_0_0_1px_var(--border)]",
className,
)}
>
{emptyLabel}
</p>
);
}
const peek = items.slice(0, maxVisible);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: only tracks hover/focus to expand the stack; any interactive trailing content underneath is its own focusable control.
<div
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
onFocus={() => setExpanded(true)}
onBlur={onBlur}
className={cn("relative isolate flex flex-col gap-2", className)}
>
{!expanded && peek.length > 1 ? (
<span
aria-hidden="true"
className="absolute inset-x-2 top-2 -z-10 h-12 rounded-xl bg-card shadow-[0_0_0_1px_var(--border)]"
/>
) : null}
{!expanded && peek.length > 2 ? (
<span
aria-hidden="true"
className="absolute inset-x-4 top-4 -z-20 h-12 rounded-xl bg-card/70 shadow-[0_0_0_1px_var(--border)]"
/>
) : null}
<AnimatePresence mode="popLayout" initial={false}>
{(expanded ? items : peek.slice(0, 1)).map((item) => (
<Card key={item.id} item={item} />
))}
</AnimatePresence>
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/notification-stack
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx motion tailwind-merge - 3
Add the source files
components/motion/notification-stack.tsx "use client"; // easeui.dev/components/motion/notification-stack import { AnimatePresence, motion } from "motion/react"; import { type FocusEvent, type ReactNode, useState } from "react"; import { SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface NotificationStackItem { id: string; title: string; description?: string; /** A small trailing element, such as a retry count or a status icon. */ trailing?: ReactNode; } export interface NotificationStackProps { items: NotificationStackItem[]; /** Controlled expanded state. */ expanded?: boolean; /** Starting state when uncontrolled. Default false. */ defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; /** How many cards peek out behind the top one when collapsed. Default 3. */ maxVisible?: number; emptyLabel?: string; className?: string; } function Card({ item }: { item: NotificationStackItem }) { return ( <motion.div layout initial={{ opacity: 0, y: -8, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15 } }} transition={SPRING_LAYOUT} style={{ willChange: "transform" }} className="relative z-10 flex items-start gap-3 rounded-xl bg-background p-3 shadow-[0_0_0_1px_var(--border)]" > <span className="min-w-0 flex-1"> <span className="block truncate text-sm font-medium text-foreground">{item.title}</span> {item.description ? ( <span className="mt-0.5 block truncate text-xs text-muted-foreground">{item.description}</span> ) : null} </span> {item.trailing ? <span className="shrink-0 text-xs font-medium">{item.trailing}</span> : null} </motion.div> ); } /** * A deck of notification cards: collapsed to the top card with peeking * edges behind it, fanning into a readable list on hover or focus. */ export function NotificationStack({ items, expanded: expandedProp, defaultExpanded = false, onExpandedChange, maxVisible = 3, emptyLabel = "All caught up", className, }: NotificationStackProps) { const [uncontrolled, setUncontrolled] = useState(defaultExpanded); const isControlled = expandedProp !== undefined; const expanded = isControlled ? expandedProp : uncontrolled; const setExpanded = (next: boolean) => { if (!isControlled) setUncontrolled(next); onExpandedChange?.(next); }; const onBlur = (event: FocusEvent<HTMLDivElement>) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) setExpanded(false); }; if (items.length === 0) { return ( <p className={cn( "rounded-xl bg-background p-3 text-center text-xs text-muted-foreground shadow-[0_0_0_1px_var(--border)]", className, )} > {emptyLabel} </p> ); } const peek = items.slice(0, maxVisible); return ( // biome-ignore lint/a11y/noStaticElementInteractions: only tracks hover/focus to expand the stack; any interactive trailing content underneath is its own focusable control. <div onMouseEnter={() => setExpanded(true)} onMouseLeave={() => setExpanded(false)} onFocus={() => setExpanded(true)} onBlur={onBlur} className={cn("relative isolate flex flex-col gap-2", className)} > {!expanded && peek.length > 1 ? ( <span aria-hidden="true" className="absolute inset-x-2 top-2 -z-10 h-12 rounded-xl bg-card shadow-[0_0_0_1px_var(--border)]" /> ) : null} {!expanded && peek.length > 2 ? ( <span aria-hidden="true" className="absolute inset-x-4 top-4 -z-20 h-12 rounded-xl bg-card/70 shadow-[0_0_0_1px_var(--border)]" /> ) : null} <AnimatePresence mode="popLayout" initial={false}> {(expanded ? items : peek.slice(0, 1)).map((item) => ( <Card key={item.id} item={item} /> ))} </AnimatePresence> </div> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| items | {} | - | - |
| expanded? | boolean | - | Controlled expanded state. |
| defaultExpanded? | boolean | false | Starting state when uncontrolled. Default false. |
| onExpandedChange? | ((expanded: boolean) => void) | - | - |
| maxVisible? | number | 3 | How many cards peek out behind the top one when collapsed. Default 3. |
| emptyLabel? | string | All caught up | - |
| className? | string | - | - |
Updated