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

Live Preview

Installation

Install via the ZenBlocks CLI to automatically handle dependencies:

npx shadcn@latest add https://zenblocks-three.vercel.app/r/toast.json

Method 2: Manual

  1. Install Dependencies

    npm install framer-motion lucide-react
  2. 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

  1. 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>
  );
}
  1. 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:

PropTypeDefaultDescription
titlestring-Main heading
descriptionstring-Supporting text
actionReactNode-Action button (e.g. "Undo")
durationnumber5000Auto-close time in ms
variant"default" | "success" | "error" | "warning" | "info""default"Visual style

Accessibility

  • Uses role="status" (default) or role="alert" (destructive)
  • Action buttons are keyboard accessible
  • Respects user timing preferences
  • Hotkey Alt + T moves 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 layout prop for smooth repositioning

Performance Notes

  • Uses React Context for global queue state
  • toast function is stable (safe in useEffect dependencies)
  • 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 Toaster at root level to prevent z-index clipping
  • Requires framer-motion for animations
  • Toast queue is managed globally via Context
  • Swipe gesture works on touch devices