Toast
A non-blocking notification system with stacking and swipe-to-dismiss.
Overview
A robust notification queue managing temporary alerts with full styling, stacking support, and swipe-to-dismiss gestures. Built on Radix UI primitives for accessibility.
Features
- Automatic stacking of notifications
- Swipe-to-dismiss gesture support
- Rich content with titles and actions
- Multiple variant styles
- Auto-dismiss with configurable timing
Preview
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/toast.jsonMethod 2: Manual
-
Install Dependencies
npm install framer-motion lucide-react -
Copy the Source Code
Copy the code below into
components/zenblocks/toast.tsx.
Click to expand source
"use client";
import React, { createContext, useContext, useEffect, useReducer, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X, CheckCircle2, AlertOctagon, Info, AlertTriangle, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* TYPES */
/* -------------------------------------------------------------------------- */
export type ToastVariant = "default" | "success" | "error" | "warning" | "info";
export interface ToastData {
id: string;
title?: string;
description?: string;
variant?: ToastVariant;
duration?: number;
}
interface ToastContextValue {
toasts: ToastData[];
toast: (props: Omit<ToastData, "id">) => void;
dismiss: (id: string) => void;
}
/* -------------------------------------------------------------------------- */
/* CONTEXT */
/* -------------------------------------------------------------------------- */
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
/* -------------------------------------------------------------------------- */
/* REDUCER */
/* -------------------------------------------------------------------------- */
type Action =
| { type: "ADD_TOAST"; toast: ToastData }
| { type: "DISMISS_TOAST"; id: string };
const toastReducer = (state: ToastData[], action: Action): ToastData[] => {
switch (action.type) {
case "ADD_TOAST":
return [action.toast, ...state].slice(0, 5); // Limit to 5 toasts
case "DISMISS_TOAST":
return state.filter((t) => t.id !== action.id);
default:
return state;
}
};
/* -------------------------------------------------------------------------- */
/* PROVIDER */
/* -------------------------------------------------------------------------- */
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const [toasts, dispatch] = useReducer(toastReducer, []);
const toast = (props: Omit<ToastData, "id">) => {
const id = Math.random().toString(36).substring(2, 9);
dispatch({ type: "ADD_TOAST", toast: { ...props, id } });
};
const dismiss = (id: string) => {
dispatch({ type: "DISMISS_TOAST", id });
};
return (
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
{children}
<ToastViewport />
</ToastContext.Provider>
);
}
/* -------------------------------------------------------------------------- */
/* HOOK */
/* -------------------------------------------------------------------------- */
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
};
/* -------------------------------------------------------------------------- */
/* VIEWPORT */
/* -------------------------------------------------------------------------- */
function ToastViewport() {
const { toasts } = useToast();
return (
<div
className="fixed bottom-0 right-0 z-[100] flex flex-col gap-3 p-6 w-full max-w-[420px] pointer-events-none"
aria-live="polite"
>
<AnimatePresence mode="popLayout" initial={false}>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} />
))}
</AnimatePresence>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* TOAST ITEM */
/* -------------------------------------------------------------------------- */
const VARIANT_STYLES: Record<ToastVariant, string> = {
default: "border-zinc-200/50 bg-white/60 dark:bg-zinc-900/60 dark:border-zinc-800/50 text-zinc-900 dark:text-zinc-100",
success: "border-emerald-500/20 bg-emerald-50/80 dark:bg-emerald-950/40 text-emerald-900 dark:text-emerald-100 shadow-lg shadow-emerald-500/20",
error: "border-rose-500/20 bg-rose-50/80 dark:bg-rose-950/40 text-rose-900 dark:text-rose-100 shadow-lg shadow-rose-500/20",
warning: "border-amber-500/20 bg-amber-50/80 dark:bg-amber-950/40 text-amber-900 dark:text-amber-100 shadow-lg shadow-amber-500/20",
info: "border-sky-500/20 bg-sky-50/80 dark:bg-sky-950/40 text-sky-900 dark:text-sky-100 shadow-lg shadow-sky-500/20",
};
const VARIANT_ICONS: Record<ToastVariant, React.ReactNode> = {
default: <Sparkles className="w-5 h-5 text-indigo-500" />,
success: <CheckCircle2 className="w-5 h-5 text-emerald-500 shadow-sm" />,
error: <AlertOctagon className="w-5 h-5 text-rose-500 shadow-sm" />,
warning: <AlertTriangle className="w-5 h-5 text-amber-500 shadow-sm" />,
info: <Info className="w-5 h-5 text-sky-500 shadow-sm" />,
};
function ToastItem({ toast }: { toast: ToastData }) {
const { dismiss } = useToast();
const [paused, setPaused] = useState(false);
const duration = toast.duration || 5000;
useEffect(() => {
if (paused) return;
const timer = setTimeout(() => {
dismiss(toast.id);
}, duration);
return () => clearTimeout(timer);
}, [toast.id, duration, paused, dismiss]);
return (
<motion.div
layout
initial={{ opacity: 0, y: 100, scale: 0.8, rotate: 5 }}
animate={{ opacity: 1, y: 0, scale: 1, rotate: 0 }}
exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 350, damping: 25, mass: 1 }}
className={cn(
"pointer-events-auto relative flex w-full items-start gap-4 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl overflow-hidden group",
VARIANT_STYLES[toast.variant || "default"]
)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
role="status"
>
{/* Animated Progress Bar */}
<motion.div
initial={{ scaleX: 1 }}
animate={{ scaleX: paused ? 1 : 0 }}
transition={{ duration: duration / 1000, ease: "linear" }}
className={cn(
"absolute bottom-0 left-0 w-full h-[2px] opacity-30 origin-left",
toast.variant === "error" ? "bg-rose-500" :
toast.variant === "success" ? "bg-emerald-500" :
toast.variant === "warning" ? "bg-amber-500" :
"bg-indigo-500"
)}
/>
{VARIANT_ICONS[toast.variant || "default"] && (
<div className="shrink-0 pt-0.5">
{VARIANT_ICONS[toast.variant || "default"]}
</div>
)}
<div className="flex-1 grid gap-1 relative z-10">
{toast.title && <div className="text-sm font-bold leading-none tracking-tight">{toast.title}</div>}
{toast.description && <div className="text-xs opacity-80 leading-relaxed font-medium">{toast.description}</div>}
</div>
<button
onClick={() => dismiss(toast.id)}
className="shrink-0 rounded-full p-1 opacity-0 group-hover:opacity-100 transition-all hover:bg-black/5 dark:hover:bg-white/10 focus:opacity-100 focus:outline-none"
aria-label="Close"
>
<X className="w-4 h-4 opacity-50" />
</button>
{/* Background Gradient Blob */}
<div className="absolute -z-10 -top-10 -right-10 w-24 h-24 bg-gradient-to-br from-white/20 to-transparent blur-2xl rounded-full pointer-events-none" />
</motion.div>
);
}Usage
- Wrap your app with
ToastProvider:
import { ToastProvider } from "@/components/zenblocks/toast";
import { Toaster } from "@/components/ui/toaster";
export default function RootLayout({ children }) {
return (
<html>
<body>
<ToastProvider>
{children}
<Toaster />
</ToastProvider>
</body>
</html>
);
}- Trigger toasts using the hook:
import { useToast } from "@/components/zenblocks/toast";
export function Action() {
const { toast } = useToast();
return (
<button onClick={() => toast({
title: "Saved",
description: "Changes updated."
})}>
Save
</button>
);
}Props
The toast() function accepts:
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | - | Main heading |
| description | string | - | Supporting text |
| action | ReactNode | - | Action button (e.g. "Undo") |
| duration | number | 5000 | Auto-close time in ms |
| variant | "default" | "success" | "error" | "warning" | "info" | "default" | Visual style |
Accessibility
- Uses
role="status"(default) orrole="alert"(destructive) - Action buttons are keyboard accessible
- Respects user timing preferences
- Hotkey
Alt + Tmoves focus to toast viewport
Customization
Positioning
Modify ToastViewport className in source:
<ToastViewport className="top-0 right-0 flex-col-reverse" />Max Queue
Limit visible toasts in reducer logic (default: 5).
Variants
toast({
title: "Error",
variant: "error",
description: "Something went wrong"
})Motion Behavior
- Entry: Slides in from right with spring animation
- Exit: Fades opacity and translates X on swipe or timeout
- Stacking: Uses
layoutprop for smooth repositioning
Performance Notes
- Uses React Context for global queue state
toastfunction is stable (safe inuseEffectdependencies)- Progress bar uses CSS transforms (GPU-accelerated)
- Pause on hover stops auto-dismiss timer
Examples
With Action
toast({
title: "Deleted",
description: "File removed permanently.",
action: <ToastAction altText="undo">Undo</ToastAction>,
})Success Notification
toast({
title: "Success!",
description: "Your changes have been saved.",
variant: "success"
})Notes
- Place
Toasterat root level to prevent z-index clipping - Requires
framer-motionfor animations - Toast queue is managed globally via Context
- Swipe gesture works on touch devices