Overlays

Toast

A brief message in the corner that confirms something happened, with at most one follow-up action.

Built on
Popover APIaria-liveuseSyncExternalStore@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.4 kB
Dependencies
React
Code
"use client";

import { Button } from "@/components/equip/button";
import { Toaster, toast } from "@/components/equip/toast";

export default function Demo() {
  return (
    <>
      <Button
        variant="outline"
        onClick={() =>
          toast("Deploy started", {
            description: "atlas-web to eu-west-1, about 2 minutes.",
            action: { label: "View logs", onClick: () => {} },
          })
        }
      >
        Deploy atlas-web
      </Button>
      <Toaster />
    </>
  );
}

Examples

Message only

Mount one Toaster in the app layout; toast() can then be called from any client code.

Code
"use client";

import { Button } from "@/components/equip/button";
import { Toaster, toast } from "@/components/equip/toast";

export default function Example() {
  return (
    <>
      <Button variant="outline" onClick={() => toast("Invite sent to mira@example.com")}>
        Send invite
      </Button>
      <Toaster />
    </>
  );
}

Undo

The countdown pauses while the pointer or focus is on a toast, so there is time to reach the action.

Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Toaster, toast } from "@/components/equip/toast";

export default function Example() {
  const [archived, setArchived] = useState(0);
  return (
    <>
      <Button
        variant="outline"
        onClick={() => {
          setArchived((total) => total + 1);
          toast("Branch fix-retry archived", {
            duration: 10000,
            action: { label: "Undo", onClick: () => setArchived((total) => total - 1) },
          });
        }}
      >
        Archive branch ({archived})
      </Button>
      <Toaster />
    </>
  );
}

Props

PropTypeDefaultWhat it does
toast(message, options)(ReactNode, ToastOptions) => stringnoneQueues a toast and returns its id. Call it from any client code.
options.descriptionReactNodenoneA second, quieter line under the message.
options.action{ label: string; onClick: () => void }noneOne button, such as Undo. Clicking it runs onClick and dismisses the toast.
options.durationnumber6000Milliseconds before it dismisses itself. Infinity keeps it until dismissed.
dismissToast(id)(string) => voidnoneDismisses a toast early.
Toaster labelstring"Notifications"Accessible name of the region.
Toaster ...restComponentProps<"section">noneEvery native section attribute, applied to the region.

Anatomy

region.eq-toasterA manual popover held open in the top layer, bottom right.
list.eq-toaster-listThe polite live region with role="status".
root.eq-toastOne toast. data-closing during the exit.
text.eq-toast-text
message.eq-toast-message
description.eq-toast-description
action.eq-toast-action
close.eq-toast-close

Keyboard

TabReaches the action and dismiss buttons; focus inside the region pauses every countdown.
EnterSpaceRuns the action, or dismisses the toast.

Notes

  • Mount one Toaster, usually in the root layout; a second one stays empty, so toasts never show twice.
  • The region is a polite live region, so screen readers announce a new toast without interrupting.
  • Countdowns pause while the pointer or keyboard focus is anywhere in the region, and resume with the time that was left.
  • Newest toasts appear at the bottom; past four, the oldest is dismissed.
  • Never put the only way to do something in a toast: it can disappear before it is read.
  • The top layer stacks in opening order, so when a toast arrives while a modal dialog is open the region is shown again to move it above the dialog.
  • While a modal dialog is open the browser makes everything outside it inert, so a toast shows above the dialog but its buttons cannot be clicked until the dialog closes.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/toast.tsx214 lines
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  useSyncExternalStore,
  type ComponentProps,
  type FocusEvent,
  type ReactNode,
} from "react";
import "./toast.css";

export type ToastOptions = {
  /** A second line under the message. */
  description?: ReactNode;
  /** One button, such as Undo. Clicking it runs onClick and dismisses the toast. */
  action?: { label: string; onClick: () => void };
  /** Milliseconds before it dismisses itself. Infinity keeps it until dismissed. */
  duration?: number;
};

