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
npm i @mihirmodi/ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| position"top" | "bottom" | "top" | "bottom"= "top" | Which edge it arrives from. |
| align"center" | "end" | "center" | "end"= "center" | Where along that edge. `end` parks it in the corner — this site uses bottom-right, since the nav owns the top. |
| toast(text, opts)function | function | Call it from anywhere — it is a plain function against a module store, so no hook, no context, no provider. |
| opts.iconReactNode | null | ReactNode | null | Replaces the check mark. `null` removes the mark entirely. |
| opts.durationnumber | number= 2600 | Milliseconds. 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.
"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>
);
}
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 });