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.jsonMedia · DOMhover to pause
app
packet-field.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
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React