Media

Image Accordion

A row of tall image slices where the open panel widens to reveal its caption while the rest compress to labelled slivers.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowUpRight } from "lucide-react"
import { cn } from "@/lib/utils"
import type { ImageAccordionData } from "./image-accordion.contract"

/** One overshoot-free curve for the size budget, the reveal and the accent rule. */
const EASE = "ease-[cubic-bezier(0.22,1,0.36,1)]"

/** Every status renders at the same size, so the row never resizes the page under the reader. */
const ROOT_SIZE = "h-[30rem] w-full sm:h-[34rem]"

/** A pointer sweep fires a selection per sliver; only the settled one is worth speaking. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/image-accordion.json

Prompt

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 expandRatio shares of the row and every sliver takes one, and the browser tweens flex-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 to inert so 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.

On This Page