Refund Status
A refund walks down its stages while the vague estimate firms up into a real date.
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 · Refund Status
*
* A refund walks down its stages while the estimate firms up: the vague
* window a customer is given at the start crossfades into a real date
* once the money has actually left. Waiting for a refund is an anxious
* kind of waiting, so the motion is slow, downward and completely
* unexcited — no pulse, no loop, no celebration.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the tracker reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `amount`, `currentStage`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type RefundStatusTrackProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Amount being returned. */
amount?: string;
/** Destination shown under the amount. */
destination?: string;
/** Stage the refund has reached (0-based). */
currentStage?: number;
/** Date shown once the estimate has firmed up. */
firmDate?: string;
/** Fires once the track has finished advancing. */
onSettled?: () => void;
};
type VariantConfig = {
/** Seconds for one rail segment to fill. */
segment: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** Seconds before the estimate is allowed to firm up. */
firmDelay: number;
};
// Quality rule: nothing about a refund should feel eager. Every spring
// is at or above a 0.8 damping ratio so each stage marks itself once,
// the rail fills on an eased curve rather than a spring, and the dates
// crossfade in a fixed slot instead of resizing their row.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// For a refund row inside an order history list.
subtle: {
segment: 0.26,
spring: { type: "spring", stiffness: 520, damping: 42 },
firmDelay: 0.1,
},
// The fill reads as descent. All-purpose.
default: {
segment: 0.4,
spring: { type: "spring", stiffness: 400, damping: 34 },
firmDelay: 0.18,
},
// A slower walk for a dedicated refund screen the customer opened on
// purpose, probably more than once.
playful: {
segment: 0.56,
spring: { type: "spring", stiffness: 340, damping: 31 },
firmDelay: 0.26,
},
};
const ACCENT = "#2F9E6E";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const STAGES = [
{ id: "requested", label: "Return requested", meta: "Mon 11 Aug" },
{ id: "received", label: "Item received back", meta: "Wed 13 Aug" },
{ id: "sent", label: "Refund sent to your bank", meta: "Today" },
{ id: "settled", label: "Back in your account", meta: "" },
] as const;
const ROW_HEIGHT = 52;
export default function RefundStatusTrack({
variant = "default",
amount = "$86.40",
destination = "Visa ···· 4417",
currentStage = 2,
firmDate = "Thu 21 Aug",
onSettled,
}: RefundStatusTrackProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const reached = Math.max(0, Math.min(currentStage, STAGES.length - 1));
const [firm, setFirm] = useState(false);
const timeAt = (index: number) => (reduceMotion ? 0 : index * cfg.segment);
const settledAt = timeAt(reached) + (reduceMotion ? 0 : cfg.segment);
// The estimate only firms up once the money has genuinely moved. The
// timer mirrors the track rather than racing it.
useEffect(() => {
const delay = reduceMotion ? 200 : (settledAt + cfg.firmDelay) * 1000;
const timer = setTimeout(() => {
setFirm(true);
onSettled?.();
}, delay);
return () => clearTimeout(timer);
}, [settledAt, cfg.firmDelay, reduceMotion, onSettled]);
return (
<div
style={{
width: 330,
padding: 16,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
fontFamily: "inherit",
boxSizing: "border-box",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
paddingBottom: 13,
marginBottom: 14,
borderBottom: `1px solid ${tone(10)}`,
}}
>
<span>
<span style={{ display: "block", fontSize: 11, opacity: 0.55 }}>
Refund
</span>
<span
style={{
display: "block",
fontSize: 19,
fontWeight: 680,
letterSpacing: "-0.01em",
fontVariantNumeric: "tabular-nums",
}}
>
{amount}
</span>
</span>
<span style={{ fontSize: 11.5, opacity: 0.55 }}>{destination}</span>
</div>
<ol style={{ listStyle: "none", margin: 0, padding: 0 }}>
{STAGES.map((stage, index) => {
const done = index <= reached;
const last = index === STAGES.length - 1;
const at = timeAt(index);
return (
<li
key={stage.id}
aria-current={index === reached ? "step" : undefined}
style={{
display: "flex",
gap: 12,
minHeight: last ? 34 : ROW_HEIGHT,
}}
>
<span
style={{
position: "relative",
width: 18,
flexShrink: 0,
display: "flex",
justifyContent: "center",
}}
>
{!last && (
<span
aria-hidden
style={{
position: "absolute",
top: 20,
height: ROW_HEIGHT - 22,
width: 2,
borderRadius: 2,
background: tone(11),
overflow: "hidden",
}}
>
{/* The rail fills downward on a curve, not a spring:
a segment that overshoots would run past the dot
it is meant to reach. */}
<motion.span
initial={
reduceMotion
? { opacity: index < reached ? 1 : 0 }
: { scaleY: 0 }
}
animate={
reduceMotion
? { opacity: index < reached ? 1 : 0 }
: { scaleY: index < reached ? 1 : 0 }
}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: {
duration: cfg.segment,
ease: "easeInOut",
delay: at,
}
}
style={{
display: "block",
width: "100%",
height: "100%",
background: ACCENT,
transformOrigin: "top center",
}}
/>
</span>
)}
<motion.span
initial={
reduceMotion
? false
: { scale: done ? 0.5 : 1, opacity: done ? 0 : 1 }
}
animate={{ scale: 1, opacity: 1 }}
transition={
reduceMotion ? { duration: 0 } : { ...cfg.spring, delay: at }
}
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 18,
height: 18,
borderRadius: 999,
background: done ? ACCENT : tone(8),
border: done ? "none" : `1.5px solid ${tone(18)}`,
color: "#FFFFFF",
}}
>
{done && (
<svg
width="10"
height="10"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<motion.path
d="M4.5 10.5 8.4 14.3 15.5 6"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={
reduceMotion
? { duration: 0 }
: {
duration: cfg.segment * 0.6,
ease: "easeOut",
delay: at + cfg.segment * 0.2,
}
}
/>
</svg>
)}
</motion.span>
</span>
<span style={{ minWidth: 0, paddingTop: 1 }}>
<motion.span
initial={{ opacity: reduceMotion ? (done ? 1 : 0.45) : 0.3 }}
animate={{ opacity: done ? 1 : 0.45 }}
transition={{
duration: 0.28,
ease: "easeOut",
delay: at + (reduceMotion ? 0 : cfg.segment * 0.3),
}}
style={{
display: "block",
fontSize: 12.5,
fontWeight: done ? 620 : 520,
}}
>
{stage.label}
</motion.span>
{last ? (
// The estimate is the one thing here that changes its
// wording. It swaps inside a fixed-height slot so the
// row cannot resize under a customer who is re-reading
// it, and it crossfades rather than scaling.
<span
style={{
position: "relative",
display: "block",
height: 16,
marginTop: 2,
minWidth: 170,
}}
>
<AnimatePresence initial={false}>
<motion.span
key={firm ? "firm" : "window"}
initial={{ opacity: 0, y: reduceMotion ? 0 : 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -5 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 11.5,
lineHeight: "16px",
whiteSpace: "nowrap",
fontWeight: firm ? 600 : 500,
opacity: firm ? 0.8 : 0.5,
color: firm ? ACCENT : undefined,
}}
>
{firm
? `Expected ${firmDate}`
: "Expected in 3–5 business days"}
</motion.span>
</AnimatePresence>
</span>
) : (
<span
style={{
display: "block",
fontSize: 11,
opacity: 0.5,
marginTop: 2,
}}
>
{stage.meta}
</span>
)}
</span>
</li>
);
})}
</ol>
</div>
);
}About this pattern
Waiting for money to come back is an anxious kind of waiting, so this moves slowly, downward and completely unexcited — no pulse, no loop, no celebration at the end. The rail fills on an eased curve rather than a spring, because a segment that overshoots runs past the stop it is meant to reach. The one thing that changes its wording is the estimate: the window the customer was given at the start crossfades into a firm date once the money has genuinely left, inside a fixed-height slot so the row cannot resize under someone re-reading it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Order tracking
Return tracking replaces a business-day window with a concrete date once the refund is issued.
Related patterns
- Order Tracking ProgressA shipment walks its stages while the connector fills from stop to stop.
- Checkout Step ProgressThe finished stage folds into a one-line recap while the following one opens and the rail fills.
- Shipping Option SelectOne plate travels to the chosen speed while the arrival estimate restates itself and the total follows.
