Resume Where You Left
A welcome-back card settles, draws the setup already finished, then marks the step waiting to be picked up.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Resume Where You Left
*
* Coming back to something half-finished. The card settles, the work
* already done draws itself along the track, and only then does the
* unfinished step take the accent — so the person is shown their credit
* before they are shown their homework.
*
* The finished steps do not animate. They happened on a previous visit
* and re-performing them would be the product taking credit twice; they
* are simply there when the card arrives, which is what leaves the next
* step somewhere to stand out.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Card, track and step rows are mixed from the inherited text color; the
* accent is semantic and stays literal.
* Works with zero props; tune via `variant`, `steps`, `greeting`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ResumeStep = {
id: string;
label: string;
/** Set on the steps finished before this visit. */
done?: boolean;
};
export type ReturningUserResumeProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Line at the top of the card. */
greeting?: string;
/** What was left unfinished. */
subtitle?: string;
/** Your own setup steps. The embedded sample is used when omitted. */
steps?: ResumeStep[];
/** Wording of the chip on the step being returned to. */
resumeLabel?: string;
/** Wording of the action. */
actionLabel?: string;
/** Accent colour. Semantic, so it stays literal. */
accent?: string;
/** Fires when the action is pressed. */
onResume?: () => void;
};
type VariantConfig = {
/** Card entry spring. */
spring: { type: "spring"; stiffness: number; damping: number };
/** px the card rises on entry. */
lift: number;
/** Beat before the earned progress draws. */
trackDelay: number;
/** How long the earned progress takes to draw. */
trackFill: number;
/** Extra beat before the unfinished step is marked. */
focusGap: number;
};
// Damping ratios of 0.85 and up: the card arrives and stops. This is the
// first thing somebody sees on returning to an unfinished job, so it has
// to feel like being welcomed back rather than being sold to. Variants
// change pace and travel, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost immediate. For a returning-session banner seen daily.
subtle: {
spring: { type: "spring", stiffness: 520, damping: 44 },
lift: 6,
trackDelay: 0.12,
trackFill: 0.36,
focusGap: 0.08,
},
// The all-purpose setting: credit first, then the next thing to do.
default: {
spring: { type: "spring", stiffness: 400, damping: 34 },
lift: 10,
trackDelay: 0.22,
trackFill: 0.54,
focusGap: 0.14,
},
// A slower welcome, for a re-entry screen after a long absence.
playful: {
spring: { type: "spring", stiffness: 320, damping: 30 },
lift: 14,
trackDelay: 0.3,
trackFill: 0.72,
focusGap: 0.2,
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_STEPS: ResumeStep[] = [
{ id: "workspace", label: "Create your workspace", done: true },
{ id: "team", label: "Invite your team", done: true },
{ id: "billing", label: "Add billing details" },
{ id: "source", label: "Connect a data source" },
];
export default function ReturningUserResume({
variant = "default",
greeting = "Welcome back, Robin",
subtitle = "You were partway through setting up Northwind.",
steps = SAMPLE_STEPS,
resumeLabel = "Pick up here",
actionLabel = "Resume setup",
accent = "#4A7A8C",
onResume,
}: ReturningUserResumeProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
const doneCount = steps.filter((step) => step.done).length;
const earned = steps.length ? doneCount / steps.length : 0;
const nextIndex = steps.findIndex((step) => !step.done);
const focusDelay = cfg.trackDelay + cfg.trackFill + cfg.focusGap;
return (
<motion.div
initial={still ? { opacity: 0 } : { opacity: 0, y: cfg.lift }}
animate={{ opacity: 1, y: 0 }}
transition={
still
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.spring, opacity: { duration: 0.22, ease: "easeOut" } }
}
style={{
width: 300,
boxSizing: "border-box",
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
<div style={{ fontSize: 15, fontWeight: 670, letterSpacing: "-0.015em" }}>
{greeting}
</div>
<p style={{ margin: "5px 0 14px", fontSize: 11.5, lineHeight: 1.5, color: tone(54) }}>
{subtitle}
</p>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
style={{
position: "relative",
flex: 1,
height: 6,
borderRadius: 999,
background: tone(9),
overflow: "hidden",
}}
>
{/* The bar draws the credit that already exists. It is not a
celebration and it does not overshoot — it stops on the
fraction the person genuinely finished last time. */}
<motion.span
aria-hidden
initial={{ scaleX: still ? earned : 0 }}
animate={{ scaleX: earned }}
transition={{
duration: still ? 0 : cfg.trackFill,
delay: still ? 0 : cfg.trackDelay,
ease: [0.32, 0.72, 0.3, 1],
}}
style={{
position: "absolute",
inset: 0,
transformOrigin: "left center",
borderRadius: 999,
background: accent,
}}
/>
</span>
<motion.span
initial={{ opacity: still ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{
duration: still ? 0.2 : 0.3,
delay: still ? 0 : cfg.trackDelay + cfg.trackFill * 0.7,
ease: "easeOut",
}}
style={{
fontSize: 10.5,
fontWeight: 620,
color: tone(52),
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
{`${doneCount} of ${steps.length} done`}
</motion.span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2, marginTop: 14 }}>
{steps.map((step, index) => {
const isNext = index === nextIndex;
return (
<div
key={step.id}
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px 8px 11px",
borderRadius: 10,
opacity: step.done ? 0.62 : isNext ? 1 : 0.42,
}}
>
{/* Selection is painted by fading a layer in, not by
animating a colour: theme-adaptive neutrals are
color-mix() values, which no engine can interpolate. */}
{isNext && (
<motion.span
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: still ? 0.2 : 0.32,
delay: still ? 0 : focusDelay,
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
borderRadius: 10,
background: `color-mix(in srgb, ${accent} 11%, transparent)`,
pointerEvents: "none",
}}
/>
)}
{isNext && (
<motion.span
aria-hidden
initial={{ scaleY: still ? 1 : 0, opacity: still ? 1 : 0 }}
animate={{ scaleY: 1, opacity: 1 }}
transition={{
duration: still ? 0.2 : 0.3,
delay: still ? 0 : focusDelay,
ease: [0.22, 1, 0.36, 1],
}}
style={{
position: "absolute",
left: 0,
top: 5,
bottom: 5,
width: 2.5,
borderRadius: 2,
background: accent,
transformOrigin: "top center",
}}
/>
)}
<span
aria-hidden
style={{
position: "relative",
flex: "none",
display: "grid",
placeItems: "center",
width: 17,
height: 17,
borderRadius: 999,
background: step.done ? accent : "transparent",
boxShadow: step.done ? "none" : `inset 0 0 0 1.5px ${tone(24)}`,
}}
>
{step.done && (
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path
d="M2.6 6.2 4.9 8.6 9.4 3.6"
stroke="#FFFFFF"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span>
<span
style={{
position: "relative",
fontSize: 12,
fontWeight: isNext ? 640 : 560,
textDecoration: step.done ? "line-through" : "none",
textDecorationColor: tone(30),
}}
>
{step.label}
</span>
{isNext && (
<motion.span
initial={still ? { opacity: 0 } : { opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: still ? 0.2 : 0.3,
delay: still ? 0.04 : focusDelay + 0.08,
ease: [0.22, 1, 0.36, 1],
}}
style={{
position: "relative",
marginLeft: "auto",
padding: "2px 7px",
borderRadius: 999,
fontSize: 9.5,
fontWeight: 640,
letterSpacing: "0.01em",
color: accent,
background: `color-mix(in srgb, ${accent} 16%, transparent)`,
whiteSpace: "nowrap",
}}
>
{resumeLabel}
</motion.span>
)}
</div>
);
})}
</div>
<motion.button
type="button"
onClick={onResume}
initial={still ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: still ? 0.2 : 0.34,
delay: still ? 0.06 : focusDelay + 0.16,
ease: [0.22, 1, 0.36, 1],
}}
style={{
width: "100%",
marginTop: 14,
padding: "10px 14px",
borderRadius: 11,
border: "none",
background: accent,
color: "#FFFFFF",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: 650,
cursor: "pointer",
}}
>
{actionLabel}
</motion.button>
</motion.div>
);
}About this pattern
Re-entry into something half-finished, ordered so the person is shown their credit before their homework. The card settles once, the track draws to the fraction genuinely completed on the previous visit, and only after that does the unfinished step take the accent and offer somewhere to restart. The finished steps deliberately do not animate: they happened last time, and replaying them is the product taking credit twice — leaving them still is exactly what gives the next step somewhere to stand out. The track stops on its earned value with no overshoot, and the focus on the pending row is painted by fading a tint layer in rather than by animating a colour, since a theme-derived neutral is a color-mix() value no engine can interpolate.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Re-entry after a break leading with what was already completed before asking for more.
Related patterns
- Setup CompleteThe last item checks itself off, the checklist clears, and a single confirmation takes its place.
- Onboarding Step TransitionThe next panel enters from the right as the current one leaves left, with the progress bar advancing behind them.
- Import Data ConnectA line draws itself between two service marks, records run along it, and the destination takes a check when the import lands.