LAB/ Tanishk Interface Lab
MediastableReactCSS scroll-snapPointer Events

Evidence Carousel

A large media carousel for case studies — drag, keys, captions, no autoplay.

Live preview

Case 014 · visual archive

Field survey, North Ridge

Public-domain photography

Seven plates from a fictional survey archive: the topographic sheet, a 1921 reconnaissance camp, field-book notes, a 1912 street survey and three geology outcrops. Every photograph is public domain or CC0, via Wikimedia Commons — credits in public/archive/credits.json.

Adjacent peeksSlidesRatioCaptions

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.json

No 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

evidence-carousel.usage.tsx
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

Props accepted by Evidence Carousel
PropTypeDefaultNotes
itemsCarouselItem[]—{ id, caption, meta?, content } — content is any node, so images, video posters or live components all work.
labelstring—Accessible name for the carousel region.
ratiostring"16 / 10"CSS aspect-ratio for each slide.
peekbooleantrueShow the neighbouring slides at the viewport edges.
loopbooleanfalseWrap 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
tsx
"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
css
/* ============================================================
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;
}
}