type Item = ToastOptions & {
  id: string;
  message: ReactNode;
  duration: number;
  closing: boolean;
};

/* Module-level store: toast() works from anywhere on the client, no provider needed. */

const LIMIT = 4;
const EXIT_MS = 200;
const NONE: Item[] = [];
let items: Item[] = NONE;
let count = 0;
const listeners = new Set<() => void>();

function emit(next: Item[]) {
  items = next;
  for (const listener of listeners) listener();
}

function subscribe(listener: () => void) {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

/** Queues a message. Returns its id, for dismissToast. */
export function toast(message: ReactNode, options: ToastOptions = {}): string {
  const id = `eq-toast-${++count}`;
  const item: Item = { duration: 6000, ...options, id, message, closing: false };
  const open = items.filter((entry) => !entry.closing);
  if (open.length >= LIMIT) dismissToast(open[0].id);
  emit([...items, item]);
  return id;
}

/** Plays the exit and removes the toast. */
export function dismissToast(id: string) {
  if (!items.some((entry) => entry.id === id && !entry.closing)) return;
  emit(items.map((entry) => (entry.id === id ? { ...entry, closing: true } : entry)));
  window.setTimeout(() => emit(items.filter((entry) => entry.id !== id)), EXIT_MS);
}

/* Only the first mounted Toaster renders, so a second one cannot show every toast twice. */

const NO_HOSTS: string[] = [];
let hosts: string[] = NO_HOSTS;
const hostListeners = new Set<() => void>();

function setHosts(next: string[]) {
  hosts = next;
  for (const listener of hostListeners) listener();
}

function subscribeHosts(listener: () => void) {
  hostListeners.add(listener);
  return () => {
    hostListeners.delete(listener);
  };
}

export type ToasterProps = ComponentProps<"section"> & {
  /** Accessible name of the notifications region. */
  label?: string;
};

export function Toaster({ label = "Notifications", className, ref, ...rest }: ToasterProps) {
  const hostId = useId();
  const list = useSyncExternalStore(subscribe, () => items, () => NONE);
  const hostList = useSyncExternalStore(subscribeHosts, () => hosts, () => NO_HOSTS);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const region = useRef<HTMLElement>(null);
  const active = hostList[0] === hostId;
  const visible = list.filter((entry) => !entry.closing).length;
  const shown = useRef(0);

  useEffect(() => {
    setHosts([...hosts, hostId]);
    return () => setHosts(hosts.filter((host) => host !== hostId));
  }, [hostId]);

  // Kept open from mount, so the live region exists before the first message arrives.
  useEffect(() => {
    const element = region.current;
    if (active && element && !element.matches(":popover-open")) element.showPopover();
  }, [active]);

  // The top layer stacks in opening order. When a new toast arrives while a modal dialog is
  // open, show the region again so it moves above that dialog.
  useEffect(() => {
    const element = region.current;
    const added = visible > shown.current;
    shown.current = visible;
    if (!added || !element || !document.querySelector("dialog:modal")) return;
    if (element.matches(":popover-open")) element.hidePopover();
    element.showPopover();
  }, [visible]);

  // A focused toast that is removed may not fire blur, so check focus whenever the list changes.
  useEffect(() => {
    if (focused && !region.current?.contains(document.activeElement)) setFocused(false);
  }, [list, focused]);

  if (!active) return null;

  function setRegion(node: HTMLElement | null) {
    region.current = node;
    if (typeof ref === "function") ref(node);
    else if (ref) ref.current = node;
  }

  function handleBlur(event: FocusEvent<HTMLElement>) {
    if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
  }

  return (
    <section
      {...rest}
      ref={setRegion}
      popover="manual"
      aria-label={label}
      className={["eq-toaster", className].filter(Boolean).join(" ")}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      onFocus={() => setFocused(true)}
      onBlur={handleBlur}
    >
      <div className="eq-toaster-list" role="status" aria-live="polite">
        {list.map((item) => (
          <Toast key={item.id} item={item} paused={hovered || focused} />
        ))}
      </div>
    </section>
  );
}

function Toast({ item, paused }: { item: Item; paused: boolean }) {
  const remaining = useRef(item.duration);

  // Counts down only while nothing hovers or focuses the region; pausing keeps what is left.
  useEffect(() => {
    if (paused || item.closing || !Number.isFinite(item.duration)) return;
    const started = Date.now();
    const timer = window.setTimeout(() => dismissToast(item.id), remaining.current);
    return () => {
      window.clearTimeout(timer);
      remaining.current -= Date.now() - started;
    };
  }, [paused, item.closing, item.duration, item.id]);

  return (
    <div className="eq-toast" data-closing={item.closing || undefined}>
      <div className="eq-toast-text">
        <p className="eq-toast-message">{item.message}</p>
        {item.description != null && <p className="eq-toast-description">{item.description}</p>}
      </div>
      {item.action && (
        <button
          type="button"
          className="eq-toast-action"
          onClick={() => {
            item.action?.onClick();
            dismissToast(item.id);
          }}
        >
          {item.action.label}
        </button>
      )}
      <button
        type="button"
        className="eq-toast-close"
        aria-label="Dismiss"
        onClick={() => dismissToast(item.id)}
      >
        <svg
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          aria-hidden="true"
        >
          <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
        </svg>
      </button>
    </div>
  );
}
components/equip/toast.css136 lines
@layer equip {
  /* The region: a manual popover held open in the top layer, click-through where empty. */
  .eq-toaster {
    box-sizing: border-box;
    position: fixed;
    inset: auto var(--eq-space-4) var(--eq-space-4) auto;
    width: min(360px, calc(100vw - 2 * var(--eq-space-4)));
    max-height: calc(100dvh - 2 * var(--eq-space-4));
    margin: 0;
    padding: 0;
    overflow: visible;
    border: 0;
    background: transparent;
    color: var(--eq-ink);
    pointer-events: none;
  }

  .eq-toaster-list {
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    gap: var(--eq-space-2);
  }

  .eq-toast {
    display: flex;
    align-items: flex-start;
    gap: var(--eq-space-2);
    padding: var(--eq-space-3) var(--eq-space-2) var(--eq-space-3) var(--eq-space-4);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-l);
    background: var(--eq-raised);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
    pointer-events: auto;
    /* Rises from the bottom edge of the region, so it grows from its lower middle. */
    transform-origin: bottom center;
    /* Arriving: the spring carries it in and lets it settle. */
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  @starting-style {
    .eq-toast {
      opacity: 0;
      scale: 0.96;
      translate: 0 4px;
    }
  }

  /* Leaving: quick, on the plain ease, well inside toast.tsx's EXIT_MS. */
  .eq-toast[data-closing] {
    opacity: 0;
    scale: 0.96;
    translate: 0 4px;
    pointer-events: none;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur) var(--eq-ease);
  }

  .eq-toast-text {
    flex: 1;
    min-width: 0;
    padding-block: var(--eq-space-1);
  }

  .eq-toast-message {
    margin: 0;
    font-weight: var(--eq-weight-strong);
  }

  .eq-toast-description {
    margin: var(--eq-space-1) 0 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-toast-action,
  .eq-toast-close {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    height: var(--eq-h-s);
    border: 1px solid transparent;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
    white-space: nowrap;
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-toast-action:active,
  .eq-toast-close:active {
    scale: var(--eq-press);
  }

  .eq-toast-action {
    padding: 0 var(--eq-space-2);
    border-color: var(--eq-line-strong);
  }

  .eq-toast-close {
    width: var(--eq-h-s);
    padding: 0;
    color: var(--eq-ink-2);
  }

  .eq-toast-close > svg {
    width: 14px;
    height: 14px;
  }

  @media (hover: hover) {
    .eq-toast-action:hover,
    .eq-toast-close:hover {
      background: var(--eq-tint);
    }
  }

  .eq-toast-action:focus-visible,
  .eq-toast-close:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index