macOS Notification Center cards: collapsed, the newest sits in front with two peeking behind; click or press Enter to spring the stack open. New cards slide in from the top; each can be dismissed.
Notifications
Maya
nowThe new icons look great on the dark wallpaper. Ship it!
"use client"
import * as React from "react"
import { CalendarDays, GitPullRequest, Mail, MessageCircle, Rocket, ShieldCheck } from "lucide-react"
import { Notification, NotificationStack } from "@/components/ballmac/notification-stack"
type Item = { id: number; kind: keyof typeof kinds; title: string; body: string }
const kinds = {
message: { icon: <MessageCircle fill="currentColor" />, tile: "bg-linear-to-b from-chart-2/80 to-chart-2 text-white" },
mail: { icon: <Mail />, tile: "bg-linear-to-b from-chart-1/80 to-chart-1 text-white" },
calendar: { icon: <CalendarDays />, tile: "bg-white text-destructive" },
deploy: { icon: <Rocket />, tile: "bg-black/85 text-white" },
review: { icon: <GitPullRequest />, tile: "bg-linear-to-b from-chart-4/80 to-chart-4 text-white" },
security: { icon: <ShieldCheck />, tile: "bg-linear-to-b from-chart-3/80 to-chart-3 text-white" },
} as const
const pool: Omit<Item, "id">[] = [
{ kind: "deploy", title: "Deploy succeeded", body: "acme-web is live on production. Build took 42 seconds." },
{ kind: "review", title: "Review requested", body: "Maya asked you to review “Menu bar redesign” (#482)." },
{ kind: "calendar", title: "Design review in 10 minutes", body: "Studio B · with Leo, Maya and 3 others" },
{ kind: "security", title: "New sign-in", body: "Your account was used on a new Mac in Lisbon." },
{ kind: "mail", title: "Invoice #2041 paid", body: "Acme Inc. paid $4,200.00. The receipt is attached." },
{ kind: "message", title: "Leo", body: "Shipping the DMG tonight. Can you check the notarization log?" },
]
const initial: Item[] = [
{ id: 3, kind: "message", title: "Maya", body: "The new icons look great on the dark wallpaper. Ship it!" },
{ id: 2, kind: "mail", title: "Weekly usage report", body: "Active installs grew 18% this week. Top country: Germany." },
{ id: 1, kind: "calendar", title: "Standup at 10:00", body: "Daily · Zoom room 2" },
]
export default function NotificationStackDemo() {
const [items, setItems] = React.useState(initial)
const next = React.useRef(0)
const id = React.useRef(10)
React.useEffect(() => {
const timer = window.setInterval(() => {
const n = pool[next.current % pool.length]
next.current += 1
id.current += 1
setItems((current) => [{ ...n, id: id.current }, ...current].slice(0, 5))
}, 4000)
return () => window.clearInterval(timer)
}, [])
return (
<div className="relative isolate flex h-[420px] w-full max-w-[640px] justify-center overflow-hidden rounded-2xl border border-foreground/10 px-4 pt-6 sm:justify-end sm:px-6">
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 dark:brightness-[0.55]">
<div className="absolute inset-0 bg-linear-to-bl from-chart-4 via-chart-1 to-chart-2" />
<div className="absolute -inset-x-1/4 top-1/2 h-full rounded-[50%] bg-white/20 blur-2xl" />
</div>
<div aria-hidden="true" className="pointer-events-none absolute bottom-6 start-6 text-white [text-shadow:0_1px_12px_rgb(0_0_0/0.2)]">
<p className="text-sm font-medium opacity-90">Tuesday, September 29</p>
<p className="text-6xl font-semibold tracking-tight">9:41</p>
</div>
<NotificationStack className="w-full max-w-[340px]" onClearAll={() => setItems([])}>
{items.map((item, i) => (
<Notification
key={item.id}
icon={kinds[item.kind].icon}
iconClassName={kinds[item.kind].tile}
title={item.title}
time={i === 0 ? "now" : `${i * 4}m ago`}
onDismiss={() => setItems((current) => current.filter((x) => x.id !== item.id))}
>
{item.body}
</Notification>
))}
</NotificationStack>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/notification-stackInstall the dependencies.
$ pnpm add motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/notification-stack.tsx// Ballmac UI: Notification Stack. https://ui.ballmac.com/components/notification-stack "use client" import * as React from "react" import { AnimatePresence, MotionConfig, motion, type Transition } from "motion/react" import { ChevronUpIcon, XIcon } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" const stackSpring: Transition = { type: "spring", stiffness: 380, damping: 32, mass: 0.9 } /** Offset in px of each peeking card below the one in front. */ const PEEK = 9 /** Horizontal inset in px of each peeking card. */ const INSET = 10 type NotificationStackProps = Omit<React.ComponentProps<"section">, "children"> & { /** Notification elements, newest first. Give each a stable `key`; new keys animate in from the top. */ children?: React.ReactNode /** Controlled expanded state. */ expanded?: boolean /** Initial expanded state when uncontrolled. */ defaultExpanded?: boolean /** Called when the stack expands or collapses. */ onExpandedChange?: (expanded: boolean) => void /** Shows a "clear all" button when set. */ onClearAll?: () => void /** Heading shown above the expanded list; also the region's accessible name. */ label?: string } /** * Notifications grouped like macOS Notification Center: collapsed, the newest card sits in front with two * more peeking out behind it; activating the stack springs it open into a list. */ function NotificationStack({ children, expanded: expandedProp, defaultExpanded = false, onExpandedChange, onClearAll, label, className, onKeyDown, ...props }: NotificationStackProps) { const msg = useMessages() label ??= msg("notification-stack.label", "Notifications") const [uncontrolled, setUncontrolled] = React.useState(defaultExpanded) const expanded = expandedProp ?? uncontrolled const items = React.Children.toArray(children).filter(React.isValidElement) const count = items.length const stacked = !expanded && count > 1 const peeking = Math.min(count, 3) - 1 const expandRef = React.useRef<HTMLButtonElement>(null) const collapseRef = React.useRef<HTMLButtonElement>(null) const focusAfter = React.useRef<"expand" | "collapse" | null>(null) const setExpanded = (next: boolean, moveFocus = false) => { if (moveFocus) focusAfter.current = next ? "collapse" : "expand" if (expandedProp === undefined) setUncontrolled(next) onExpandedChange?.(next) } // Keep focus on the control that replaces the one just used. React.useEffect(() => { if (focusAfter.current === "collapse") collapseRef.current?.focus() if (focusAfter.current === "expand") expandRef.current?.focus() focusAfter.current = null }, [expanded]) const headingId = React.useId() return ( <MotionConfig reducedMotion="user"> <section aria-labelledby={headingId} data-slot="notification-stack" data-state={expanded ? "expanded" : "collapsed"} className={cn("group/stack relative w-full", className)} onKeyDown={(event) => { onKeyDown?.(event) if (!event.defaultPrevented && event.key === "Escape" && expanded && count > 1) { event.preventDefault() setExpanded(false, true) } }} {...props} > <div data-slot="notification-stack-header" className={cn("flex items-center gap-2 px-1 transition-[height,opacity] duration-200", expanded && count > 1 ? "mb-2 h-7 opacity-100" : "h-0 overflow-hidden opacity-0")} > <h3 id={headingId} className="flex-1 truncate text-[15px] font-semibold tracking-tight text-foreground"> {label} </h3> {expanded && count > 1 ? ( <> <button ref={collapseRef} type="button" aria-expanded="true" onClick={() => setExpanded(false, true)} className="inline-flex h-7 items-center gap-1 rounded-full bg-background/70 px-3 text-xs font-medium text-foreground/80 shadow-[0_0_0_0.5px_rgb(0_0_0/0.12)] backdrop-blur-xl outline-none transition-colors hover:bg-background/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:shadow-[0_0_0_0.5px_rgb(255_255_255/0.12)]" > <ChevronUpIcon className="size-3.5" aria-hidden="true" /> {msg("notification-stack.showLess", "Show less")} </button> {onClearAll ? ( <button type="button" aria-label={msg("notification-stack.clearAllNotifications", "Clear all notifications")} onClick={onClearAll} className="inline-flex size-7 items-center justify-center rounded-full bg-background/70 text-foreground/70 shadow-[0_0_0_0.5px_rgb(0_0_0/0.12)] backdrop-blur-xl outline-none transition-colors hover:bg-background/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:shadow-[0_0_0_0.5px_rgb(255_255_255/0.12)]" > <XIcon className="size-3.5" aria-hidden="true" /> </button> ) : null} </> ) : null} </div> <motion.div layout="size" transition={stackSpring} className="relative" style={{ paddingBottom: stacked ? peeking * PEEK : 0 }}> <ul className="relative flex flex-col gap-2"> <AnimatePresence initial={false} mode="popLayout"> {items.map((child, index) => { const back = stacked && index > 0 // Collapsed, the expand button stands in for the whole stack; the front card describes it. const covered = stacked && index === 0 const depth = Math.min(index, 2) const hidden = stacked && index > 2 return ( <motion.li key={child.key ?? index} layout data-slot="notification-stack-item" data-peek={back ? "" : undefined} id={covered ? `${headingId}-front` : undefined} aria-hidden={back || undefined} inert={back || covered || undefined} initial={{ opacity: 0, y: -28, scale: 0.94 }} animate={{ opacity: hidden ? 0 : 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.18 } }} transition={stackSpring} className={cn("list-none", back ? "absolute" : "relative", "[&[data-peek]_[data-slot=notification-content]]:opacity-0")} style={{ zIndex: count - index, ...(back ? { top: depth * PEEK, bottom: -depth * PEEK, left: depth * INSET, right: depth * INSET } : {}), }} > {child} </motion.li> ) })} </AnimatePresence> </ul> {stacked ? ( <button ref={expandRef} type="button" aria-expanded="false" aria-label={msg("notification-stack.showAllNotifications", "Show all {count} notifications", { count })} aria-describedby={`${headingId}-front`} data-slot="notification-stack-expand" onClick={() => setExpanded(true, true)} className="absolute inset-0 z-[1000] rounded-[18px] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" /> ) : null} {stacked && onClearAll ? ( <button type="button" aria-label={msg("notification-stack.clearAllNotifications", "Clear all notifications")} onClick={onClearAll} className="absolute -top-2 -start-2 z-[1001] flex size-6 items-center justify-center rounded-full bg-background/90 text-foreground/70 opacity-0 shadow-[0_0_0_0.5px_rgb(0_0_0/0.18),0_2px_6px_rgb(0_0_0/0.15)] backdrop-blur-xl outline-none transition-opacity group-hover/stack:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [@media(hover:none)]:opacity-100" > <XIcon className="size-3" aria-hidden="true" /> </button> ) : null} </motion.div> </section> </MotionConfig> ) } type NotificationProps = Omit<React.ComponentProps<"div">, "title"> & { /** App icon, drawn in a 36px rounded tile. Style the tile with iconClassName. */ icon?: React.ReactNode /** Classes for the icon tile (background gradient, color). */ iconClassName?: string /** Bold first line. */ title: React.ReactNode /** Short relative time, e.g. "now" or "9m ago". */ time?: React.ReactNode /** Shows a dismiss button (on hover and focus) that calls this. */ onDismiss?: () => void /** Accessible label of the dismiss button. */ dismissLabel?: string } /** One notification card: icon, title, time and body text (children), with translucent vibrancy. */ function Notification({ icon, iconClassName, title, time, onDismiss, dismissLabel, className, children, ...props }: NotificationProps) { const msg = useMessages() dismissLabel ??= msg("notification-stack.dismissLabel", "Dismiss") return ( <div data-slot="notification" className={cn( "group/notification relative h-full rounded-[18px] border border-white/40 bg-background/70 p-3 text-start shadow-[0_0_0_0.5px_rgb(0_0_0/0.1),0_8px_24px_-10px_rgb(0_0_0/0.3)] backdrop-blur-2xl backdrop-saturate-150", "dark:border-white/10 dark:bg-background/60 dark:shadow-[0_0_0_0.5px_rgb(0_0_0/0.6),0_8px_24px_-10px_rgb(0_0_0/0.6)]", className )} {...props} > <div data-slot="notification-content" className="flex gap-3 transition-opacity duration-200"> {icon ? ( <span data-slot="notification-icon" className={cn( "flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-muted text-foreground shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.2)] [&_svg:not([class*='size-'])]:size-5", iconClassName )} > {icon} </span> ) : null} <div className="min-w-0 flex-1"> <div className="flex items-baseline gap-2"> <p className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">{title}</p> {time ? <span className="shrink-0 text-xs text-muted-foreground">{time}</span> : null} </div> {children ? <div className="mt-0.5 line-clamp-2 text-[13px] leading-snug text-foreground/75">{children}</div> : null} </div> </div> {onDismiss ? ( <button type="button" aria-label={dismissLabel} onClick={onDismiss} data-slot="notification-dismiss" className="absolute -top-2 -start-2 flex size-6 items-center justify-center rounded-full bg-background/90 text-foreground/70 opacity-0 shadow-[0_0_0_0.5px_rgb(0_0_0/0.18),0_2px_6px_rgb(0_0_0/0.15)] backdrop-blur-xl outline-none transition-opacity group-hover/notification:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [@media(hover:none)]:opacity-100" > <XIcon className="size-3" aria-hidden="true" /> </button> ) : null} </div> ) } export { Notification, NotificationStack, type NotificationProps, type NotificationStackProps }Update the import paths to match your project setup.
Usage
import { Notification, NotificationStack } from "@/components/ballmac/notification-stack"The full example is in the Code tab above.
API reference
<NotificationStack>
| Prop | Type | Default |
|---|---|---|
childrenNotification elements, newest first. Give each a stable `key`; new keys animate in from the top. | React.ReactNode | — |
expandedControlled expanded state. | boolean | — |
defaultExpandedInitial expanded state when uncontrolled. | boolean | false |
onExpandedChangeCalled when the stack expands or collapses. | (expanded: boolean) => void | — |
onClearAllShows a "clear all" button when set. | () => void | — |
labelHeading shown above the expanded list; also the region's accessible name. | string | — |
<Notification>
| Prop | Type | Default |
|---|---|---|
iconApp icon, drawn in a 36px rounded tile. Style the tile with iconClassName. | React.ReactNode | — |
iconClassNameClasses for the icon tile (background gradient, color). | string | — |
title*Bold first line. | React.ReactNode | — |
timeShort relative time, e.g. "now" or "9m ago". | React.ReactNode | — |
onDismissShows a dismiss button (on hover and focus) that calls this. | () => void | — |
dismissLabelAccessible label of the dismiss button. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Focuses the stack's expand button when collapsed, or each card's dismiss button and Show less when expanded |
| Enter / Space | Expands the collapsed stack (focus moves to Show less) or activates the focused button |
| Escape | Collapses an expanded stack and returns focus to it |
Use with AI
Render <NotificationStack> with <Notification key icon iconClassName title time onDismiss>body</Notification> children, newest first. Keep items in state; prepend to add (animates in), filter to dismiss. expanded/defaultExpanded/onExpandedChange control the stack; onClearAll adds a clear button. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Notification Stack (@ballmac/notification-stack) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An activity or notification feed in a dashboard corner
- A landing-page hero showing the alerts a product sends (deploys, reviews, payments)
- Grouping several recent events so they take the space of one
Not for
- Transient feedback after a single action (use a toast)
- A single live activity that changes over time (use dynamic-island)
Registry JSON: https://ui.ballmac.com/r/notification-stack.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Dynamic Island
A black pill or MacBook-style notch that morphs between live activities (timers, uploads, an AI thinking state, notifications) with a spring layout animation. Each view is announced politely.
Mac Window
macOS window chrome: traffic lights whose glyphs appear on hover, a centered title or unified toolbar, an optional translucent sidebar with vibrancy, and active and inactive appearances.
Control Center
The macOS Control Center panel in frosted glass: toggle tiles with round badges, grouped Wi-Fi, Bluetooth and AirDrop rows, pill sliders with the icon in the track, and a Now Playing tile. Real toggle buttons and Radix sliders.
Desktop Icons
A macOS desktop of icons: box-select with the pointer, drag to move with snapping to grid cells, double-click or Return to open, arrow keys to move focus and Alt plus arrows to move an icon.