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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pin-gallery.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PinGallery" component (zod for the
contract; no other dependencies). It is a section that pins while its gallery
scrolls sideways: vertical page scroll through the pinned range is mapped onto
the horizontal offset of a track of cards.
Contract
- pin-gallery.contract.ts is the single source of truth:
pinGalleryItemSchema = { id, title, caption?, eyebrow?, image?, href?,
accent? (int 1-5) }
pinGallerySchema = { status: "loading" | "empty" | "error" | "ready",
items: PinGalleryItem[] }
The component's props are `z.infer` of that schema plus presentation props —
never a second hand-written interface.
- Export a forwardRef <section> extending React.HTMLAttributes<HTMLElement>:
label? (accessible name, default "Pinned gallery"), stageHeight? (CSS length,
default "min(88vh, 680px)"), scrollLength? (stage-heights of vertical scroll
that drive the whole track; default derived from the item count — half a stage
each, min 1 — clamped to >= 0.5), itemWidth? (CSS length, uniform for every
card), snap? (default true), pin? (default true), offsetTop? (px parking line
under a fixed header), onActiveChange?(index), onRetry?. children render as
the section heading inside the stage, in every state. className merges via
cn() onto the root.
- Two boxes, one class hook: the ROOT is the invisible scroll track (height =
calc(stageHeight * (1 + scrollLength)) while pinned, otherwise no height at
all) and takes className, style and the spread props; the STAGE is the sticky
card the viewer sees, restyled by editing its classes in the file. Say so in
the JSDoc, naming both boxes — consumers otherwise style a multi-screen
invisible column and wonder why nothing moved.
- Derived mode, never stored twice:
view = status === "ready" && items.length === 0 ? "empty" : status
travels = MEASURED: track.scrollWidth - viewport.clientWidth > 1, held in
state that starts optimistic so the server renders the pinned
markup and the common case never shifts on hydration
pinned = pin && !reducedMotion && view === "ready" && items.length > 1
&& travels
Expose view / mode as data-status / data-mode ("pinned" | "scroller") on the
root.
- Publish the travel ratio as a --pin-gallery-progress custom property (0-1) on
the root, written imperatively. That is the extension point: parallax layers,
chapter bars and background washes read it with plain CSS calc() and cost
zero re-renders.
Behavior
- Geometry is measured against the scrollport, not the window: walk up from the
root to the first ancestor whose computed overflow-y is neither visible nor
clip (stopping at body/documentElement), because that is the box position:
sticky anchors to. Resolve it once per effect run (and on resize), then read
only rects per frame. Fall back to the viewport.
runway = sectionHeight - stageHeight
passed = scrollportTop + offsetTop - sectionTop
progress = clamp01(passed / runway)
offsetX = progress * (trackScrollWidth - viewportClientWidth)
Write offsetX as translate3d(-offsetX, 0, 0) on the track. Because it is a
clamp of the section's own position, the pin releases cleanly at both ends and
the page never has to be hijacked, captured or preventDefault-ed.
- One rAF per tick: a passive scroll listener in the CAPTURE phase (scroll does
not bubble, but it does propagate down, so one listener covers the page and
every nested scroller), resize, and a ResizeObserver on the root and the track
all funnel into one schedule() that no-ops while a frame is pending. An
IntersectionObserver cancels the pending frame while the section is off
screen. Never write layout from an event handler.
- Active item while pinned = round(progress * (n - 1)) — the same map the snap
points use there, so the dots, the rail fill and the counter can never
disagree (the scroller measures instead; see the clamped-target rule below).
Push it into React state only when the index actually changes, and mirror it
to onActiveChange through a ref so a new callback identity never re-subscribes
anything.
- Snap points are jumps, not magnets: clicking a dot (or pressing arrow keys on
the rail) computes the scroll position for that item — pinned, where progress
equals i/(n-1); in the scroller, the clamped target below — and calls one
ordinary scrollTo({ behavior: reducedMotion ? "auto" : "smooth" }) on the
scrollport. Nothing snaps back when the viewer stops scrolling — the scrollbar
stays theirs.
- Keyboard reachability while pinned: the track is overflow-x: CLIP (not hidden,
not auto) so it is not a scroll container the browser can scroll behind your
back. Handle focus in the capture phase instead: when a focused descendant
matches :focus-visible, find its [data-pin-gallery-item] ancestor and jump to
that item's snap point with behavior "auto". Gating on :focus-visible is what
stops a mouse click from yanking the page.
- Dot rail = roving tabindex: only the active dot is tabbable, ArrowLeft/Right
walk the snap points and move focus with them, Home/End jump to the ends, and
only those keys are preventDefault-ed (never ArrowUp/Down — that is the page's
scroll).
- A track that already fits earns no runway: BOTH modes recompute `travels` per
frame through the same one-line helper (track.scrollWidth -
viewport.clientWidth > 1) and push it into state only when it flips. One
shared helper is what stops the two modes from measuring differently and
flipping the section back and forth; and a gallery whose cards already fit the
window — wide screen, three short items — must drop the reservation rather
than leave a screen of dead scroll under a stage that never moves.
- Fallback that still works: under prefers-reduced-motion: reduce, with
pin={false}, with fewer than two items, or with a track that already fits its
viewport, the section reserves no runway, collapses to one stage height and
the track becomes a real overflow-x: auto scroller with scroll-snap-type: x
mandatory and scroll-snap-align: start on the cards (last one excepted, see
below). The same rail, counter and dots stay live, driven by the viewport's
own scrollLeft. Content is never hidden and no interaction is lost — only the
choreography.
- Scroller snap targets are CLAMPED, on both sides: an item's target is
min(card.offsetLeft - firstCard.offsetLeft, scrollWidth - clientWidth),
because with cards narrower than the viewport the last two or three can never
reach the leading edge. Use that same clamped target for the dot jump AND for
measuring the active card: nearest target wins, and if the nearest one IS the
end of the range (on a track that can scroll at all) the active item is the
last card — the trailing cards share that position, and without the rule the
counter could never read n / n against an already full rail. Give the last card
scroll-snap-align: end as well — a snap position outside the scroll range is
not one mandatory snapping can rest at, so without it the end of the track,
and with it the last item, is simply unreachable. Skip any of this and the dot
you just clicked disagrees with aria-current and with the counter.
- Read prefers-reduced-motion and (pointer: coarse) through matchMedia with a
change listener (useSyncExternalStore, server snapshot false) so flipping the
OS setting mid-session switches modes live. On coarse pointers, drop the card
hover lift and cover zoom: a hover state that sticks after a tap is worse than
no hover state.
- Cleanup is total: rAF, scroll + resize listeners, both observers and both
matchMedia listeners are removed on unmount or mode change, and the track's
inline transform is cleared so the browser gets it back untouched.
- SSR: no window/document access during render — the media flags come from
useSyncExternalStore's server snapshot and every measurement lives in an
effect, so the server renders the pinned markup and hydration is stable.
Rendering & styling
- Root: relative w-full, height from the calc above, data-mode, data-status,
--pin-gallery-progress. Stage: relative isolate flex flex-col gap-4
overflow-clip rounded-2xl border bg-card p-4 sm:p-6, sticky with top:
offsetTop only while pinned.
- Header row: children on the left; on the right a role="status" counter
("03 / 08 · Title") — polite, and exactly what a sighted viewer already sees.
- Track: <ol> of <li> cards, flex h-full items-stretch gap-3, each li a fixed
itemWidth with data-index. A card with href renders as an <a> with a
focus-visible ring, a hover AND focus-visible lift; a card without one renders
as a <div> with no pointer affordance at all — never a fake link.
- Card: rounded-xl border bg-background/70, cover image object-cover with a
group-hover/group-focus-visible scale, an index pill in the corner, a hairline
accent from var(--chart-N) above the copy, title + line-clamp-2 caption. On
non-active cards only the DECORATION dims — opacity-70 on the cover and the
hairline, with the active card ringed in its accent via color-mix. Never fade
the card root: that would drag the eyebrow and the text-muted-foreground
caption, which clear 4.5:1 by a hair, under the AA floor on every card but
one, and most cards on stage are inactive.
- Rail: h-1 rounded-full bg-muted with a fill that is transform: scaleX(progress)
written straight to the DOM (aria-hidden — the counter is the accessible
progress indicator), plus the dot group with aria-current on the active dot.
Each dot paints an 8px bar inside a 24px button, because that is the target a
thumb (and WCAG 2.2 target size) needs; the active one widens instead of only
changing colour.
- Decorative aura: one blurred radial of var(--chart-1) on a full-width carrier
translated by translateX(calc(var(--pin-gallery-progress) * 100%)) — a
percentage of ITSELF, which walks the stage end to end without a single layout
pass. aria-hidden, pure CSS, no per-frame JavaScript.
- Four first-class states, all at the same stage height so the box never jumps:
ready (the track), loading (skeleton cards at the real card width, pulse
dropped under motion-reduce), empty (dashed panel), error (panel plus an
optional Try again button wired to onRetry).
- Semantic tokens only: bg-card / bg-background / bg-muted / bg-primary /
text-muted-foreground / border / ring / var(--chart-1..5) — no hardcoded
colours, dark mode for free.
Customization levers
- Pacing: scrollLength is the knob that matters. 0.5-1 per item feels brisk,
2 per item is a slow reveal; the default (0.5 per item, min 1) keeps a long
gallery from eating three screens of page. Pair a long window with wide cards,
never the reverse.
- Density: itemWidth sets how many cards are on stage at once —
"clamp(15rem, 62vw, 21rem)" shows two to three on a desktop, drop to 12rem for
a filmstrip of six. Uniform width is what keeps the snap points evenly spaced;
if you need mixed widths, replace the i/(n-1) snap map with measured
card.offsetLeft values.
- Chrome: snap={false} removes the dot rail and the jump targets for a
continuous filmstrip; keep the rail and drop the counter (or vice versa) by
deleting one block — they read the same state.
- Framing: stageHeight "100svh" makes it a takeover and avoids the mobile
URL-bar resize; a fixed px height makes it a band inside a longer page. Set
offsetTop to your fixed header's height so the stage parks below it.
- Layering: drive anything off calc(var(--pin-gallery-progress) * 100%) —
a chapter bar, a headline that slides at half speed, a background that shifts
hue. Add per-card parallax by translating the cover image by a fraction of the
same variable.
- Card anatomy: swap the cover for a video, a logo lockup or pure type; the
track only cares about the card's width. Route accent through your brand
scale instead of var(--chart-N) if the gallery must stay monochrome.
- Direction: for a right-to-left gallery, negate the offset and start the track
at the far end — the geometry is one sign change, the snap map is unchanged.Concepts
- Borrowed axis — the section does not invent scrolling; it reserves a stretch of ordinary page (
stageHeight × (1 + scrollLength)) and spends the viewer's own vertical gesture on a horizontal offset. Nothing is captured orpreventDefault-ed, so momentum, trackpads, spacebar and screen-reader cursors all behave exactly as they would without the component — and the pin releases the moment the reserved stretch runs out, at either end. - The pin window — the reserved height is the pacing control. Shorten it and the same gallery flies past; lengthen it and each card gets a beat. Every fallback drops the reservation entirely — including the measured one, where a track that already fits its box on a wide screen never earns a runway — because a section that no longer travels must not leave a dead column of scroll behind.
- Scrollport-relative geometry — progress is measured against the nearest scrollable ancestor, the same box
position: stickyanchors to, not againstwindow. That is what lets the gallery live inside a modal, a docs preview or any nested scroller and still map correctly. - rAF coalescing — scroll fires far faster than the screen refreshes. Scroll, resize, ResizeObserver and IntersectionObserver all funnel into one
schedule()that no-ops while a frame is pending, so a burst of events costs one geometry read and one style write; off screen, it costs nothing at all. - Snap points as jumps, not magnets — each item owns a position:
i / (n-1)of the runway while pinned, its own clamped left edge in the scroller. The dots scroll to it on demand, and nothing pulls the page back when the viewer stops mid-card, which is the difference between a gallery that helps and one that fights. The clamp is not a detail: cards narrower than the viewport can only ever park the last two or three at the end of the scroll range, so the jump and the measurement share one clamped target, and arriving at that shared end position names the last card — otherwise the dot you clicked,aria-currentand the counter tell three different stories, and08 / 08is a reading the gallery can never show. - Focus follows the driving axis — while pinned the track is
overflow-x: clip, so the browser cannot quietly scroll it to reveal a focused card. The component translates that focus into a vertical jump instead, gated on:focus-visibleso a mouse click never yanks the page: tabbing through the gallery lands on the card you just focused. - Motion consent without content loss — reduced motion does not disable the gallery, it changes the transport: the same cards become an ordinary CSS scroll-snap scroller, with the same rail, counter, dots and links. The flag is read through a live
matchMedialistener, so switching the OS setting mid-session switches transports immediately.
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.
Animated Text
Character-level text animation — typewriter, scramble and flowing gradient as one component's variants.