Toast
Stacked toasts that fan out on hover, swipe to dismiss, pause while you read, and morph from loading to done.
- AnimatePresence
- drag
- useAnimate
- useSyncExternalStore
- ResizeObserver
- aria-live
Toasts appear bottom-right. Hover to expand · swipe right or press Esc to dismiss.
Install
npx shadcn@latest add https://tcosta.dev/r/toast.jsonOr copy the files below into components/toast/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need motion.
Source
toaster.tsx
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { useState, type FocusEvent } from 'react';
import { stackTransition } from './config';
import { ToastItem } from './toast-item';
import { useToasts } from './toast-store';
import { usePageHidden } from './use-page-hidden';
import { useStackLayout } from './use-stack-layout';
export function Toaster({ visibleToasts = 3 }: { visibleToasts?: number }) {
const toasts = useToasts();
const isPageHidden = usePageHidden();
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const isExpanded = (isHovered || isFocused) && toasts.length > 0;
const { onHeight, stackHeight, layoutOf } = useStackLayout(toasts, visibleToasts, isExpanded);
const onBlur = (event: FocusEvent<HTMLOListElement>) => {
if (!event.currentTarget.contains(event.relatedTarget)) setIsFocused(false);
};
return (
<section
aria-label="Notifications"
className="pointer-events-none fixed right-4 bottom-4 z-50 w-[min(356px,calc(100vw-2rem))]"
>
<motion.ol
aria-live="polite"
initial={false}
animate={{ height: stackHeight }}
transition={stackTransition}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsFocused(true)}
onBlur={onBlur}
className="pointer-events-auto relative"
>
<AnimatePresence initial={false}>
{toasts.map((toast, index) => {
const isInStack = index < visibleToasts;
return (
<ToastItem
key={toast.id}
toast={toast}
index={index}
total={toasts.length}
isInStack={isInStack}
isExpanded={isExpanded}
isPaused={isExpanded || isPageHidden || !isInStack}
layout={layoutOf(index)}
onHeight={onHeight}
/>
);
})}
</AnimatePresence>
</motion.ol>
</section>
);
}
toast-item.tsx
import { motion, useAnimate, type PanInfo } from 'motion/react';
import { cn } from '@/lib/utils';
import { DEFAULT_DURATION, SCALE_STEP, stackTransition } from './config';
import { ToastContent } from './toast-content';
import { dismiss, type ToastData } from './toast-store';
import { useReportHeight } from './use-stack-layout';
import { useToastTimer } from './use-toast-timer';
type ToastItemProps = {
toast: ToastData;
index: number;
total: number;
isInStack: boolean;
isExpanded: boolean;
isPaused: boolean;
layout: { y: number; height: number | undefined };
onHeight: (id: number, height: number) => void;
};
const itemClassName = cn(
'group absolute inset-x-0 bottom-0 cursor-grab touch-pan-y overflow-hidden outline-none active:cursor-grabbing',
'rounded-xl border bg-popover text-popover-foreground shadow-[0_4px_16px_rgb(0_0_0/0.08)]',
'focus-visible:ring-[3px] focus-visible:ring-ring/50',
);
export function ToastItem({
toast,
index,
total,
isInStack,
isExpanded,
isPaused,
layout,
onHeight,
}: ToastItemProps) {
const [scope, animate] = useAnimate<HTMLLIElement>();
const content = useReportHeight<HTMLDivElement>(toast.id, onHeight);
const isFront = index === 0;
useToastTimer({
id: toast.id,
type: toast.type,
duration: toast.duration ?? DEFAULT_DURATION,
isPaused,
});
// Removing a focused element doesn't fire `blur`, which would leave the stack expanded and paused.
const dismissSelf = () => {
const focused = document.activeElement;
if (focused instanceof HTMLElement && scope.current?.contains(focused)) focused.blur();
dismiss(toast.id);
};
// Swiped far or fast enough: fly out, then dismiss. Otherwise it springs back.
const onDragEnd = async (_: unknown, info: PanInfo) => {
if (info.offset.x < 80 && info.velocity.x < 400) return;
await animate(scope.current, { x: 420, opacity: 0 }, { duration: 0.18, ease: 'easeOut' });
dismissSelf();
};
return (
<motion.li
ref={scope}
tabIndex={0}
aria-hidden={!isInStack || undefined}
initial={{ opacity: 0, y: 32 }}
animate={{
opacity: isInStack ? 1 : 0,
y: layout.y,
scale: isExpanded ? 1 : 1 - index * SCALE_STEP,
height: layout.height ?? 'auto',
}}
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } }}
transition={stackTransition}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0.04, right: 0.8 }}
onDragEnd={onDragEnd}
onKeyDown={(event) => event.key === 'Escape' && dismissSelf()}
style={{ zIndex: total - index, transformOrigin: 'top center' }}
className={cn(itemClassName, !isInStack && 'pointer-events-none')}
>
{/* Toasts behind the front one hide their content, so only their edge peeks. */}
<motion.div
ref={content}
initial={false}
animate={{ opacity: isExpanded || isFront ? 1 : 0 }}
transition={{ duration: 0.2 }}
className="flex items-start gap-3 p-4"
>
<ToastContent toast={toast} onDismiss={dismissSelf} />
</motion.div>
</motion.li>
);
}
toast-content.tsx
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { swapTransition } from './config';
import { CloseIcon, ToastIcon } from './icons';
import type { ToastData } from './toast-store';
const actionClassName = cn(
'shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground',
'hover:bg-primary/90 active:scale-[0.97]',
);
const closeClassName = cn(
'-mt-1 -mr-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity',
'group-hover:opacity-100 group-focus-visible:opacity-100 focus-visible:opacity-100',
'hover:bg-accent hover:text-accent-foreground',
);
// Icon and title cross-fade when a promise toast turns into success/error.
const swap = {
icon: {
initial: { opacity: 0, scale: 0.5, filter: 'blur(3px)' },
animate: { opacity: 1, scale: 1, filter: 'blur(0px)' },
exit: { opacity: 0, scale: 0.5, filter: 'blur(3px)' },
},
title: {
initial: { opacity: 0, y: 4 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -4 },
},
};
type ToastContentProps = {
toast: ToastData;
onDismiss: () => void;
};
export function ToastContent({ toast, onDismiss }: ToastContentProps) {
const { type, title, description, action } = toast;
return (
<>
<span className="relative mt-0.5 flex size-4 shrink-0">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={type} {...swap.icon} transition={swapTransition} className="flex">
<ToastIcon type={type} />
</motion.span>
</AnimatePresence>
</span>
<div className="min-w-0 flex-1">
<AnimatePresence mode="popLayout" initial={false}>
<motion.p
key={type}
{...swap.title}
transition={swapTransition}
className="text-sm font-medium"
>
{title}
</motion.p>
</AnimatePresence>
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
</div>
{action && (
<button
type="button"
onClick={() => {
action.onClick();
onDismiss();
}}
className={actionClassName}
>
{action.label}
</button>
)}
<button
type="button"
aria-label="Dismiss notification"
onClick={onDismiss}
className={closeClassName}
>
<CloseIcon />
</button>
</>
);
}
toast-store.ts
import { useSyncExternalStore, type ReactNode } from 'react';
import { DEFAULT_DURATION } from './config';
export type ToastType = 'default' | 'success' | 'error' | 'loading';
export type ToastOptions = {
description?: ReactNode;
/** ms before auto-dismiss. `Infinity` keeps it until dismissed. */
duration?: number;
action?: { label: string; onClick: () => void };
};
export type ToastData = ToastOptions & { id: number; type: ToastType; title: ReactNode };
type Message<T> = ReactNode | ((value: T) => ReactNode);
// Module-level store, so `toast()` works from anywhere without a provider.
const EMPTY: ToastData[] = [];
let toasts = EMPTY;
let nextId = 1;
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function create(type: ToastType, title: ReactNode, options: ToastOptions = {}) {
const id = nextId++;
toasts = [{ id, type, title, ...options }, ...toasts];
emit();
return id;
}
function update(id: number, patch: Partial<ToastData>) {
toasts = toasts.map((toast) => (toast.id === id ? { ...toast, ...patch } : toast));
emit();
}
export function dismiss(id?: number) {
toasts = id === undefined ? EMPTY : toasts.filter((toast) => toast.id !== id);
emit();
}
function resolve<T>(message: Message<T>, value: T) {
return typeof message === 'function' ? (message as (value: T) => ReactNode)(value) : message;
}
function promise<T>(
promise: Promise<T>,
messages: { loading: ReactNode; success: Message<T>; error: Message<unknown> },
options?: ToastOptions,
) {
const id = create('loading', messages.loading, { ...options, duration: Infinity });
const duration = options?.duration ?? DEFAULT_DURATION;
promise.then(
(data) => update(id, { type: 'success', title: resolve(messages.success, data), duration }),
(error) => update(id, { type: 'error', title: resolve(messages.error, error), duration }),
);
return promise;
}
export const toast = Object.assign(
(title: ReactNode, options?: ToastOptions) => create('default', title, options),
{
success: (title: ReactNode, options?: ToastOptions) => create('success', title, options),
error: (title: ReactNode, options?: ToastOptions) => create('error', title, options),
promise,
dismiss,
},
);
export function useToasts() {
return useSyncExternalStore(
subscribe,
() => toasts,
() => EMPTY,
);
}
use-stack-layout.ts
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { GAP, PEEK } from './config';
import type { ToastData } from './toast-store';
type OnHeight = (id: number, height: number) => void;
/** Position of each toast in the stack, from their measured heights. */
export function useStackLayout(toasts: ToastData[], visibleToasts: number, isExpanded: boolean) {
const [heights, setHeights] = useState<Record<number, number>>({});
const onHeight = useCallback<OnHeight>((id, height) => {
setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }));
}, []);
const shown = toasts.slice(0, visibleToasts);
const heightOf = (toast: ToastData) => heights[toast.id] ?? 0;
const frontHeight = shown[0] ? heightOf(shown[0]) : 0;
const gaps = Math.max(shown.length - 1, 0);
const stackHeight = isExpanded
? shown.reduce((sum, toast) => sum + heightOf(toast), 0) + GAP * gaps
: frontHeight + PEEK * gaps;
// Expanded: stacked above the newer toasts in front. Collapsed: peeking behind the front one.
const layoutOf = (index: number) => {
const offset = shown.slice(0, index).reduce((sum, toast) => sum + heightOf(toast) + GAP, 0);
return {
y: isExpanded ? -offset : -index * PEEK,
height: isExpanded || index === 0 ? heights[toasts[index].id] : frontHeight,
};
};
return { onHeight, stackHeight, layoutOf };
}
/** Reports the element's natural height, now and whenever it resizes. */
export function useReportHeight<T extends HTMLElement>(id: number, onHeight: OnHeight) {
const ref = useRef<T>(null);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(() => onHeight(id, element.offsetHeight));
observer.observe(element);
onHeight(id, element.offsetHeight);
return () => observer.disconnect();
}, [id, onHeight]);
return ref;
}
use-toast-timer.ts
import { useEffect, useRef } from 'react';
import { dismiss, type ToastType } from './toast-store';
type UseToastTimerOptions = {
id: number;
type: ToastType;
duration: number;
isPaused: boolean;
};
/** Auto-dismiss countdown that pauses, and remembers the time left, while `isPaused`. */
export function useToastTimer({ id, type, duration, isPaused }: UseToastTimerOptions) {
const remaining = useRef(duration);
// A promise toast that settles gets a fresh countdown.
useEffect(() => {
remaining.current = duration;
}, [duration, type]);
useEffect(() => {
if (isPaused || !Number.isFinite(duration)) return;
const started = Date.now();
const timeout = setTimeout(() => dismiss(id), remaining.current);
return () => {
clearTimeout(timeout);
remaining.current -= Date.now() - started;
};
}, [isPaused, duration, id, type]);
}
use-page-hidden.ts
import { useEffect, useState } from 'react';
export function usePageHidden() {
const [isHidden, setIsHidden] = useState(false);
useEffect(() => {
const onChange = () => setIsHidden(document.hidden);
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}, []);
return isHidden;
}
config.ts
import type { Transition } from 'motion/react';
export const DEFAULT_DURATION = 4000;
export const GAP = 12; // between toasts when expanded
export const PEEK = 10; // how much each toast behind peeks out when collapsed
export const SCALE_STEP = 0.05;
// Low bounce: the stack should settle, not wobble.
export const stackTransition: Transition = { type: 'spring', duration: 0.45, bounce: 0.12 };
export const swapTransition: Transition = { type: 'spring', duration: 0.3, bounce: 0 };
icons.tsx
import type { ToastType } from './toast-store';
function Spinner() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
className="animate-spin text-muted-foreground"
aria-hidden
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
const glyphs = {
default: { className: 'text-muted-foreground', path: 'M12 11v5.5M12 7.5v.01' },
success: { className: 'text-emerald-500', path: 'm8 12.5 2.5 2.5L16 9.5' },
error: { className: 'text-destructive', path: 'M12 7.5v5.5M12 16.5v.01' },
};
export function ToastIcon({ type }: { type: ToastType }) {
if (type === 'loading') return <Spinner />;
const { className, path } = glyphs[type];
return (
<svg width="16" height="16" viewBox="0 0 24 24" className={className} aria-hidden>
<circle cx="12" cy="12" r="10" fill="currentColor" />
<path
d={path}
fill="none"
stroke="white"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function CloseIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
aria-hidden
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
index.ts
export { Toaster } from './toaster';
export { toast, type ToastOptions, type ToastType } from './toast-store';