Pinned Horizontal Scroll Gallery

A full-height section pins while vertical scrolling drives a row of large cards sideways; each card's image parallaxes against its frame and a hairline progress bar tracks the journey.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 981 words of exact values — rewritten live as you tune the Playground.

prompt.md · Pinned Horizontal Scroll Gallery
### EFFECT: Pinned Horizontal Scroll Gallery
**What it does (visual):** A tall intro block says "Scroll" and leads into a full-viewport section with a row of five large cards (each ~60vw wide, 70vh tall) sitting side by side, overflowing to the right. As the user scrolls down, the section stays fixed on screen (pinned) and the row of cards slides to the left at exactly scroll speed, so the last card ends flush with the right edge when the pin releases. Inside each card the image moves slightly slower than its frame (a ±6% parallax), and each card's large index number and title drift up into view as the card crosses the centre of the screen. A 1px progress bar across the bottom of the pinned section fills from left to right in sync with the horizontal travel. Scrolling back up reverses everything perfectly (it's scrubbed, not time-based). After the gallery, a normal outro section continues.

**Tech stack:**
- GSAP 3.13+ and ScrollTrigger.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- Works with Lenis if present (`lenis.on('scroll', ScrollTrigger.update)`).

**HTML structure:**
```html
<section class="hg-intro"><p class="hg-intro__label">Scroll ↓</p><h1 class="hg-intro__title">Selected work</h1></section>
<section class="hg">
  <div class="hg__track">
    <article class="hg__card">
      <div class="hg__media"><div class="hg__img" style="--c1:#1d2a3a;--c2:#0a0a0a"></div></div>
      <div class="hg__meta"><span class="hg__num">01</span><h2 class="hg__title">Aurora</h2><p class="hg__tag">Brand · 2026</p></div>
    </article>
    <!-- repeat 4 more .hg__card -->
  </div>
  <div class="hg__progress"><span class="hg__progress-bar"></span></div>
</section>
<section class="hg-outro"><p>End of gallery</p></section>
```
(The demo paints each image as a CSS gradient so it needs no asset; swap `.hg__img` for an `<img>` in production.)

**CSS requirements:**
- `.hg-intro`, `.hg-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw;`
- `.hg`: `position: relative; height: 100vh; overflow: hidden; display: flex; align-items: center;`
- `.hg__track`: `display: flex; gap: 4vw; padding-inline: 6vw; will-change: transform;` (content-width = sum of cards + gaps + padding).
- `.hg__card`: `position: relative; flex: 0 0 auto; width: min(60vw, 820px); height: 70vh;`
- `.hg__media`: `position: absolute; inset: 0; overflow: hidden; border-radius: 12px;`
- `.hg__img`: `position: absolute; inset: -8% 0; height: 116%; background: radial-gradient(120% 80% at 30% 20%, var(--c1), var(--c2)); will-change: transform;` (taller than the frame so the parallax never reveals edges).
- `.hg__meta`: `position: absolute; left: 2rem; bottom: 2rem; color: #fff; mix-blend-mode: difference;`
- `.hg__num`: display serif, `font-size: clamp(3rem, 8vw, 7rem); line-height: .8; opacity: .5;`
- `.hg__title`: display serif, `font-size: clamp(1.75rem, 3vw, 3rem); letter-spacing: -0.03em;`
- `.hg__progress`: `position: absolute; left: 6vw; right: 6vw; bottom: 2rem; height: 1px; background: rgba(255,255,255,.12);`
- `.hg__progress-bar`: `display: block; height: 100%; width: 100%; background: var(--accent, #D4FF3A); transform-origin: left; transform: scaleX(0);`

**Animation spec (exact values):**
1. `const track = document.querySelector('.hg__track'); const getDistance = () => track.scrollWidth - window.innerWidth;`
2. Horizontal scrub tween:
   ```js
   const scrollTween = gsap.to(track, {
     x: () => -getDistance(), ease: 'none',
     scrollTrigger: { trigger: '.hg', start: 'top top', end: () => '+=' + getDistance() * 1, scrub: 1, pin: true, pinSpacing: true, anticipatePin: 1, invalidateOnRefresh: true, markers: false }
   });
   ```
   - target `.hg__track`, property `x`, from `0` → `-distance`, ease `none`, scrub `1` (1s catch-up; 0 = locked), pin `.hg`, `pinSpacing: true`, end = distance × 1.
3. Progress bar (colour `#d4ff3a`): `gsap.to('.hg__progress-bar', { scaleX: 1, ease: 'none', scrollTrigger: { trigger: '.hg', start: 'top top', end: () => '+=' + getDistance() * 1, scrub: true } })`.
4. Per-card parallax (one per `.hg__card`, using `containerAnimation: scrollTween`):
   - `gsap.fromTo(card.querySelector('.hg__img'), { yPercent: -6, xPercent: -6 }, { yPercent: 6, xPercent: 6, ease: 'none', scrollTrigger: { trigger: card, containerAnimation: scrollTween, start: 'left right', end: 'right left', scrub: true } })`
   - Meta rise (enabled: true): `gsap.from(card.querySelector('.hg__meta'), { y: 40, opacity: 0, duration: 1, ease: 'power3.out', scrollTrigger: { trigger: card, containerAnimation: scrollTween, start: 'left 70%', toggleActions: 'play none none reverse' } })`
