# Scroll Components — Interaction Toolbox

> **Scope:** frontend-scroll-driven
> **Layer:** 3
> **Keywords:** pinned gallery, horizontal scroll, modal, lightbox, parallax, count-up, reveal, scroll progress, accordion
> **Load When:** ui-designer active, or building a scroll-driven marketing page

**Verified against:** GSAP 3.13 (ScrollTrigger, matchMedia, context) + React 18 + Lenis 1.x, shipped to production. Last-verified: 2026-08-21.

---

A toolbox of scroll-driven interaction patterns, not a mandatory list. Pick what the page needs. Every recipe assumes the wiring in `frontend/scroll-driven/smooth-scroll.md` (shared motion registry, Lenis on the GSAP ticker, `window.__lenis` exposed) and cleans itself up on unmount.

The frame-scrub hero is documented separately in `frontend/scroll-driven/frame-scrub.md`.

---

## Section 1: useReveal (The Base Brick)

Most content (headings, paragraphs, cards) needs nothing more than this. Use `.d1 .d2 .d3` to stagger siblings.

```jsx
export function useReveal() {
  useEffect(() => {
    if (prefersReducedMotion) return
    const els = gsap.utils.toArray('.reveal')
    const ctx = gsap.context(() => {
      els.forEach((el) => gsap.fromTo(el,
        { opacity: 0, y: 28 },
        { opacity: 1, y: 0, duration: 0.9, ease: 'power3.out',
          scrollTrigger: { trigger: el, start: 'top 88%', once: true } },
      ))
    })
    return () => ctx.revert()
  }, [])
}
```

```css
.reveal { will-change: opacity, transform; }
.reveal.d1 { transition-delay: .08s } .reveal.d2 { transition-delay: .16s } .reveal.d3 { transition-delay: .24s }
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1 !important; transform: none !important; }
}
```

`once: true` matters: re-revealing on every pass reads as nervous, not premium.

**If you implement this with `IntersectionObserver` instead of ScrollTrigger, keep `threshold: 0`.** A threshold is a fraction of the observed element's own area, so a block taller than the viewport (a long photo grid, a tall editorial section) never reaches 15 percent visible at once. The callback never fires, the element keeps `opacity: 0` forever, and it still occupies its full height in the layout: the page shows a large blank gap and nothing in the console. Use `threshold: 0` with a `rootMargin`, which fires as soon as the first pixel enters, whatever the element's height. The ScrollTrigger version above is immune because `start: 'top 88%'` measures the element's top against the viewport, not its area.

---

## Section 2: Pinned Horizontal Gallery

A section that pins to the viewport and slides cards horizontally as the user scrolls vertically. Desktop only; on mobile it falls back to normal vertical scrolling via CSS.

```jsx
useEffect(() => {
  const mm = gsap.matchMedia()
  mm.add('(min-width: 861px) and (prefers-reduced-motion: no-preference)', () => {
    const section = sectionRef.current, track = trackRef.current
    section.classList.add('lines--horizontal')
    const distance = () => Math.max(0, track.scrollWidth - window.innerWidth)
    const tween = gsap.to(track, {
      x: () => -distance(),
      ease: 'none',
      scrollTrigger: {
        trigger: section, start: 'top top', end: () => `+=${distance()}`,
        scrub: 0.5, pin: true, anticipatePin: 1, invalidateOnRefresh: true,
      },
    })
    return () => {
      section.classList.remove('lines--horizontal')
      gsap.set(track, { x: 0 })
      tween.scrollTrigger?.kill(); tween.kill()
    }
  })
  return () => mm.revert()
}, [])
```

`invalidateOnRefresh: true` is required here: without it the travel distance stays frozen at the first measurement and the pin ends in the wrong place after a resize.

To lengthen the journey and give cards more breathing room, increase the track gap or padding. `scrollWidth` grows and the pin lasts longer.

---

## Section 3: Expanding Modal

A card that opens into a detail panel. The entry pattern is: mount the node, then add the `.in` class on the next frame so the CSS transition fires.

```jsx
const [active, setActive] = useState(null)
const [shown, setShown] = useState(false)

const open = (i) => {
  setActive(i)
  requestAnimationFrame(() => setShown(true))
  window.__lenis?.stop()
}
const close = () => {
  setShown(false)
  window.__lenis?.start()
  setTimeout(() => setActive(null), 380)   // matches the CSS transition
}

useEffect(() => {
  if (active === null) return
  const onKey = (e) => { if (e.key === 'Escape') close() }
  window.addEventListener('keydown', onKey)
  return () => window.removeEventListener('keydown', onKey)
}, [active])
```

**Render overlays outside any pinned section.** A pin applies a `transform` to its container, which becomes the containing block for `position: fixed` and breaks the overlay. Put the modal in a fragment as a sibling of the section.

**Mark the scrollable panel `data-lenis-prevent`.** Stopping the smooth scroller is not enough: while stopped it calls `preventDefault()` on any `touchmove` that reaches it, so a panel with `overflow-y: auto` scrolls with the wheel and is frozen under a finger. Whatever sits at the bottom of the panel, usually the CTA, becomes unreachable on a phone. See `frontend/scroll-driven/smooth-scroll.md`, Section 3.

```jsx
<div className="lmodal__panel" data-lenis-prevent>
```

