Source Code

<section class="ssf-hero">
  <div class="ssf-visual" id="ssfVisual">
    <div class="ssf-frame">
      <div class="ssf-slide ssf-slide-active" data-slide="0">
        <div class="ssf-mock-bar"></div>
        <div class="ssf-mock-line" style="width:70%"></div>
        <div class="ssf-mock-line" style="width:45%"></div>
        <div class="ssf-mock-chip"></div>
      </div>
      <div class="ssf-slide" data-slide="1">
        <div class="ssf-mock-grid">
          <span></span><span></span><span></span><span></span>
        </div>
      </div>
      <div class="ssf-slide" data-slide="2">
        <div class="ssf-mock-chart">
          <i style="height:40%"></i><i style="height:72%"></i><i style="height:58%"></i><i style="height:88%"></i><i style="height:64%"></i>
        </div>
      </div>
      <div class="ssf-slide" data-slide="3">
        <div class="ssf-mock-lock">
          <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
        </div>
      </div>
    </div>
  </div>

  <div class="ssf-copy">
    <span class="ssf-eyebrow">Why teams switch</span>
    <h1 class="ssf-h1">One workspace,<br>every reason to stay</h1>

    <ul class="ssf-features" id="ssfFeatures">
      <li class="ssf-feature ssf-feature-active" data-slide="0">
        <span class="ssf-num">01</span>
        <div><h3>Unified workspace</h3><p>Docs, tasks, and chat live in one place instead of five disconnected tabs.</p></div>
      </li>
      <li class="ssf-feature" data-slide="1">
        <span class="ssf-num">02</span>
        <div><h3>Flexible layouts</h3><p>Switch between grid, list, and board views without losing your filters.</p></div>
      </li>
      <li class="ssf-feature" data-slide="2">
        <span class="ssf-num">03</span>
        <div><h3>Real-time analytics</h3><p>Every dashboard updates live as your team works — no manual refresh.</p></div>
      </li>
      <li class="ssf-feature" data-slide="3">
        <span class="ssf-num">04</span>
        <div><h3>Enterprise-grade security</h3><p>SSO, audit logs, and field-level permissions built in from day one.</p></div>
      </li>
    </ul>
  </div>
</section>

Split Hero with Scroll-Highlighted Feature List — Free HTML CSS JS Snippet

Split Hero with Scroll-Highlighted Feature List · Heroes · Plain HTML, CSS & JS · Live preview

What's included

Features

Sticky visual panel pinned with plain CSS position: sticky, no scroll-tied JS positioning
IntersectionObserver drives activation — no scroll event listener or per-scroll math
rootMargin trims the observed viewport to a centered band for natural activation timing
Single activateSlide() function keeps the visual and the text list perfectly in sync
data-slide attribute pairing — add a feature/slide pair without touching index logic
Pure CSS opacity/transform crossfade between visual slides
Responsive fallback: sticky panel becomes a static block above the list on narrow screens
No animation library or scroll-hijacking dependency
Export as HTML file, React JSX, or React + Tailwind CSS
Mobile (375px), Tablet (768px), Desktop device preview buttons

About this UI Snippet

Scrollytelling Split Hero — Sticky Visual Synced to Feature List via IntersectionObserver

Screenshot of the Split Hero with Scroll-Highlighted Feature List snippet rendered live

A plain feature list explains what a product does; a plain product screenshot shows what it looks like. This snippet does both at once and connects them: a sticky visual panel stays pinned on one side while a scrolling list of feature descriptions passes on the other, and each feature "activates" its own matching visual slide exactly when it crosses the vertical center of the screen — the classic scrollytelling technique, built with no scroll-tied JavaScript math.

`position: sticky`, not a JS-calculated pin

The .ssf-visual panel uses plain CSS position: sticky; top: 64px — it's pinned by the browser's own layout engine for as long as its parent .ssf-hero grid column is taller than the viewport, then unpins naturally once the feature list runs out. No scroll-position math, no transform recalculated on every scroll event.

`IntersectionObserver` instead of scroll-position math

Rather than listening to scroll and computing which feature is "current" by comparing getBoundingClientRect() coordinates on every single event (expensive, and easy to get subtly wrong), each .ssf-feature list item is registered with a single IntersectionObserver. The browser itself notifies the callback only when a feature's intersection state actually changes — dramatically cheaper than a scroll listener, and correct by construction.

Why `rootMargin` is set to `'-45% 0px -45% 0px'`

A default IntersectionObserver reports an element as intersecting as soon as any part of it enters the viewport — which would trigger activation the moment a feature merely scrolls into view at the bottom, well before a reader has actually reached it. Setting rootMargin to -45% on the top and bottom shrinks the *effective* observed viewport down to a thin 10%-tall band centered vertically — so a feature only reports as intersecting once it scrolls through roughly the middle of the screen, which is where a reader's attention naturally sits.

Slide and feature state share one function

activateSlide(index) toggles .ssf-slide-active on the matching visual slide *and* .ssf-feature-active on the matching list item in the same call, driven off each element's own data-slide attribute rather than an array index — so the visual and the text list can never fall out of sync with each other, and adding a fifth feature is just adding a fifth data-slide="4" pair.

CSS-only slide crossfade

Each .ssf-slide is absolutely positioned within the same frame with opacity/transform: scale transitions; only the currently active one has opacity: 1. This means the crossfade between visuals is a pure CSS transition triggered by a class toggle, not a JavaScript-driven animation loop.

Customizing it

