Undo toast whose remaining lifetime is a literal fuse — a hairline along the bottom edge burns right-to-left with an ember at the front, answering "when does this go away" without a numeric countdown.
npx shadcn add https://design.helpmarq.com /r/toast-undo-fuse.jsonregistry/core/toast-undo-fuse/component.tsx"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
// ---------------------------------------------------------------------------
// ShortFuse — an undo toast whose remaining lifetime IS its border. A 1px
// hairline along the bottom edge is an SVG line whose stroke-dashoffset
// animates linearly from 0 -> 100 (pathLength-normalized) over `duration`, so
// the unburnt portion visibly shortens from the right edge toward the left. A
// 3px ember dot tracks the burn front via a mirrored `left` animation. Both
// run on the Web Animations API so pause/resume is exactly `.pause()`/`.play()`
// — no manual timers to desync, and `.currentTime` stays truthful for free.
//
// Hover or keyboard focus pauses both animations and springs the ember's
// scale down (k=300, zeta=0.7) — a physical pinch, not an invisible timer
// stopping somewhere off-screen. Escape or the fuse running out dismisses.
// Undo reads the current burn progress, cancels the forward animations, and
// plays a reverse pair (same duration/4, ease-out-expo) back to unburnt
// before the toast exits. Colors: --border for the unburnt line, --foreground
// for the ember, --accent only on the Undo button — the toast surface is
// --background with a 1px --border and 12px radius.
//
// prefers-reduced-motion: the line depletes in discrete steps (stepped
// easing, no ember animation at all — it's hidden), and Undo snaps back
// instantly rather than replaying the reverse-burn.
// ---------------------------------------------------------------------------
const EASE_OUT_EXPO = "cubic-bezier(0.16, 1, 0.3, 1)";
const SPRING_K = 300; // s^-2 — ember pinch stiffness
const SPRING_ZETA = 0.7; // damping ratio, slight settle overshoot
const EMBER_REST_SCALE = 1;
const EMBER_PAUSE_SCALE = 0.45;
const EXIT_MS = 180;
type Phase = "burning" | "reversing" | "exiting" | "gone";
function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
function animationElapsedMs(anim: Animation | null): number {
const ct = anim?.currentTime;
return typeof ct === "number" ? ct : 0;
}
export interface ShortFuseProps {
/** The undo-able message, e.g. "Message archived". Required. */
message: string;
/** Label on the action button. Default "Undo". */
actionLabel?: string;
/** Total fuse life in ms before it auto-dismisses. Default 6000. */
duration?: number;
/** Fires once the reverse-burn and exit finish after Undo is pressed. */
onUndo?: () => void;
/** Fires once the exit finishes after the fuse burns out, or Escape. */
onDismiss?: () => void;
className?: string;
}
export function ShortFuse({
message,
actionLabel = "Undo",
duration = 6000,
onUndo,
onDismiss,
className = "",
}: ShortFuseProps) {
const rootRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<SVGLineElement>(null);
const emberRef = useRef<HTMLSpanElement>(null);
const lineAnimRef = useRef<Animation | null>(null);
const emberAnimRef = useRef<Animation | null>(null);
const phaseRef = useRef<Phase>("burning");
// ember spring (rAF), independent of the WAAPI position animation
const scaleRef = useRef(EMBER_REST_SCALE);
const scaleVelRef = useRef(0);
const springTargetRef = useRef(EMBER_REST_SCALE);
const springRafRef = useRef(0);
const springLastRef = useRef(0);
// latest callbacks by ref so the mount effect never needs to re-run when a
// caller passes a fresh inline function every render
const onUndoRef = useRef(onUndo);
const onDismissRef = useRef(onDismiss);
useEffect(() => {
onUndoRef.current = onUndo;
}, [onUndo]);
useEffect(() => {
onDismissRef.current = onDismiss;
}, [onDismiss]);
const [phase, setPhase] = useState<Phase>("burning");
const [description, setDescription] = useState(
() => `Auto-dismisses in ${Math.round(duration / 1000)}s unless undone.`
);
const autoId = useId();
const descId = `sf-desc-${autoId}`;
const setPhaseBoth = useCallback((p: Phase) => {
phaseRef.current = p;
setPhase(p);
}, []);
// -- ember spring loop -----------------------------------------------------
const wakeSpring = useCallback(() => {
if (springRafRef.current) return;
springLastRef.current = performance.now();
const step = (now: number) => {
const dt = Math.min(Math.max((now - springLastRef.current) / 1000, 0), 0.032);
springLastRef.current = now;
const c = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);
const target = springTargetRef.current;
const x = scaleRef.current;
const v = scaleVelRef.current;
const a = SPRING_K * (target - x) - c * v;
const nv = v + a * dt;
const nx = x + nv * dt;
scaleRef.current = nx;
scaleVelRef.current = nv;
const el = emberRef.current;
if (el) el.style.transform = `translate(-50%, -50%) scale(${nx})`;
if (Math.abs(target - nx) > 0.002 || Math.abs(nv) > 0.01) {
springRafRef.current = requestAnimationFrame(step);
} else {
scaleRef.current = target;
if (el) el.style.transform = `translate(-50%, -50%) scale(${target})`;
springRafRef.current = 0;
}
};
springRafRef.current = requestAnimationFrame(step);
}, []);
// -- exit + settle ----------------------------------------------------------
const finishExit = useCallback(
(reason: "undone" | "dismissed") => {
setPhaseBoth("gone");
if (reason === "undone") onUndoRef.current?.();
else onDismissRef.current?.();
},
[setPhaseBoth]
);
const runExit = useCallback(
(reason: "undone" | "dismissed") => {
setPhaseBoth("exiting");
const root = rootRef.current;
if (!root || typeof root.animate !== "function") {
finishExit(reason);
return;
}
const reduced = prefersReducedMotion();
const anim = root.animate(
[
{ transform: "translateX(0)", opacity: 1 },
{ transform: "translateX(12px)", opacity: 0 },
],
{ duration: reduced ? 1 : EXIT_MS, easing: "ease-out", fill: "forwards" }
);
anim.finished.then(() => finishExit(reason)).catch(() => finishExit(reason));
},
[finishExit, setPhaseBoth]
);
// -- mount: set up the two forward burn animations --------------------------
useEffect(() => {
const line = lineRef.current;
const ember = emberRef.current;
if (!line) return;
const reduced = prefersReducedMotion();
line.style.strokeDasharray = "100";
line.style.strokeDashoffset = "0";
const easing = reduced ? "steps(8, end)" : "linear";
const lineAnim = line.animate(
[{ strokeDashoffset: "0" }, { strokeDashoffset: "100" }],
{ duration, easing, fill: "forwards" }
);
lineAnimRef.current = lineAnim;
let emberAnim: Animation | null = null;
if (!reduced && ember) {
emberAnim = ember.animate([{ left: "100%" }, { left: "0%" }], {
duration,
easing,
fill: "forwards",
});
emberAnimRef.current = emberAnim;
}
let cancelled = false;
lineAnim.finished
.then(() => {
if (!cancelled && phaseRef.current === "burning") runExit("dismissed");
})
.catch(() => {});
return () => {
cancelled = true;
lineAnim.cancel();
emberAnim?.cancel();
if (springRafRef.current) {
cancelAnimationFrame(springRafRef.current);
springRafRef.current = 0;
}
};
// duration is the only value this timeline actually depends on; onUndo /
// onDismiss are read from refs (via runExit -> finishExit) so a fresh
// inline prop from the caller never restarts the burn.
}, [duration, runExit]);
// -- pause / resume on hover or focus ----------------------------------------
const updateDescription = useCallback(
(paused: boolean) => {
const remaining = Math.max(
0,
Math.round((duration - animationElapsedMs(lineAnimRef.current)) / 1000)
);
setDescription(
paused
? `Paused. ${remaining}s remaining unless undone.`
: `Auto-dismisses in ${remaining}s unless undone.`
);
},
[duration]
);
const setPaused = useCallback(
(paused: boolean) => {
if (phaseRef.current !== "burning") return;
if (prefersReducedMotion()) return; // stepped depletion has no pause affordance
lineAnimRef.current?.[paused ? "pause" : "play"]();
emberAnimRef.current?.[paused ? "pause" : "play"]();
springTargetRef.current = paused ? EMBER_PAUSE_SCALE : EMBER_REST_SCALE;
wakeSpring();
updateDescription(paused);
},
[updateDescription, wakeSpring]
);
const handleUndo = useCallback(() => {
if (phaseRef.current !== "burning") return;
const reduced = prefersReducedMotion();
const elapsed = animationElapsedMs(lineAnimRef.current);
const dashoffsetNow = Math.min(100, Math.max(0, (elapsed / duration) * 100));
lineAnimRef.current?.cancel();
emberAnimRef.current?.cancel();
setPhaseBoth("reversing");
const line = lineRef.current;
const ember = emberRef.current;
const revDuration = reduced ? 1 : Math.max(140, elapsed / 4);
const revEasing = reduced ? "linear" : EASE_OUT_EXPO;
const revLine = line?.animate(
[{ strokeDashoffset: `${dashoffsetNow}` }, { strokeDashoffset: "0" }],
{ duration: revDuration, easing: revEasing, fill: "forwards" }
);
if (!reduced && ember) {
ember.animate(
[{ left: `${100 - dashoffsetNow}%` }, { left: "100%" }],
{ duration: revDuration, easing: revEasing, fill: "forwards" }
);
}
springTargetRef.current = EMBER_REST_SCALE;
wakeSpring();
if (revLine) {
revLine.finished.then(() => runExit("undone")).catch(() => runExit("undone"));
} else {
runExit("undone");
}
}, [duration, runExit, setPhaseBoth, wakeSpring]);
const handleKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape" && phaseRef.current === "burning") {
e.stopPropagation();
lineAnimRef.current?.pause();
emberAnimRef.current?.pause();
runExit("dismissed");
}
},
[runExit]
);
if (phase === "gone") return null;
return (
<div
ref={rootRef}
role="status"
aria-live="polite"
aria-atomic="true"
aria-describedby={descId}
tabIndex={0}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={(e) => {
if (!rootRef.current?.contains(e.relatedTarget as Node)) setPaused(false);
}}
onKeyDown={handleKeyDown}
className={`relative w-full max-w-sm overflow-hidden rounded-[12px] border border-border bg-background px-4 py-3 pb-4 text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-border ${className}`}
>
<div className="flex items-start gap-3">
<p className="min-w-0 flex-1 text-sm leading-snug">{message}</p>
<button
type="button"
data-toast-undo-fuse-undo=""
onClick={handleUndo}
className="shrink-0 cursor-pointer rounded-[6px] px-2 py-1 text-xs font-medium tracking-wide text-accent transition-opacity duration-150 hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{actionLabel}
</button>
</div>
<span id={descId} className="sr-only">
{description}
</span>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-[3px] overflow-visible"
>
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox="0 0 100 3"
preserveAspectRatio="none"
>
<line
ref={lineRef}
x1="0"
y1="1.5"
x2="100"
y2="1.5"
stroke="var(--border)"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
pathLength={100}
/>
</svg>
<span
ref={emberRef}
className="absolute top-1/2 h-[3px] w-[3px] rounded-full bg-foreground motion-reduce:hidden"
style={{ left: "100%", transform: "translate(-50%, -50%) scale(1)" }}
/>
</div>
</div>
);
}
Reach for toast-undo-fuse when the job is a single timed, undo-able notification and the timer itself should read as physical rather than numeric — the border is the countdown, hover or focus visibly pinches it to a pause, and Undo reverses the burn instead of just canceling a number. It shares the "toast" role with toast-gravity-stack, which is the pick instead when the job is a multi-toast feed with gravity, stacking and severity sorting — toast-gravity-stack's individual cards do not carry their own depleting timer chrome the way toast-undo-fuse's does.
A single undo toast (role=status, aria-live=polite, aria-atomic) whose remaining lifetime is rendered as its own bottom border rather than a digit. The border is a full-width SVG <line> with pathLength=100 so stroke-dasharray/stroke-dashoffset are resolution-independent; a Web Animations API animation drives stroke-dashoffset linearly from 0 to 100 over `duration` (default 6000ms), which shortens the visible (unburnt, --border colored) segment from the right edge toward the left as time elapses. A sibling absolutely-positioned span is the 3px ember — --foreground colored, 1:1 vertically centered on the line — animated via a mirrored `left` WAAPI animation (100% to 0%) so it always sits exactly at the current burn front. Both animations are created once at mount and never restarted by prop identity churn (onUndo/onDismiss are read through refs); pausing is `.pause()` on both Animation objects, which freezes `.currentTime` for free, so the accessible description (a visually-hidden span bound via aria-describedby) can read genuine remaining seconds off the animation clock instead of a separate drifting interval. Hovering the toast or focusing it (Tab reaches the toast itself before the Undo button, and it is a real focus target, not a visual-only affordance) pauses both animations and springs the ember's scale down to 0.45 with an underdamped 1D spring (k=300 s^-2, zeta=0.7, rAF-driven, sleeps once settled) — the pause is a physical pinch of the fuse, not an invisible timer stopping somewhere off-screen. Undo is a real <button> (--accent text, the only place --accent appears); clicking it reads the current burn progress off the forward animation's currentTime, cancels both forward animations, and plays a reverse pair back to the unburnt state at 4x speed (duration/4, floored at 140ms) with ease-out-expo, then runs a 180ms exit translate/fade before calling onUndo. Escape, or the fuse simply running out, skips the reverse and runs the same exit before calling onDismiss. The toast surface is --background with a 1px --border and 12px radius; overflow-hidden lets the corner radius clip the hairline's ends cleanly. prefers-reduced-motion: the line animation switches to stepped easing (steps(8, end)) so it depletes in discrete jumps instead of a continuous sweep, the ember is not rendered or animated at all, hover/focus pausing is disabled (nothing to pause), and Undo snaps back to unburnt in a single frame instead of replaying the reverse-burn — the toast stays fully usable, just without any of the physical motion. Props: message (required), actionLabel (default "Undo"), duration, onUndo, onDismiss. Zero dependencies beyond React.