```css
.lmodal { position: fixed; inset: 0; z-index: 300; display: grid; place-items: center;
  background: rgba(0,0,0,0); backdrop-filter: blur(0px);
  transition: background .38s, backdrop-filter .38s; }
.lmodal.in { background: rgba(0,0,0,.6); backdrop-filter: blur(6px); }
.lmodal__panel { opacity: 0; transform: translateY(24px) scale(.98);
  transition: opacity .38s, transform .38s cubic-bezier(.2,.7,.2,1); }
.lmodal.in .lmodal__panel { opacity: 1; transform: none; }
```

---

## Section 4: Fullscreen Lightbox

Same `.in` pattern, plus arrow-key navigation and a paused scroller.

```jsx
const go = (dir) => setLb((i) => (i + dir + PHOTOS.length) % PHOTOS.length)

useEffect(() => {
  if (lb === null) return
  const onKey = (e) => {
    if (e.key === 'Escape') close()
    else if (e.key === 'ArrowRight') go(1)
    else if (e.key === 'ArrowLeft') go(-1)
  }
  window.addEventListener('keydown', onKey)
  return () => window.removeEventListener('keydown', onKey)
}, [lb])
```

Grid items that open the lightbox need `role="button"`, `tabIndex={0}` and an `onKeyDown` handling Enter and Space, plus a zoom affordance on hover.

---

## Section 5: Parallax Drift

Images move at slightly different speeds to suggest depth. Subtle wins: 5 to 8 percent of drift is enough.

```jsx
mm.add('(prefers-reduced-motion: no-preference)', () => {
  const imgs = gsap.utils.toArray(gridRef.current.querySelectorAll('img'))
  const tweens = imgs.map((img, i) => {
    const from = i % 2 === 0 ? -5 : 5
    return gsap.fromTo(img, { yPercent: from }, {
      yPercent: -from, ease: 'none',
      scrollTrigger: { trigger: img.closest('figure'), start: 'top bottom', end: 'bottom top', scrub: true },
    })
  })
  return () => {
    tweens.forEach((t) => { t.scrollTrigger?.kill(); t.kill() })
    ScrollTrigger.refresh()
  }
})
```

Give the `<figure>` `overflow: hidden` and scale the image slightly above 100 percent so the drift never exposes an edge.

---

## Section 6: Count-Up

For a rating or statistic. Animate a plain object and write the text on each update.

```jsx
const obj = { v: 0 }
gsap.to(obj, {
  v: 4.9, duration: 1.6, ease: 'power2.out',
  scrollTrigger: { trigger: el, start: 'top 85%', once: true },
  onUpdate: () => { el.textContent = obj.v.toFixed(1).replace('.', ',') },
})
```

Format for the locale at write time (the decimal comma above is pt-BR). Never animate a number the business cannot back up.

---

## Section 7: Persistent Elements

**Slim translucent nav** that gains a background once scrolled:

```jsx
const [solid, setSolid] = useState(false)
useEffect(() => {
  const onScroll = () => setSolid(window.scrollY > 40)
  window.addEventListener('scroll', onScroll, { passive: true })
  return () => window.removeEventListener('scroll', onScroll)
}, [])
```

**Scroll progress bar** that prefers the smooth scroller when present:

```jsx
useEffect(() => {
  const onScroll = () => {
    const h = document.documentElement.scrollHeight - innerHeight
    if (ref.current) ref.current.style.transform = `scaleX(${h > 0 ? scrollY / h : 0})`
  }
  const l = window.__lenis
  l ? l.on('scroll', onScroll) : window.addEventListener('scroll', onScroll, { passive: true })
  onScroll()
  return () => { l ? l.off('scroll', onScroll) : window.removeEventListener('scroll', onScroll) }
}, [])
```

Transform-based progress (`scaleX` with `transform-origin: 0 50%`) stays on the compositor. Animating `width` does not.

---

## Section 8: Accordion

Use native `<details>`. It provides toggle, keyboard support and accessibility for free.

```css
.faq__item summary { cursor: pointer; list-style: none; display: flex;
  justify-content: space-between; align-items: center; }
.faq__item summary::-webkit-details-marker { display: none; }
.faq__item .faq__icon { transition: transform .3s; }
.faq__item[open] .faq__icon { transform: rotate(45deg); }
```

Native `<details>` cannot animate its own height. If height animation is required, drive the inner content with GSAP rather than replacing the element with a custom widget.

---

## Section 9: Cross-Cutting Rules

These apply to every recipe above:

- **Pause the scroller on overlays.** `window.__lenis?.stop()` on open, `.start()` on close, or the background scrolls behind the overlay. Add `data-lenis-prevent` to the panel that scrolls inside it, or touch scrolling stays dead while the scroller is stopped.
- **Overlays live outside pinned sections.** A pin's `transform` breaks `position: fixed` inside it.
- **Anything clickable that is not a `<button>` or `<a>`** gets `role="button"`, `tabIndex={0}` and Enter/Space handling. Escape closes overlays. `:focus-visible` stays visible.
- **Every effect has a static fallback under reduced motion.** Scrub and parallax simply do not mount.
- **Every effect reverts on cleanup** via `gsap.context()` or `matchMedia()`, or hot reloads duplicate triggers.
