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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/coverflow-carousel.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CoverflowCarousel" component with zod and
lucide-react (chevrons, an image-off glyph, a refresh glyph and a warning glyph).
No animation library, no timers, no carousel dependency.
Contract
- A zod schema in a sibling contract file is the single source of truth for one
cover: { id, title, subtitle?, image?, alt?, href?, accent? } where accent is
an integer 1-5 naming a var(--chart-N). Props are z.infer of it plus
{ status: "loading" | "empty" | "error" | "ready"; items: Item[] } — never a
parallel hand-written interface.
- image is optional on purpose: a catalogue knows its titles before its artwork
resolves, and a cover with no picture still has to hold its place on the arc.
Missing artwork renders a token-tinted panel whose hue is FNV-1a hashed from
the id, so the server and the client pick the same colour and a reload never
reshuffles the palette.
- href is the consumer's route and nothing else: the component renders one real
<a> in the caption bar for the centred cover. onActivate(item, index) is the
programmatic path (Enter, Space, or clicking the already-centred cover).
- forwardRef div extending React.HTMLAttributes<HTMLDivElement>; the accessible
name is the native aria-label prop (default "Covers"), which lands on the
listbox rather than on the wrapper. className merges through cn().
- Presentation props with defaults: index? / defaultIndex (0) / onIndexChange;
onRetry?; reflection (true); aspect "square" | "video" | "portrait"
("square"); depth (130px of translateZ per neighbour); tilt (52deg on the
first neighbour); spread (1, multiplier on the horizontal gap); maxVisible (3
covers either side); wheel (true); maxCoverWidth (300); ctaLabel ("Open");
emptyText; errorText.
Behavior
- ONE fractional number drives everything: `position`, in cover units. Every
cover's transform is a pure function of `offset = itemIndex - position`, so
every frame of a drag is a legal resting state and there are no slides to
interpolate between. activeIndex is just Math.round(position).
- Transform per cover: x = sign * (min(|offset|,1) * step + max(0,|offset|-1) *
step * 0.55) — the second neighbour onwards compresses, and that compression
IS the arc; z = -min(|offset|, maxVisible) * depth; rotateY = -sign *
min(|offset|,1) * tilt; scale = 1 - min(|offset|, maxVisible) * 0.05; a
bg-background overlay dims by 0.22 per cover, capped two out. Opacity ramps to
0 exactly at maxVisible + 1, which is also where the cover stops being
rendered, so entering and leaving the window is invisible.
- Drag: pointerdown stores the start x and start position and cancels any settle
in flight. pointermove writes the latest clientX to a ref and schedules ONE
rAF; the frame computes position = startPosition - dx / step and commits a
single state write, however many pointermove events the device fired. Past the
first and last cover the position keeps moving at 35% of the finger — a rubber
band, not a wall.
- Take the pointer capture in pointermove, the first time the travel passes 5px,
never in pointerdown: capture held by the listbox retargets the compatibility
click to it, and clicking a cover would stop working entirely. Wrap the
setPointerCapture call in try/catch (a pointer that is already gone throws) and
clear the drag's "moved" flag at the START of every gesture, because after a
captured drag the trailing click never reaches the cover to consume it.
- Release projects a flick: a smoothed px/s velocity times 0.22s of coasting,
clamped to two covers, added to the current position, then rounded. A gesture
that moved less than 5px is a click, not a drag, and the trailing click the
browser fires after a real drag is swallowed once.
- Wheel: attach the listener by hand with { passive: false } (React registers
onWheel passively at the root, where preventDefault does nothing). Consume the
event ONLY when |deltaX| > |deltaY| — a vertical wheel belongs to the page,
always, which is what keeps the carousel from trapping a scroll. Normalise
deltaMode 1/2 to pixels, accumulate into a ref, commit at most one step per
rAF, and reset the accumulator after 220ms of quiet so a stale burst cannot
fire later. All four states share one stage ref, so the effect's deps must
include whatever picks the panel (ready-with-covers vs empty), or the listener
stays bolted to a detached node and the wheel silently dies.
- Keyboard: the ring is one tab stop. ArrowLeft/ArrowRight step, Home/End jump
to the ends, Enter/Space activate. ArrowUp/ArrowDown and PageUp/PageDown are
deliberately left alone so the page still scrolls. Every step targets the
RESTING index, not the current fractional position, so held keys queue instead
of fighting the animation.
- Settle: one rAF loop easing position toward the target with
`1 - exp(-rate * dt)` (frame-rate independent — identical on 60Hz and 144Hz),
stopping and committing the exact integer once the remainder drops below
0.0015. There is never more than one settle frame, one drag frame and one
wheel frame in flight, and all three are cancelled on unmount.
- Accessibility: the ring is role="listbox" aria-orientation="horizontal" with
aria-activedescendant pointing at the centred option, so DOM focus never moves
as the ring turns. Each cover is role="option" with aria-selected,
aria-posinset and aria-setsize — the set size is declared because only a
window of covers is in the DOM. The focus indicator is painted on the centred
option via group-focus-visible. A polite live region says
"<title>, cover 3 of 8" — the same sentence the caption bar shows.
- prefers-reduced-motion: reduce swaps the ring for a plain horizontal
scroll-snap list. Same items, same listbox and same key map; no perspective,
no rAF, no drag. Selecting scrolls the SCROLLER's own offset (never
scrollIntoView, which drags the page). Read the query through matchMedia with
a change listener so a mid-session flip is honoured, and clean the listener up.
- Coarse pointers: the hover highlight is not wired at all (matchMedia
"(pointer: coarse)"). Dragging stays, under touch-action: pan-y, so a vertical
swipe still scrolls the page and merely cancels the drag via pointercancel.
- Four first-class states: loading paints three inert pulsing panels in the
arc's own geometry (so the stage keeps its height) plus an sr-only
role="status"; empty and a ready list of length 0 both render the same
bordered sentence; error renders role="alert" and, only when onRetry is
supplied, a Try again button. A single cover draws no arrows, no counter and
no grab cursor.
- Cover size follows the component's own box through a ResizeObserver on the
stage (width only, so it cannot feed itself a resize loop), clamped into
[132, maxCoverWidth]. Before the first measurement it renders at maxCoverWidth
so server and client agree and nothing jumps.
Rendering & styling
- Semantic tokens only: bg-card / bg-muted / bg-background / border /
border-primary/60 / ring-ring / text-foreground / text-muted-foreground /
bg-primary + text-primary-foreground for the CTA, and var(--chart-1..5) mixed
with color-mix(in oklab, ...) for the placeholder tint and the centre glow. No
hex, rgb() or oklch() literals anywhere.
- Perspective lives on the listbox (about 4x the cover width);
transform-style: preserve-3d lives on the track INSIDE it, because an element
that is both overflow-hidden and preserve-3d flattens.
- The reflection is a mirrored copy in a clipping box: transform-origin top plus
translateY(100%) scaleY(-1) puts the cover's bottom edge against the real one,
and mask-image: linear-gradient(to bottom, currentColor, transparent) fades it
out with no colour literal at all. It is aria-hidden and pointer-events-none.
- Images: object-cover, loading="lazy", decoding="async", draggable={false}
(native image dragging would otherwise swallow the pointer stream), and
alt defaults to "" because the option's own label already names the cover.
- Reduced motion removes decoration only: the dim overlay's transition and the
ring itself go, while stepping, activating and the link keep working.
Customization levers
- The look of the arc is four numbers: tilt (0 = a flat dimmed strip, 70 = an
aggressive fan), depth (raise it for a deeper tunnel), spread (below 1 the
covers overlap like a card fan, above 1 they read as a shelf) and maxVisible
(2 for a tight deck, 5 for a crowd — it also controls how many covers exist in
the DOM).
- Shape and density: aspect picks the crop ("portrait" for posters, "video" for
stills); maxCoverWidth caps how big a cover gets on a wide page, while the
container decides the rest.
- Chrome: reflection={false} halves the image nodes and shortens the stage;
drop the CTA by omitting href; drop the counter and arrows by rendering the
caption bar yourself around a controlled index.
- Motion feel: SETTLE_RATE is the only knob for how the ring lands (8 = languid,
16 = snappy); FLICK_SECONDS and MAX_FLICK decide how far a hard swipe throws
it; RUBBER_BAND is the resistance at the ends.
- Wiring: leave it uncontrolled for a showcase; pass index + onIndexChange when
a tab bar, a router or a keyboard shortcut elsewhere also owns the selection.
onActivate is where a modal, a player or a router push belongs — the component
never navigates by itself.
- Data: give every cover a stable id (it is the React key AND the placeholder
tint's seed); set accent explicitly when brand colour matters more than
variety.Concepts
- Fractional position — the carousel stores one continuous number, not a slide index. Because a transform is a pure function of
index - position, the halfway point of a drag is as valid a state as any rest position, which is what makes the ring feel physical rather than stepped. - Compression is the arc — only the first neighbour rotates and travels a full step; everything beyond it moves at 55% of a step and stops rotating further. That falloff is what reads as depth, and it is why five covers fit where three flat slides would.
- Rubber band and flick projection — past the ends the position keeps moving at a third of the finger, and on release a smoothed velocity is projected forward for a fifth of a second and clamped to two covers. Both are pure arithmetic on the same number; neither needs a physics engine.
- One frame per input burst — pointermove, wheel and the settle each own exactly one
requestAnimationFrameslot, so a 240Hz mouse and a trackpad flick commit the same amount of work as a slow drag, and unmount cancels all three. - Activedescendant instead of roving focus — the ring is one tab stop and the current cover is named by
aria-activedescendant, so turning the ring never moves DOM focus (which would otherwise be lost every time a cover leaves the render window). - The fallback is the same widget — with motion reduced the ring becomes a scroll-snap strip that keeps the listbox role, the key map and the selection; nothing is hidden and nothing waits on an animation that will never run.
Image Accordion
A row of tall image slices where the open panel widens to reveal its caption while the rest compress to labelled slivers.
Pinned Scroll Gallery
A section that pins while its gallery travels sideways — vertical scroll drives a horizontal track of cards, with a progress rail, per-item snap points and a plain scroll-snap scroller when motion is off.