Undo Snackbar
A removed row leaves a bar behind, and a hairline drains across it toward the point of no return.
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, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Undo Snackbar
*
* A destructive action leaves a snackbar behind: a hairline drains
* across the bar toward the point of no return, and taking the undo
* stops the clock and swaps the line for a confirmation.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The bar 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`, `title`, `windowMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type UndoSnackbarProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** What just happened, in the past tense. */
title?: string;
/** How long the undo window stays open, in ms. Also drives the line. */
windowMs?: number;
/** Label of the reversing control. */
actionLabel?: string;
/** Line shown once the action has been reversed. */
revertedTitle?: string;
/** Fires when the user takes the undo. */
onUndo?: () => void;
/** Fires when the window closes untouched. */
onExpire?: () => void;
};
type Phase = "open" | "reverted" | "gone";
type VariantConfig = {
travel: number;
spring: { type: "spring"; stiffness: number; damping: number };
swapDuration: number;
exitDuration: number;
};
// A snackbar appears after something was destroyed, so it has to land
// flat and stay readable: every spring here sits at or above a 0.8
// damping ratio. Variants differ in travel and speed, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely travels. For lists where rows are removed all day.
subtle: {
travel: 10,
spring: { type: "spring", stiffness: 480, damping: 44 },
swapDuration: 0.14,
exitDuration: 0.15,
},
// Enough travel to register as an arrival, one soft settle.
default: {
travel: 18,
spring: { type: "spring", stiffness: 380, damping: 34 },
swapDuration: 0.18,
exitDuration: 0.18,
},
// Further and quicker — visible from across a wide screen.
playful: {
travel: 28,
spring: { type: "spring", stiffness: 420, damping: 34 },
swapDuration: 0.2,
exitDuration: 0.2,
},
};
const ACCENT = "#7C7CF0";
const RESTORED = "#10B981";
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** How long the confirmation is held before the bar withdraws. */
const REVERTED_HOLD_MS = 900;
export default function UndoSnackbar({
variant = "default",
title = "Q3 revenue report deleted",
windowMs = 4800,
actionLabel = "Undo",
revertedTitle = "Report restored",
onUndo,
onExpire,
}: UndoSnackbarProps) {
const [phase, setPhase] = useState<Phase>("open");
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The callbacks live in refs so an inline arrow from the parent can't
// re-trigger the effect and restart the undo window halfway through.
const onExpireRef = useRef(onExpire);
useEffect(() => {
onExpireRef.current = onExpire;
}, [onExpire]);
useEffect(() => {
if (phase !== "open") return;
const timer = setTimeout(() => {
setPhase("gone");
onExpireRef.current?.();
}, windowMs);
return () => clearTimeout(timer);
}, [phase, windowMs]);
useEffect(() => {
if (phase !== "reverted") return;
const timer = setTimeout(() => setPhase("gone"), REVERTED_HOLD_MS);
return () => clearTimeout(timer);
}, [phase]);
// Reduced motion: the bar still arrives and still leaves, it just does
// not travel. The draining line stays either way — how long is left to
// act is information, not decoration.
const offset = reduceMotion ? 0 : cfg.travel;
const handleUndo = () => {
if (phase !== "open") return;
setPhase("reverted");
onUndo?.();
};
return (
<AnimatePresence>
{phase !== "gone" && (
<motion.div
role="status"
aria-live="polite"
initial={{ opacity: 0, y: offset }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: offset,
// Leaving undercuts the arrival: faster, on a plain ease-in.
transition: { duration: cfg.exitDuration, ease: "easeIn" },
}}
transition={
reduceMotion
? { duration: 0.16, ease: "easeOut" }
: {
...cfg.spring,
opacity: { duration: 0.16, ease: "easeOut" },
}
}
// Translate and opacity only. Scaling the bar would scale the
// sentence inside it, which is the one thing text must not do.
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 12,
width: 336,
padding: "12px 12px 14px 14px",
borderRadius: 12,
// A snackbar covers page content, so the surface has to be
// opaque. `Canvas`/`CanvasText` are the CSS system colors for
// page background and page text: the bar is light in a light
// app and dark in a dark one, and legible either way.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 12px 30px rgba(0,0,0,0.2)",
overflow: "hidden",
}}
>
{/* Both states occupy one grid cell, so the bar reserves the
wider of the two up front and the swap can't reflow it. */}
<div style={{ display: "grid", flex: 1, minWidth: 0 }}>
<motion.div
aria-hidden={phase !== "open"}
style={{ gridArea: "1 / 1", display: "flex", alignItems: "center", gap: 9 }}
animate={{ opacity: phase === "open" ? 1 : 0 }}
transition={{ duration: cfg.swapDuration, ease: "easeOut" }}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: 20,
height: 20,
borderRadius: "50%",
background: tone(10),
display: "grid",
placeItems: "center",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<path
d="M3 4.5h10M6.4 4.5V3.2h3.2v1.3M4.4 4.5l.6 8.1h6l.6-8.1"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.75"
/>
</svg>
</span>
<span
style={{
fontSize: 13.5,
fontWeight: 500,
lineHeight: 1.35,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{title}
</span>
</motion.div>
<motion.div
aria-hidden={phase !== "reverted"}
style={{ gridArea: "1 / 1", display: "flex", alignItems: "center", gap: 9 }}
initial={false}
animate={{ opacity: phase === "reverted" ? 1 : 0 }}
transition={{ duration: cfg.swapDuration, ease: "easeOut" }}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: 20,
height: 20,
borderRadius: "50%",
background: `color-mix(in srgb, ${RESTORED} 18%, transparent)`,
display: "grid",
placeItems: "center",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<path
d="M3.4 8.4 6.3 11.3 12.6 5"
stroke={RESTORED}
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span style={{ fontSize: 13.5, fontWeight: 500, lineHeight: 1.35 }}>
{revertedTitle}
</span>
</motion.div>
</div>
<motion.button
type="button"
onClick={handleUndo}
disabled={phase !== "open"}
animate={{ opacity: phase === "open" ? 1 : 0 }}
transition={{ duration: cfg.swapDuration, ease: "easeOut" }}
style={{
flexShrink: 0,
padding: "6px 10px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
letterSpacing: 0.1,
color: ACCENT,
background: `color-mix(in srgb, ${ACCENT} 12%, transparent)`,
border: 0,
borderRadius: 8,
cursor: phase === "open" ? "pointer" : "default",
}}
>
{actionLabel}
</motion.button>
{/* The window, drawn as a transform: scaleX on a pinned line is
free per frame, where animating width would relayout the bar
sixty times a second. Linear, because a countdown that eases
is lying about the clock. It is removed the moment the undo
is taken — the deadline no longer exists. */}
<AnimatePresence>
{phase === "open" && (
<motion.div
aria-hidden
initial={{ scaleX: 1 }}
animate={{ scaleX: 0 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={{ duration: windowMs / 1000, ease: "linear" }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 2,
transformOrigin: "0% 50%",
background: ACCENT,
opacity: 0.8,
}}
/>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
);
}About this pattern
The safety net under a destructive action. The bar arrives on a short travel, and a line drains across its bottom edge for exactly as long as the reversal is still possible — the deadline is drawn rather than guessed at. Taking the undo removes the line instead of freezing it, because the moment the action is reversed the deadline no longer exists, and the label swaps in place to say so before the bar withdraws. The two states share one grid cell so the wider of them is reserved up front: the swap cannot reflow the bar, and the sentence never moves sideways while it changes.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
Deleting an issue leaves a bottom bar holding a timed reversal open.
Related patterns
- Toast Slide InA toast slides in from the edge, rests while a thin line counts down, then leaves the way it came.
- Connection RestoredThe offline bar turns green, confirms, and retracts in one continuous move.
- Queue Position AdvanceYour place in line steps down, the figure swapping upward as the bar of people ahead shortens.