Media

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import type { PinGalleryData, PinGalleryItem } from "./pin-gallery.contract"

/** A window shorter than this leaves no room to move the track through. */
const MIN_SCROLL_LENGTH = 0.5
/** Vertical scroll window (in stage heights) reserved per item when `scrollLength` is left to the component. */
const TRAVEL_PER_ITEM = 0.5

const clamp01 = (value: number) => Math.min(1, Math.max(0, value))

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pin-gallery.json

Prompt

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 or preventDefault-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: sticky anchors to, not against window. 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-current and the counter tell three different stories, and 08 / 08 is 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-visible so 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 matchMedia listener, so switching the OS setting mid-session switches transports immediately.

On This Page