mm.
← All components

Toast

One slot, not a stack — because the second toast is the one you meant.

The idea

A stack of toasts is a queue you have built for your reader to work through, and the second one is already competing with the first for a glance that was only ever going to happen once. Here a new toast replaces the one showing, and the id is what makes that read as a replacement rather than a re-render.

Install

terminal1 line
npm i @mihirmodi/ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Toast — usage11 lines
import { Toaster, toast } from "@mihirmodi/ui";

// Mount once, near the root.
<Toaster position="top" />;

// Then from anywhere at all — an event handler, a promise chain, a module
// with no React in it. It is a plain function against a module store.
toast("Copied to clipboard");
toast("Saved", { duration: 1200 });
toast("Upload failed", { icon: <AlertIcon />, duration: 0 }); // 0 = until dismissed
toast.dismiss();

Props

PropNotes
position"top" | "bottom"Which edge it arrives from.
align"center" | "end"Where along that edge. `end` parks it in the corner — this site uses bottom-right, since the nav owns the top.
toast(text, opts)functionCall it from anywhere — it is a plain function against a module store, so no hook, no context, no provider.
opts.iconReactNode | nullReplaces the check mark. `null` removes the mark entirely.
opts.durationnumberMilliseconds. Zero or less means it stays until dismissed.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/Toaster.tsx68 lines
"use client";

import { useSyncExternalStore } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CheckIcon } from "../lib/glyph";
import { getServerToast, getToast, subscribeToasts } from "../lib/toastStore";

const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

export type ToasterProps = {
  /** Which edge it comes from. Default `top`. */
  position?: "top" | "bottom";
  /**
   * Where along that edge. `center` (default) for a confirmation that is about
   * the whole page. `end` parks it in the corner, out of the way of the content
   * it is reporting on — the usual place on a desktop layout with a fixed nav
   * along the top.
   */
  align?: "center" | "end";
  className?: string;
};

/**
 * Mount once, near the root. Everything else goes through `toast()`.
 *
 * It is `fixed` and `pointer-events-none` across the full width, with only the
 * pill itself taking pointer events — so a toast can never swallow a click meant
 * for the page underneath it, however briefly it is on screen.
 *
 * `aria-live="polite"` is on the container rather than the pill, and the
 * container is always mounted. A live region has to exist *before* the text
 * arrives in it, or the screen reader has nothing to watch and the announcement
 * is silently lost — which is the single most common way a toast is inaccessible.
 */
export function Toaster({ position = "top", align = "center", className = "" }: ToasterProps) {
  const toast = useSyncExternalStore(subscribeToasts, getToast, getServerToast);
  const reduce = useReducedMotion();
  const from = position === "top" ? -8 : 8;

  return (
    <div
      aria-live="polite"
      className={`pointer-events-none fixed inset-x-0 z-[60] flex ${
        align === "end" ? "justify-end px-5" : "justify-center"
      } ${position === "top" ? "top-5" : "bottom-5"} ${className}`}
    >
      <LazyMotion features={domAnimation} strict>
        <AnimatePresence>
          {toast && (
            <m.div
              key={toast.id}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: from }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, transition: CLOSE }}
              transition={reduce ? { duration: 0.15 } : { duration: 0.2, ease: [0.175, 0.885, 0.32, 1.1] }}
              className="glass-panel dock-shadow pointer-events-auto flex items-center gap-2.5 rounded-full border-[0.5px] border-gray-alpha-400 px-4 py-2.5 text-[13px] leading-none text-gray-1000"
            >
              {toast.icon !== null && (
                <span className="text-quiet">{toast.icon ?? <CheckIcon />}</span>
              )}
              <span>{toast.text}</span>
            </m.div>
          )}
        </AnimatePresence>
      </LazyMotion>
    </div>
  );
}
packages/ui/src/lib/toastStore.ts78 lines
import type { ReactNode } from "react";

export type ToastOptions = {
  /** Replaces the default check mark. `null` removes the mark entirely. */
  icon?: ReactNode | null;
  /** Milliseconds on screen. Default 2600. */
  duration?: number;
};

export type ToastItem = { id: number; text: string } & ToastOptions;

/**
 * One slot, not a stack.
 *
 * A stack of toasts is a queue you have built for your user to work through, and
 * the second one is already competing with the first for a glance that was only
 * ever going to happen once. A toast confirms *the thing you just did*; if two
 * arrive at once, the second is the one you meant.
 *
 * So a new toast replaces the one showing. The id is what makes that read as a
 * replacement rather than as a re-render — `<Toaster>` keys on it, so React tears
 * the old one down and runs its exit while the new one runs its entrance.
 */
let current: ToastItem | null = null;
let seq = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>();

const emit = () => {
  for (const fn of listeners) fn();
};

export function subscribeToasts(fn: () => void): () => void {
  listeners.add(fn);
  return () => {
    listeners.delete(fn);
  };
}

export const getToast = (): ToastItem | null => current;
/** The server has no toasts. Keeps `useSyncExternalStore` happy under SSR. */
export const getServerToast = (): ToastItem | null => null;

function show(text: string, opts: ToastOptions = {}): number {
  if (timer) clearTimeout(timer);
  const id = ++seq;
  current = { id, text, ...opts };
  emit();
  const ms = opts.duration ?? 2600;
  // A duration of 0 or less means "until dismissed" — for a toast that is
  // reporting something the reader has to act on.
  if (ms > 0) {
    timer = setTimeout(() => {
      // Guard against a stale timer clearing a *newer* toast: only the toast
      // that set this timer may retire it.
      if (current?.id === id) dismiss();
    }, ms);
  }
  return id;
}

export function dismiss(): void {
  if (timer) clearTimeout(timer);
  timer = null;
  current = null;
  emit();
}

/**
 * Fire a toast from anywhere — an event handler, a promise chain, a module with
 * no React in it at all. It is a plain function against a module-level store, so
 * it needs no hook, no context and no provider.
 *
 *     toast("Copied to clipboard");
 *     toast("Saved", { duration: 1200 });
 *     toast("Upload failed", { icon: <AlertIcon />, duration: 0 });
 */
export const toast = Object.assign(show, { dismiss });