Dark Toast Notification Stack with Undo and Progress

A stack of toast notifications in the bottom right corner of a dark frame. It opens with three toasts: an upload with a progress bar, an error with an Edit action and a success message with Undo. The three buttons on the page add more toasts, which join the bottom of the stack and close by themselves after six seconds. At most four show at once. On phones the stack spans the width of the frame with a small margin. The frame has a set height and the stack is positioned inside it, so it previews correctly in a box.

Code

import { useEffect, useRef, useState } from "react";

type Kind = "success" | "error" | "progress";
type Toast = { id: number; kind: Kind; title: string; body: string; action?: string; progress?: number; stay?: boolean };

const starting: Toast[] = [
  { id: 1, kind: "progress", title: "Uploading 3 photos", body: "harbour-sunrise.jpg, 2 of 3", progress: 64, stay: true },
  { id: 2, kind: "error", title: "Could not send invite", body: "[email protected] bounced. Check the address.", action: "Edit", stay: true },
  { id: 3, kind: "success", title: "Post moved to Archive", body: "Spring menu launch is hidden from your site.", action: "Undo", stay: true },
];

const samples: Record<string, Omit<Toast, "id">> = {
  save: { kind: "success", title: "Changes saved", body: "Your site settings are up to date." },
  invite: { kind: "error", title: "Could not send invite", body: "The mail server did not respond. Try again in a minute.", action: "Retry" },
  upload: { kind: "progress", title: "Uploading cover image", body: "studio-wall.png, 1 of 1", progress: 28 },
};

const triggers = [
  { key: "save", label: "Save changes" },
  { key: "invite", label: "Send invite" },
  { key: "upload", label: "Upload image" },
];

const icons: Record<Kind, { path: string; tone: string }> = {
  success: { path: "m7.5 12.5 3 3 6-6.5", tone: "bg-teal-300 text-slate-950" },
  error: { path: "M12 7.5v5.5m0 3.3v.2", tone: "bg-rose-500 text-white" },
  progress: { path: "M12 16V8m0 0-3.5 3.5M12 8l3.5 3.5", tone: "bg-slate-600 text-white" },
};

const maxVisible = 4;
const lifetime = 6000;

export default function ToastNotificationStackDark() {
  const [toasts, setToasts] = useState<Toast[]>(starting);
  const nextId = useRef(starting.length + 1);
  const timers = useRef<Record<number, number>>({});

  const dismiss = (id: number) => setToasts((list) => list.filter((toast) => toast.id !== id));

  const push = (key: string) => {
    const toast = { ...samples[key], id: nextId.current };
    nextId.current += 1;
    setToasts((list) => [...list, toast].slice(-maxVisible));
  };

  useEffect(() => {
    toasts.forEach((toast) => {
      if (toast.stay || timers.current[toast.id]) return;
      timers.current[toast.id] = window.setTimeout(() => dismiss(toast.id), lifetime);
    });
  }, [toasts]);

  useEffect(() => () => Object.values(timers.current).forEach((timer) => window.clearTimeout(timer)), []);

  return (
    <div className="relative h-[600px] overflow-hidden bg-slate-950 text-slate-100">
      <div className="mx-auto max-w-5xl px-5 py-8 sm:px-10 sm:py-12">
        <p className="text-xs font-semibold uppercase tracking-wider text-teal-300">Toast demo</p>
        <h2 className="mt-2 text-xl font-semibold tracking-tight sm:text-2xl">Press a button to add a toast</h2>
        <p className="mt-2 max-w-md text-sm leading-relaxed text-slate-300">
          New toasts join the bottom of the stack and leave on their own after six seconds. The three already showing stay until you close them.
        </p>
        <div className="mt-6 flex flex-wrap gap-2.5">
          {triggers.map((trigger, index) => (
            <button
              key={trigger.key}
              type="button"
              onClick={() => push(trigger.key)}
              className={`min-h-[40px] rounded-full px-4 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 ${index === 0 ? "bg-teal-300 text-slate-950 hover:bg-teal-200" : "bg-slate-800 text-slate-100 hover:bg-slate-700"}`}
            >
              {trigger.label}
            </button>
          ))}
        </div>
      </div>

      <div aria-live="polite" aria-label="Notifications" role="region" className="absolute inset-x-4 bottom-4 flex flex-col gap-2.5 sm:inset-x-auto sm:bottom-6 sm:right-6 sm:w-[380px]">
        {toasts.map((toast) => {
          const icon = icons[toast.kind];
          return (
            <div key={toast.id} role={toast.kind === "error" ? "alert" : "status"} className="overflow-hidden rounded-2xl bg-slate-800 shadow-xl shadow-black/40 ring-1 ring-white/10">
              <div className="flex items-start gap-3 py-2.5 pl-3.5 pr-1.5">
                <span className={`mt-1 flex h-8 w-8 flex-none items-center justify-center rounded-full ${icon.tone}`}>
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5"><path d={icon.path} /></svg>
                </span>
                <div className="min-w-0 flex-1 py-1">
                  <p className="text-sm font-semibold text-white">{toast.title}</p>
                  <p className="mt-0.5 text-sm leading-snug text-slate-300">{toast.body}</p>
                </div>
                <div className="flex flex-none items-center">
                  {toast.action && (
                    <button type="button" onClick={() => dismiss(toast.id)} className="min-h-[40px] rounded-lg px-2.5 text-sm font-semibold text-teal-300 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-300">
                      {toast.action}
                    </button>
                  )}
                  <button type="button" onClick={() => dismiss(toast.id)} aria-label={`Close: ${toast.title}`} className="flex h-10 w-10 items-center justify-center rounded-lg text-slate-300 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-300">
                    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true" className="h-4 w-4"><path d="m5 5 10 10M15 5 5 15" /></svg>
                  </button>
                </div>
              </div>
              {toast.progress !== undefined && (
                <div role="progressbar" aria-label={toast.title} aria-valuemin={0} aria-valuemax={100} aria-valuenow={toast.progress} className="h-1 bg-slate-700">
                  <div className="h-full rounded-r-full bg-teal-300" style={{ width: `${toast.progress}%` }} />
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/toast-notification-stack-dark.json

When to use it

How to customize it

Accessibility

Questions

How long should a toast stay on screen?

Around four to six seconds suits a short confirmation. Add time for longer text. Toasts that report an error or carry an action should stay until the person closes them, because a message that vanishes before it is read is worse than none.

How do I show a toast from anywhere in my app?

Lift the `toasts` state and the `push` function into a context provider near the top of your app, render the stack once inside it, and call `push` from any component through a hook.

Where should toasts appear?

Bottom right or top right on desktop and across the bottom on phones are the common choices. Pick one place and keep it, and make sure it does not cover the main button of the screen underneath.

Why are the toasts drawn inside a frame here?

So the demo can be previewed in a box. The stack is `absolute` inside a container with `relative` and a set height. For a live app, switch it to `fixed` so it stays in the corner of the window while the page scrolls.

How many toasts should show at once?

Three or four at most. Past that people stop reading them. This stack drops the oldest when a fifth arrives, which is what `maxVisible` controls.

Related: Dismissible Alert Set: Success, Warning, Error and Info, Delete Confirmation Dialog with Summary, Button Set: Primary, Secondary, Ghost, Icon and Loading.

More alerts and feedback or all free UI components. Start for free to have a full site built around it.

More in Feedback

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap