ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

Sticky steps

Media stays put while the copy scrolls; each step swaps the shot. The product-page pattern, with --sv-scene doing the swapping.

Use it for: Product features, "how it works", case-study walkthroughs, onboarding explainers.
Knobs: data-sv-scenes (step count), wrapper height (scroll per step), --i on each shot/step, the crossfade math (see CSS)

01
02
03
  1. Step 1

    Track the section

    One data-sv-pin wrapper, one sticky child. The driver writes --sv-scene as you scroll.

  2. Step 2

    Give each piece an index

    Shots and steps carry --i. Distance to the scene is one max(). That is the crossfade.

  3. Step 3

    Ship it

    No observers per step, no timeline library. Three scenes here; make it thirty.

Install: npx scrollvars add sticky-steps
Styles: import 'scrollvars/styles/pin.css'

// ScrollVars fx · sticky-steps
// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css' (sv-stage)
// Media stays put while the copy scrolls; each step swaps the shot. --sv-scene does the
// swapping, the crossfade is one max() per element. Without JS the shots stack in flow.
// The CSS below is one constant string injected as raw HTML: React 18's server
// renderer escapes a <style> child (`>` becomes `&gt;`) and <style> is raw text, so
// the entity never decodes. Here that dropped the whole --st-d rule (its selector
// list carries a child combinator) and every shot stayed at full opacity. CSP is
// unchanged from any inline <style>: a style-src nonce or hash, or lift the string
// into your own stylesheet.
'use client'
import * as React from 'react'
import { onMotionChange, prefersReducedMotion, refresh as refreshFit } from 'scrollvars'
import { useScenes } from 'scrollvars/react'

const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect

const css = `
.sv-steps .st-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); align-items: center; gap: clamp(24px, 5vw, 64px); padding: 32px clamp(20px, 5vw, 64px); box-sizing: border-box; min-height: 100%; }
.sv-steps .st-media { position: relative; aspect-ratio: 4 / 3; border-radius: 18px; overflow: hidden; display: grid; }
.sv-steps .st-shot { margin: 0; display: grid; place-items: center; }
.sv-steps .st-shot > * { width: 100%; height: 100%; object-fit: cover; }
.sv-steps .st-shot, .sv-steps .st-steps > li, .sv-steps .st-dots i {
  --st-d: min(1, max(calc(var(--sv-scene, 0) - var(--i)), calc(var(--i) - var(--sv-scene, 0)))); }
.sv-on .sv-steps:where(.st-ready, .st-measuring) .st-shot { position: absolute; inset: 0; }
.sv-on .sv-steps:where(.st-ready) .st-shot { opacity: calc(1 - var(--st-d)); scale: calc(1.06 - var(--st-d) * .06); }
.sv-steps:not(.st-ready):not(.st-measuring) .st-media { overflow: visible; }
.sv-steps:not(.sv) .sv-stage, .sv-steps.st-static .sv-stage { position: static; height: auto; overflow: visible; }
.sv-steps.st-static { height: auto !important; }
.sv-steps.st-static .st-grid { min-height: 0; }
.sv-steps.st-static .st-media { aspect-ratio: auto; gap: 8px; }
@media (prefers-reduced-motion: reduce) { .sv-on .sv-steps .st-shot { position: static; opacity: 1; scale: none; } .sv-steps .st-media { gap: 8px; aspect-ratio: auto; } }
/* the same under html[data-sv-motion="reduce"], the site's own switch */
.sv-on:where([data-sv-motion="reduce"]) .sv-steps .st-shot { position: static; opacity: 1; scale: none; }
:where([data-sv-motion="reduce"]) .sv-steps .st-media { gap: 8px; aspect-ratio: auto; }
.sv-steps .st-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: clamp(20px, 5vh, 44px); }
.sv-steps .st-steps > li { opacity: calc(.65 + .35 * (1 - var(--st-d))); translate: calc(var(--st-d) * -8px) 0; } /* the floor, with the labels at .8, keeps an inactive step's text at 4.5:1 (axe) */
html:not(.sv-on) .sv-steps .st-steps > li { opacity: 1; translate: none; }
.sv-steps .st-steps b { display: block; font-size: 12px; letter-spacing: .12em; text-transform: uppercase; opacity: .8; margin-bottom: 6px; }
.sv-steps .st-steps h3 { margin: 0 0 6px; font-size: clamp(20px, 2.6vw, 28px); }
.sv-steps .st-steps p { margin: 0; max-width: 36ch; opacity: .8; }
.sv-steps .st-dots { position: absolute; left: 50%; bottom: 18px; translate: -50% 0; display: flex; gap: 8px; }
.sv-steps .st-dots i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: calc(1 - var(--st-d) * .7); scale: calc(1.6 - var(--st-d) * .6); }
.sv-steps.st-static .st-steps > li { opacity: 1; translate: none; }
/* Placed after the rules above (same specificity, later wins): the stage is
   unpinned under reduced motion, so --sv-scene keeps writing but every
   non-active step would otherwise sit at 30% opacity forever and the copy
   would slide with the raw scroll. Reset both the steps and the dots. */
@media (prefers-reduced-motion: reduce) { .sv-steps .st-steps > li { opacity: 1; translate: none; } .sv-steps .st-dots i { opacity: 1; scale: none; } }
/* the same under html[data-sv-motion="reduce"], the site's own switch */
:where([data-sv-motion="reduce"]) .sv-steps .st-steps > li { opacity: 1; translate: none; }
:where([data-sv-motion="reduce"]) .sv-steps .st-dots i { opacity: 1; scale: none; }
@supports not (translate: 0) {
  .sv-on .sv-steps .st-shot { position: static; opacity: 1; scale: none; }
  .sv-steps .st-media { aspect-ratio: auto; gap: 8px; }
}
.sv-on [data-sv-off].sv-steps .st-shot { position: static; opacity: 1; scale: none; }
[data-sv-off].sv-steps .st-media { aspect-ratio: auto; gap: 8px; }
[data-sv-flow].sv-steps .st-grid { min-height: 0; }
.sv-on [data-sv-flow].sv-steps .st-shot { position: static; opacity: 1; scale: none; }
[data-sv-flow].sv-steps .st-media { aspect-ratio: auto; gap: 8px; }
[data-sv-flow].sv-steps .st-steps > li { opacity: 1; translate: none; }
[data-sv-flow].sv-steps .st-dots { display: none; }
.sv-steps.st-static .st-dots { display: none; }
@media (max-width: 640px) { .sv-steps .st-grid { grid-template-columns: 1fr; align-content: center; gap: 18px; } }
`

