Sticky Scroll example

How it works

A section that pins while its items take turns: the media cuts over, the copy fades, and a timeline on the side fills as you scroll.

StickyScroll pins a two-column section and walks through its items with the scroll. On the left, every item (a media and a block of copy) sits in the same grid cell, and only one is visible at a time. On the right, a passive timeline (c--nav-c) shows where you are: a dot per item and a connector between them that fills with the scroll. It is built on GSAP ScrollTrigger, taken from the Manager like every other library in the framework, and everything lives in src/js/handler/sticky-scroll: no package, the class is local.

The swap is not a crossfade that follows the scroll pixel by pixel. The scroll progress is turned into a position in item indexes (0 is the first item, 1 the second), and the visible item only changes when that position crosses the halfway point between two indexes. Between swaps the copy holds still, which is what keeps it readable; only the connector moves, doubling as a progress bar towards the next swap.

The markup

A section with the js--sticky-scroll hook and the options as data-* attributes. Inside, the wrapper (js--sticky-scroll-wrapper) is what gets pinned; the items (js--sticky-scroll-item) each carry a media (js--sticky-scroll-media) and a copy (js--sticky-scroll-content); and the nav items (js--sticky-scroll-nav-item) must be as many as the items and in the same order, since both are matched by index.

<!-- The section carries the hook and the options. The wrapper is what gets pinned -->
<section class="c--timeline-a js--sticky-scroll" data-breakpoint="810" data-scroll-per-item="1" data-shift="10" data-duration="0.35">
  <div class="f--container">
    <div class="c--timeline-a__wrapper js--sticky-scroll-wrapper">

      <!-- Left: one item per step, each with a media and a copy -->
      <div class="c--timeline-a__wrapper__item-left">
        <article class="c--timeline-a__wrapper__item-left__item js--sticky-scroll-item">
          <img class="c--timeline-a__wrapper__item-left__item__media js--sticky-scroll-media" src="…" alt="…">
          <div class="c--timeline-a__wrapper__item-left__item__content js--sticky-scroll-content">
            <h2 class="c--timeline-a__wrapper__item-left__item__content__title">Send money in minutes</h2>
            <p class="c--timeline-a__wrapper__item-left__item__content__subtitle">…</p>
          </div>
        </article>
        …
      </div>

      <!-- Right: the passive timeline, one nav item per step, in the same order -->
      <div class="c--timeline-a__wrapper__item-right">
        <nav class="c--nav-c" aria-label="Timeline progress">
          <ul class="c--nav-c__list-group">
            <li class="c--nav-c__list-group__list-item js--sticky-scroll-nav-item">
              <span class="c--nav-c__list-group__list-item__wrapper" aria-hidden="true">
                <span class="c--nav-c__list-group__list-item__icon"></span>
                <span class="c--nav-c__list-group__list-item__dash"></span>
              </span>
              <span class="c--nav-c__list-group__list-item__title">Send money</span>
            </li>
            …
          </ul>
        </nav>
      </div>

    </div>
  </div>
</section>

In the project the markup comes from TimelineA.astro, which renders c--timeline-a with a NavC inside and accepts the four options as props:

<!-- TimelineA renders the markup above. The options are props; leave one out and the handler uses its default -->
<TimelineA payload={{
  navLabel: 'Timeline progress',
  items: [
    { label: 'Send money', title: 'Send money in minutes', subtitle: '…', imgSrc: '…', imgAlt: 'Send money' },
    { label: 'Track transfers', title: 'Track every transfer', subtitle: '…', imgSrc: '…', imgAlt: 'Track transfers' },
    …
  ],
}} />

The handler

One selector, one config. Handler.js reads the four attributes from the section and falls back to the defaults when one is missing or not a number:

this.config = ({ element }) => {
  const { breakpoint, scrollPerItem, shift, duration } = element.dataset;

  return {
    element,
    Manager: this.Manager,
    terraDebug: this.terraDebug,
    breakpoint: Number(breakpoint) || 810,
    scrollPerItem: Number(scrollPerItem) || 1,
    shift: Number(shift) || 10,
    duration: Number(duration) || 0.35,
  };
};

