<Gallery> — plain shots carousel (hive spec)
A trivial, embla-backed image carousel — the last unbuilt component from CLAUDE.md's original Phase-1 MVP list. Prev/next buttons, left/right arrow-key navigation, and dot indicators over a fixed
{src, alt, caption?}image list. Plug-and-play, importable into any app, publicly showcased at/components/gallery.
Design discussion (RESOLVED architecture — read first):
.pHive/epics/gallery/docs/design-discussion.md — this doc documents that decision, it
does not re-derive it.
Why (operator intent — honor this)
CLAUDE.md's original Phase-1 MVP list named five components; four shipped first
(<LayerViewer>, <VideoAnnotator>/<VideoTour>, plus the Phase-2/3 work that followed).
<Gallery> is the fifth and last of that original list — CLAUDE.md's own framing calls it
out explicitly as "plain shots carousel (shadcn carousel — trivial)." embla-carousel-react
(^8.3.0) has been sitting in package.json as an installed-but-unused dependency since
project scaffolding specifically for this component; this story is what finally uses it.
The props — deliberately minimal
export interface GalleryImage {
src: string;
alt: string;
caption?: string;
}
export interface GalleryProps {
images: GalleryImage[];
className?: string;
/** Wrap around at the ends. Default true. When false, the prev/next
* buttons disable at the first/last slide instead of wrapping. */
loop?: boolean;
}
DELIBERATELY MINIMAL PROPS — same "deliberately narrow prop surface" convention this
repo already established for <Model3D>'s ModelDef (see components/Model3D/Model3D.tsx's
do-not-list doc comment for the same rationale pattern, applied here to a different
component): images / className / loop is the whole prop surface, on purpose. No
lightbox, no zoom, no lazy-loading — CLAUDE.md calls this a "trivial" carousel, and the
acceptance criteria for this story bound the prop surface explicitly so it stays that way.
If a future epic needs a lightbox or zoom, that's either a new component or a deliberate,
separately-reviewed prop addition — not a silent expansion of this one.
Behavior
- One slide per image, rendered full-width inside an embla viewport/container pair
(
ref={emblaRef}on the viewport div, aflexrow container, oneflex-[0_0_100%]slide div per image — the standard embla-carousel-react DOM shape). Each slide isrole="group"/aria-roledescription="slide"/aria-label="Slide N of TOTAL", following the W3C carousel authoring-practice pattern; the whole component isrole="region"/aria-roledescription="carousel"/aria-label="Image gallery". - Prev/next buttons (
lucide-react'sChevronLeft/ChevronRight), positioned absolutely over the image area, callemblaApi.scrollPrev()/scrollNext(). Theirdisabledstate is driven directly off embla's owncanScrollPrev()/canScrollNext()— see "Loop behavior" below for what that means at the boundaries. - Keyboard navigation. The carousel region is focusable (
tabIndex={0}) and listens forArrowLeft/ArrowRightonKeyDown, calling the samescrollPrev/scrollNextthe buttons call (event.preventDefault()so arrow keys don't also scroll the page). - Dot indicators, one per image, below the image area. The active dot
(
aria-current="true", filled withbg-accent) tracks embla'sselectedScrollSnap()via its"select"/"reInit"events; clicking a dot callsemblaApi.scrollTo(index)to jump directly to that slide. - Caption, if
captionis present on that image, renders under the image inside the same slide. - Empty state. Given
images={[]}, it renders"No images."rather than an empty carousel shell (same empty-state convention as<FileList>'s"No files.").
Loop behavior
loop (default true) is forwarded straight to useEmblaCarousel({ loop }) — embla's own
loop semantics, not hand-rolled wrap-around logic:
loop=true(default):canScrollPrev()/canScrollNext()are alwaystrueonce there's more than one slide — clickingnextpast the last slide wraps to the first, and vice versa. Neither button is ever disabled.loop=false:canScrollPrev()isfalseat the first slide,canScrollNext()isfalseat the last slide — the corresponding button disables (nativedisabledattribute) instead of wrapping.
What this component deliberately does NOT do
- No lightbox / full-screen zoom view — clicking or tapping an image does nothing beyond whatever the caller's own page chrome provides.
- No pinch/scroll zoom on the image itself.
- No lazy-loading / virtualization of off-screen images —
imagesis expected to be a reasonably small, already-resolved list (a property's shot gallery, not an infinite feed). - No autoplay — navigation is always the visitor's own action (click, arrow key, or dot).
- No fetch of an image manifest from anywhere — same "the array is the whole input"
convention as
<FileList>'sfilesprop; the caller is responsible for however it obtained theGalleryImage[].
Usage
import { Gallery } from "@/components/Gallery";
import type { GalleryImage } from "@/components/Gallery";
const images: GalleryImage[] = [
{ src: "/gallery-samples/front.jpg", alt: "Front elevation" },
{ src: "/gallery-samples/back.jpg", alt: "Back yard", caption: "Back yard, dusk" },
{ src: "/gallery-samples/kitchen.jpg", alt: "Kitchen" },
];
<Gallery images={images} className="h-full w-full" />
// Non-looping variant — prev/next disable at the boundaries instead of wrapping:
<Gallery images={images} loop={false} />
No next/dynamic({ ssr: false }) wrapping is needed — unlike <LayerViewer>/<Model3D>/
<VoxelTerrain>, <Gallery> touches no WebGL/map API at module scope (embla-carousel-react
only touches the DOM once mounted, same as any ordinary React component), so it mounts
directly like <FileList>.
Copy-portable into a standalone consumer like personal-site — everything this component
needs is embla-carousel-react + lucide-react, no app/ or gating dependency, same
precedent as components/FileList/index.ts and components/Model3D/index.ts.
Acceptance criteria
- Given an array of 3+ images, when
<Gallery>mounts, then all images render as carousel slides with prev/next navigation working via click and left/right arrow keys. Verified:Gallery.test.tsx's render/click-nav/keyboard-nav specs. - Given
loopis omitted, when the carousel reaches the last slide and next is clicked, then it wraps to the first slide (defaulttrue). Verified:Gallery.test.tsx's loop-wrap spec, plus asserting the next button is never disabled while looping. - Given
loop={false}, when the carousel reaches the last slide, then the next button is disabled rather than wrapping. Verified:Gallery.test.tsx's no-loop spec, both directions (next at the last slide, prev at the first slide). - Given the dot indicators, then they show the correct active-slide state and clicking
a dot navigates directly to that slide.
Verified:
Gallery.test.tsx's dot-indicator spec. - Given
npm test, when run after this story, then it passes with the new Gallery tests included.
Phase fit
- This story (
galleryepic,gallery-component-and-docs):<Gallery>+ its tests + this doc. - Next (
gallery-showcase-integration, same epic): the public showcase page at/components/gallery, fed by the realpublic/showcase-samples/2806-prado-tour/*.jpgphotos already established as public-appropriate sample data (used by<VideoTour>'s own showcase), plus the component-framework docs-site ToC entry andgenerateStaticParamsslug wiring. Not this story's scope. - Deliberately out of scope, permanently: lightbox/zoom/lazy-load — see "What this
component deliberately does NOT do" above; this is CLAUDE.md's own "trivial" framing for
<Gallery>, not a phase-deferred feature.