Toast
A brief, auto-dismissing notification that appears at the edge of the screen to inform users of the result of an action without interrupting their workflow.
Use Cases
- Confirm a successful form submission (item saved, profile updated)
- Warn about a non-critical issue (slow connection, feature deprecation)
- Show error feedback after a failed API call
- Inform about background events (new message received, file uploaded)
- Display undo actions after destructive operations (item deleted — undo)
- Notify about permission or access changes
Simplest Implementation
"use client";
import { useState, useEffect } from "react";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
duration?: number;
}
export function Toast({ message, open, onClose, duration = 3000 }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div className="fixed bottom-4 right-4 z-50 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
{message}
</div>
);
}A minimal toast that auto-dismisses after duration milliseconds. The useEffect cleanup clears the timer if the toast is closed early or the component unmounts, preventing stale state updates.
Variations
Success / Error / Warning / Info Types
"use client";
import { useEffect } from "react";
type ToastType = "success" | "error" | "warning" | "info";
interface ToastProps {
message: string;
type?: ToastType;
open: boolean;
onClose: () => void;
duration?: number;
}
const typeClasses: Record<ToastType, string> = {
success: "bg-green-600 text-white",
error: "bg-red-600 text-white",
warning: "bg-yellow-500 text-gray-900",
info: "bg-blue-600 text-white",
};
const typeIcons: Record<ToastType, string> = {
success: "✓",
error: "✕",
warning: "⚠",
info: "ℹ",
};
export function Toast({ message, type = "info", open, onClose, duration = 3000 }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div
role="alert"
className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium shadow-lg ${typeClasses[type]}`}
>
<span aria-hidden="true">{typeIcons[type]}</span>
{message}
</div>
);
}Maps toast types to color classes and icons using Record. The role="alert" attribute ensures screen readers announce the toast immediately when it appears.
With Action Button
"use client";
import { useEffect } from "react";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
export function Toast({ message, open, onClose, duration = 5000, action }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
<span>{message}</span>
{action && (
<button
onClick={() => {
action.onClick();
onClose();
}}
className="shrink-0 rounded px-2 py-1 font-semibold text-blue-400 hover:bg-gray-800"
>
{action.label}
</button>
)}
</div>
);
}The action button calls its handler and then closes the toast. The duration is set longer (5 seconds) to give users enough time to read and act. The action button uses shrink-0 to prevent it from compressing on narrow screens.
With Progress Bar
"use client";
import { useEffect, useState } from "react";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
duration?: number;
}
export function Toast({ message, open, onClose, duration = 4000 }: ToastProps) {
const [progress, setProgress] = useState(100);
useEffect(() => {
if (!open) {
setProgress(100);
return;
}
const interval = 50;
const step = (interval / duration) * 100;
const timer = setInterval(() => {
setProgress((prev) => {
const next = prev - step;
if (next <= 0) {
clearInterval(timer);
onClose();
return 0;
}
return next;
});
}, interval);
return () => clearInterval(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-80 overflow-hidden rounded-lg bg-gray-900 shadow-lg">
<div className="px-4 py-3 text-sm text-white">{message}</div>
<div className="h-1 bg-gray-700">
<div
className="h-full bg-blue-500 transition-none"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
}The progress bar drains from 100% to 0% over the duration using setInterval. The transition-none class prevents Tailwind transitions from interfering with the smooth JavaScript-driven animation. Progress resets to 100% when the toast closes.
Stacked Toasts
"use client";
import { useState, useCallback, useEffect } from "react";
interface ToastItem {
id: number;
message: string;
}
let toastId = 0;
export function useToast() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const add = useCallback((message: string) => {
const id = ++toastId;
setToasts((prev) => [...prev, { id, message }]);
}, []);
const remove = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return { toasts, add, remove };
}
function ToastCard({ item, onRemove }: { item: ToastItem; onRemove: (id: number) => void }) {
useEffect(() => {
const timer = setTimeout(() => onRemove(item.id), 3000);
return () => clearTimeout(timer);
}, [item.id, onRemove]);
return (
<div className="rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
{item.message}
</div>
);
}
export function ToastContainer({ toasts, onRemove }: { toasts: ToastItem[]; onRemove: (id: number) => void }) {
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2">
{toasts.map((t) => (
<ToastCard key={t.id} item={t} onRemove={onRemove} />
))}
</div>
);
}Each toast manages its own auto-dismiss timer independently. The container uses flex-col-reverse so new toasts stack above older ones. The useToast hook is placed in a parent component, and ToastContainer is rendered once at the layout level.
Positioned (Top / Bottom)
"use client";
import { useEffect } from "react";
type Position = "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
position?: Position;
duration?: number;
}
const positionClasses: Record<Position, string> = {
"top-right": "top-4 right-4",
"top-left": "top-4 left-4",
"bottom-right": "bottom-4 right-4",
"bottom-left": "bottom-4 left-4",
"top-center": "top-4 left-1/2 -translate-x-1/2",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
};
export function Toast({ message, open, onClose, position = "bottom-right", duration = 3000 }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div
role="status"
className={`fixed z-50 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg ${positionClasses[position]}`}
>
{message}
</div>
);
}A Record maps position names to Tailwind positioning classes. The centered positions use left-1/2 -translate-x-1/2 for horizontal centering regardless of toast width.
Dismissible with Close Button
"use client";
import { useEffect } from "react";
interface ToastProps {
message: string;
open: boolean;
onClose: () => void;
duration?: number;
dismissible?: boolean;
}
export function Toast({ message, open, onClose, duration = 3000, dismissible = true }: ToastProps) {
useEffect(() => {
if (!open) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
return (
<div
role="alert"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg"
>
<span>{message}</span>
{dismissible && (
<button
onClick={onClose}
aria-label="Dismiss"
className="shrink-0 rounded p-0.5 text-gray-400 hover:text-white"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}Adds a close icon that lets users dismiss the toast manually. The aria-label="Dismiss" provides an accessible name for the icon-only button. The auto-dismiss timer still runs, so the toast closes on its own if the user ignores it.
Complex Implementation
"use client";
import { createContext, useContext, useCallback, useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
// --- Types ---
type ToastType = "success" | "error" | "warning" | "info";
type Position = "top-right" | "top-left" | "bottom-right" | "bottom-left";
interface ToastAction {
label: string;
onClick: () => void;
}
interface ToastItem {
id: number;
message: string;
type: ToastType;
duration: number;
action?: ToastAction;
dismissible: boolean;
}
interface ToastOptions {
type?: ToastType;
duration?: number;
action?: ToastAction;
dismissible?: boolean;
}
interface ToastContextValue {
toast: (message: string, options?: ToastOptions) => number;
dismiss: (id: number) => void;
dismissAll: () => void;
}
// --- Context ---
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside ToastProvider");
return ctx;
}
// --- Toast card ---
const typeConfig: Record<ToastType, { bg: string; icon: string }> = {
success: { bg: "bg-green-600", icon: "✓" },
error: { bg: "bg-red-600", icon: "✕" },
warning: { bg: "bg-yellow-500 text-gray-900", icon: "⚠" },
info: { bg: "bg-blue-600", icon: "ℹ" },
};
function ToastCard({
item,
onDismiss,
}: {
item: ToastItem;
onDismiss: (id: number) => void;
}) {
const [visible, setVisible] = useState(false);
const [progress, setProgress] = useState(100);
const pausedRef = useRef(false);
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
useEffect(() => {
const interval = 50;
const step = (interval / item.duration) * 100;
const timer = setInterval(() => {
if (pausedRef.current) return;
setProgress((prev) => {
const next = prev - step;
if (next <= 0) {
clearInterval(timer);
setVisible(false);
setTimeout(() => onDismiss(item.id), 200);
return 0;
}
return next;
});
}, interval);
return () => clearInterval(timer);
}, [item.id, item.duration, onDismiss]);
const config = typeConfig[item.type];
return (
<div
role="alert"
onMouseEnter={() => (pausedRef.current = true)}
onMouseLeave={() => (pausedRef.current = false)}
className={`pointer-events-auto w-80 overflow-hidden rounded-lg shadow-lg transition-all duration-200 ${
visible ? "translate-x-0 opacity-100" : "translate-x-4 opacity-0"
} ${config.bg} ${item.type === "warning" ? "" : "text-white"}`}
>
<div className="flex items-start gap-2 px-4 py-3">
<span className="mt-0.5 text-sm" aria-hidden="true">
{config.icon}
</span>
<p className="flex-1 text-sm font-medium">{item.message}</p>
{item.dismissible && (
<button
onClick={() => {
setVisible(false);
setTimeout(() => onDismiss(item.id), 200);
}}
aria-label="Dismiss"
className="shrink-0 rounded p-0.5 opacity-70 hover:opacity-100"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{item.action && (
<div className="px-4 pb-3">
<button
onClick={() => {
item.action!.onClick();
onDismiss(item.id);
}}
className="text-sm font-semibold underline underline-offset-2 opacity-90 hover:opacity-100"
>
{item.action.label}
</button>
</div>
)}
<div className={`h-1 ${item.type === "warning" ? "bg-yellow-600/30" : "bg-white/20"}`}>
<div
className="h-full bg-white/50 transition-none"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
}
// --- Provider ---
let nextId = 0;
interface ToastProviderProps {
children: React.ReactNode;
position?: Position;
maxToasts?: number;
}
const positionClasses: Record<Position, string> = {
"top-right": "top-4 right-4 flex-col",
"top-left": "top-4 left-4 flex-col",
"bottom-right": "bottom-4 right-4 flex-col-reverse",
"bottom-left": "bottom-4 left-4 flex-col-reverse",
};
export function ToastProvider({ children, position = "bottom-right", maxToasts = 5 }: ToastProviderProps) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const dismissAll = useCallback(() => setToasts([]), []);
const toast = useCallback(
(message: string, options: ToastOptions = {}) => {
const id = ++nextId;
const item: ToastItem = {
id,
message,
type: options.type ?? "info",
duration: options.duration ?? 4000,
action: options.action,
dismissible: options.dismissible ?? true,
};
setToasts((prev) => [...prev.slice(-(maxToasts - 1)), item]);
return id;
},
[maxToasts]
);
return (
<ToastContext.Provider value={{ toast, dismiss, dismissAll }}>
{children}
{mounted &&
createPortal(
<div
aria-live="polite"
className={`fixed z-50 flex gap-2 pointer-events-none ${positionClasses[position]}`}
>
{toasts.map((t) => (
<ToastCard key={t.id} item={t} onDismiss={dismiss} />
))}
</div>,
document.body
)}
</ToastContext.Provider>
);
}Key aspects:
- Context-driven API --
useToast()returns atoast()function that can be called from any component without prop drilling. The provider renders the toast container via a portal. - Pause on hover -- a
useRefflag pauses the countdown timer when the user hovers over the toast, usingonMouseEnter/onMouseLeave. A ref is used instead of state to avoid re-renders on every interval tick. - Slide-in animation -- each toast starts with
translate-x-4 opacity-0and transitions totranslate-x-0 opacity-100viarequestAnimationFrame. On dismiss, the reverse plays for 200ms before the item is removed from state. - Max toast limit --
slice(-(maxToasts - 1))trims the oldest toasts when the cap is exceeded, preventing the screen from filling with notifications. - Progress bar per toast -- each
ToastCardmanages its own progress bar independently viasetInterval. The bar pauses when hovered. - Portal rendering --
createPortalrenders the container atdocument.bodyto avoid stacking context issues. The container ispointer-events-nonewhile individual cards arepointer-events-autoso clicks pass through the empty space. aria-live="polite"-- the container announces new toasts to screen readers without interrupting the current speech, making the component accessible by default.
Gotchas
-
Stale closure in timer callbacks -- if
onClosechanges identity between renders (e.g., an inline arrow function), theuseEffectcleanup and re-setup can cause the timer to restart. Wrap the parent'sonCloseinuseCallbackto keep a stable reference. -
Multiple toasts overwriting each other -- if you store a single
{ open, message }in state, firing a second toast before the first finishes replaces the first silently. Use an array-based approach (like the stacked variation) for reliable queuing. -
Missing
role="alert"oraria-live-- without these attributes, screen readers do not announce the toast. Userole="alert"on individual toasts oraria-live="polite"on the container. -
Z-index conflicts with modals -- toasts fixed at
z-50can appear behind modals or drawers that use higher z-index values. Ensure the toast container's z-index is the highest in your stacking order. -
Timer not clearing on unmount -- if the component unmounts before
setTimeoutfires, the callback runs on stale state. Always return a cleanup function fromuseEffectto clear the timer. -
Progress bar animation jank -- using CSS
transitionon the progress bar width competes with the JavaScript interval-driven updates, causing choppy movement. Applytransition-none(Tailwind) to the progress bar and let JavaScript control the width directly. -
Toast appearing behind the keyboard on mobile -- bottom-positioned toasts can be hidden by the virtual keyboard on mobile devices. Use top-positioned toasts in form-heavy flows, or listen for the
visualViewportresize event to adjust placement.