The class

StickyScroll.js creates the pin inside a gsap.matchMedia context that only matches above the breakpoint and without prefers-reduced-motion. The wrapper is pinned for (items - 1) × scrollPerItem viewports, so the scroll distance grows with the number of items, and the end is a function so a resize recomputes it. Both onUpdate and onRefresh report the progress multiplied by the number of steps, which is the position in item indexes described above:

this.scrollTrigger = this.ScrollTrigger.create({
  id: `StickyScroll-${this.DOM.items.length}`,
  trigger: this.DOM.element,
  start: "top top",
  // End is a function so a resize recalculates it on refresh.
  end: () => `+=${window.innerHeight * steps * this.scrollPerItem}`,
  pin: this.DOM.wrapper,
  pinSpacing: true,
  invalidateOnRefresh: true,
  markers: this.terraDebug, // markers only in terraDebug mode (?debug)
  onUpdate: (self) => this.update(self.progress * steps),
  onRefresh: (self) => this.update(self.progress * steps),
});

On every update the class writes two CSS custom properties on each nav item, and the SCSS of c--nav-c does the painting. --nav-c-reached drives the opacity of the dot and the label; it goes from 0 to 1 over the last quarter of the incoming connector's travel, so a dot is never lit before the line has reached it. --nav-c-fill is how far the connector has travelled towards the next item, drawn with hard colour stops so the boundary stays crisp:

this.gsap.set(navItem, {
    // Dot and label come up over the last stretch of the connector's
    // travel and hit full strength exactly as it arrives, so the dot
    // is never lit before the line has reached it.
    "--nav-c-reached": clamp(0, 1, (position - index + this.reachRamp) / this.reachRamp),
    // The connector fills across the whole gap, doubling as the
    // progress bar towards the next swap.
    "--nav-c-fill": `${clamp(0, 1, position - index) * 100}%`,
  });
});

When the rounded position changes, the class swaps the items on its own timing, not the scroll's. The leaving copy fades out and moves shift pixels away from the direction of travel over duration seconds; at the seam, every media that is not arriving is set to opacity 0 and the arriving one to 1 in the same frame, so a picture is never shown half transparent and two are never visible together; then the arriving copy comes in from shift pixels on the other side over another duration. A whole swap takes 2 × duration. The class also keeps aria-hidden on the items, aria-current on the nav item, and pointer-events on the visible item only, since the hidden ones share its cell.

What the framework does for you

  • Lazy loading. A section is only built when it gets within 100px of the viewport (Boostify, scroll method). GSAP and ScrollTrigger are imported once and cached by the Manager.
  • Page transitions. Every instance is destroyed before swup replaces the content and rebuilt on the new page. The matchMedia context is reverted, which kills the pin and clears every inline style it set.
  • Breakpoint and reduced motion. Below data-breakpoint (810px by default, the tabletm breakpoint of the foundation) or with prefers-reduced-motion, nothing pins or animates: the section is a plain vertical stack of the items, and the timeline is hidden by the SCSS. The c--timeline-a--is-loaded modifier is only added while the effect runs, and the whole pinned layout in the SCSS hangs off it, so there is nothing to undo. A section with a single item does not run either.
  • Height changes. StickyScroll is registered with modifyHeight: true in resources.js: the pin adds scroll distance, so once it is built the ScrollTriggers below it are refreshed, and AnchorTo fires StickyScroll:load before it scrolls so an anchor below the section lands in the right place.
  • Debug. Add ?debug to the URL to see the ScrollTrigger markers of the pin.

Default

No attributes: one viewport of scroll per item, a 10px shift and a 0.35s fade each way.

Default

Four items, nothing configured. Each swap owns one viewport of scroll, so the whole section pins for three viewports. The copy slides 10px as it fades, a travel small enough to read as a settle rather than a move, and each half of the swap takes 0.35s. The media switches in a single frame at the seam.