5. Intro: `gsap.from('.hg-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
6. After images load / fonts ready → `ScrollTrigger.refresh()`.

**Interaction:** No pointer interaction — purely scroll-driven and fully reversible. Scrolling with mouse wheel, trackpad or keyboard all work because the effect reads the page's scroll position.

**Responsive behavior:** Use `gsap.matchMedia()`:
- `(min-width: 768px)`: everything above.
- `(max-width: 767px)`: do NOT pin. Set `.hg { height: auto; }`, `.hg__track { overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }`, `.hg__card { width: 82vw; height: 60vh; scroll-snap-align: center; }` and skip the horizontal tween; keep the meta rise using a normal (non-container) ScrollTrigger; hide the progress bar.

**Accessibility:** With `prefers-reduced-motion: reduce`, do not pin or translate: the track becomes a natively scrollable horizontal row (same CSS as the mobile branch) and parallax is disabled; the progress bar is hidden. Cards are `<article>`s with real headings so screen readers get a list of titled items. If cards are links, give them a visible focus ring.

**Performance rules:** Only `transform` is animated (`x`, `yPercent`, `scaleX`). `will-change: transform` is on `.hg__track` and `.hg__img` only. `invalidateOnRefresh: true` recomputes the distance on resize instead of leaving stale values. Everything is created inside `gsap.context()` and reverted on unmount (`ctx.revert()` also unpins and removes the pin spacer). Avoid `scrub: true` for the main tween — `scrub: 1` smooths the wheel steps.

**Customization knobs:**
- Card width (`min(60vw, 820px)`) and gap (`4vw`) — controls total travel.
- `scrub` (1) — higher = more lag/smoothing.
- Parallax amount (`±6` yPercent/xPercent).
- Progress bar colour (`#d4ff3a`).
- Travel length multiplier (1) — `end: '+=' + getDistance() * 1` slows or speeds the horizontal travel relative to vertical scroll.
- Caption rise on/off (true).

**Acceptance criteria:**
- [ ] Section pins at `top top` and releases exactly when the last card is flush right.
- [ ] Horizontal travel reverses when scrolling up; no jumps on resize.
- [ ] Images parallax inside their frames without exposing edges.
- [ ] Progress bar is 0% at pin start and 100% at pin end.
- [ ] Under 768px and with reduced motion the gallery is a native horizontal scroll row; no pinning.
- [ ] No console errors; no horizontal page scrollbar outside the gallery.

Source

Full source code

<section class="hg-intro">
  <p class="hg-intro__label">Scroll ↓</p>
  <h1 class="hg-intro__title">Selected work</h1>
</section>

<section class="hg">
  <div class="hg__track">
    <article class="hg__card">
      <div class="hg__media"><div class="hg__img" style="--c1:#2a3d5c;--c2:#0a0a0a"></div></div>
      <div class="hg__meta"><span class="hg__num">01</span><h2 class="hg__title">Aurora</h2><p class="hg__tag">Brand identity · 2026</p></div>
    </article>
    <article class="hg__card">
      <div class="hg__media"><div class="hg__img" style="--c1:#5c3a2a;--c2:#0a0a0a"></div></div>
      <div class="hg__meta"><span class="hg__num">02</span><h2 class="hg__title">Ember</h2><p class="hg__tag">E-commerce · 2026</p></div>
    </article>
    <article class="hg__card">
      <div class="hg__media"><div class="hg__img" style="--c1:#2a5c48;--c2:#0a0a0a"></div></div>
      <div class="hg__meta"><span class="hg__num">03</span><h2 class="hg__title">Moss</h2><p class="hg__tag">Editorial · 2025</p></div>
    </article>
    <article class="hg__card">
      <div class="hg__media"><div class="hg__img" style="--c1:#4a2a5c;--c2:#0a0a0a"></div></div>
      <div class="hg__meta"><span class="hg__num">04</span><h2 class="hg__title">Violet Hour</h2><p class="hg__tag">Campaign · 2025</p></div>
    </article>
    <article class="hg__card">
      <div class="hg__media"><div class="hg__img" style="--c1:#5c5a2a;--c2:#0a0a0a"></div></div>
      <div class="hg__meta"><span class="hg__num">05</span><h2 class="hg__title">Saffron</h2><p class="hg__tag">Product launch · 2025</p></div>
    </article>
  </div>
  <div class="hg__progress" aria-hidden="true"><span class="hg__progress-bar"></span></div>
</section>

<section class="hg-outro">
  <p class="hg-outro__text">End of gallery — the page keeps scrolling normally.</p>
</section>