Route Progress Top
A top-edge bar advances during navigation and completes with a quick finish.
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 · Route Progress Top
*
* The thin bar along the top edge that covers a navigation. It leaps to
* a visible head start, trickles toward — never to — the end while the
* next view is being fetched, then completes in a quick beat and gets out
* of the way. The first navigation runs on mount; the links start more.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the frame reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type RouteProgressTopProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Route shown once the first navigation completes. */
defaultIndex?: number;
/** Notified when a navigation completes, with the new index. */
onNavigate?: (index: number) => void;
};
type VariantConfig = {
/** Seconds the trickle takes to crawl to its ceiling. */
trickle: number;
/** Fraction of the track the trickle is allowed to reach. */
ceiling: number;
/** Seconds of simulated fetching before the view is ready. */
fetchSeconds: number;
/** Seconds the completing run takes. */
finish: number;
/** px of accent glow around the bar. */
glow: number;
};
// Quality rule: nothing here springs. A progress bar that overshoots has
// claimed to be more finished than it is, so the trickle is a long
// ease-out that decelerates toward its ceiling and the completion is a
// short ease-out that lands exactly on the end. Variants change the
// pacing, never the shape.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely there. For an app that navigates constantly.
subtle: {
trickle: 2,
ceiling: 0.84,
fetchSeconds: 0.9,
finish: 0.16,
glow: 0,
},
// The all-purpose setting: readable without asking for attention.
default: {
trickle: 2.4,
ceiling: 0.9,
fetchSeconds: 1.1,
finish: 0.18,
glow: 6,
},
// A brighter head with a longer crawl, for a marketing-side shell.
playful: {
trickle: 2.8,
ceiling: 0.94,
fetchSeconds: 1.3,
finish: 0.22,
glow: 10,
},
};
const ACCENT = "#7C7CF0";
/** 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)`;
/** Decelerating hard: fast off the mark, then a crawl. The whole lie of a
* trickle is in this curve. */
const TRICKLE_EASE: [number, number, number, number] = [0.06, 0.86, 0.28, 1];
const ROUTES = [
{
label: "Overview",
heading: "Revenue is up 12.4%",
body: "Three of five channels beat their weekly target.",
rows: ["Direct · $18,240", "Marketplace · $9,120", "Partners · $4,860"],
},
{
label: "Invoices",
heading: "6 invoices open",
body: "Two are past due by more than a week.",
rows: ["INV-10482 · $248.00", "INV-10479 · $1,150.00", "INV-10476 · $64.00"],
},
{
label: "Usage",
heading: "82% of plan used",
body: "At this rate the plan tops out on the 26th.",
rows: ["API calls · 1.9M", "Storage · 412 GB", "Seats · 18 of 25"],
},
] as const;
type Phase = "loading" | "completing" | "idle";
export default function RouteProgressTop({
variant = "default",
defaultIndex = 0,
onNavigate,
}: RouteProgressTopProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [route, setRoute] = useState(defaultIndex);
const [pending, setPending] = useState<number | null>(defaultIndex);
// The first navigation starts on mount: the pattern is what a page
// looks like while it is arriving, so it should be arriving.
const [phase, setPhase] = useState<Phase>("loading");
// Fetch finishes → commit the view and let the bar complete.
useEffect(() => {
if (phase !== "loading") return;
const timer = setTimeout(() => {
setRoute((current) => pending ?? current);
setPending(null);
setPhase("completing");
if (pending !== null) onNavigate?.(pending);
}, cfg.fetchSeconds * 1000);
return () => clearTimeout(timer);
}, [phase, pending, cfg.fetchSeconds, onNavigate]);
// Completed → after the bar has run out and faded, reset it to zero
// off-screen so the next navigation starts from the left edge again.
useEffect(() => {
if (phase !== "completing") return;
const timer = setTimeout(() => setPhase("idle"), 460);
return () => clearTimeout(timer);
}, [phase]);
const navigate = (index: number) => {
// Re-navigating to the route you are already on still runs the bar —
// that is what a router does, and hiding it would make the app look
// unresponsive to a deliberate click.
setPending(index);
setPhase("loading");
};
// Reduced motion: the bar still reports the three states — started,
// finishing, gone — it just stops crawling to do it.
const bar = {
loading: {
target: { scaleX: reduceMotion ? 0.35 : cfg.ceiling, opacity: 1 },
transition: {
scaleX: reduceMotion
? { duration: 0 }
: { duration: cfg.trickle, ease: TRICKLE_EASE },
opacity: { duration: 0.08 },
},
},
completing: {
target: { scaleX: 1, opacity: 0 },
transition: {
scaleX: { duration: reduceMotion ? 0 : cfg.finish, ease: "easeOut" as const },
// The fade waits for the run to land: a bar that disappears
// before it reaches the end reads as a failure.
opacity: { duration: 0.24, delay: reduceMotion ? 0.12 : cfg.finish + 0.06 },
},
},
idle: {
target: { scaleX: 0, opacity: 0 },
// Invisible already, so the rewind is instant rather than animated.
transition: { duration: 0 },
},
}[phase];
const current = ROUTES[route];
const active = pending ?? route;
return (
<div
style={{
position: "relative",
width: 336,
height: 252,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 36px rgba(0,0,0,0.16)",
overflow: "hidden",
}}
>
{/* The bar is pinned to this frame's top edge so the pattern works
inside a card. In an app shell, give it `position: fixed; top: 0;
left: 0; right: 0` and a z-index above your header — everything
else about it is unchanged. */}
<motion.div
role="progressbar"
aria-label="Loading the next view"
aria-hidden={phase === "idle"}
initial={{ scaleX: 0, opacity: 0 }}
animate={bar.target}
transition={bar.transition}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 2.5,
zIndex: 3,
// Scale, not width: one compositor property, so the bar keeps
// its frame rate however busy the page under it is.
transformOrigin: "0% 50%",
background: ACCENT,
boxShadow: cfg.glow ? `0 0 ${cfg.glow}px ${ACCENT}` : "none",
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "12px 12px 10px",
borderBottom: `1px solid ${tone(12)}`,
}}
>
<span style={{ fontSize: 12.5, fontWeight: 650, marginRight: 6 }}>
Acme
</span>
{ROUTES.map((entry, index) => {
const isActive = index === active;
return (
<button
key={entry.label}
type="button"
onClick={() => navigate(index)}
aria-current={index === route ? "page" : undefined}
style={{
padding: "5px 9px",
borderRadius: 8,
border: 0,
background: isActive ? tone(10) : "transparent",
color: "inherit",
fontFamily: "inherit",
fontSize: 12,
fontWeight: isActive ? 600 : 500,
opacity: isActive ? 1 : 0.55,
cursor: "pointer",
}}
>
{entry.label}
</button>
);
})}
</div>
{/* The view underneath recedes slightly while the next one is on
its way — the same signal the bar is giving, in the place the
reader is actually looking. */}
<motion.div
animate={{ opacity: phase === "loading" ? 0.45 : 1 }}
transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }}
style={{ padding: "14px 16px" }}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={route}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.1 : 0.18, ease: "easeOut" }}
>
<div style={{ fontSize: 15, fontWeight: 650 }}>{current.heading}</div>
<div style={{ fontSize: 12.5, opacity: 0.55, marginTop: 4 }}>
{current.body}
</div>
<div style={{ marginTop: 12 }}>
{current.rows.map((row) => (
<div
key={row}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "8px 10px",
marginTop: 6,
borderRadius: 10,
background: tone(7),
border: `1px solid ${tone(10)}`,
fontSize: 12,
}}
>
<span
aria-hidden
style={{
width: 6,
height: 6,
borderRadius: 999,
background: ACCENT,
opacity: 0.8,
}}
/>
{row}
</div>
))}
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
);
}About this pattern
The bar that covers the gap between a click and the next view. It works because it is honest about what it does not know: a leap to a visible head start, then a long decelerating crawl toward a ceiling it never reaches, because arriving at the end before the data does is a lie the reader will catch. When the view commits, the bar completes in a short beat, waits for that run to land, and only then fades — a bar that disappears before it reaches the end reads as a failure. It scales rather than resizing, so it costs one compositor property no matter how busy the page beneath it is, and the view underneath dims while the next one is on its way.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
A slim accent bar runs along the top edge while the next view is fetched.
Related patterns
- Scroll Progress BarA hairline on the top edge tracks how far through a long article the reader has come.
- Tab Indicator SlideThe active-tab underline travels to the tab you picked instead of blinking out and back.
- Breadcrumb Trail AppendGoing one level deeper slides a new crumb in from the right while the trail behind it recedes.