Setup Complete
The last item checks itself off, the checklist clears, and a single confirmation takes its place.
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 type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Setup Complete
*
* The last item checks itself off and the whole list resolves into one
* line. Deliberately restrained: no confetti, no particles, nothing
* bounces — the reward for finishing setup is that the setup disappears.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `items`, `title`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CompletionCelebrationProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Checklist labels. The last one is the one being completed. */
items?: string[];
/** Headline of the resolved state. */
title?: string;
/** Line under the headline. */
body?: string;
/** Check and badge color. */
accent?: string;
/** Fires when the list has resolved into the confirmation. */
onResolved?: () => void;
};
type VariantConfig = {
/** When the list starts clearing, in seconds. */
clearAt: number;
/** Gap between rows leaving, in seconds. */
rowStagger: number;
/** How far the confirmation rises into place, in px. */
rise: number;
/** One slow pass of light across the confirmation. 0 turns it off. */
shimmerSeconds: number;
};
// Quality rule, and the whole point of this pattern: a celebration that
// overacts reads as cheap, and it reads that way every single time after
// the first. So the vocabulary here is deliberately poor — a check that
// draws, a list that clears, one optional pass of light. No spring
// anywhere, because there is nothing here that should overshoot.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The plainest resolve: list out, line in, no light at all.
subtle: { clearAt: 0.7, rowStagger: 0.03, rise: 5, shimmerSeconds: 0 },
// One soft pass of light after the line lands. The all-purpose setting.
default: { clearAt: 0.85, rowStagger: 0.04, rise: 7, shimmerSeconds: 0.9 },
// Longer beats and a slower sweep — still no bounce, because a
// milestone that springs is a milestone nobody trusts twice.
playful: { clearAt: 1, rowStagger: 0.055, rise: 9, shimmerSeconds: 1.15 },
};
/** Neutral surfaces are mixed from the inherited text color, so the card
* reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_ITEMS = [
"Name your workspace",
"Invite your team",
"Connect your calendar",
"Create the first document",
];
/** Fixed row and panel geometry, so the collapse is a known distance
* rather than a measurement taken mid-animation. */
const ROW_HEIGHT = 30;
const CONFIRM_HEIGHT = 54;
export default function CompletionCelebration({
variant = "default",
items = SAMPLE_ITEMS,
title = "Workspace ready",
body = "Every step is done. All of them stay editable in settings.",
accent = "#3E9B6B",
onResolved,
}: CompletionCelebrationProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const listHeight = items.length * ROW_HEIGHT;
const lastIndex = items.length - 1;
const confirmAt = cfg.clearAt + 0.3;
// Reduced motion: the list is already resolved. The information — you
// are finished — is identical; only the resolving is dropped.
if (reduceMotion) {
return (
<Frame>
<Header count={`${items.length} of ${items.length}`} />
<div style={{ height: CONFIRM_HEIGHT, marginTop: 10 }}>
<Confirmation
accent={accent}
title={title}
body={body}
shimmer={null}
/>
</div>
</Frame>
);
}
return (
<Frame>
<Header count={`${items.length} of ${items.length}`} />
<motion.div
initial={{ height: listHeight }}
animate={{ height: CONFIRM_HEIGHT }}
// A real size change, so it is allowed to be a height tween —
// kept short and eased, and it is the only one in the file.
transition={{ delay: cfg.clearAt + 0.1, duration: 0.38, ease: "easeInOut" }}
onAnimationComplete={() => onResolved?.()}
style={{ position: "relative", marginTop: 10, overflow: "hidden" }}
>
<div style={{ position: "absolute", left: 0, right: 0, top: 0 }}>
{items.map((item, index) => (
<motion.div
key={item}
initial={{ opacity: 1, y: 0 }}
animate={{ opacity: 0, y: -6 }}
transition={{
delay: cfg.clearAt + index * cfg.rowStagger,
duration: 0.26,
ease: "easeOut",
}}
style={{
display: "flex",
alignItems: "center",
gap: 9,
height: ROW_HEIGHT,
fontSize: 13,
}}
>
<span
style={{
display: "grid",
placeItems: "center",
width: 17,
height: 17,
borderRadius: 999,
border: `1.5px solid ${
index === lastIndex ? accent : tone(20)
}`,
background: index === lastIndex ? "transparent" : tone(10),
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
{index === lastIndex ? (
// The last item completes in front of the reader:
// the stroke draws itself, once, and that is the
// entire celebration.
<motion.path
d="M3.6 8.4 6.6 11.4 12.4 5.2"
stroke={accent}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ delay: 0.18, duration: 0.34, ease: "easeOut" }}
/>
) : (
<path
d="M3.6 8.4 6.6 11.4 12.4 5.2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.4"
/>
)}
</svg>
</span>
<span style={{ opacity: index === lastIndex ? 0.95 : 0.5 }}>
{item}
</span>
</motion.div>
))}
</div>
<motion.div
initial={{ opacity: 0, y: cfg.rise }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: confirmAt, duration: 0.34, ease: "easeOut" }}
style={{ position: "absolute", left: 0, right: 0, top: 0 }}
>
<Confirmation
accent={accent}
title={title}
body={body}
shimmer={
cfg.shimmerSeconds
? { delay: confirmAt + 0.25, duration: cfg.shimmerSeconds }
: null
}
/>
</motion.div>
</motion.div>
</Frame>
);
}
function Frame({ children }: { children: ReactNode }) {
return (
<div
style={{
width: 320,
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(6),
boxSizing: "border-box",
}}
>
{children}
</div>
);
}
function Header({ count }: { count: string }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: 11,
fontWeight: 650,
letterSpacing: 0.4,
textTransform: "uppercase",
opacity: 0.4,
}}
>
<span>Set up</span>
<span>{count}</span>
</div>
);
}
function Confirmation({
accent,
title,
body,
shimmer,
}: {
accent: string;
title: string;
body: string;
shimmer: { delay: number; duration: number } | null;
}) {
return (
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 11,
height: CONFIRM_HEIGHT,
padding: "0 12px",
borderRadius: 12,
border: `1px solid ${tone(12)}`,
background: tone(7),
boxSizing: "border-box",
overflow: "hidden",
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
flex: "none",
width: 26,
height: 26,
borderRadius: 999,
background: accent,
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
d="M3.6 8.4 6.6 11.4 12.4 5.2"
stroke="#ffffff"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span style={{ minWidth: 0 }}>
<span style={{ display: "block", fontSize: 14, fontWeight: 650 }}>
{title}
</span>
<span
style={{
display: "block",
marginTop: 2,
fontSize: 11.5,
lineHeight: 1.4,
opacity: 0.55,
}}
>
{body}
</span>
</span>
{shimmer && (
// One pass, then gone. A shimmer that repeats turns a
// confirmation into a loading state.
<motion.span
aria-hidden
initial={{ x: "-130%" }}
animate={{ x: "130%" }}
transition={{
delay: shimmer.delay,
duration: shimmer.duration,
ease: "easeInOut",
}}
style={{
position: "absolute",
top: 0,
bottom: 0,
width: "70%",
background: `linear-gradient(100deg, transparent, ${tone(13)}, transparent)`,
pointerEvents: "none",
}}
/>
)}
</div>
);
}About this pattern
The end of a setup flow, played straight. The final item's stroke draws itself, the four rows fade up and out in quick order, the panel eases down to one line, and a confirmation settles into the space the list used to occupy. The only ornament is a single slow pass of light across that line, and even that is off in the quietest variant. No confetti, no particles, no bounce: a milestone that overacts reads as cheap, and it reads that way every time after the first — the reward for finishing setup is that the setup is gone. The height change is the one tween here, because a list collapsing genuinely is a size change; everything else is opacity and a few pixels of travel.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Setup checklist
A finished activation list is replaced by a single confirmation row rather than a fanfare.
Related patterns
- Onboarding Step TransitionThe next panel enters from the right as the current one leaves left, with the progress bar advancing behind them.
- Resume Where You LeftA welcome-back card settles, draws the setup already finished, then marks the step waiting to be picked up.
- Import Data ConnectA line draws itself between two service marks, records run along it, and the destination takes a check when the import lands.