Sticky steps
Media stays put while the copy scrolls; each step swaps the shot. The product-page pattern, with --sv-scene doing the swapping.
- Step 1
Track the section
One data-sv-pin wrapper, one sticky child. The driver writes --sv-scene as you scroll.
- Step 2
Give each piece an index
Shots and steps carry --i. Distance to the scene is one max(). That is the crossfade.
- Step 3
Ship it
No observers per step, no timeline library. Three scenes here; make it thirty.
// 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 `>`) 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>
)
}