More Scroll Snippets
Sticky Scroll Features — Free HTML CSS JS Snippet
Sticky Scroll Features · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Sticky Scroll Features — Text Scrolls, the Media Panel Stays and Swaps

The sticky scroll feature section is the two-column layout on nearly every modern SaaS landing page: feature copy scrolls up the left column while a media panel on the right stays pinned in place, swapping its content each time a new step reaches the middle of the viewport. This snippet builds it with plain HTML, CSS position: sticky, and an IntersectionObserver — no animation library required.
position: sticky does the pinning, not JavaScript
The right column's .ssf-frame is position: sticky; top: 0; height: 100vh. Because its parent .ssf-media grid track is as tall as all four steps combined, the frame rides along fixed on screen for the entire section and releases naturally at both ends. That's the whole pinning mechanism — no scroll listeners, no transform math, and no layout jump when the section starts or ends, which is the classic bug in JS-pinned versions.
A middle-band IntersectionObserver picks the active step
Each text step is observed with rootMargin: '-45% 0px -45% 0px', which shrinks the observer's viewport to a thin 10%-tall band across the middle of the screen. A step only "intersects" while it occupies that band, so exactly one step is active at a time and the swap happens when a step's content is centered — where the reader is actually looking — rather than when its edge touches the viewport top.
Panels crossfade by class, animated purely in CSS
All four media panels are absolutely stacked inside the sticky frame. activate(i) toggles a single is-active class; CSS transitions handle the rest — inactive panels sit at opacity: 0; transform: scale(.9) translateY(26px), and the active one eases up to full size with a cubic-bezier(.22,1,.36,1) overshoot curve. Keeping the animation in CSS means the observer callback does no style math and the crossfade runs compositor-only.
Steps dim instead of disappearing
Inactive text steps stay visible at opacity: .28, so the reader always sees the sequence context — what came before, what's next — while the active step reads at full contrast. The dots under the media panel mirror the same index, stretching the active dot into a pill for a subtle progress affordance.
Each step is 88vh tall on purpose
Step height controls pacing: at ~88vh, each feature owns almost a full screen of scrolling, long enough to read the copy before the next swap fires. Shorten min-height for a snappier section or lengthen it for slower storytelling — the observer logic is height-agnostic.
Mobile collapses to a single column
Below 760px the media column is hidden and steps stack at full opacity, because a sticky side panel has no room on narrow screens. If you want media on mobile, an inline image per step beats a sticky frame there.
How the middle band actually computes
Negative rootMargin percentages shrink the observation rectangle relative to the viewport itself: -45% from the top and -45% from the bottom leaves a band spanning from 45% to 55% of the screen height. With threshold: 0, a step intersects the moment any pixel of it enters that band — so tall steps activate as soon as their leading edge reaches the center, and because two 88vh steps can't both occupy a 10%-tall band, the "exactly one active" guarantee falls out of the geometry rather than any bookkeeping code. That's also why no debouncing is needed on fast scrolls: the browser coalesces crossings into ordered callbacks.
Customizing it
Swap the gradient placeholder panels for screenshots, videos, or live components; add steps by duplicating a text block and a panel (the dots generate automatically). Pair it with a scroll pin steps section for a GSAP-pinned variant, a scroll reveal grid below, or a scroll spy nav for page-level navigation.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to reason through the middle-band geometry alone. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why rootMargin of "-45% 0px -45% 0px" guarantees exactly one step is ever active at a time, or why position: sticky on the media frame needs its parent grid column to be as tall as all four steps combined in order to pin and release correctly. The same assistant can help optimize it — asking whether the panel crossfade's cubic-bezier overshoot curve should be tuned differently for a longer feature list, or whether real video panels would need extra logic in activate() to pause the outgoing clip and play the incoming one. It's also useful for extending the effect: ask it to add a progress bar alongside the dots, support a fifth step without manually retuning anything, or swap the gradient placeholder panels for lazy-loaded screenshots that only decode once their step activates. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "sticky scroll features" two-column showcase in plain HTML, CSS, and JavaScript using only CSS position: sticky and the native IntersectionObserver API — no animation library, no scroll event listener.
Requirements:
- A two-column CSS grid: a left column of several feature step blocks stacked vertically, each roughly 85-90% of the viewport tall and carrying a data-panel index attribute; a right column containing a media frame using position: sticky with top: 0 and height: 100vh, itself containing several absolutely-stacked panel elements (one per step) plus a row of progress dots, all starting hidden except the first.
- The sticky frame's parent grid column must be exactly as tall as all the step blocks combined (which falls out naturally from the grid layout) so the sticky element pins for the whole section and releases cleanly at both the top and bottom boundaries with no manual height calculation.
- Write an activate(index) function that toggles an is-active class on the matching panel, the matching dot, and the matching step block, and removes it from all others — all animation must happen via CSS transitions on opacity and transform (scale plus translateY) triggered purely by that class toggle, not through JavaScript-driven style animation.
- Create a single IntersectionObserver watching every step block, using a rootMargin with large negative top and bottom percentages (e.g. -45% on each side) so its effective observation area is a thin band across the vertical middle of the viewport, and threshold 0.
- In the observer's callback, call activate with the intersecting step's data-panel value converted to a number.
- Generate the progress dots dynamically in JavaScript by looping over the panel elements (not hardcoded in markup), and make inactive step blocks stay partially visible (dimmed via reduced opacity) rather than fully hidden, so the reader retains context of the whole sequence.
- Add a CSS media query that collapses to a single column and hides the sticky media entirely below a reasonable breakpoint (e.g. 760px), restoring full opacity to all steps in that mode.Step by step
How to Use
- 1Paste HTML, CSS, and JSNo CDN needed — sticky positioning and an observer do everything.
- 2Scroll into the sectionThe media panel pins while text steps scroll past.
- 3Watch the swap pointPanels change when a step reaches mid-viewport.
- 4Check the dotsThe active dot stretches into a pill per step.
- 5Resize under 760pxThe layout collapses to a clean single column.
- 6Swap in real mediaReplace gradient panels with screenshots or video.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The frame is position: sticky with top: 0 and height: 100vh inside a grid column as tall as all four steps combined. Sticky elements ride along with the viewport within their parent's bounds, so the browser pins and releases the panel natively — no scroll listeners, no transforms, and no jump at the section boundaries.
An IntersectionObserver watches every step with rootMargin: '-45% 0px -45% 0px', which shrinks the detection area to a thin band across the middle of the viewport. A step only intersects while it occupies that band, so exactly one is active at a time and swaps fire when content is centered under the reader's eyes.
The observer callback only toggles is-active; CSS transitions on opacity and transform do the visual work with a cubic-bezier(.22,1,.36,1) overshoot. That keeps the crossfade compositor-only and means you can retune timing, easing, or the entrance direction entirely in the stylesheet without touching the logic.
Duplicate one .ssf-step block (bump its data-panel index) and one .ssf-panel with new --pc1/--pc2 gradient stops — the dots are generated from the panel count so they update automatically. Step min-height controls pacing: shorten it for a snappier section, lengthen it to give each feature more reading time.
Yes — the panels are just absolutely stacked divs, so anything renders inside them. For videos, use the activate() hook to also play the incoming panel's video and pause the outgoing one, keeping only one decoding at a time; muted + playsinline lets them autoplay. Live components (charts, mini-demos) work the same way, though heavy ones should defer initialization until their first activation to keep initial load light.
Keep the sticky CSS as-is (Tailwind: sticky top-0 h-screen) and move the observer into a mount effect — useEffect, onMounted, or ngAfterViewInit — storing the active index in state instead of toggling classes manually. Render panels and dots from a features array, and disconnect the observer in the cleanup so it doesn't fire after unmount.