Animated toast notifications in React with Motion
A toast is a short message that appears at the edge of the screen after something happens: "Invite sent", "Deployed to production", "Couldn't save changes". It shouldn't interrupt the person, and it should leave on its own.
The animation is most of what makes a toast feel right. It should arrive quickly so the person connects it to what they just did, sit still while they read it, and get out of the way without dragging attention back to itself. When several arrive at once, they should stack neatly instead of covering half the page.
This guide builds a stacked toast system in about 150 lines with React 19, TypeScript, Tailwind CSS v4 and Motion (formerly Framer Motion). It covers:
- a small store, so any part of your app can call
toast("Saved") - enter and exit animations with
AnimatePresence - a collapsed stack that fans out on hover or focus
- timers that pause while the stack is open
- screen reader announcements and reduced motion
npm install motionStep 1: a store you can call from anywhere
Toasts are triggered from event handlers, server action results and data hooks, often far from where they're rendered. A tiny external store keeps that simple. useSyncExternalStore subscribes React to it.
// toast-store.tsimport { useSyncExternalStore } from "react";export type Toast = { id: string; title: string; description?: string; tone?: "default" | "error"; /** Milliseconds before it closes. 0 keeps it open. */ duration?: number;};let toasts: Toast[] = [];const EMPTY: Toast[] = [];const listeners = new Set<() => void>();function emit() { for (const listener of listeners) listener();}export function toast(title: string, options: Omit<Toast, "id" | "title"> = {}) { const id = crypto.randomUUID(); toasts = [{ id, title, duration: 5000, ...options }, ...toasts]; emit(); return id;}export function dismiss(id: string) { toasts = toasts.filter((t) => t.id !== id); emit();}function subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); };}export function useToasts() { return useSyncExternalStore(subscribe, () => toasts, () => EMPTY);}Two details:
- New toasts go to the front of the array. Index 0 is always the newest, which makes the stacking maths in the next step simple.
- The server snapshot is a constant empty array.
useSyncExternalStoreneeds the same reference on every call, or React treats it as a change and renders forever.
Now toast("Invite sent") or toast("Couldn't save", { tone: "error", duration: 0 }) works from any client code.
Step 2: the toaster
The toaster renders the newest three toasts. Collapsed, they sit on top of each other: the newest in front, each older one 10px higher and 5% smaller, so you see the edges of a deck. Hovered or focused, the stack fans out into a list.
// toaster.tsx"use client";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import { useEffect, useRef, useState } from "react";import { dismiss, useToasts, type Toast } from "./toast-store";const VISIBLE = 3;const HEIGHT = 64; // every toast is h-16, so the expanded offsets need no measuringconst GAP = 8;const PEEK = 10;const rise = { type: "spring", visualDuration: 0.4, bounce: 0 } as const;const leave = { duration: 0.15, ease: [0.7, 0, 0.84, 0] } as const;export function Toaster() { const toasts = useToasts(); const reduced = useReducedMotion(); const [expanded, setExpanded] = useState(false); const [announcement, setAnnouncement] = useState<Toast | null>(null); const announced = useRef(new Set<string>()); useEffect(() => { if (toasts.length === 0) setExpanded(false); // Announce each toast once, when it arrives. Not again when a newer one is dismissed. const newest = toasts[0]; if (newest && !announced.current.has(newest.id)) { announced.current.add(newest.id); setAnnouncement(newest); } }, [toasts]); const shown = Math.min(toasts.length, VISIBLE); // Covers the gaps between expanded toasts, so the pointer never "leaves" between them. const height = shown === 0 ? 0 : expanded ? shown * (HEIGHT + GAP) - GAP : HEIGHT; return ( <section aria-label="Notifications" className="pointer-events-none fixed right-4 bottom-4 z-50 w-[22rem] max-w-[calc(100vw-2rem)]"> {/* Always mounted, so screen readers have registered it before the first toast. */} <div role="status" aria-live="polite" className="sr-only"> {announcement && announcement.tone !== "error" ? `${announcement.title}. ${announcement.description ?? ""}` : ""} </div> <div role="alert" className="sr-only"> {announcement?.tone === "error" ? `${announcement.title}. ${announcement.description ?? ""}` : ""} </div> <ol className="pointer-events-auto relative" style={{ height }} onMouseEnter={() => setExpanded(true)} onMouseLeave={() => setExpanded(false)} onFocus={() => setExpanded(true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setExpanded(false); }} > <AnimatePresence initial={false}> {toasts.slice(0, VISIBLE).map((t, index) => ( <motion.li key={t.id} className="absolute inset-x-0 bottom-0 h-16 origin-bottom" style={{ zIndex: VISIBLE - index }} initial={{ opacity: 0, y: reduced ? 0 : 16, scale: reduced ? 1 : 0.97 }} animate={{ opacity: 1, y: expanded ? -index * (HEIGHT + GAP) : -index * PEEK, scale: expanded ? 1 : 1 - index * 0.05, }} exit={{ opacity: 0, y: reduced ? 0 : 8, transition: reduced ? { duration: 0.1 } : leave }} transition={reduced ? { duration: 0, opacity: { duration: 0.2 } } : { ...rise, opacity: { duration: 0.2 } }} > <ToastCard toast={t} paused={expanded} front={index === 0} /> </motion.li> ))} </AnimatePresence> </ol> </section> );}function ToastCard({ toast, paused, front }: { toast: Toast; paused: boolean; front: boolean }) { useAutoDismiss(toast, paused); return ( <div className="flex h-full items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 text-sm shadow-lg"> <div className={`min-w-0 flex-1 transition-opacity ${front || paused ? "opacity-100" : "opacity-0"}`}> <p className="truncate font-medium text-zinc-950">{toast.title}</p> {toast.description && <p className="truncate text-zinc-500">{toast.description}</p>} </div> <button type="button" onClick={() => dismiss(toast.id)} aria-label="Dismiss" className="rounded-md px-2 py-1 text-zinc-500 hover:bg-zinc-100"> ✕ </button> </div> );}/** Closes the toast after its duration, and pauses the clock while `paused`. */function useAutoDismiss({ id, duration = 5000 }: Toast, paused: boolean) { const remaining = useRef(duration); useEffect(() => { if (paused || duration === 0) return; const started = Date.now(); const timer = setTimeout(() => dismiss(id), remaining.current); return () => { clearTimeout(timer); remaining.current -= Date.now() - started; }; }, [paused, id, duration]);}Mount <Toaster /> once, near the root of your app, for example in your root layout.
How the stack works
Every toast is absolutely positioned at the bottom of the list, and only y and scale change:
- Collapsed:
y = -index * 10andscale = 1 - index * 0.05. The front toast is at full size, the next is 10px up and 95%, then 20px up and 90%. - Expanded:
y = -index * (64 + 8), which is one toast height plus a gap, at full size.
Because both states only use transforms, the browser never has to recalculate layout while the stack moves, and three toasts cost almost nothing per frame.
The catch is that every toast has a fixed height (h-16, which is 64px). That is what lets the expanded offsets be simple multiplication. Toasts with different heights need each one measured and the offsets summed. If your toasts vary a lot, that is the point where a library starts to save real time.
The list's own height grows when the stack expands, so it covers the gaps between toasts. Without that, moving the pointer from one toast to the next passes over empty space, the list receives mouseleave, and the stack snaps shut under the cursor.
Older toasts hide their text while collapsed (opacity-0 when not in front). Only the front toast is readable, so the edges of the cards behind it read as a stack, not as overlapping text.
Why these animation values
Enter: a spring with visualDuration: 0.4 and bounce: 0. The toast rises 16px from slightly smaller (97%) while fading in. A spring is the right choice because toasts often arrive while others are moving: when a new one pushes the stack back, every toast changes target at once, and springs carry their current speed into the new target instead of restarting. bounce: 0 keeps it calm. A toast that overshoots looks excited about itself, which is wrong for "Saved".
Exit: 150ms with an ease-in curve ([0.7, 0, 0.84, 0]). Exits are shorter than entrances because the person has already read the message. Ease-in starts slowly and accelerates away, which reads as leaving. The toast only moves 8px on the way out, half as far as it came in, so it doesn't pull the eye.
Opacity on its own 200ms tween. Fading on the same spring as the movement can leave a toast semi-transparent for longer than it should be. Separating the two keeps the text readable as soon as it arrives.
Step 3: timers that pause
A toast that disappears while someone is reading it, or reaching for its button, is a common complaint. useAutoDismiss keeps the remaining time in a ref. When the stack expands, the effect's cleanup runs, clears the timer and subtracts the time that has passed. When the stack collapses, a new timer starts with whatever was left.
Pausing on focus as well as hover matters for keyboard users, who reach the dismiss button with Tab and need the toast to stay while they do.
Set duration: 0 for messages that must stay until dismissed, such as errors that need action. The same applies to a loading toast: show a spinner with duration: 0, then replace it with the result when the work finishes. Changing the title in place reads better than closing one toast and opening another, and a text swap animation makes that change easy to follow.
Step 4: what screen readers hear
Toasts appear without moving focus, so a screen reader user won't know one arrived unless it is announced. The toaster does this with two visually hidden live regions:
role="status"(polite) for normal toasts. The screen reader finishes what it is saying, then reads the toast.role="alert"(assertive) for errors. These interrupt.
The toaster remembers which toasts it has announced, so each one is read once, when it arrives. Without that, dismissing the newest toast would make the one behind it the "latest" and read it out a second time.
Both regions are always mounted. A live region that appears in the same moment as its text is often not announced, because the screen reader hasn't registered it yet. Rendering the regions empty from the start, then changing their text, is reliable.
A few more rules:
- The container is a
sectionwitharia-label="Notifications", so it shows up as a landmark people can jump to. - Every toast has a dismiss button with an accessible name.
- Don't put the only way to do something inside a toast. An Undo button in a toast is a nice shortcut, but someone using a screen magnifier may never see it before it leaves. Make the same action available elsewhere.
Reduced motion
With "Reduce motion" on, the toaster keeps the fades and drops the movement: toasts appear in place, the stack doesn't rise or fan out with animation, and exits are a 100ms fade. Fading is generally comfortable for people with motion sensitivity. Sliding and scaling are what cause trouble.
Common mistakes
- Wrapping
AnimatePresencein a condition. Writing{toasts.length > 0 && <AnimatePresence>...}unmountsAnimatePresencealong with the last toast, so the last toast vanishes with no exit animation. Keep it mounted and put the condition inside. - Using the array index as the key. When a toast is removed, every key shifts and the wrong toast plays its exit. Use a stable id.
- Animating
heightortop. These trigger layout on every frame. Animateyandscale. - Toasts for form errors. An error about a specific field belongs next to that field, where it stays until fixed.
- Too many at once. Show three at most and queue the rest. In this toaster, older toasts beyond three wait in the store and appear when a slot frees up.
- Toasts that steal focus. A toast should never move focus. That is what dialogs are for.
If you use shadcn/ui, note that its older Toast component is deprecated in favour of Sonner, a toast library with its own stacking. The technique above is useful when you want full control over the motion, or a design that matches the rest of your animated components.
FAQ
How do I animate a toast notification in React?
Render your toasts inside Motion's AnimatePresence, give each a stable key, and set initial, animate and exit values on a motion element. A short rise with a fade on a spring works well for entering, and a faster ease-in fade for leaving.
How do I make toasts stack on top of each other?
Position every toast absolutely at the same edge, then offset older ones by their index: a few pixels up and a few percent smaller each. On hover or focus, change the offset to the toast height plus a gap so they fan out into a list.
How long should a toast stay on screen?
Around five seconds suits a short message. Pause the timer while the pointer is over the toasts or focus is inside them, and keep error toasts that need action open until they are dismissed.
Are toast notifications accessible?
They can be. Announce them through a live region that is always in the page, use a polite region for normal messages and an assertive one for errors, never move focus to a toast, and make any action in a toast available somewhere else too.
If you'd rather not build it
The Eased Toast is a free, MIT-licensed toast stack built on Base UI Toast. It adds what this guide leaves out:
- toasts of any height, with each one keeping its natural height in the stack, instead of a fixed 64px
- promise toasts that start as a spinner and turn into a check or an error in place, with the title swapping smoothly
- an
actionoption for Undo, swipe to dismiss, and F6 to move focus into the toasts - error toasts announced with high priority, and a full-width layout at the bottom of the screen on phones
npx shadcn add https://eased.dev/r/toast.jsonFor undoing a change in a list or table, where the undo belongs next to the content, see Undo Pill.