About
Toast notifications with success, error, warning and info variants, action buttons, stacking and configurable placement. Built on Base UI for accessible, swipe to dismiss feedback.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Toast to your project and fits it to your theme.
Code
How Toast is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { IconArrowRight } from "@tabler/icons-react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Toast, ToastProvider, ToastViewport, type ToastPlacement, type ToastVariant } from "@/components/ui/toast";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const Button = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost";
leftIcon?: React.ReactNode;
}
>(function Button({ variant = "primary", leftIcon, className, children, ...props }, ref) {
return (
<button
ref={ref}
type="button"
className={cn(
"inline-flex h-9 items-center justify-center gap-2 rounded-[10px] px-4 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] [&_svg]:size-4",
variant === "primary" && "bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
variant === "secondary" && "border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] hover:bg-[var(--muted)]",
variant === "ghost" && "text-[var(--foreground)] hover:bg-[var(--muted)]",
className,
)}
{...props}
>
{leftIcon}
{children}
</button>
);
});
const primitivePreviewCardSurfaceFrameClass = cn(
"relative isolate overflow-hidden rounded-2xl",
"border",
);
const primitivePreviewCardSurfaceLightClass = cn(
"border-black/[0.08] bg-[#f4f4f5]",
"[box-shadow:0_1px_2px_rgba(0,0,0,.06),0_8px_24px_-12px_rgba(0,0,0,.08)]",
);
const primitivePreviewCardSurfaceClass = cn(
primitivePreviewCardSurfaceFrameClass,
primitivePreviewCardSurfaceLightClass,
// Full static `dark:bg-[#0a0a0b]` kept for Tailwind content scan - same as
// PRIMITIVE_PREVIEW_CANVAS_DARK_BG / PRIMITIVE_PREVIEW_CANVAS_DARK.
"dark:border-white/[0.06] dark:bg-[#0a0a0b] dark:[box-shadow:0_1px_2px_rgba(0,0,0,.4),0_8px_24px_-12px_rgba(0,0,0,.6)]",
);
const cardSurface = primitivePreviewCardSurfaceClass;
function PreviewPanel({
title,
subtitle,
children,
className,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(cardSurface, "w-full max-w-lg p-5", className)}>
<div className="mb-4 border-b border-[var(--border)] pb-3">
<p className="text-sm font-semibold tracking-[-0.015em] text-[var(--foreground)]">
{title}
</p>
{subtitle ? (
<p className="mt-1 text-[12px] text-[var(--muted-foreground)]">{subtitle}</p>
) : null}
</div>
{children}
</div>
);
}
type ToastDemoCopy = {
variant: ToastVariant;
title: string;
description: string;
actionLabel?: string;
};
type ActiveToast = ToastDemoCopy & {
id: number;
placement: ToastPlacement;
};
const previewPlacements: ToastPlacement[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
"top-center",
];
let toastId = 0;
let activeToasts: ActiveToast[] = [];
const toastListeners = new Set<() => void>();
const toastTimers = new Map<number, number>();
let hostId = 0;
let primaryHostId: number | null = null;
const mountedHostIds: number[] = [];
const hostListeners = new Set<() => void>();
function emitToastChange() {
toastListeners.forEach((listener) => listener());
}
function getToastSnapshot() {
return activeToasts;
}
function subscribeToastStore(listener: () => void) {
toastListeners.add(listener);
return () => {
toastListeners.delete(listener);
};
}
function dismissPreviewToast(id: number) {
const timer = toastTimers.get(id);
if (timer) {
window.clearTimeout(timer);
toastTimers.delete(id);
}
activeToasts = activeToasts.filter((toast) => toast.id !== id);
emitToastChange();
}
function pushPreviewToast(
toast: ToastDemoCopy,
placement: ToastPlacement = "top-right",
persist = false,
) {
const id = ++toastId;
const nextToast = { ...toast, id, placement };
const placementCounts = new Map<ToastPlacement, number>();
const nextToasts = [nextToast, ...activeToasts].filter((item) => {
const count = placementCounts.get(item.placement) ?? 0;
if (count >= 4) return false;
placementCounts.set(item.placement, count + 1);
return true;
});
const nextToastIds = new Set(nextToasts.map((item) => item.id));
activeToasts.forEach((item) => {
if (!nextToastIds.has(item.id)) {
const timer = toastTimers.get(item.id);
if (timer) {
window.clearTimeout(timer);
toastTimers.delete(item.id);
}
}
});
activeToasts = nextToasts;
emitToastChange();
if (persist) return;
const timer = window.setTimeout(() => {
dismissPreviewToast(id);
}, 4600);
toastTimers.set(id, timer);
}
function emitHostChange() {
hostListeners.forEach((listener) => listener());
}
function syncPrimaryHost() {
const nextPrimaryHostId =
primaryHostId && mountedHostIds.includes(primaryHostId)
? primaryHostId
: (mountedHostIds[0] ?? null);
if (primaryHostId !== nextPrimaryHostId) {
primaryHostId = nextPrimaryHostId;
emitHostChange();
}
}
function usePrimaryToastHost() {
const [id] = React.useState(() => ++hostId);
const [isPrimary, setIsPrimary] = React.useState(false);
React.useEffect(() => {
const update = () => setIsPrimary(primaryHostId === id);
mountedHostIds.push(id);
hostListeners.add(update);
syncPrimaryHost();
update();
return () => {
const index = mountedHostIds.indexOf(id);
if (index !== -1) {
mountedHostIds.splice(index, 1);
}
hostListeners.delete(update);
syncPrimaryHost();
};
}, [id]);
return isPrimary;
}
function ToastDemoStackHost() {
const isPrimary = usePrimaryToastHost();
const shouldReduceMotion = useReducedMotion();
const [portalRoot, setPortalRoot] = React.useState<HTMLElement | null>(null);
const toasts = React.useSyncExternalStore(
subscribeToastStore,
getToastSnapshot,
getToastSnapshot,
);
React.useEffect(() => {
setPortalRoot(document.body);
}, []);
if (!isPrimary || !portalRoot) return null;
return createPortal(
<ToastProvider>
{previewPlacements.map((placement) => (
<ToastPlacementStack
key={placement}
placement={placement}
shouldReduceMotion={shouldReduceMotion}
toasts={toasts.filter((toast) => toast.placement === placement)}
/>
))}
</ToastProvider>,
portalRoot,
);
}
function ToastPlacementStack({
placement,
shouldReduceMotion,
toasts,
}: {
placement: ToastPlacement;
shouldReduceMotion: boolean | null;
toasts: ActiveToast[];
}) {
const [expanded, setExpanded] = React.useState(false);
const isBottom = placement.startsWith("bottom");
const isLeft = placement.endsWith("left");
const origin = `${isBottom ? "bottom" : "top"} ${isLeft ? "left" : "right"}`;
if (toasts.length === 0) return null;
const collapsedHeight = 72 + Math.max(0, toasts.length - 1) * 12;
const expandedHeight = 72 + Math.max(0, toasts.length - 1) * 82;
const stackHeight = expanded || shouldReduceMotion ? expandedHeight : collapsedHeight;
return (
<ToastViewport
placement={placement}
className={cn("z-[9999] block h-[360px]", isBottom ? "bottom-6" : "top-24")}
>
<motion.div
animate={{ height: stackHeight }}
transition={{
type: "spring",
stiffness: shouldReduceMotion ? 1000 : 520,
damping: shouldReduceMotion ? 100 : 38,
}}
className={cn("pointer-events-auto absolute inset-x-0 w-full", isBottom ? "bottom-0" : "top-0")}
onHoverStart={() => setExpanded(true)}
onHoverEnd={() => setExpanded(false)}
onFocusCapture={() => setExpanded(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setExpanded(false);
}
}}
>
<AnimatePresence initial={false}>
{toasts.map((toast, index) => {
const direction = isBottom ? -1 : 1;
const collapsedY = direction * index * 12;
const expandedY = direction * index * 82;
const collapsedScale = Math.max(0.9, 1 - index * 0.035);
const isExpanded = expanded || shouldReduceMotion;
return (
<motion.div
key={toast.id}
initial={{
opacity: 0,
y: shouldReduceMotion ? 0 : -12,
scale: 0.98,
}}
animate={{
opacity: isExpanded ? 1 : Math.max(0.72, 1 - index * 0.1),
y: isExpanded ? expandedY : collapsedY,
scale: isExpanded ? 1 : collapsedScale,
}}
exit={{
opacity: 0,
y: shouldReduceMotion ? 0 : -8,
scale: 0.98,
}}
transition={{
opacity: { duration: shouldReduceMotion ? 0 : 0.16 },
y: {
type: "spring",
stiffness: shouldReduceMotion ? 1000 : 520,
damping: shouldReduceMotion ? 100 : 38,
},
scale: { duration: shouldReduceMotion ? 0 : 0.18, ease: [0.22, 1, 0.36, 1] },
}}
style={{
zIndex: toasts.length - index,
transformOrigin: origin,
}}
className={cn("absolute inset-x-0 w-full", isBottom ? "bottom-0" : "top-0")}
>
<Toast
variant={toast.variant}
title={toast.title}
description={toast.description}
action={
toast.actionLabel ? (
<Button
type="button"
size="sm"
variant="secondary"
rightIcon={<IconArrowRight />}
rightIconMotion="arrow"
>
{toast.actionLabel}
</Button>
) : null
}
/>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
</ToastViewport>
);
}
function ToastTrigger({
buttonLabel,
toast,
placement = "top-right",
}: {
buttonLabel: string;
toast: ToastDemoCopy;
placement?: ToastPlacement;
}) {
// Show one toast on mount so the preview is never empty.
React.useEffect(() => {
const timer = window.setTimeout(() => pushPreviewToast(toast, placement, true), 400);
return () => window.clearTimeout(timer);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<ToastDemoStackHost />
<Button
type="button"
variant="secondary"
rightIcon={<IconArrowRight />}
rightIconMotion="arrow"
onClick={() => pushPreviewToast(toast, placement)}
>
{buttonLabel}
</Button>
</>
);
}
export function ToastPreview() {
return (
<PreviewPanel title="Notifications" subtitle="Transient status cards with smooth stacked delivery.">
<div className="flex flex-wrap items-center gap-2.5">
<ToastTrigger
buttonLabel="Show toast"
toast={{
variant: "success",
title: "Template archive ready",
description: "The buyer ZIP was generated and uploaded.",
}}
/>
</div>
</PreviewPanel>
);
}
export default function Demo() {
return <ToastPreview />;
}








