Evidence Carousel
A large media carousel for case studies — drag, keys, captions, no autoplay.
Case 014 · visual archive
Field survey, North Ridge
Public-domain photography
Drag, arrow keys, or the buttons
01What it does
One view dominant, neighbours peeking in from the edges, a quiet counter and caption underneath, and every way of moving it — drag, buttons, arrow keys, native touch scroll — stays in sync because the index is derived from the strip’s own resting position. Nothing advances on its own; the reader is the only thing that moves it.
In this previewSample content: seven plates of real public-domain photography (Wikimedia Commons) arranged as a fictional survey archive. Drag the strip with a mouse, or use the arrow keys while it is focused.
02Install
Registry install
Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/evidence-carousel/ with its stylesheet beside it.
npx shadcn@latest add <lab-origin>/r/evidence-carousel.jsonNo npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.
- components/lab/evidence-carousel/evidence-carousel.tsxcomponent
- components/lab/evidence-carousel/evidence-carousel.cssstyles
Machine-readable item: /r/evidence-carousel.json ↗
03Usage
import { EvidenceCarousel } from "@/components/lab/evidence-carousel/evidence-carousel";
const views = [
{ id: "v1", caption: "First capture", content: <img src="/v1.jpg" alt="" /> },
{ id: "v2", caption: "Detail pass", content: <img src="/v2.jpg" alt="" /> },
];
export function Gallery() {
return <EvidenceCarousel items={views} label="Product gallery" ratio="16 / 10" />;
}04Props
| Prop | Type | Default | Notes |
|---|---|---|---|
items | CarouselItem[] | — | { id, caption, meta?, content } — content is any node, so images, video posters or live components all work. |
label | string | — | Accessible name for the carousel region. |
ratio | string | "16 / 10" | CSS aspect-ratio for each slide. |
peek | boolean | true | Show the neighbouring slides at the viewport edges. |
loop | boolean | false | Wrap from last to first when the buttons are used. |
onIndexChange | (index: number) => void | — | Notifies the host after any movement settles. |
05Accessibility
- A labelled region with aria-roledescription=carousel; each slide is a labelled group with aria-current on the active one.
- Left/Right, Home and End keys move the strip; the viewport itself is one tab stop.
- Buttons expose disabled states at the ends (unless loop is on); the caption is a polite live region.
- Videos inside slides are the host’s concern — the Lab demo shows posters, so nothing autoplays.
06Source
2 files, 514 lines. Nothing private, no configuration required.
components/lab/evidence-carousel/evidence-carousel.tsx— press enter to show or hide the source
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from "react";
import "./evidence-carousel.css";
export type CarouselItem = {
id: string;
caption: string;
meta?: string;
/** Any node: an image, a video poster, a live component. */
content: ReactNode;
};
type EvidenceCarouselProps = {
items: CarouselItem[];
/** Accessible name for the carousel region. */
label: string;
/** CSS aspect-ratio applied to every slide. */
ratio?: string;
/** Show the neighbouring slides at the viewport edges. */
peek?: boolean;
/** Wrap from last to first when the buttons are used. */
loop?: boolean;
/** Show the caption row under the strip. */
captions?: boolean;
onIndexChange?: (index: number) => void;
className?: string;
};
const pad = (value: number) => String(value).padStart(2, "0");
/**
* EVIDENCE CAROUSEL — one large view at a time.
*
* The strip is a plain horizontal scroller with scroll-snap: the
* browser owns the touch gesture and the layout, the index is derived
* from where the strip has come to rest, and the buttons and arrow keys
* drive the same scroll position — so every way of moving it stays in
* sync. Nothing advances on its own; the reader is the only thing that
* moves this.
*/
export function EvidenceCarousel({
items,
label,
ratio = "16 / 10",
peek = true,
loop = false,
captions = true,
onIndexChange,
className,
}: EvidenceCarouselProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
const reducedRef = useRef(false);
const jumpRef = useRef(true);
/* The strip's own geometry is the source of truth. Positions are
measured against the viewport box rather than offsetLeft, so the
centring is correct wherever the carousel sits on the page. */
const slideCentre = useCallback((slide: HTMLElement, viewport: HTMLElement) => {
const slideBox = slide.getBoundingClientRect();
const viewportBox = viewport.getBoundingClientRect();
return (
slideBox.left - viewportBox.left + viewport.scrollLeft + slideBox.width / 2
);
}, []);
const activeFromScroll = useCallback((): number => {
const viewport = viewportRef.current;
if (!viewport) return 0;
const slides = Array.from(
viewport.querySelectorAll<HTMLElement>("[data-ec-slide]"),
);
const centre = viewport.scrollLeft + viewport.clientWidth / 2;
let best = 0;
let bestDistance = Number.POSITIVE_INFINITY;
slides.forEach((slide, slideIndex) => {
const distance = Math.abs(slideCentre(slide, viewport) - centre);
if (distance < bestDistance) {
bestDistance = distance;
best = slideIndex;
}
});
return best;
}, [slideCentre]);
const goTo = useCallback(
(next: number, smooth = true) => {
const viewport = viewportRef.current;
if (!viewport) return;
const slides = Array.from(
viewport.querySelectorAll<HTMLElement>("[data-ec-slide]"),
);
if (slides.length === 0) return;
const clamped = loop
? ((next % slides.length) + slides.length) % slides.length
: Math.max(0, Math.min(slides.length - 1, next));
const slide = slides[clamped];
if (!slide) return;
const left =
slideCentre(slide, viewport) - viewport.clientWidth / 2;
viewport.scrollTo({
left,
behavior: reducedRef.current || !smooth ? "auto" : "smooth",
});
},
[loop, slideCentre],
);
/* the index follows the strip */
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedRef.current = motion.matches;
const onMotion = () => {
reducedRef.current = motion.matches;
};
motion.addEventListener("change", onMotion);
let frame = 0;
const measure = () => {
frame = 0;
setIndex(activeFromScroll());
};
const onScroll = () => {
if (!frame) frame = window.requestAnimationFrame(measure);
};
viewport.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
if (jumpRef.current) {
jumpRef.current = false;
goTo(0, false);
}
measure();
return () => {
if (frame) window.cancelAnimationFrame(frame);
viewport.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
motion.removeEventListener("change", onMotion);
};
}, [activeFromScroll, goTo]);
useEffect(() => {
onIndexChange?.(index);
}, [index, onIndexChange]);
/* a mouse drag moves the strip; touch already scrolls natively */
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
let dragging = false;
let startX = 0;
let startLeft = 0;
let travelled = 0;
const down = (event: PointerEvent) => {
if (event.pointerType !== "mouse" || event.button !== 0) return;
dragging = true;
travelled = 0;
startX = event.clientX;
startLeft = viewport.scrollLeft;
viewport.setPointerCapture(event.pointerId);
viewport.dataset.dragging = "true";
};
const move = (event: PointerEvent) => {
if (!dragging) return;
const dx = event.clientX - startX;
travelled = Math.max(travelled, Math.abs(dx));
viewport.scrollLeft = startLeft - dx;
};
const up = (event: PointerEvent) => {
if (!dragging) return;
dragging = false;
delete viewport.dataset.dragging;
if (viewport.hasPointerCapture(event.pointerId)) {
viewport.releasePointerCapture(event.pointerId);
}
if (travelled > 6) goTo(activeFromScroll());
};
viewport.addEventListener("pointerdown", down);
viewport.addEventListener("pointermove", move);
viewport.addEventListener("pointerup", up);
viewport.addEventListener("pointercancel", up);
return () => {
viewport.removeEventListener("pointerdown", down);
viewport.removeEventListener("pointermove", move);
viewport.removeEventListener("pointerup", up);
viewport.removeEventListener("pointercancel", up);
};
}, [goTo, activeFromScroll]);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowRight") {
event.preventDefault();
goTo(index + 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
goTo(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
goTo(0);
} else if (event.key === "End") {
event.preventDefault();
goTo(items.length - 1);
}
};
if (items.length === 0) return null;
const atStart = !loop && index === 0;
const atEnd = !loop && index === items.length - 1;
return (
<section
className={`lab-carousel${className ? ` ${className}` : ""}`}
role="group"
aria-roledescription="carousel"
aria-label={label}
data-peek={peek ? "true" : "false"}
>
<div
className="lab-carousel-viewport"
ref={viewportRef}
tabIndex={0}
onKeyDown={onKeyDown}
aria-label={`${label}: ${items.length} slides. Use the previous and next buttons or the arrow keys.`}
>
<ul className="lab-carousel-track">
{items.map((item, itemIndex) => (
<li
className="lab-carousel-slide"
key={item.id}
data-ec-slide
data-active={itemIndex === index ? "true" : "false"}
aria-label={`${itemIndex + 1} of ${items.length}: ${item.caption}`}
aria-roledescription="slide"
role="group"
style={{ "--ec-ratio": ratio } as CSSProperties}
>
<div className="lab-carousel-media">{item.content}</div>
</li>
))}
</ul>
</div>
<div className="lab-carousel-bar">
<div className="lab-carousel-controls">
<button
type="button"
className="lab-carousel-btn"
onClick={() => goTo(index - 1)}
disabled={atStart}
aria-label="Previous slide"
>
<span aria-hidden="true">←</span>
</button>
<button
type="button"
className="lab-carousel-btn"
onClick={() => goTo(index + 1)}
disabled={atEnd}
aria-label="Next slide"
>
<span aria-hidden="true">→</span>
</button>
</div>
{captions ? (
<p className="lab-carousel-caption" aria-live="polite">
<span className="lab-carousel-caption-text">
{items[index]?.caption ?? ""}
</span>
{items[index]?.meta ? (
<span className="lab-carousel-meta">{items[index]?.meta}</span>
) : null}
</p>
) : (
<span className="lab-carousel-caption" />
)}
<p className="lab-carousel-count">
<span className="lab-carousel-count-now">{pad(index + 1)}</span>
<span aria-hidden="true" className="lab-carousel-count-sep">
/
</span>
<span>{pad(items.length)}</span>
</p>
</div>
<div className="lab-carousel-progress" aria-hidden="true">
<span
style={{ transform: `scaleX(${(index + 1) / items.length})` }}
/>
</div>
</section>
);
}components/lab/evidence-carousel/evidence-carousel.css— press enter to show or hide the source
/* ============================================================
EVIDENCE CAROUSEL — self-contained styles.
============================================================ */
.lab-carousel {
--ec-ink: #141418;
--ec-muted: #5f5f68;
--ec-faint: #8c8c94;
--ec-line: rgba(20, 20, 24, 0.12);
--ec-line-soft: rgba(20, 20, 24, 0.07);
--ec-surface: #ffffff;
--ec-accent: #4f27e0;
display: flex;
flex-direction: column;
gap: 12px;
}
.lab-carousel-viewport {
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
scroll-snap-type: x mandatory;
padding-inline: max(0px, calc((100% - var(--ec-slide-w)) / 2));
-webkit-overflow-scrolling: touch;
}
.lab-carousel-viewport::-webkit-scrollbar {
display: none;
}
.lab-carousel-viewport:focus-visible {
outline: 2px solid var(--ec-accent);
outline-offset: 4px;
border-radius: 4px;
}
.lab-carousel-viewport[data-dragging="true"] {
scroll-snap-type: none;
cursor: grabbing;
}
.lab-carousel-viewport[data-dragging="true"] .lab-carousel-slide {
user-select: none;
}
.lab-carousel-track {
display: flex;
gap: 14px;
margin: 0;
padding: 0;
list-style: none;
}
.lab-carousel-slide {
flex: 0 0 auto;
width: var(--ec-slide-w);
scroll-snap-align: center;
opacity: 0.45;
transition: opacity 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.lab-carousel-slide[data-active="true"] {
opacity: 1;
}
.lab-carousel {
--ec-slide-w: 100%;
}
.lab-carousel[data-peek="true"] {
--ec-slide-w: 74%;
}
.lab-carousel-media {
aspect-ratio: var(--ec-ratio, 16 / 10);
border-radius: 12px;
overflow: hidden;
background: #ededeb;
border: 1px solid var(--ec-line-soft);
}
.lab-carousel-media > * {
width: 100%;
height: 100%;
}
.lab-carousel-bar {
display: flex;
align-items: center;
gap: 14px;
padding-inline: 2px;
}
.lab-carousel-controls {
display: flex;
gap: 6px;
}
.lab-carousel-btn {
appearance: none;
border: 1px solid var(--ec-line);
background: var(--ec-surface);
color: var(--ec-ink);
width: 34px;
height: 34px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
transition: border-color 150ms ease, color 150ms ease, opacity 150ms ease;
}
.lab-carousel-btn:hover:not(:disabled) {
border-color: rgba(20, 20, 24, 0.55);
}
.lab-carousel-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.lab-carousel-caption {
flex: 1;
min-width: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
font-size: 0.875rem;
color: var(--ec-muted);
line-height: 1.45;
}
.lab-carousel-caption-text {
color: var(--ec-ink);
}
.lab-carousel-meta {
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 0.625rem;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--ec-faint);
}
.lab-carousel-count {
margin: 0;
display: flex;
align-items: baseline;
gap: 3px;
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 0.6875rem;
letter-spacing: 0.05em;
color: var(--ec-faint);
}
.lab-carousel-count-now {
color: var(--ec-ink);
}
.lab-carousel-count-sep {
opacity: 0.6;
}
.lab-carousel-progress {
height: 2px;
border-radius: 2px;
background: var(--ec-line-soft);
overflow: hidden;
}
.lab-carousel-progress span {
display: block;
height: 100%;
background: var(--ec-ink);
transform-origin: left center;
transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (max-width: 640px) {
.lab-carousel[data-peek="true"] {
--ec-slide-w: 88%;
}
.lab-carousel-bar {
flex-wrap: wrap;
}
.lab-carousel-caption {
order: 3;
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.lab-carousel-slide,
.lab-carousel-progress span {
transition: none;
}
}



