Toast Slide In
A toast slides in from the edge, rests while a thin line counts down, then leaves the way it came.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Toast Slide In
*
* A toast that slides in from the edge, rests while a thin progress
* line runs out, then leaves the way it came.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The panel follows the host app's color scheme, so it lands light on a
* light page and dark on a dark one.
* Works with zero props; tune via `variant`, `from`, `holdMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ToastSlideInProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Edge the toast travels from — match it to where the toast is docked. */
from?: "bottom" | "top" | "right";
/** Headline line. */
title?: string;
/** Supporting line. Pass an empty string for a single-line toast. */
description?: string;
/** How long the toast rests before it leaves, in ms. Also the progress line. */
holdMs?: number;
/** Fires once the toast has finished leaving. */
onDismiss?: () => void;
};
type VariantConfig = {
travel: number;
spring: { type: "spring"; stiffness: number; damping: number };
exitDuration: number;
};
// Quality rule: a toast interrupts whatever the user was doing, so it has
// to land flat — every spring here sits at or above a 0.8 damping ratio.
// Variants differ in how far the toast travels and how fast it arrives,
// never in how many times it bounces.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Short travel, no overshoot whatsoever. For toasts that fire often
// enough that arrival should barely register.
subtle: {
travel: 12,
spring: { type: "spring", stiffness: 480, damping: 44 },
exitDuration: 0.16,
},
// Enough travel to read as "this arrived", one soft settle. All-purpose.
default: {
travel: 20,
spring: { type: "spring", stiffness: 380, damping: 34 },
exitDuration: 0.18,
},
// Further and quicker — noticeable across a large screen without
// turning rubbery.
playful: {
travel: 30,
spring: { type: "spring", stiffness: 420, damping: 34 },
exitDuration: 0.2,
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral for the border and the icon well: mixing the
* text color in scope with `transparent` lands correctly on a light
* surface and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ToastSlideIn({
variant = "default",
from = "bottom",
title = "Invite sent",
description = "maya@northwind.app can now edit this project.",
holdMs = 3600,
onDismiss,
}: ToastSlideInProps) {
const [open, setOpen] = useState(true);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
useEffect(() => {
const timer = setTimeout(() => setOpen(false), holdMs);
return () => clearTimeout(timer);
}, [holdMs]);
// Reduced motion: the toast still arrives and still leaves, it just
// doesn't travel. The progress line stays either way — "how long you
// have left to act" is information, not decoration.
const offset = reduceMotion
? { x: 0, y: 0 }
: from === "right"
? { x: cfg.travel, y: 0 }
: { x: 0, y: from === "top" ? -cfg.travel : cfg.travel };
return (
<AnimatePresence onExitComplete={onDismiss}>
{open && (
<motion.div
role="status"
aria-live="polite"
initial={{ opacity: 0, ...offset }}
animate={{ opacity: 1, x: 0, y: 0 }}
exit={{
opacity: 0,
...offset,
// Leaving is not an event worth watching: it undercuts the
// arrival on a plain ease-in, faster than it came.
transition: { duration: cfg.exitDuration, ease: "easeIn" },
}}
transition={
reduceMotion
? { duration: 0.16, ease: "easeOut" }
: {
...cfg.spring,
// Opacity on its own quick curve; springing it looks muddy.
opacity: { duration: 0.16, ease: "easeOut" },
}
}
// Translate + opacity only. Scaling the panel would scale the
// message text with it, which is the one thing text must never do.
style={{
position: "relative",
width: 320,
display: "flex",
alignItems: "flex-start",
gap: 11,
padding: "13px 13px 15px",
borderRadius: 14,
// A toast covers page content, so this surface has to be
// opaque — a translucent panel would let the text underneath
// read through it. `Canvas`/`CanvasText` are the CSS system
// colors for page background and page text: they follow the
// host app's color scheme, so the toast is light in a light
// app and dark in a dark one, and the pair is legible either
// way. Everything inside then mixes from `currentColor`.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 12px 32px rgba(0,0,0,0.2)",
overflow: "hidden",
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: 22,
height: 22,
marginTop: 1,
borderRadius: "50%",
background: `color-mix(in srgb, ${ACCENT} 18%, transparent)`,
display: "grid",
placeItems: "center",
}}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
<path
d="M3.4 8.6 6.4 11.6 12.6 5.2"
stroke={ACCENT}
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, lineHeight: 1.35 }}>
{title}
</div>
{description ? (
<div
style={{
fontSize: 12.5,
lineHeight: 1.45,
opacity: 0.55,
marginTop: 2,
}}
>
{description}
</div>
) : null}
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Dismiss notification"
style={{
flexShrink: 0,
width: 22,
height: 22,
marginTop: 1,
padding: 0,
display: "grid",
placeItems: "center",
background: "none",
border: 0,
borderRadius: 6,
color: "inherit",
opacity: 0.45,
cursor: "pointer",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M4 4 12 12M12 4 4 12"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
</button>
{/* Time remaining, drawn as a transform: scaleX on a pinned line
costs nothing per frame, where animating width would relayout
the toast 60 times a second. Linear because a progress line
that eases is lying about the clock. */}
<motion.div
aria-hidden
initial={{ scaleX: 1 }}
animate={{ scaleX: 0 }}
transition={{ duration: holdMs / 1000, ease: "linear" }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 2,
transformOrigin: "0% 50%",
background: ACCENT,
opacity: 0.85,
}}
/>
</motion.div>
)}
</AnimatePresence>
);
}About this pattern
Transient feedback that must not steal the cursor: an action succeeded, a job finished, an undo window is open. The panel travels in from the edge it is docked against, holds while a hairline progress bar runs out, then retreats faster than it arrived. The countdown line is the part most toasts skip, and it is the part that makes the toast feel fair — the user can see how long they have to hit Undo instead of guessing. Motion is translate-and-fade only, so the message text never deforms on the way in.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
A transient bar that holds an undo window open, then withdraws unasked.
Related patterns
- Undo SnackbarA removed row leaves a bar behind, and a hairline drains across it toward the point of no return.
- Connection RestoredThe offline bar turns green, confirms, and retracts in one continuous move.
- Banner DismissAn announcement strip fades its message, then collapses its own height so the page closes the gap.