126 / Feedback
Notification Stack
Several notifications kept as one compact stack. It spreads into a readable list on hover, focus or tap, and folds back when you leave.
Notifications
4New comment on Launch checklist
2m agoCan we move the beta date to Thursday?Build passed on main
9m agoAll 3 checks finished in 4 minutes.Design review starts in 15 minutes
12m agoRoom 4 and the video link are in the invite.
"use client"
import * as React from "react"
import {
CalendarClock,
GitBranch,
HardDrive,
MessageSquare,
} from "lucide-react"
import {
NotificationStack,
type NotificationStackItem,
} from "mischief-ui/notification-stack"
const initial: NotificationStackItem[] = [
{
id: "comment",
title: "New comment on Launch checklist",
description: "Can we move the beta date to Thursday?",
time: "2m ago",
icon: <MessageSquare size={16} />,
},
{
id: "build",
title: "Build passed on main",
description: "All 3 checks finished in 4 minutes.",
time: "9m ago",
icon: <GitBranch size={16} />,
},
{
id: "meeting",
title: "Design review starts in 15 minutes",
description: "Room 4 and the video link are in the invite.",
time: "12m ago",
icon: <CalendarClock size={16} />,
},
{
id: "storage",
title: "Storage is almost full",
description: "9.2 GB of 10 GB used. Old exports can be removed.",
time: "1h ago",
icon: <HardDrive size={16} />,
},
]
const later: NotificationStackItem = {
id: "reply",
title: "Reply on Launch checklist",
description: "Thursday works. I will update the plan.",
time: "Just now",
icon: <MessageSquare size={16} />,
}
const action =
"border-border hover:bg-muted focus-visible:ring-ring/50 min-h-9 rounded-[var(--radius)] border px-3 text-sm outline-none focus-visible:ring-4 disabled:opacity-50"
export function NotificationStackDemo() {
const [items, setItems] = React.useState(initial)
const replied = items.some((item) => item.id === later.id)
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<NotificationStack
items={items}
onDismiss={(id) =>
setItems((list) => list.filter((item) => item.id !== id))
}
onClearAll={() => setItems([])}
/>
<div className="flex flex-wrap gap-2 pt-8">
<button
type="button"
className={action}
disabled={replied}
onClick={() => setItems((list) => [later, ...list])}
>
Get a reply
</button>
<button
type="button"
className={action}
onClick={() => setItems(initial)}
>
Reset
</button>
</div>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/notification-stackimport { NotificationStack } from "mischief-ui/notification-stack"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react" import { cn } from "@/lib/utils" export interface NotificationStackItem { id: string title: string description?: React.ReactNode /** A short time, such as "2m ago". */ time?: string icon?: React.ReactNode}Usage
export function Inbox() {
const [items, setItems] = React.useState(notifications)
return (
<NotificationStack
items={items}
onDismiss={(id) => setItems((list) => list.filter((item) => item.id !== id))}
onClearAll={() => setItems([])}
/>
)
}What a card holds
Each item has an id and a title, and may have a description, a short time such as 2m ago, and an icon. The newest item goes first in the array and sits on top of the stack. The host owns the array: onDismiss hands back an id after the card has left, and onClearAll empties it.
How it opens
A resting mouse, keyboard focus anywhere inside, or a tap on a card spreads the stack. Leaving with the mouse or with focus folds it back, and a tap outside closes what a tap opened. visibleCount sets how many cards peek behind the first while it is folded.
On touch, a card swiped sideways past a short distance is dismissed in the direction it was thrown. A shorter swipe springs back. Vertical scrolling is left to the page.
API
itemsNotificationStackItem[]The notifications, newest first: { id, title, description?, time?, icon? }.onDismiss(id: string) => voidRemoves one notification.onClearAll() => voidRemoves all of them. Omit it and no Clear all button is drawn.labelstringThe heading and accessible name. Defaults to Notifications.visibleCountnumberCards showing behind the first while folded. Defaults to 3.emptyMessage, dismissLabel, clearLabelstringThe empty line and button copy.classNamestringClasses for the root section....sectionPropsHTMLAttributesNative section attributes.Accessibility
The stack is a section named by its heading, and each card is a list item with a dismiss button named for its notification. Tabbing in spreads the stack so every card is readable and reachable. Dismissing a card moves focus to the next card, and Clear all leaves focus on the stack, so focus is never dropped to the page. Cards hidden behind the fold are out of the tab order and hidden from assistive technology until it opens. A notification that arrives is announced politely, and the ones already there are not. Swiping is a touch shortcut; every card also has a button. Reduced motion removes the spring and the exit slide, and dismissal happens at once.