Mobile & Consumer

Story Progress

Segmented auto-advancing progress strips with hold-to-pause, tap zones, and a rAF-driven fill that resumes exactly.

Install

npx shadcn@latest add @paragon/story-progress

story-progress.tsx

"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>
  );
}