Toast / notification motion
A toast should arrive with a spring and leave with a quiet ease — and announce itself to screen readers without stealing focus.
In one line: a toast that springs up when it arrives and eases quietly out of the way when it leaves reads as a confident, momentary aside — provided it announces itself to assistive tech and never grabs your focus.
What it is
A toast is a small, transient message that appears at the edge of the screen to confirm something happened — "Changes saved", "Couldn't connect", "A new version is available". It is not a dialog: it doesn't block the page, it doesn't demand a decision, and it goes away on its own after a few seconds.
The motion has two distinct halves. On the way in, the toast slides up a little and fades on, with a spring — a tiny overshoot that makes it feel like it landed rather than appeared. On the way out, it fades and slides down on a plain ease, quieter and quicker, because a departure shouldn't draw the eye the way an arrival does. In between, it sits still and waits to be read.
Why it matters
The shape of the motion is a message in itself. A spring on entry says something arrived, look here; a soft ease on exit says nothing to see, carry on. Match the two and the toast feels like a considered aside instead of a flicker.
But a toast is also one of the easiest components to make inaccessible. The whole point is to inform, yet the information lives in a region that often appears and vanishes silently — a sighted user catches it, a screen-reader user never hears it. The fix is an aria-live region: additions to it are announced without moving focus, so the message reaches everyone and nobody is yanked out of what they were doing. Get the politeness level right (gentle for routine confirmations, interrupting only for errors), give people a real way to dismiss it and enough time to read it, and the toast earns its place instead of just decorating the corner.
See it
Tweak it3
How it works
Four decisions carry the whole component:
- Enter on a spring, exit on an ease. The arrival uses a spring from a single offset state to rest — one
from→to, so just two keyframes, which is all a spring supports. The exit uses a short fixed-duration ease; it's quieter and faster than the entrance on purpose. AnimatePresencefor the exit. A toast that's removed from state would normally vanish instantly. Wrapping the list inAnimatePresencekeeps the element mounted long enough to play itsexitanimation before it's gone.- Auto-dismiss that pauses. A timer removes the toast after about 3.5 seconds. Hovering or focusing the toast pauses that timer, so a reader — or someone tabbing to the dismiss button — never has it disappear mid-thought.
aria-livepoliteness. The toast region isaria-live="polite"so additions are announced after the screen reader finishes its current utterance. Errors switch to"assertive"to interrupt. Either way focus stays put — the announcement comes to the user, not the other way around.
Build it
/* Positioned region: toasts stack here without shifting page layout. */
.toast-region {
position: fixed;
inset-inline: 0;
bottom: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding-inline: 1rem;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
gap: 0.75rem;
max-width: 24rem;
padding: 0.75rem;
border: 1px solid var(--border, #2a2a2a);
border-radius: 0.5rem;
background: var(--surface-raised, #1b1b1b);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.25);
}
/* ENTER: spring-ish overshoot via a cubic-bezier with a slight bounce. */
.toast.is-entering {
animation: toast-in 320ms cubic-bezier(0.22, 1.2, 0.36, 1);
}
/* EXIT: quieter and quicker — a plain ease down and out. */
.toast.is-leaving {
animation: toast-out 220ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(12px); }
}
/* Reduced motion: appear/disappear with opacity only, no slide. */
@media (prefers-reduced-motion: reduce) {
.toast.is-entering,
.toast.is-leaving {
animation: none;
}
}import { useEffect, useState } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
const AUTO_DISMISS_MS = 3500;
export function Toasts() {
const [toasts, setToasts] = useState([]);
let nextId = useState(() => ({ n: 0 }))[0];
function show(variant = "success") {
setToasts((prev) => [...prev, { id: nextId.n++, variant }]);
}
function dismiss(id) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}
// Errors interrupt; everything else waits its turn.
const assertive = toasts.some((t) => t.variant === "error");
return (
<>
<button type="button" onClick={() => show("success")}>
Show toast
</button>
<div
aria-live={assertive ? "assertive" : "polite"}
aria-relevant="additions"
style={{
position: "fixed",
insetInline: 0,
bottom: "1rem",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.5rem",
pointerEvents: "none",
}}
>
<AnimatePresence initial={false}>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
))}
</AnimatePresence>
</div>
</>
);
}
function ToastItem({ toast, onDismiss }) {
const reduce = useReducedMotion();
const [paused, setPaused] = useState(false);
// Auto-dismiss, paused while hovered or focused.
useEffect(() => {
if (paused) return;
const timer = setTimeout(onDismiss, AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [paused, onDismiss]);
return (
<motion.div
role="status"
// Enter: spring from one offset to rest (two keyframes — OK for a spring).
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16 }}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 520, damping: 30 } }
}
// Exit: a quiet, quick ease — handled by AnimatePresence.
exit={
reduce
? { opacity: 0, transition: { duration: 0.12 } }
: { opacity: 0, y: 12, transition: { duration: 0.22, ease: [0.4, 0, 1, 1] } }
}
onHoverStart={() => setPaused(true)}
onHoverEnd={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
style={{ pointerEvents: "auto" }}
>
<span>{toast.variant === "success" ? "Changes saved." : "Heads up."}</span>
<button type="button" onClick={onDismiss} aria-label="Dismiss notification">
×
</button>
</motion.div>
);
}Make it yours
Use the controls beside the demo above to change variant, dock position, and auto-dismiss — each change updates the example live.
Experiment in the playgroundReproduce it with an LLM
Reproduce it with an LLM
You are a senior front-end engineer. Build a toast notification with tasteful enter/exit motion in React using the Motion library: it slides up and fades in on appear, and slides down and fades out on dismiss, with a spring on entry and a quick ease on exit. Requirements: the toast is an aria-live='polite' region (assertive only for errors) so screen readers announce it; it is dismissible by a real button and auto-dismisses after a few seconds (pausing on hover/focus); animate only transform and opacity; fully disable motion (appear/disappear instantly) under prefers-reduced-motion: reduce; never animate layout. Return only the component code with imports.
Pitfalls & accessibility
Related
Anatomy of a micro-interaction
Every small feedback moment has four parts — trigger, rules, feedback, and loops. Name them and you can design any of them on purpose.
Loading & skeleton states
A skeleton that mirrors the real layout makes waiting feel shorter and prevents the layout jump a spinner can't.
Easing & spring
Easing scripts a move over a fixed time; a spring simulates physics and settles on its own. Knowing which to reach for is half of good motion.
Further reading
- Your animation library's
AnimatePresencedocs, for how exit animations survive unmounting. - The ARIA live-region guidance on politeness levels, for when
politebeatsassertive. - The platform notification and status-message patterns, for how transient feedback reaches assistive tech without stealing focus.