Segmented auto-advancing progress strips with hold-to-pause, tap zones, and a rAF-driven fill that resumes exactly.
npx shadcn@latest add @paragon/story-progress"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface StoryProgressProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
/** Number of segments. */
count: number;
/** ms each segment plays. */
duration?: number;
/** Controlled active index. */
index?: number;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
/** Pause the auto-advance (hold-to-pause is also built in). */
paused?: boolean;
/** Called when the last segment finishes. */
onComplete?: () => void;
/** Left/right tap zones to go back/forward. */
tapZones?: boolean;
static?: boolean;
}
/**
* Instagram-style segmented progress bars. The active segment fills left to
* right over `duration`; completed segments read full, upcoming ones empty.
* Press-and-hold (or the `paused` prop) freezes the fill mid-segment and
* resumes from the same point — the fill is driven by a rAF clock, not a CSS
* keyframe, so pausing is exact and interruptible. Tap zones jump segments;
* arrow keys work too. Reduced motion advances by steps without the sweep.
*/
export function StoryProgress({
count,
duration = 4000,
index: indexProp,
defaultIndex = 0,
onIndexChange,
paused = false,
onComplete,
tapZones = true,
static: isStatic = false,
className,
...props
}: StoryProgressProps) {
const reduced = useReducedMotion() ?? false;
const still = isStatic || reduced;
const [uncontrolled, setUncontrolled] = React.useState(defaultIndex);
const index = indexProp ?? uncontrolled;
const [held, setHeld] = React.useState(false);
const fills = React.useRef<(HTMLSpanElement | null)[]>([]);
const raf = React.useRef<number | null>(null);
const startTs = React.useRef<number | null>(null);
const elapsed = React.useRef(0);
const indexRef = React.useRef(index);
indexRef.current = index;
const setIndex = React.useCallback(
(next: number) => {
const clamped = Math.max(0, Math.min(count - 1, next));
elapsed.current = 0;
startTs.current = null;
if (indexProp === undefined) setUncontrolled(clamped);
onIndexChange?.(clamped);
},
[count, indexProp, onIndexChange],
);
const paint = React.useCallback((activeIndex: number, ratio: number) => {
for (let i = 0; i < count; i++) {
const el = fills.current[i];
if (!el) continue;
const scale = i < activeIndex ? 1 : i === activeIndex ? ratio : 0;
el.style.transform = `scaleX(${scale})`;
}
}, [count]);
const isPaused = paused || held;
React.useEffect(() => {
if (still) {
paint(index, 1);
return;
}
if (isPaused) {
if (raf.current) cancelAnimationFrame(raf.current);
startTs.current = null;
return;
}
const tick = (ts: number) => {
if (startTs.current === null) startTs.current = ts - elapsed.current;
elapsed.current = ts - startTs.current;
const ratio = Math.min(1, elapsed.current / duration);
paint(indexRef.current, ratio);
if (ratio >= 1) {
if (indexRef.current >= count - 1) {
onComplete?.();
return;
}
setIndex(indexRef.current + 1);
return;
}
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => {
if (raf.current) cancelAnimationFrame(raf.current);
};
}, [index, isPaused, still, duration, count, paint, setIndex, onComplete]);
// Reset elapsed when the segment changes.
React.useEffect(() => {
elapsed.current = 0;
startTs.current = null;
paint(index, still ? 1 : 0);
}, [index, still, paint]);
const holdHandlers = {
onPointerDown: () => setHeld(true),
onPointerUp: () => setHeld(false),
onPointerCancel: () => setHeld(false),
onPointerLeave: () => setHeld(false),
};
return (
<div
className={cn("relative w-full", className)}
onKeyDown={(e) => {
if (e.key === "ArrowRight") {
e.preventDefault();
setIndex(index + 1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
setIndex(index - 1);
}
}}
{...props}
>
<div
className="flex gap-1"
role="group"
aria-label={`Story ${index + 1} of ${count}`}
>
{Array.from({ length: count }, (_, i) => (
<span
key={i}
className="h-1 flex-1 overflow-hidden rounded-full bg-foreground/25"
>
<span
ref={(el) => {
fills.current[i] = el;
}}
aria-hidden
className="block h-full origin-left rounded-full bg-foreground"
style={{ transform: "scaleX(0)" }}
/>
</span>
))}
</div>
{tapZones && (
<div className="absolute inset-0 flex" {...holdHandlers}>
<button
type="button"
aria-label="Previous"
className="h-full w-1/3 outline-none"
onClick={() => setIndex(index - 1)}
/>
<span className="h-full flex-1" aria-hidden />
<button
type="button"
aria-label="Next"
className="h-full w-1/3 outline-none"
onClick={() => setIndex(index + 1)}
/>
</div>
)}
</div>
);
}