Add a fifth feature/slide pair by giving both a new matching data-slide value — the observer and activateSlide() both work generically off that attribute, no index-counting logic to update. Adjust how "centered" a feature needs to be before activating by changing the rootMargin percentages — a smaller magnitude activates earlier, a larger one requires the feature to be closer to dead-center. On narrow viewports the sticky visual switches to a normal static block above a full-width feature list, since scrollytelling reads better as a full-width story on mobile.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Rather than reasoning through the rootMargin math alone, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why rootMargin is set to "-45% 0px -45% 0px" on the IntersectionObserver, what viewport band that leaves for detecting "the currently active feature," and why that's more reliable than computing scroll offsets by hand on a scroll event listener. The same assistant can help you extend it — ask it to add a scroll-linked progress indicator alongside the feature list showing how far through the story the reader is, animate the visual slide transition with a slide/wipe effect instead of a plain crossfade, or convert the vanilla IntersectionObserver logic into a React hook that tracks the active index in state. It's also useful for auditing the responsive fallback: ask whether the mobile layout (where the sticky panel becomes static) should instead keep a smaller sticky preview, or whether a horizontal scroll-snap carousel would read better on touch devices than a stacked column. 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:

text
Build a split-screen hero section in plain HTML, CSS, and vanilla JavaScript that implements scrollytelling — a sticky visual panel on one side that swaps between several "slides" as the reader scrolls past matching feature descriptions on the other side — using only IntersectionObserver, no scroll-hijacking library.

Requirements:
- A two-column layout: a visual panel on one side containing several absolutely-positioned "slide" elements stacked on top of each other (each a distinct mock UI illustration built from plain divs/CSS, no images), and a vertical list of feature descriptions on the other side, each with a heading and a short paragraph.
- The visual panel must be pinned in place using CSS position: sticky (not JavaScript scroll-position calculations) so it stays visible while the feature list scrolls past it, for as long as the feature list column is taller than the viewport.
- Each feature list item and its matching visual slide must share an identifying data attribute (e.g. data-slide="0"). Use a single IntersectionObserver watching all the feature list items, configured with a rootMargin that shrinks its effective viewport to a thin band centered vertically on the screen, so a feature is only considered "active" once it scrolls through roughly the middle of the viewport rather than the instant it appears at the bottom edge.
- When a feature becomes the active one (per the observer callback), toggle an active class on both that feature's list item and its matching visual slide using their shared data attribute — through one shared function, not duplicated logic — so the visual and text list can never fall out of sync with each other.
- The visual slide crossfade must be a pure CSS opacity/transform transition triggered by the class toggle, not a JavaScript animation loop.
- On narrow viewports, the layout must collapse to a single column with the visual panel becoming a normal static block instead of staying sticky, since a pinned side panel does not translate well to mobile.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Scroll down the feature listThe sticky visual panel on the left stays pinned while you scroll past each feature on the right.
  2. 2
    Watch the active feature highlightWhichever feature is nearest the vertical center gets a colored left border and full opacity.
  3. 3
    Watch the visual crossfadeThe matching slide fades in on the sticky panel exactly as its feature becomes active.
  4. 4
    Add a fifth featureAdd a new .ssf-feature list item and a matching .ssf-slide, both sharing the same data-slide value.
  5. 5
    Adjust the activation zoneChange the rootMargin percentages in the JS panel to make features activate earlier or later.
  6. 6
    Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.

Real-world uses

Common Use Cases

SaaS product landing pages
Walk a visitor through 3-5 core features with a synced visual instead of a flat bullet list.
Feature announcement and changelog pages
Pair with scroll sticky features for a longer-form version further down the page.
Learn IntersectionObserver-based scrollytelling
Study how rootMargin shrinks the effective viewport instead of computing scroll offsets manually.
Agency and portfolio case study pages
Reuse the sticky-visual-plus-synced-text pattern to narrate a project's key decisions.
Platform and API documentation intros
Introduce core capabilities with a live-updating diagram before linking into full docs.
Related: Feature Tabs Showcase
See the Feature Tabs Showcase for a click-driven alternative to this scroll-driven pattern.

Got questions?

Frequently Asked Questions

No — it uses plain CSS position: sticky with a top offset. The browser's own layout engine keeps it pinned within its parent grid column for as long as that column is taller than the viewport, and unpins it naturally once the feature list scrolls past. No scroll listener repositions it.

A scroll listener would need to run expensive getBoundingClientRect() calculations on every single scroll event (which can fire dozens of times per second) to figure out which feature is currently centered. IntersectionObserver instead asks the browser to notify a callback only when an element's intersection state actually changes, which is both cheaper and less error-prone than manual scroll-position math.

rootMargin: "-45% 0px -45% 0px" shrinks the observer's effective viewport by 45% from both the top and bottom, leaving only a thin 10%-tall band centered vertically. A feature is only reported as "intersecting" once it scrolls into that centered band — not simply whenever any part of it becomes visible at the edge of the screen — which is what makes the activation timing feel natural rather than premature.

Both the .ssf-slide elements and the .ssf-feature list items carry a matching data-slide attribute. A single activateSlide(index) function toggles the active class on whichever slide and whichever feature share that exact data-slide value, so there is only one place that could ever get the pairing wrong, and no separate index counters to keep aligned.

Add a new li.ssf-feature with data-slide="4" to the feature list, and a new div.ssf-slide with the same data-slide="4" to the visual frame. No JavaScript changes are needed — the observer is attached generically to every .ssf-feature element, and activateSlide() matches purely on the shared attribute value.

Below 820px, the CSS switches .ssf-hero to a single column and the visual panel from position: sticky to a static, fixed-height block sitting above the feature list — since scrollytelling with a pinned side panel does not translate well to narrow viewports, the layout instead reads as a normal top-to-bottom feature list with a preview image on top.