Image Accordion
A row of tall image slices where the open panel widens to reveal its caption while the rest compress to labelled slivers.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/image-accordion.jsonPrompt
Build a React + TypeScript + Tailwind "ImageAccordion" component (zod for the
contract, lucide-react for one arrow icon, and a cn() class-merge helper).
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
items: { id, src, alt, title, caption, eyebrow?, href?, linkLabel? }[] }.
- Props = z.infer of that schema plus:
activeId? (controlled), defaultActiveId?, onActiveChange?(id),
expandRatio? (default 5, clamped 2-12), selectOnHover? (default true),
label? (accessible name of the set, default "Image panels"),
onRetry? (error state only), className, and the rest of the native div
props spread on the root so a ref reaches the real element.
Behavior
- Four first-class branches: loading (a row of pulsing slivers with one wide
slice, mirroring the real silhouette), empty (also used when status is
"ready" with zero items), error (message + "Try again" only when onRetry
exists), ready (the panel row). Every branch renders at the same height so
the page never jumps when the data lands.
- Exactly one panel is open at all times. The open index is DERIVED: an id
that was never supplied, or that a refresh removed, resolves to the first
item — no reset effect, and no frame where everything is collapsed.
- Size budget: each panel is flex-basis 0 with flex-grow = expandRatio when
open and 1 otherwise, and the transition runs on flex-grow. The root is
flex-col below sm and flex-row above it, so the same budget buys height on
a phone and width on a desktop — one code path, two axes.
- Selection is reached three ways and they all agree: pointer enter (only on
fine pointers — query "(pointer: coarse)" with matchMedia and fall back to
tap), click/tap, and focus. Nothing is hover-only.
- Keyboard: the row is a single roving tab stop (tabIndex 0 on the open
panel, -1 on the rest). ArrowRight/ArrowDown move forward, ArrowLeft/
ArrowUp back, Home/End jump to the ends, all wrapping; selection follows
focus, so arrowing across the row opens each caption in turn.
- onActiveChange fires exactly once per selection. The arrow handler selects
and then moves focus, and that focus() dispatches synchronously while the
render still holds the old selection — so raise a ref around the focus call
and have the focus handler bail out on it. Without that guard a controlled
consumer routing onActiveChange into a router or into analytics gets two
identical calls for every arrow press.
- Announce the settled selection ("<title>, panel n of N") in a visually
hidden aria-live="polite" region, debounced by ~160ms so a pointer sweep
across the slivers does not queue one utterance per sliver. Clear the
timeout on unmount and on every re-run. Record the mount default silently
— nobody chose it.
- Structure per panel: image, gradient scrim, dim layer, accent rule, an
absolutely positioned full-slice <button aria-expanded aria-controls
aria-label={title}>, then a pointer-events-none overlay holding the
decorative eyebrow/title and a role="region" caption. The caption is a
SIBLING of the button, never a child: it contains a real link, and an <a>
inside a <button> is neither valid HTML nor operable. Collapsed regions
get `inert`, so their link leaves the tab order and the a11y tree while
still being able to animate.
- The caption reveals with a grid-rows 0fr -> 1fr + opacity transition, and
the collapsed title rotates to writing-mode: vertical-rl only in row mode.
The clipping box that makes that reveal possible would also cut the link's
focus ring, so give it a little inner room (padding cancelled by a negative
inline margin, which keeps the copy aligned with the title).
- href is optional; a panel without one renders no link at all rather than a
dead "#". Destinations are the consumer's real routes.
Rendering & styling
- The copy overlay pays a smaller frame while collapsed (p-2 against the open
panel's p-4 sm:p-5, tweened with the same duration as the width): a sliver
is only a few dozen pixels wide at a high expandRatio, and a full-size
padding would leave the vertical label no room and clip it away.
- Semantic tokens only: bg-muted panel bed, a from-background scrim so
text-foreground / text-muted-foreground copy stays legible over any photo,
border + shadow-lg on the open panel, ring-ring focus-visible rings, and
var(--chart-1..5) cycled BY INDEX for the accent rule and eyebrow dot —
deterministic, so server and client paint the same colours.
- Read prefers-reduced-motion through matchMedia (subscribed, not sampled
once) and drop the caption's stagger delay to 0ms; pair it with
motion-reduce: variants on every transition. With motion off the component
still works: the open panel is open, the caption is visible, only the
tweening is gone.
- Images are plain <img> with loading="lazy" (the first one eager),
decoding="async" and draggable={false}; alt comes from the contract.
- cn() merges className last, so a consumer can override the height.
Customization levers
- expandRatio: 3 for a gentle spread, 8-10 for one dominant hero slice.
- Panel count and ratio share one budget: a sliver is
(row width - gaps) / (expandRatio + count - 1) wide, and it needs roughly
40px to keep a vertical label. 5 panels at ratio 6 fit a ~560px row; push
the ratio to 10 there and the labels go. Raise the ratio only as far as the
count allows, or paginate the data instead.
- Height: the root ships h-[30rem] sm:h-[34rem]; pass your own h-* (and the
sm: variant) through className.
- Stack breakpoint: swap sm: for md: on the flex-direction and the vertical
writing-mode pair to keep the stack longer on tablets.
- Selection model: selectOnHover={false} for a click-only gallery; hoist
activeId + onActiveChange to sync the row with a map, a filter or a route.
- Copy blocks: drop eyebrow or linkLabel from the contract, or add a
meta line (year, client) inside the region beside the caption.
- Accent: swap var(--chart-N) for a single brand token, or drop the rule and
the dot entirely — nothing else depends on them.Concepts
- Mandatory single selection — the open index is derived from the data every render, so a removed or unknown id falls back to the first panel instead of leaving the row collapsed; there is no "nothing open" state to design for.
- Flex-grow budget — width is not measured or animated in JS: the open panel takes
expandRatioshares of the row and every sliver takes one, and the browser tweensflex-grow. Flip the container to a column and the identical budget buys height, which is the whole responsive story. - Selection follows focus — arrowing across the row opens each caption in turn, so what a mouse reveals by hovering a keyboard reveals by arrowing; nothing lives behind hover alone.
- Trigger and region as siblings — the caption holds a real link, so it can never be a child of the trigger button. It sits beside it, wired by
aria-controls, and collapses toinertso its link leaves both the tab order and the accessibility tree. - Debounced announcement — a pointer sweeping five slivers changes the selection five times; the polite region waits for the selection to settle before speaking, and the mount default is recorded silently because nobody chose it.
- Deterministic accents — the chart token for each panel comes from its index, not a random pick, so the server render and every client render paint the same rule colours.
Liquid Distortion Image
A picture that ripples where the pointer touches it — an SVG feTurbulence + feDisplacementMap twin, masked to a soft lens that follows the cursor and settles back when it leaves.
Coverflow Carousel
A 3D perspective ring of covers — the centre faces you, neighbours rotate away into depth with dimming and an optional reflection, driven by one continuous position that drag, horizontal wheel and arrow keys all share.