GodUIGodUI
129Follow on X

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.

01/03One active index, two columns
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
tsx
<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>
01

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.

02/03The trigger

rootMargin: "-50% 0px -50% 0px"

Idle
not intersecting: index unchanged
Crossing
intersecting: setActive(index) fires
tsx
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 },);
02

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:

tsx
 
// 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.

03/03Result

Collaborate in real time

Cursors, comments, and presence keep the whole team on the same page.
Collaborate

Ship with confidence

Preview every change and roll out when it feels right.
Ship

Scale calmly

Infrastructure that grows with you, never against you.
Scale
03

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

Sticky ScrollSS: Compositor-only
SscaleScale spring / press
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →