EmpireUI
Get Pro
← Blog7 min read#react#toast-notifications#tailwind-css

Building a Custom Toast System: No Library Required

Skip react-hot-toast and build a custom toast notification system in React with Tailwind. Full control, zero dependencies, and production-ready in under 100 lines.

Terminal screen showing React component code with colorful syntax highlighting on a dark background

Why You Probably Don't Need react-hot-toast

Honestly, most React toast libraries are solving a problem that's a lot simpler than they make it look. You pull in react-hot-toast or react-toastify, it works fine for two weeks, then you need a custom icon, a different animation, or a progress bar — and suddenly you're fighting the library's internal assumptions instead of just building the thing you want.

The core mechanics of a toast system are: a global list of notifications, a way to add and remove items from that list, and a fixed-position container that renders them. That's it. No dark magic. You can own this entire system in one file.

This isn't a knock on those libraries — they're well-maintained and cover a lot of edge cases. But if you're already using Tailwind v4.0.2 and you want pixel-level control over your notification UI, rolling your own is genuinely the faster path. And if you care about bundle size, skipping a 12kB dependency isn't nothing.

Designing the Toast State Model

Before writing a single line of JSX, think about the data shape. Each toast needs a unique ID (so React can track it), a message, a type (success, error, info, warning), and a duration. That's the minimum. You might also want a title field and an optional action — a button the user can click.

A simple TypeScript interface locks this in early and saves you debugging grief later. Type the union for type explicitly rather than using string. It makes autocomplete work and prevents typos like 'sucess' from silently doing nothing.

Keep the state as a flat array. Don't nest toasts inside categories or group them by type — that complexity is almost never worth it. The order in the array maps directly to the order on screen, which keeps the mental model obvious.

Building the useToast Hook

The hook is where most of the logic lives. You'll use useState to hold the array of toasts and expose three functions: addToast, removeToast, and convenience wrappers like toast.success() and toast.error(). A useCallback on removeToast is worth the line — it gets passed as a prop to individual toast components and you don't want to recreate it on every render.

For the auto-dismiss timer, reach for setTimeout inside addToast. Store the timeout ID on the toast object itself so you can cancel it if the user manually dismisses before the timer fires. This avoids the classic bug where a toast disappears right as the user is trying to click it.

Here's a working implementation you can drop straight into your project:

import { useState, useCallback } from 'react';

export type ToastType = 'success' | 'error' | 'info' | 'warning';

export interface Toast {
  id: string;
  message: string;
  title?: string;
  type: ToastType;
  duration: number;
}

type ToastOptions = Partial<Omit<Toast, 'id' | 'message'>>;

export function useToast() {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const removeToast = useCallback((id: string) => {
    setToasts(prev => prev.filter(t => t.id !== id));
  }, []);

  const addToast = useCallback(
    (message: string, options: ToastOptions = {}) => {
      const id = crypto.randomUUID();
      const duration = options.duration ?? 4000;

      setToasts(prev => [
        ...prev,
        { id, message, type: 'info', duration, ...options },
      ]);

      setTimeout(() => removeToast(id), duration);
    },
    [removeToast]
  );

  const toast = {
    success: (msg: string, opts?: ToastOptions) =>
      addToast(msg, { ...opts, type: 'success' }),
    error: (msg: string, opts?: ToastOptions) =>
      addToast(msg, { ...opts, type: 'error' }),
    info: (msg: string, opts?: ToastOptions) =>
      addToast(msg, { ...opts, type: 'info' }),
    warning: (msg: string, opts?: ToastOptions) =>
      addToast(msg, { ...opts, type: 'warning' }),
  };

  return { toasts, removeToast, toast };
}

The ToastContainer and Individual Toast Components

The container is just a fixed-position div that sits outside your main layout. Bottom-right is the conventional spot, but bottom-center works well on mobile. Use a z-index of at least 9000 — you'll thank yourself the first time you open a modal while a toast is showing. With Tailwind, fixed bottom-4 right-4 z-[9000] flex flex-col gap-3 w-80 covers you.

Individual toast items should handle their own enter and exit animations. CSS transitions on opacity and transform keep things smooth without pulling in Framer Motion. A translate-y-2 opacity-0 starting state that transitions to translate-y-0 opacity-100 on mount looks clean with a 200ms ease-out. For exit, flip it — but you need to add the exit class before actually removing from state, which means a brief delay.

The color coding per type is where Tailwind utility classes shine. Map each ToastType to a left-border color: border-l-4 border-emerald-500 for success, border-red-500 for error, border-sky-500 for info, border-amber-500 for warning. The background can be a translucent dark panel — rgba(15, 15, 20, 0.92) with a backdrop-blur-sm — which works beautifully if you check out how glassmorphism effects work.

Wiring It Up with Context (The Right Way)

If you only need toasts in one or two components, just call useToast() at the page level and pass toast down as props. Simple. But realistically you'll want to fire a toast from inside a deeply nested form handler or a data-fetching utility, and prop-drilling that far is painful.

The solution is a ToastContext. Create a context, wrap your app root with a ToastProvider that calls useToast() internally, and export a useToastContext() helper. Any component in the tree can then call useToastContext().toast.error('Something went wrong') without caring where it is in the hierarchy.

One thing worth mentioning: if you're using React's new use() hook in React 19+, you can skip the custom context helper and just use(ToastContext) directly in any async component. Either way works. This pattern also pairs well with a react-hook-form integration — you can fire error toasts directly from your form's onError callback.

Accessibility: The Part Developers Always Skip

Here's the thing: a toast that pops up and disappears is genuinely tricky for screen reader users. The element appears outside the normal focus flow and auto-dismisses — that's a rough experience if you're relying on assistive technology.

The fix is role="status" for informational toasts and role="alert" for errors. Both are ARIA live regions. role="alert" maps to aria-live="assertive", which interrupts whatever the screen reader is currently saying. Use it only for errors. role="status" uses aria-live="polite" — it waits for a pause. That's right for success and info messages.

Also add aria-atomic="true" on the container so the full message is announced, not just the changed portion. And don't rely only on color to convey toast type — include a text label or icon with a visible aria-label. Why does this matter? Because skipping it means your app fails WCAG 2.1 AA, which is a real problem for any product that touches enterprise customers or government contracts.

Styling and Theming with Tailwind v4

With Tailwind v4.0.2 and CSS custom properties, you can theme your toast system with a handful of variables rather than duplicating class strings. Define --toast-bg, --toast-border, and --toast-text in your :root and override them inside a .dark selector. Your toast component then uses bg-[var(--toast-bg)] instead of conditionally toggling bg-zinc-900 and bg-white.

This approach also makes it trivial to match whatever theme toggle system you're already using. If your app switches themes by toggling a class on <html>, your toasts follow automatically with zero JavaScript.

For the progress bar — an optional but nice touch — a simple div with animate-shrink using a custom Tailwind keyframe handles it cleanly. Define @keyframes shrink { from { width: 100% } to { width: 0% } } in your CSS layer and tie the animation duration to the toast's duration prop via an inline style: style={{ animationDuration: ${duration}ms }}. Four lines of CSS, no library needed. If you want to see more patterns like this, the React performance guide covers when to reach for CSS animations vs JavaScript ones.

Production Considerations and Common Bugs

A few things will bite you if you skip them. First: deduplication. If a user clicks a button three times fast, you don't want three identical toasts stacking up. Keep a Set of active message hashes and bail early in addToast if the same message is already showing. Or enforce a max queue length — six toasts on screen at once is already chaos.

Second: memory leaks. If a component unmounts while a toast timer is still running, the setTimeout callback will call setToasts on an unmounted component. In React 18+ this doesn't throw, but it's still wasted work. Clean up with a useEffect return that calls clearTimeout on all pending timers when the provider unmounts.

Third: SSR. crypto.randomUUID() isn't available in all Node environments. If you're on Next.js with server components, either lazy-initialize the toast system on the client or use a fallback ID generator like Date.now().toString(36) + Math.random().toString(36).slice(2). Not pretty, but it works.

FAQ

Can I use this custom toast system with Next.js App Router?

Yes, but your ToastProvider needs to be a Client Component since it uses useState and context. Add 'use client' at the top of the provider file and import it into your root layout. The toast hook itself can then be called from any Client Component in the tree.

How do I handle toasts from server actions in Next.js?

Server actions can't call client-side toast functions directly. The pattern is to return a result object from your server action — something like { success: boolean, message: string } — and then call your toast function in the client component that invoked the action, based on the returned value.

What's the right duration for toast notifications?

4000ms (4 seconds) is a solid default for success and info toasts. Error toasts should be longer — 6000ms or even persistent with a manual dismiss button — because users need time to read and understand what went wrong. Avoid anything shorter than 2500ms; it's too fast for most users to read.

How do I stack multiple toasts without them overlapping?

Use a flex column container with a gap — flex flex-col gap-3 works well. Each new toast appends to the array, so they stack naturally. If you want newest-on-top, render the array reversed with [...toasts].reverse().map(...). Keep a max of 5-6 toasts visible and queue the rest.

Can I add an action button (like 'Undo') to a toast?

Absolutely. Add an optional action: { label: string; onClick: () => void } field to your Toast interface. In the toast component, conditionally render a button when toast.action is defined. Call onClick and then removeToast(id) inside the handler so the toast dismisses after the action fires.

Is this approach testable with React Testing Library?

Very much so. Wrap your test renders with the ToastProvider, then use userEvent to trigger whatever fires a toast, and assert on getByRole('status') or getByRole('alert'). Because you own the implementation, you can also test useToast directly with renderHook from @testing-library/react.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Architecture & Patterns: The Complete 2026 GuideuseReducer Patterns: Complex State Without a State ManagerDrag-and-Drop Sortable Lists in React: No Library RequiredCustom Select Dropdown in React: Searchable, Multi-Select