export interface StickyStep {
  title: React.ReactNode
  text?: React.ReactNode
  /** The shot for this step: an <img>, <video>, or any node. */
  media: React.ReactNode
  label?: string
}

// React 19 knows inert as a boolean attribute (a string would be dropped as falsy); React 18
// does not know it and drops booleans, so it gets the empty string instead. Both render inert="".
const INERT = (React.version.startsWith('18') ? { inert: '' } : { inert: true }) as unknown as Record<string, never>

// Without native inert (Firefox 78-111, Safari 14.1-15.4, inside the README floor)
// the attribute is inert in name only: an inactive shot's links stay tabbable while
// the shot itself is visually hidden. Crossfading never turns on there; every shot
// stays static and reachable instead.
const inertSupported = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype

export function StickySteps({ steps, className, nonce }: { steps: StickyStep[]; className?: string; nonce?: string }) {
  // the active index (integer changes only) makes the inactive shots inert, so a
  // crossfaded shot cannot keep focusable links; applied after mount so the
  // server markup stays fully usable without JS
  const status = React.useRef<import('scrollvars').AttachmentStatus>('attaching')
  const flow = React.useRef(true)
  const sync = React.useRef<(() => void) | undefined>(undefined)
  const { ref, scene } = useScenes<HTMLDivElement>(steps.length, {
    // an empty list (a CMS editor removed every step) pins nothing: the stage
    // stays in flow at its natural height instead of a 0vh wrapper
    pin: steps.length ? steps.length * 100 + 'vh' : undefined,
    onStatus: value => { status.current = value; sync.current?.() },
    onFlow: value => { flow.current = value; sync.current?.() },
  })
  const currentScene = React.useRef(scene)
  currentScene.current = scene
  // after mount only (server markup stays fully usable), and never under reduced
  // motion, where the shots stack in flow and must all stay reachable. Live:
  // a switch mid-session drops or restores inert/aria-hidden immediately.
  const [interactive, setInteractive] = React.useState(false)
  useLayoutEffect(() => {
    const el = ref.current
    if (!el) return
    // Read the actual layout: failed boot, the watchdog and missing CSS all
    // leave static shots reachable. Only the crossfade hides inactive shots.
    const update = () => {
      const shot = el.querySelector('.st-shot')
      const stage = el.querySelector('.sv-stage')
      // Stack before the driver's first fit read, without hiding shots from AT.
      const empty = !shot
      const measuring = !empty && status.current === 'attaching' && !el.hasAttribute('data-sv-flow') && !prefersReducedMotion()
      let ready = !empty && status.current === 'active' && !flow.current && !prefersReducedMotion() && inertSupported
      if (ready || measuring) el.classList.remove('st-static')
      el.classList.toggle('st-measuring', measuring)
      el.classList.toggle('st-ready', ready)
      ready = ready && !!stage && getComputedStyle(stage).position === 'sticky' &&
        !!shot && getComputedStyle(shot).position === 'absolute'
      const fit = el.querySelector<HTMLElement>('[data-sv-fit]')
      // Keep the candidate's stage constrained until the driver reads overflow.
      // Expanding it here would swallow the fit latch on content replacement.
      const pendingFit = ready && !!fit && Math.max(fit.offsetHeight, fit.scrollHeight) > stage!.clientHeight + 1
      ready = ready && !!fit && Math.max(fit.offsetHeight, fit.scrollHeight) <= stage!.clientHeight + 1
      el.classList.toggle('st-measuring', measuring || pendingFit)
      el.classList.toggle('st-ready', ready)
      el.classList.toggle('st-static', !ready && !pendingFit && (empty || status.current !== 'attaching'))
      el.querySelectorAll('.st-shot').forEach((shot, i) => {
        // Set both directions even when React batches reduce then auto into
        // one render whose interactive state has not changed.
        if (ready && i !== currentScene.current) {
          shot.setAttribute('inert', '')
          shot.setAttribute('aria-hidden', 'true')
        } else {
          shot.removeAttribute('inert')
          shot.removeAttribute('aria-hidden')
        }
      })
      setInteractive(ready)
      // Candidate probing writes classes too. Do not observe our own writes
      // again, especially when missing CSS rejects that candidate.
      observer?.takeRecords()
      if (pendingFit) refreshFit()
    }
    let observer: MutationObserver | undefined
    let stopMotion: (() => void) | undefined
    let stopped = false
    const stop = () => {
      if (stopped) return
      stopped = true
      sync.current = undefined
      const attempt = (fn: () => void) => { try { fn() } catch { /* continue settling this controller */ } }
      attempt(() => observer?.disconnect())
      attempt(() => stopMotion?.())
      attempt(() => el.classList.remove('st-measuring'))
      attempt(() => el.classList.remove('st-ready'))
      attempt(() => el.classList.add('st-static'))
      el.querySelectorAll('.st-shot').forEach(shot => {
        attempt(() => shot.removeAttribute('inert'))
        attempt(() => shot.removeAttribute('aria-hidden'))
      })
      setInteractive(false)
    }
    const fail = (error: unknown) => {
      if (stopped) return
      stop()
      try { console.error('[scrollvars] StickySteps enhancement failed', error) } catch { /* reporting is best effort */ }
    }
    const refresh = () => {
      if (stopped) return
      try { update() } catch (error) { fail(error) }
    }
    sync.current = refresh
    try {
      observer = new MutationObserver(refresh)
      observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
      observer.observe(el, { attributes: true, attributeFilter: ['class', 'data-sv-flow'] })
      stopMotion = onMotionChange(refresh)
      if (stopped) { stopMotion(); return stop }
      refresh()
      // The first fit must run next frame, including for an offscreen section.
      if (!stopped && status.current === 'attaching') refreshFit()
    } catch (error) { fail(error) }
    return stop
  }, [ref])
  useLayoutEffect(() => { sync.current?.() }, [scene, steps])
  return (
    <div ref={ref} className={className ? 'sv-steps ' + className : 'sv-steps'}>
      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
      <div className="sv-stage"><div data-sv-fit="" className="st-grid">
        <div className="st-media">
          {steps.map((s, i) => (
            <figure
              key={i}
              className="st-shot"
              style={{ '--i': i } as React.CSSProperties}
              {...(interactive && i !== scene ? INERT : {})}
              aria-hidden={interactive && i !== scene ? true : undefined}
            >
              {s.media}
            </figure>
          ))}
        </div>
        <ol className="st-steps">
          {steps.map((s, i) => (
            <li key={i} style={{ '--i': i } as React.CSSProperties}>
              <b>{s.label ?? 'Step ' + (i + 1)}</b>
              <h3>{s.title}</h3>
              {s.text && <p>{s.text}</p>}
            </li>
          ))}
        </ol>
        <div className="st-dots" aria-hidden="true">
          {steps.map((_, i) => (
            <i key={i} style={{ '--i': i } as React.CSSProperties} />
          ))}
        </div>
      </div></div>
    </div>
  )
}

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.