Toast Stack
Notifications that stack into one card, fan out on hover, swipe away and follow a promise.
npx shadcn@latest add https://sundaeui.dev/r/toast-stack.json'use client'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import './toast-stack.css'
type Kind = 'default' | 'success' | 'error' | 'loading'
export interface ToastItem {
id: number
title: string
description?: string
kind: Kind
duration: number
action?: { label: string; onClick: () => void }
leaving?: boolean
}
type Options = Partial<Pick<ToastItem, 'description' | 'duration' | 'action'>>
// ─── Store ─────────────────────────────────────────────────────────────
function createStore() {
let items: ToastItem[] = []
let nextId = 1
const subs = new Set<() => void>()
const emit = () => subs.forEach((s) => s())
const update = (id: number, patch: Partial<ToastItem>) => {
items = items.map((t) => (t.id === id ? { ...t, ...patch } : t))
emit()
}
const push = (kind: Kind, title: string, opts: Options = {}) => {
const id = nextId++
items = [{ id, title, kind, duration: kind === 'loading' ? Infinity : 4500, ...opts }, ...items].slice(0, 12)
emit()
return id
}
const dismiss = (id: number) => {
update(id, { leaving: true })
setTimeout(() => {
items = items.filter((t) => t.id !== id)
emit()
}, 260)
}
const toast = Object.assign((title: string, opts?: Options) => push('default', title, opts), {
success: (title: string, opts?: Options) => push('success', title, opts),
error: (title: string, opts?: Options) => push('error', title, opts),
dismiss,
/** Shows a loading toast that resolves to success or error. */
promise<T>(p: Promise<T>, msg: { loading: string; success: string | ((v: T) => string); error: string | ((e: unknown) => string) }) {
const id = push('loading', msg.loading)
p.then(
(v) => update(id, { kind: 'success', title: typeof msg.success === 'function' ? msg.success(v) : msg.success, duration: 4000 }),
(e) => update(id, { kind: 'error', title: typeof msg.error === 'function' ? msg.error(e) : msg.error, duration: 6000 }),
)
return p
},
})
const subscribe = (cb: () => void) => {
subs.add(cb)
return () => {
subs.delete(cb)
}
}
return { toast, dismiss, subscribe, get: () => items }
}
type Store = ReturnType<typeof createStore>
// ─── View ──────────────────────────────────────────────────────────────
const VISIBLE = 3
const GAP = 10
function Icon({ kind }: { kind: Kind }) {
if (kind === 'loading')
return (
<svg className="sd-toast__icon sd-toast__spin" viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity=".25" strokeWidth="2" />
<path d="M14 8a6 6 0 0 0-6-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
if (kind === 'success')
return (
<svg className="sd-toast__icon" data-kind="success" viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="7" fill="currentColor" />
<path d="m5 8.2 2 2 4-4.2" fill="none" stroke="var(--toast-bg)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
if (kind === 'error')
return (
<svg className="sd-toast__icon" data-kind="error" viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="7" fill="currentColor" />
<path d="M8 4.6v4M8 11.2v.2" stroke="var(--toast-bg)" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
return null
}
function Toast({ t, index, expanded, offset, onHeight, dismiss }: { t: ToastItem; index: number; expanded: boolean; offset: number; onHeight: (id: number, h: number) => void; dismiss: (id: number) => void }) {
const ref = useRef<HTMLLIElement>(null)
const [drag, setDrag] = useState(0)
const start = useRef<number | null>(null)
const remaining = useRef(t.duration)
const startedAt = useRef(0)
useEffect(() => {
const el = ref.current
if (!el) return
const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight))
ro.observe(el)
return () => ro.disconnect()
}, [t.id, onHeight])
// Auto-dismiss, paused while the stack is expanded (hovered/focused).
useEffect(() => {
remaining.current = t.duration
}, [t.duration, t.kind])
useEffect(() => {
if (!Number.isFinite(remaining.current) || expanded || t.leaving) return
startedAt.current = Date.now()
const timer = setTimeout(() => dismiss(t.id), remaining.current)
return () => {
clearTimeout(timer)
remaining.current -= Date.now() - startedAt.current
}
}, [expanded, t.id, t.leaving, t.kind, dismiss])
const hidden = index >= VISIBLE && !expanded
const y = expanded ? -offset : -index * 12
const scale = expanded ? 1 : 1 - index * 0.05
return (
<li
ref={ref}
className="sd-toast"
data-kind={t.kind}
data-leaving={t.leaving || undefined}
data-front={index === 0 || undefined}
aria-hidden={hidden || undefined}
style={{
transform: `translate3d(${drag}px, ${y}px, 0) scale(${scale})`,
opacity: hidden ? 0 : drag ? 1 - Math.min(Math.abs(drag) / 240, 0.8) : undefined,
zIndex: 100 - index,
transition: start.current !== null ? 'none' : undefined,
}}
onPointerDown={(e) => {
if ((e.target as HTMLElement).closest('button')) return
start.current = e.clientX
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => start.current !== null && setDrag(e.clientX - start.current)}
onPointerUp={() => {
if (Math.abs(drag) > 90) dismiss(t.id)
start.current = null
setDrag(0)
}}
onPointerCancel={() => {
start.current = null
setDrag(0)
}}
>
<div className="sd-toast__body" data-dim={!expanded && index > 0 ? '' : undefined}>
<Icon kind={t.kind} />
<div className="sd-toast__text">
<p className="sd-toast__title">{t.title}</p>
{t.description && <p className="sd-toast__desc">{t.description}</p>}
</div>
{t.action && (
<button
type="button"
className="sd-toast__action"
onClick={() => {
t.action!.onClick()
dismiss(t.id)
}}
>
{t.action.label}
</button>
)}
<button type="button" className="sd-toast__close" aria-label="Dismiss notification" onClick={() => dismiss(t.id)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
</li>
)
}
export interface ToasterProps {
/** Corner to anchor to. */
position?: 'bottom-right' | 'bottom-left' | 'bottom-center'
className?: string
}
function ToasterView({ store, position = 'bottom-right', className }: ToasterProps & { store: Store }) {
const list = useSyncExternalStore(store.subscribe, store.get, store.get)
const [expanded, setExpanded] = useState(false)
const [heights, setHeights] = useState<Record<number, number>>({})
const onHeight = useRef((id: number, h: number) => setHeights((m) => (m[id] === h ? m : { ...m, [id]: h }))).current
useEffect(() => {
if (!list.length) setExpanded(false)
}, [list.length])
let acc = 0
const offsets = list.map((t) => {
const o = acc
acc += (heights[t.id] ?? 64) + GAP
return o
})
return (
<section aria-label="Notifications" className={['sd-toaster', className].filter(Boolean).join(' ')} data-position={position}>
<ol
className="sd-toaster__list"
aria-live="polite"
aria-relevant="additions text"
style={{ height: expanded ? acc : (heights[list[0]?.id] ?? 0) + Math.min(list.length - 1, VISIBLE - 1) * 12 }}
onPointerEnter={() => setExpanded(true)}
onPointerLeave={() => setExpanded(false)}
onFocus={() => setExpanded(true)}
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget) && setExpanded(false)}
>
{list.map((t, i) => (
<Toast key={t.id} t={t} index={i} expanded={expanded} offset={offsets[i]} onHeight={onHeight} dismiss={store.dismiss} />
))}
</ol>
</section>
)
}
/**
* Creates an isolated toaster. Most apps only need the default instance
* exported below; create more for embedded widgets or tests.
*/
export function createToaster() {
const store = createStore()
function Toaster(props: ToasterProps) {
return <ToasterView store={store} {...props} />
}
return { toast: store.toast, Toaster }
}
const defaultToaster = createToaster()
/** Render once near the root of your app. Then call toast() from anywhere. */
export const Toaster = defaultToaster.Toaster
export const toast = defaultToaster.toast/* ToastStack — Sundae S—016 */
.sd-toaster {
--toast-bg: #ffffff;
--toast-fg: #18181b;
--toast-muted: #71717a;
--toast-line: rgb(0 0 0 / 0.07);
--toast-success: #16a34a;
--toast-error: #dc2626;
--toast-width: 356px;
position: fixed;
z-index: 9999;
bottom: 20px;
width: var(--toast-width);
max-width: calc(100vw - 32px);
pointer-events: none;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
}
:where(.dark) .sd-toaster {
--toast-bg: #1c1c1f;
--toast-fg: #f4f4f5;
--toast-muted: #a1a1aa;
--toast-line: rgb(255 255 255 / 0.08);
--toast-success: #4ade80;
--toast-error: #f87171;
}
.sd-toaster[data-position='bottom-right'] {
right: 20px;
}
.sd-toaster[data-position='bottom-left'] {
left: 20px;
}
.sd-toaster[data-position='bottom-center'] {
left: 50%;
transform: translateX(-50%);
}
.sd-toaster__list {
position: relative;
margin: 0;
padding: 0;
list-style: none;
transition: height 400ms cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: auto;
}
.sd-toast {
position: absolute;
right: 0;
bottom: 0;
left: 0;
transform-origin: 50% 100%;
transition:
transform 420ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 300ms ease;
touch-action: pan-y;
animation: sd-toast-in 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes sd-toast-in {
from {
opacity: 0;
translate: 0 16px;
scale: 0.96;
}
}
.sd-toast[data-leaving] {
opacity: 0 !important;
scale: 0.94;
transition:
opacity 240ms ease,
scale 240ms ease,
transform 240ms ease;
}
.sd-toast__body {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 13px 12px 13px 14px;
border-radius: 12px;
background: var(--toast-bg);
color: var(--toast-fg);
box-shadow:
0 0 0 1px var(--toast-line),
0 4px 12px -2px rgb(0 0 0 / 0.08),
0 12px 32px -8px rgb(0 0 0 / 0.14);
transition: opacity 300ms ease;
}
/* Cards behind the front one hide their content so the stack reads as one object. */
.sd-toast__body[data-dim] > * {
opacity: 0;
transition: opacity 200ms ease;
}
.sd-toast__icon {
flex: none;
width: 16px;
height: 16px;
margin-top: 1px;
animation: sd-toast-icon 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.sd-toast__icon[data-kind='success'] {
color: var(--toast-success);
}
.sd-toast__icon[data-kind='error'] {
color: var(--toast-error);
}
@keyframes sd-toast-icon {
from {
scale: 0.4;
opacity: 0;
}
}
.sd-toast__spin {
color: var(--toast-muted);
animation: sd-toast-spin 800ms linear infinite;
}
@keyframes sd-toast-spin {
to {
rotate: 360deg;
}
}
.sd-toast__text {
flex: 1;
min-width: 0;
}
.sd-toast__title {
margin: 0;
font-size: 13.5px;
font-weight: 550;
line-height: 1.35;
}
.sd-toast__desc {
margin: 2px 0 0;
font-size: 13px;
line-height: 1.4;
color: var(--toast-muted);
}
.sd-toast__action {
flex: none;
height: 26px;
padding: 0 10px;
border: 0;
border-radius: 7px;
background: var(--toast-fg);
color: var(--toast-bg);
font: inherit;
font-size: 12.5px;
font-weight: 550;
cursor: pointer;
}
.sd-toast__close {
flex: none;
display: grid;
width: 22px;
height: 22px;
place-items: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--toast-muted);
cursor: pointer;
opacity: 0;
transition:
opacity 150ms ease,
background-color 150ms ease;
}
.sd-toast:hover .sd-toast__close,
.sd-toast__close:focus-visible {
opacity: 1;
}
.sd-toast__close:hover {
background: var(--toast-line);
color: var(--toast-fg);
}
.sd-toast__close svg {
width: 14px;
height: 14px;
}
@media (hover: none) {
.sd-toast__close {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.sd-toast,
.sd-toaster__list {
transition-duration: 1ms !important;
animation: none !important;
}
.sd-toast__spin {
animation-duration: 2s !important;
}
}Overview
New toasts slide in at the front, and older ones tuck behind, scaled down, so a burst of notifications reads as a single object instead of a tower. Hovering or focusing the stack fans it into a list using each toast’s measured height, and pauses every timer until you leave.
Drag a toast sideways to dismiss it. toast.promise() shows a spinner that resolves into a success or error state in place, with the icon springing in. Actions such as Retry or Undo sit inline.
The store is a tiny module-level subscription consumed with useSyncExternalStore. There is no context provider and no dependency. Render <Toaster /> once, then call toast() from anywhere, including outside React.
When to use
- A single interaction you want to feel finished, dropped into an existing screen.
When not to use
- A whole page layout: look at Sections or Templates.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/toast-stack.jsonnpx shadcn@latest add https://sundaeui.dev/r/toast-stack.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/toast-stack.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/toast-stack.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy toast-stack.tsx and toast-stack.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
// app/layout.tsx
import { Toaster } from '@/components/sundae/toast-stack'
<body>{children}<Toaster /></body>
// anywhere
import { toast } from '@/components/sundae/toast-stack'
toast.success('Payment received', { description: '$48.00 from Kinfolk Market' })
toast.error('Upload failed', { action: { label: 'Retry', onClick: retry } })
toast.promise(publish(), { loading: 'Publishing…', success: 'Published', error: 'Publish failed' })Props
| Prop | Type | Default |
|---|---|---|
positionToaster anchor. | 'bottom-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' |
toast(title, opts?)Shows a default toast. Also available as toast.success and toast.error. | (string, { description?, duration?, action? }) => id | — |
toast.promise(p, msgs)A loading toast that resolves in place. | (Promise, { loading, success, error }) => Promise | — |
toast.dismiss(id)Dismisses a toast programmatically. | (id: number) => void | — |
createToaster()Creates an isolated toaster, for example for an embedded widget. | () => { toast, Toaster } | — |
Accessibility
- The list is an aria-live="polite" region inside a labelled section, so new toasts are announced without stealing focus.
- Timers pause while the stack is hovered or contains focus, giving people time to read and act.
- Every toast has a labelled dismiss button, and you don’t need to swipe.
- Reduced motion removes the slide and stack animations.
Customization
- Use --toast-bg, --toast-fg, --toast-success, --toast-error and --toast-width.
- Adjust VISIBLE (stacked depth) and the default duration in the source.
FAQ
- Is Toast Stack free?
- Yes. Toast Stack is free for personal and commercial projects, with full source on this page.
- How do I install Toast Stack?
- Run npx shadcn@latest add https://sundaeui.dev/r/toast-stack.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Toast Stack support keyboard and screen readers?
- The list is an aria-live="polite" region inside a labelled section, so new toasts are announced without stealing focus. Timers pause while the stack is hovered or contains focus, giving people time to read and act.
- Does Toast Stack respect reduced motion?
- Reduced motion removes the slide and stack animations.
More like this
All components →- Pro
Sheet Drawer
Components · Refined Fold Accordion
Components · UnderstatedAvatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Hold to Confirm
Components · RefinedReaction Burst
Components · Playful