Media
AutoplayCarousel
Photo carousel that never crops, with a progress-bar timer and a pause button.
npx bezel-add add autoplay-carouselAbout
An accessible autoplay image carousel for React that never crops: each photo sits on a plate sized to its own shape over a blurred copy of itself. A progress bar is the timer, and it pauses on hover, focus, touch, off screen and in hidden tabs. It has arrows, dots, swipe and arrow keys, and starts paused under prefers-reduced-motion.
Key props: slides (src, alt, optional caption and size), label (accessible name), interval (ms per slide, default 5000), aspect (stage width divided by height, default 1.5)
packages/ui/src/media/AutoplayCarousel.tsx · 347 lines
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
/*
* AutoplayCarousel: a photo carousel that moves on by itself and never crops.
*
* Every photo sits on a plate sized to its own proportions inside the stage,
* over a blurred, darkened copy of itself, so portraits and landscapes share one
* stage without losing an edge. The progress bar under the photo is the timer:
* its `animationend` advances the slide, so the bar and the change can never
* drift apart, and anything that pauses the bar pauses the carousel.
*
* It holds while the pointer is over it, while keyboard focus is inside, while a
* finger is down, when it is out of view and when the tab is hidden. The pause
* button stops it outright, and reduced motion starts it paused. Changes made by
* the reader are announced; the automatic ones are not, so nothing talks over
* the page every few seconds.
*/
const CSS = `
.bz-ac{container-type:inline-size;position:relative;color:var(--bz-ink,#0a0a0a);font-family:var(--bz-font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif)}
.bz-ac *,.bz-ac *::before,.bz-ac *::after{box-sizing:border-box}
.bz-ac:focus-visible{outline:2px solid var(--bz-focus-ring,#912c22);outline-offset:4px;border-radius:20px}
.bz-ac-viewport{position:relative;overflow:hidden;border-radius:20px;background:var(--bz-void,#0c0c0f);touch-action:pan-y}
.bz-ac-track{display:flex;transition:transform 600ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1))}
.bz-ac-slide{--bz-ac-stage:var(--bz-ac-wide,1.5);position:relative;flex:0 0 100%;margin:0;aspect-ratio:var(--bz-ac-stage);overflow:hidden;isolation:isolate}
@container (max-width:560px){.bz-ac-slide{--bz-ac-stage:var(--bz-ac-narrow,1)}}
.bz-ac-backdrop{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;filter:blur(32px) saturate(1.1) brightness(0.45);transform:scale(1.25)}
.bz-ac-plate{position:absolute;inset:0;margin:auto;width:min(100%,calc(100% * var(--bz-ac-photo,1.5) / var(--bz-ac-stage)));height:min(100%,calc(100% * var(--bz-ac-stage) / var(--bz-ac-photo,1.5)));overflow:hidden;box-shadow:0 0 0 1px rgba(255,255,255,0.14)}
.bz-ac-img{display:block;width:100%;height:100%;object-fit:cover}
.bz-ac-caption{position:absolute;left:12px;bottom:14px;z-index:1;max-width:calc(100% - 24px);margin:0;padding:6px 12px;border-radius:999px;background:rgba(12,12,15,0.66);color:#ffffff;font-size:0.8125rem;font-weight:500;line-height:1.3}
.bz-ac-nav{position:absolute;top:50%;z-index:2;display:grid;place-items:center;width:48px;height:48px;margin-top:-24px;padding:0;border:1px solid rgba(255,255,255,0.35);border-radius:999px;background:rgba(12,12,15,0.55);color:#ffffff;cursor:pointer;transition:background-color 150ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1)),transform 150ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1))}
.bz-ac-nav[data-side="start"]{left:12px}
.bz-ac-nav[data-side="end"]{right:12px}
.bz-ac-nav svg{width:18px;height:18px}
.bz-ac-nav:focus-visible{outline:2px solid #ffffff;outline-offset:2px;box-shadow:0 0 0 6px rgba(12,12,15,0.6)}
.bz-ac-nav:active{transform:scale(0.97)}
@media (hover:hover){.bz-ac-nav:hover{background:rgba(12,12,15,0.82)}}
.bz-ac-progress{position:absolute;right:0;bottom:0;left:0;z-index:2;height:3px;background:rgba(255,255,255,0.25)}
.bz-ac-bar{display:block;height:100%;background:#ffffff;transform:scaleX(0);transform-origin:left center;animation:bz-ac-fill 5000ms linear forwards}
@keyframes bz-ac-fill{to{transform:scaleX(1)}}
.bz-ac:not([data-playing="true"]) .bz-ac-bar{animation-play-state:paused}
.bz-ac-controls{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:10px}
.bz-ac-toggle{display:grid;flex:none;place-items:center;width:48px;height:48px;margin-right:4px;padding:0;border:1px solid var(--bz-line-strong,rgba(10,10,10,0.13));border-radius:999px;background:var(--bz-paper,#ffffff);color:var(--bz-ink,#0a0a0a);cursor:pointer;transition:background-color 150ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1)),transform 150ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1))}
.bz-ac-toggle svg{width:14px;height:14px}
.bz-ac-toggle:active{transform:scale(0.97)}
@media (hover:hover){.bz-ac-toggle:hover{background:var(--bz-paper-sunken,#fafafa)}}
.bz-ac-toggle:focus-visible,.bz-ac-dot:focus-visible{outline:2px solid var(--bz-focus-ring,#912c22);outline-offset:2px}
.bz-ac-dots{display:flex;align-items:center}
.bz-ac-dot{display:grid;place-items:center;width:48px;height:48px;padding:0;border:0;border-radius:999px;background:transparent;cursor:pointer}
.bz-ac-dot::before{content:"";width:8px;height:8px;border-radius:999px;background:rgba(10,10,10,0.45);transition:width 300ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1)),background-color 300ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1))}
.bz-ac-dot[aria-current="true"]::before{width:24px;background:var(--bz-ink,#0a0a0a)}
.bz-ac-counter{min-width:64px;font:500 0.8125rem/1 var(--bz-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-variant-numeric:tabular-nums;text-align:center;color:var(--bz-ink-muted,#4a4a4c)}
.bz-ac-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
@media (prefers-reduced-motion:reduce){.bz-ac-track,.bz-ac-nav,.bz-ac-toggle,.bz-ac-dot::before{transition:none}.bz-ac-nav:active,.bz-ac-toggle:active{transform:none}}
`;
const RM_QUERY = "(prefers-reduced-motion: reduce)";
const subscribeReducedMotion = (onChange: () => void) => {
const query = window.matchMedia(RM_QUERY);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const readReducedMotion = () => window.matchMedia(RM_QUERY).matches;
const serverReducedMotion = () => false;
export type CarouselSlide = {
src: string;
alt: string;
caption?: string;
/** Intrinsic size, so the plate is right before the image loads. */
width?: number;
height?: number;
};
export type AutoplayCarouselProps = {
slides: CarouselSlide[];
/** Names the carousel for assistive tech. */
label: string;
/** Time on each slide, in milliseconds. */
interval?: number;
/** Stage width divided by height. */
aspect?: number;
/** Stage proportions when the carousel is 560px wide or less. */
narrowAspect?: number;
/** More slides than this show a counter instead of dots. */
maxDots?: number;
pauseLabel?: string;
className?: string;
};
export function AutoplayCarousel({
slides,
label,
interval = 5000,
aspect = 1.5,
narrowAspect = 1,
maxDots = 6,
pauseLabel = "Pause slideshow",
className = "",
}: AutoplayCarouselProps) {
const reduced = useSyncExternalStore(subscribeReducedMotion, readReducedMotion, serverReducedMotion);
const count = slides.length;
const [index, setIndex] = useState(0);
const [autoPref, setAutoPref] = useState<boolean | null>(null);
const [held, setHeld] = useState(false);
const [ratios, setRatios] = useState<Record<number, number>>({});
const [announcement, setAnnouncement] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const holds = useRef(new Set<string>());
const swipe = useRef<{ x: number; y: number } | null>(null);
const current = count ? Math.max(0, Math.min(index, count - 1)) : 0;
const auto = autoPref ?? !reduced;
const playing = auto && !held && count > 1;
const hold = useCallback((reason: string, on: boolean) => {
const set = holds.current;
if (on) set.add(reason);
else set.delete(reason);
setHeld(set.size > 0);
}, []);
const go = useCallback(
(next: number, announce: boolean) => {
if (!count) return;
const i = ((next % count) + count) % count;
setIndex(i);
if (announce) {
const caption = slides[i]?.caption;
setAnnouncement(`Photo ${i + 1} of ${count}${caption ? `: ${caption}` : ""}`);
}
},
[count, slides],
);
useEffect(() => {
const el = rootRef.current;
if (!el || !("IntersectionObserver" in window)) return;
const io = new IntersectionObserver(([entry]) => hold("offscreen", !entry.isIntersecting), { threshold: 0.25 });
io.observe(el);
return () => io.disconnect();
}, [hold]);
useEffect(() => {
const sync = () => hold("hidden", document.visibilityState === "hidden");
sync();
document.addEventListener("visibilitychange", sync);
return () => document.removeEventListener("visibilitychange", sync);
}, [hold]);
if (!count) return null;
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowRight") {
event.preventDefault();
go(current + 1, true);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
go(current - 1, true);
}
};
const onFocus = (event: ReactFocusEvent<HTMLDivElement>) => {
let visible = true;
try {
visible = (event.target as Element).matches(":focus-visible");
} catch {
visible = true;
}
if (visible) hold("focus", true);
};
const onBlur = (event: ReactFocusEvent<HTMLDivElement>) => {
const next = event.relatedTarget as Node | null;
if (next && rootRef.current?.contains(next)) return;
hold("focus", false);
};
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse") return;
swipe.current = { x: event.clientX, y: event.clientY };
hold("press", true);
};
const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = swipe.current;
swipe.current = null;
hold("press", false);
if (!start) return;
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
if (Math.abs(dx) < 44 || Math.abs(dx) <= Math.abs(dy)) return;
go(current + (dx < 0 ? 1 : -1), true);
};
const rootStyle = {
["--bz-ac-wide" as string]: aspect,
["--bz-ac-narrow" as string]: narrowAspect,
} as CSSProperties;
return (
<>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<div
ref={rootRef}
className={`bz-ac ${className}`.trim()}
role="region"
aria-roledescription="carousel"
aria-label={label}
tabIndex={0}
data-playing={playing ? "true" : "false"}
style={rootStyle}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") hold("hover", true);
}}
onPointerLeave={() => hold("hover", false)}
>
<div
className="bz-ac-viewport"
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={() => {
swipe.current = null;
hold("press", false);
}}
>
<div className="bz-ac-track" style={{ transform: `translate3d(${-current * 100}%, 0, 0)` }}>
{slides.map((slide, i) => {
const ratio = slide.width && slide.height ? slide.width / slide.height : ratios[i];
return (
<figure
key={`${slide.src}-${i}`}
className="bz-ac-slide"
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${count}${slide.caption ? `: ${slide.caption}` : ""}`}
aria-hidden={i === current ? undefined : true}
style={ratio ? ({ ["--bz-ac-photo" as string]: ratio } as CSSProperties) : undefined}
>
<img className="bz-ac-backdrop" src={slide.src} alt="" aria-hidden="true" decoding="async" loading={i === 0 ? "eager" : "lazy"} />
<div className="bz-ac-plate">
<img
className="bz-ac-img"
src={slide.src}
alt={slide.alt}
decoding="async"
loading={i === 0 ? "eager" : "lazy"}
draggable={false}
onLoad={(event) => {
if (slide.width && slide.height) return;
const img = event.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
setRatios((prev) => ({ ...prev, [i]: img.naturalWidth / img.naturalHeight }));
}
}}
/>
</div>
{slide.caption ? <figcaption className="bz-ac-caption">{slide.caption}</figcaption> : null}
</figure>
);
})}
</div>
{count > 1 ? (
<>
<button type="button" className="bz-ac-nav" data-side="start" aria-label="Previous photo" onClick={() => go(current - 1, true)}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 5l-7 7 7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button type="button" className="bz-ac-nav" data-side="end" aria-label="Next photo" onClick={() => go(current + 1, true)}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className="bz-ac-progress" aria-hidden="true">
<span
key={current}
className="bz-ac-bar"
style={{ animationDuration: `${interval}ms`, opacity: auto ? 1 : 0 }}
onAnimationEnd={() => go(current + 1, false)}
/>
</div>
</>
) : null}
</div>
{count > 1 ? (
<div className="bz-ac-controls">
<button
type="button"
className="bz-ac-toggle"
aria-label={pauseLabel}
aria-pressed={!auto}
onClick={() => setAutoPref(!auto)}
>
{auto ? (
<svg viewBox="0 0 14 14" aria-hidden="true">
<rect x="2.5" y="1.5" width="3" height="11" rx="1" fill="currentColor" />
<rect x="8.5" y="1.5" width="3" height="11" rx="1" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 2.2v9.6c0 .6.7 1 1.2.6l7.2-4.8a.7.7 0 0 0 0-1.2L4.7 1.6c-.5-.4-1.2 0-1.2.6z" fill="currentColor" />
</svg>
)}
</button>
{count <= maxDots ? (
<div className="bz-ac-dots">
{slides.map((slide, i) => (
<button
key={`${slide.src}-dot-${i}`}
type="button"
className="bz-ac-dot"
aria-label={`Show photo ${i + 1}${slide.caption ? `: ${slide.caption}` : ""}`}
aria-current={i === current ? "true" : undefined}
onClick={() => go(i, true)}
/>
))}
</div>
) : (
<p className="bz-ac-counter" aria-hidden="true">
{String(current + 1).padStart(String(count).length, "0")} / {count}
</p>
)}
</div>
) : null}
<p className="bz-ac-sr" aria-live="polite">
{announcement}
</p>
</div>
</>
);
}