Indeterminate Bar
A slim segment crosses the top edge on a loop for work whose length nobody can predict.
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 { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Indeterminate Bar
*
* The honest loader for work whose length nobody knows. A slim segment
* crosses the top edge on a loop, accelerating out of the left and
* easing into the right, with a second segment trailing it so the track
* is never empty for long. It reports activity and refuses to imply
* progress — no fake percentage, no arc creeping toward a finish line
* it cannot see.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The track is mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; pass `active` to drive it from your request
* state and `children` to wrap your own content.
* Requires the automatic JSX runtime (default since React 17).
*/
export type IndeterminateBarProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive this from your request state. */
active?: boolean;
/** Bar thickness in px. */
thickness?: number;
/** Bar color. A state color, so it stays literal. */
accent?: string;
/** Status line beside the bar. Empty string hides it. */
label?: string;
/** Panel content. Falls back to embedded sample content. */
children?: ReactNode;
/** Panel width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Seconds for one crossing. */
cycle: number;
/** Segment width as a share of the track. */
segment: number;
/** Where the trailing segment sits in the cycle, 0–1. */
trailOffset: number;
/** Opacity of the trailing segment. */
trailOpacity: number;
};
// Quality rule: this loops for as long as the request takes, so it has
// to stay unremarkable at the fiftieth crossing as well as the first.
// Variants change pace and segment length; none of them add a bounce,
// and none of them speed up to suggest the end is near.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Slow and narrow — a background task the user did not start.
subtle: {
cycle: 2,
segment: 0.26,
trailOffset: 0.58,
trailOpacity: 0.3,
},
// The all-purpose setting: readable at a glance, quiet in the corner
// of the eye.
default: {
cycle: 1.55,
segment: 0.34,
trailOffset: 0.55,
trailOpacity: 0.4,
},
// Brisker and wider, for a blocking wait the user is watching.
playful: {
cycle: 1.15,
segment: 0.42,
trailOffset: 0.52,
trailOpacity: 0.5,
},
};
const ACCENT = "#4C7DF0";
/** Theme-adaptive neutral: mixing the inherited text color with
* transparent yields a track and a border that are correctly toned in
* either theme. The bar color stays literal — it is a state color. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function IndeterminateBar({
variant = "default",
active = true,
thickness = 3,
accent = ACCENT,
label = "Preparing export",
children,
width = 336,
}: IndeterminateBarProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
return (
<div
aria-busy={active}
style={{
width,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(4),
overflow: "hidden",
}}
>
{/* The track holds its height whether or not anything is running,
so switching the loader on cannot nudge the page. */}
<div
role="progressbar"
aria-label={label || "Loading"}
style={{
position: "relative",
height: thickness,
background: active ? tone(9) : "transparent",
overflow: "hidden",
}}
>
{/* Reduced motion still has to answer "is anything happening?".
It answers with a steady tinted track instead of a segment
crossing the screen every second and a half. */}
{active && reduceMotion ? (
<div
style={{
position: "absolute",
inset: 0,
background: accent,
opacity: 0.38,
}}
/>
) : null}
{active && !reduceMotion
? [0, 1].map((index) => (
<motion.div
key={index}
initial={{ x: "-105%" }}
animate={{ x: ["-105%", "62%", "296%"] }}
transition={{
duration: cfg.cycle,
// Out of the left quickly, into the right gently: the
// asymmetry is what makes it read as travel rather
// than as a metronome.
times: [0, 0.55, 1],
ease: ["easeIn", "easeOut"],
repeat: Infinity,
delay: index === 1 ? cfg.cycle * cfg.trailOffset : 0,
}}
style={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
width: `${cfg.segment * 100}%`,
borderRadius: thickness,
background: accent,
opacity: index === 1 ? cfg.trailOpacity : 1,
}}
/>
))
: null}
</div>
<div style={{ padding: "14px 16px 16px" }}>
{label ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 14,
}}
>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{label}</span>
{/* The wording is the only thing that changes state here.
It fades; it never resizes. */}
<AnimatePresence initial={false} mode="wait">
<motion.span
key={active ? "running" : "idle"}
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ fontSize: 11.5 }}
>
{active ? "Time remaining unknown" : "Ready"}
</motion.span>
</AnimatePresence>
</div>
) : null}
{children ?? <SampleBody />}
</div>
</div>
);
}
/** Embedded sample so the component renders something real with zero
* props. Replace it by passing `children`. */
function SampleBody() {
const rows = [
{ name: "Transactions", detail: "184,206 rows" },
{ name: "Refunds", detail: "3,118 rows" },
{ name: "Disputes", detail: "412 rows" },
];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{rows.map((row) => (
<div
key={row.name}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "9px 12px",
borderRadius: 10,
background: tone(6),
fontSize: 12.5,
}}
>
<span style={{ fontWeight: 500 }}>{row.name}</span>
<span style={{ opacity: 0.5 }}>{row.detail}</span>
</div>
))}
</div>
);
}About this pattern
The loader you reach for when there is no percentage to report and inventing one would be a lie. A short segment travels the track, leaving the left quickly and easing into the right, with a fainter second segment offset behind it so the track is rarely empty. The track keeps its height whether or not anything is running, so switching the loader on cannot nudge the page. Two rules keep it honest: it never accelerates near the right edge, which would imply a finish line it cannot see, and it stays quiet enough to survive fifty crossings without becoming the most interesting thing on screen.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
A thin strip runs along the very top of the window while the next view is fetched.
Related patterns
- Download Arrow ProgressThe download glyph floods with color from the bottom as the transfer advances.
- Content Placeholder PulsePlaceholder blocks rise and fall together on one slow cadence, so the region reads as dormant rather than busy.
- File Upload ProgressA file row's bar fills as bytes land while the percentage and the counter roll with it.