<!-- TimelineA renders the markup above. The options are props; leave one out and the handler uses its default -->
<TimelineA payload={{
  navLabel: 'Timeline progress',
  items: [
    { label: 'Send money', title: 'Send money in minutes', subtitle: '…', imgSrc: '…', imgAlt: 'Send money' },
    { label: 'Track transfers', title: 'Track every transfer', subtitle: '…', imgSrc: '…', imgAlt: 'Track transfers' },
    …
  ],
}} />
Live example
Send money

Send money in minutes

Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum aperiam totam expedita harum deleniti voluptate sint perspiciatis.

Track transfers

Track every transfer

Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur cupiditate itaque recusandae praesentium tempore magnam.

Pay bills

Pay bills without queues

Lorem ipsum dolor sit amet consectetur adipisicing elit. Esse tempora quidem nihil inventore accusantium praesentium.

Get support

Support wherever you are

Lorem ipsum dolor sit amet consectetur adipisicing elit. Harum deleniti voluptate sint perspiciatis esse tempora.

Slower with more travel

Two viewports per item, a 40px shift and a 0.6s fade: the same section with every option pushed.

Attributes scroll-per-item="2"shift="40"duration="0.6"

The three options that change the feel. scrollPerItem is how much scroll each step owns: at 2, three items pin for four viewports and the connector fills at half the pace, which gives the reader longer with each block of copy. shift is the vertical travel of the copy while it fades; at 40 the move is part of the animation rather than a settle. duration is the length of each half of the swap, so at 0.6 a whole swap takes 1.2s. The media is not affected by any of them: it always cuts over at the seam.

The breakpoint is left at its default. Pass breakpoint to run the effect from another width; it is a min-width, so 1300 would keep the stack up to laptop.

<TimelineA payload={{
  navLabel: 'Delivery phases',
  scrollPerItem: 2,
  shift: 40,
  duration: 0.6,
  items: [
    { label: 'Plan', title: 'Plan the sprint', subtitle: '…', imgSrc: '…', imgAlt: 'Plan' },
    { label: 'Design', title: 'Design every screen', subtitle: '…', imgSrc: '…', imgAlt: 'Design' },
    { label: 'Ship', title: 'Ship it to production', subtitle: '…', imgSrc: '…', imgAlt: 'Ship' },
  ],
}} />

<!-- What it renders -->
<section class="c--timeline-a js--sticky-scroll" data-scroll-per-item="2" data-shift="40" data-duration="0.6">…</section>
Live example
scroll-per-item="2"shift="40"duration="0.6"
Plan

Plan the sprint

Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum aperiam totam expedita harum deleniti voluptate.

Design

Design every screen

Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur cupiditate itaque recusandae praesentium.

Ship

Ship it to production

Lorem ipsum dolor sit amet consectetur adipisicing elit. Esse tempora quidem nihil inventore accusantium.

Options reference

Everything you can set, on the section or as TimelineA props.

Reference

Attributes (on the section) and the matching TimelineA props

Attribute / propDefaultWhat it does
data-breakpoint / breakpoint810Min width in px where the effect runs. Below it the section is a plain vertical stack
data-scroll-per-item / scrollPerItem1Viewport heights of scroll per item. The section pins for (items - 1) × scrollPerItem viewports
data-shift / shift10Vertical travel in px applied to the copy while it fades, away on the way out and back on the way in
data-duration / duration0.35Seconds each half of the swap takes: the fade out, then the fade in

Each value is read with Number(value) || default, so an empty, missing or non-numeric attribute falls back to the default, and so does 0.

TimelineA (the rest of the payload)

PropValuesWhat it does
itemsarray, requiredOne per step. Each has label (the nav), title, subtitle, imgSrc and imgAlt. The nav falls back to title when there is no label
navLabelstring, default "Section progress"aria-label of the timeline nav
customClassstringExtra classes on the section

The class only reports progress: the look of the dots, the connector and the greyed-out state comes from c--nav-c, through --nav-c-reached and --nav-c-fill, and the stacked layout from c--timeline-a--is-loaded. Both are styling, not part of the handler.