Anatomy of Sticky Scroll
How one IntersectionObserver line and one active index drive a dimming list on the left and a crossfading sticky panel on the right, with no scroll math or per-item listeners.
↓Scroll to step through it
One active index, two columns
StickyScroll never reads a scroll offset. It watches for one thing,
which item's element is crossing the container's vertical center, and
every visual response on both sides of the layout follows from that
single active index.
The left column is a plain scrolling list; the right column is a
position: sticky panel that always renders items[active].content.
Neither column knows about the other. They only share the index.
The trigger
The detection is one IntersectionObserver, scoped to the component's own
scroll container, with a rootMargin that collapses the entire viewport
down to a single horizontal line at its center.
-50% off both the top and bottom of the root leaves only a
zero-height strip in the middle. An item reports "intersecting" only
at the instant it crosses that strip, so active always tracks
whichever section is nearest the panel's vertical center.
Once active changes, the left column and the right panel both react to
it with the same tuning, through different Motion primitives. The list
dims or brightens in place, while the panel replaces its content outright:
// left column — same element stays mounted, values animate
<motion.span animate={{ scaleY: isActive ? 1 : 0.4, opacity: isActive ? 1 : 0.3 }} transition={SPRING} />
// right panel — content is keyed by `active`, so it mounts/unmounts
<AnimatePresence mode="wait">
<motion.div key={active} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} transition={SPRING} />
</AnimatePresence>Both read the same SPRING constant (stiffness: 320, damping: 32, mass: 0.9), so even though one is a value animation and the other is an
enter/exit swap, they resolve on the same beat.
Mobile falls back to inline: below the md breakpoint the sticky panel is
hidden, since a narrow screen has no room for a pinned column next to a
scrolling one. Each list item instead renders its own content inline,
directly beneath its copy, so nothing is lost. It is laid out
top-to-bottom instead of side-by-side.
The result
One observer line and one active index drive two independent renderers that read the same spring. Scroll inside the panel above to try it.
- List
- scrolls; each item dims when it isn't active
- Center line
- rootMargin -50%/-50%: the one trigger line
- Panel
- sticky, crossfades to match the active item
<div className="grid gap-x-10 md:grid-cols-2"><div>{items.map((item, i) => <Section key={item.title} isActive={i === active} {...item} />)}</div><div className="hidden md:block"> <div className="sticky top-0 h-[30rem]">{items[active]?.content}</div></div></div>Anatomy of Sticky Scroll
How one IntersectionObserver line and one active index drive a dimming list on the left and a crossfading sticky panel on the right, with no scroll math or per-item listeners.
- List
- scrolls; each item dims when it isn't active
- Center line
- rootMargin -50%/-50%: the one trigger line
- Panel
- sticky, crossfades to match the active item
<div className="grid gap-x-10 md:grid-cols-2"><div>{items.map((item, i) => <Section key={item.title} isActive={i === active} {...item} />)}</div><div className="hidden md:block"> <div className="sticky top-0 h-[30rem]">{items[active]?.content}</div></div></div>One active index, two columns
StickyScroll never reads a scroll offset. It watches for one thing,
which item's element is crossing the container's vertical center, and
every visual response on both sides of the layout follows from that
single active index.
The left column is a plain scrolling list; the right column is a
position: sticky panel that always renders items[active].content.
Neither column knows about the other. They only share the index.
rootMargin: "-50% 0px -50% 0px"
- Idle
- not intersecting: index unchanged
- Crossing
- intersecting: setActive(index) fires
const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { const index = itemRefs.current.indexOf(entry.target); if (index !== -1) setActive(index); } }},{ root: containerRef.current, rootMargin: "-50% 0px -50% 0px", threshold: 0 },);The trigger
The detection is one IntersectionObserver, scoped to the component's own
scroll container, with a rootMargin that collapses the entire viewport
down to a single horizontal line at its center.
-50% off both the top and bottom of the root leaves only a
zero-height strip in the middle. An item reports "intersecting" only
at the instant it crosses that strip, so active always tracks
whichever section is nearest the panel's vertical center.
Once active changes, the left column and the right panel both react to
it with the same tuning, through different Motion primitives. The list
dims or brightens in place, while the panel replaces its content outright:
// left column — same element stays mounted, values animate
<motion.span animate={{ scaleY: isActive ? 1 : 0.4, opacity: isActive ? 1 : 0.3 }} transition={SPRING} />
// right panel — content is keyed by `active`, so it mounts/unmounts
<AnimatePresence mode="wait">
<motion.div key={active} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} transition={SPRING} />
</AnimatePresence>Both read the same SPRING constant (stiffness: 320, damping: 32, mass: 0.9), so even though one is a value animation and the other is an
enter/exit swap, they resolve on the same beat.
Mobile falls back to inline: below the md breakpoint the sticky panel is
hidden, since a narrow screen has no room for a pinned column next to a
scrolling one. Each list item instead renders its own content inline,
directly beneath its copy, so nothing is lost. It is laid out
top-to-bottom instead of side-by-side.
The result
One observer line and one active index drive two independent renderers that read the same spring. Scroll inside the panel above to try it.
Accessibility
Under prefers-reduced-motion, every transition becomes { duration: 0 }. The dimming and the crossfade both still happen but resolve
instantly instead of easing, so the state change stays legible without
any motion.
Motion Score
scaleScale spring / pressopacityFade / cross-fade