ShotReel

An auto-advancing gallery where the progress bar is the clock — hover pauses the bar, which pauses the rotation, so the two can never desync.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/shot-reel.json
Media · DOMhover to pause
app
packet-field.tsx

ShotReelesc or double-click to exit full screendouble-tap to exit

demo/shot-reel.tsx
"use client";

import * as React from "react";
import {
  cx,
  inertValue,
  prefersStill,
  useMotionRate,
  useReducedMotion,
} from "@pouriahlabs/wisp-ui";

/**
 * ShotReel — an auto-advancing gallery where the progress bar *is* the clock.
 *
 * The bar's `animationend` is what advances the slide. That one decision
 * removes a whole class of bug: with a `setInterval` driving rotation and a CSS
 * animation driving the bar, the two drift, and pausing one doesn't pause the
 * other. Hovering, scrolling the reel off-screen or backgrounding the tab all
 * pause the same bar animation, which pauses the rotation, because there is
 * only one clock — and a paused CSS animation resumes exactly where it left
 * off, so nothing skips or restarts.
 *
 * Under reduced motion the bar never runs, so nothing advances — the reel rests
 * on its first slide with the track full, which is the still frame.
 *
 * The dots are a labelled button group, not ARIA tabs — a carousel's slides
 * aren't independent panels of content the way tabs are, and a `tablist`
 * without the arrow-key roving-tabindex behaviour it promises is worse than
 * no pattern at all. `aria-current` marks the active one instead.
 *
 * Hovering pauses for a mouse, but WCAG 2.2.2 requires a mechanism to pause
 * auto-advancing content that a keyboard can reach too — the play/pause
 * button is that mechanism, independent of `pauseOnHover` and of the
 * off-screen/backgrounded pause above.
 */

export interface ShotReelSlide {
  /** Caption under the frame. */
  label: string;
  /** Slide content — an image, a screenshot component, anything. */
  content: React.ReactNode;
}

export interface ShotReelProps extends React.HTMLAttributes<HTMLDivElement> {
  slides: readonly ShotReelSlide[];
  /** Milliseconds per slide. Drives the bar, which drives the rotation. */
  interval?: number;
  /** Pause rotation while the pointer is over the reel. */
  pauseOnHover?: boolean;
}

export function ShotReel({
  slides,
  interval = 3400,
  pauseOnHover = true,
  className,
  ...props
}: ShotReelProps) {
  const wrapper = React.useRef<HTMLDivElement>(null);
  const [index, setIndex] = React.useState(0);
  const still = useReducedMotion();
  // The bar is the clock, so its duration is the whole rotation's rate: divide
  // it by the reader's speed and the slides advance to match.
  const { speed } = useMotionRate();
  const [offScreen, setOffScreen] = React.useState(false);
  const [manuallyPaused, setManuallyPaused] = React.useState(false);
  const paused = offScreen || manuallyPaused;
  // Bumping this key remounts the bar, which restarts its animation — more
  // reliable than toggling a class and forcing reflow.
  const [run, setRun] = React.useState(0);

  // Off-screen or backgrounded is exactly the same "pause the clock" case as
  // hovering — a class toggle, so the CSS animation resumes from where it
  // was rather than a JS-driven rotation having to track elapsed time itself.
  React.useEffect(() => {
    const node = wrapper.current;
    if (!node) return;

    let onScreen = false;
    const evaluate = () => setOffScreen(!onScreen || document.hidden);

    const observer = new IntersectionObserver(
      (entries) => {
        onScreen = entries[0]?.isIntersecting ?? false;
        evaluate();
      },
      { rootMargin: "120px" },
    );
    observer.observe(node);

    document.addEventListener("visibilitychange", evaluate);

    return () => {
      observer.disconnect();
      document.removeEventListener("visibilitychange", evaluate);
    };
  }, []);

  const advance = React.useCallback(() => {
    if (prefersStill()) return;
    setIndex((i) => (i + 1) % Math.max(1, slides.length));
    setRun((r) => r + 1);
  }, [slides.length]);

  return (
    <div
      ref={wrapper}
      className={cx("wisp-reel", pauseOnHover && "is-pausable", paused && "is-paused", className)}
      {...props}
    >
      <div className="wisp-reel-frames">
        {slides.map((slide, i) => (
          <div
            key={i}
            className={cx("wisp-reel-frame", i === index && "is-shown")}
            aria-hidden={i !== index}
            inert={inertValue(i !== index)}
          >
            {slide.content}
          </div>
        ))}
      </div>

      <div className="wisp-reel-foot">
        <span className="wisp-reel-label">{slides[index]?.label}</span>
        <span className="wisp-reel-controls">
          {!still ? (
            <button
              type="button"
              aria-pressed={manuallyPaused}
              aria-label={manuallyPaused ? "Play" : "Pause"}
              className="wisp-reel-toggle"
              onClick={() => setManuallyPaused((p) => !p)}
            >
              {manuallyPaused ? "▶" : "❙❙"}
            </button>
          ) : null}
          <span className="wisp-reel-dots" role="group" aria-label="Slides">
            {slides.map((slide, i) => (
              <button
                key={i}
                type="button"
                aria-current={i === index ? "true" : undefined}
                aria-label={slide.label}
                className={cx("wisp-reel-dot", i === index && "is-active")}
                onClick={() => {
                  setIndex(i);
                  setRun((r) => r + 1);
                }}
              />
            ))}
          </span>
        </span>
      </div>

      <div className="wisp-reel-track">
        {still ? (
          <div className="wisp-reel-bar is-full" />
        ) : (
          <div
            key={run}
            className="wisp-reel-bar is-running"
            style={{ animationDuration: `${interval / (speed || 1)}ms` }}
            onAnimationEnd={advance}
          />
        )}
      </div>
    </div>
  );
}

Certified against the contract