Sticky Steps (Basic)

A scroll-driven feature list where each step's image stays sticky while the text scrolls past. The active step is determined by whichever anchor is closest to the viewport center, with before/active/after status attributes you can style freely in CSS.

scrollstickystepsfeaturesintersectioncss

Code

HTML
html
<section class="sticky-steps">
  <div class="sticky-steps__container">
    <div data-sticky-steps-init class="sticky-steps__collection">
      <div class="sticky-steps__list">

        <div data-sticky-steps-item data-sticky-steps-item-status="active" class="sticky-steps__item">
          <div data-sticky-steps-anchor class="sticky-steps__text">
            <span class="sticky-steps__eyebrow">Feature A</span>
            <h2 class="sticky-steps__h2">Sticky Steps</h2>
            <p class="sticky-steps__p">In CSS, position: sticky is a hybrid positioning method that combines the behaviors of relative and fixed positioning.</p>
          </div>
          <div class="sticky-steps__media">
            <div class="sticky-steps__sticky">
              <div class="sticky-steps__visual">
                <img src="https://cdn.prod.website-files.com/69ae9e6ddf70dcdf27a5f726/69aeb2dec0b5fa47975b9542_placeholder-4.avif" loading="lazy" alt="" class="sticky-steps__cover-image">
              </div>
            </div>
          </div>
        </div>

        <div data-sticky-steps-item data-sticky-steps-item-status="after" class="sticky-steps__item">
          <div data-sticky-steps-anchor class="sticky-steps__text">
            <span class="sticky-steps__eyebrow">Feature B</span>
            <h2 class="sticky-steps__h2">Hybrid positioning</h2>
            <p class="sticky-steps__p">In CSS, position: sticky is a hybrid positioning method that combines the behaviors of relative and fixed positioning.</p>
          </div>
          <div class="sticky-steps__media">
            <div class="sticky-steps__sticky">
              <div class="sticky-steps__visual">
                <img src="https://cdn.prod.website-files.com/69ae9e6ddf70dcdf27a5f726/69aeb2deb2fd62dc067748f0_placeholder-3.avif" loading="lazy" alt="" class="sticky-steps__cover-image">
              </div>
            </div>
          </div>
        </div>

        <div data-sticky-steps-item data-sticky-steps-item-status="after"  class="sticky-steps__item">
          <div data-sticky-steps-anchor class="sticky-steps__text">
            <span class="sticky-steps__eyebrow">Feature C</span>
            <h2 class="sticky-steps__h2">CSS Position</h2>
            <p class="sticky-steps__p">In CSS, position: sticky is a hybrid positioning method that combines the behaviors of relative and fixed positioning.</p>
          </div>
          <div class="sticky-steps__media">
            <div class="sticky-steps__sticky">
              <div class="sticky-steps__visual">
                <img src="https://cdn.prod.website-files.com/69ae9e6ddf70dcdf27a5f726/69aeb2de97fa626a31cfa6d6_placeholder-2.avif" loading="lazy" alt="" class="sticky-steps__cover-image">
              </div>
            </div>
          </div>
        </div>

        <div data-sticky-steps-item data-sticky-steps-item-status="after"class="sticky-steps__item">
          <div data-sticky-steps-anchor class="sticky-steps__text">
            <span class="sticky-steps__eyebrow">Feature D</span>
            <h2 class="sticky-steps__h2">The last step</h2>
            <p class="sticky-steps__p">In CSS, position: sticky is a hybrid positioning method that combines the behaviors of relative and fixed positioning.</p>
          </div>
          <div class="sticky-steps__media">
            <div class="sticky-steps__sticky">
              <div class="sticky-steps__visual">
                <img src="https://cdn.prod.website-files.com/69ae9e6ddf70dcdf27a5f726/69aeb2dece70d4c848502473_placeholder-1.avif" loading="lazy" alt="" class="sticky-steps__cover-image">
              </div>
            </div>
          </div>
        </div>

      </div>
    </div>
  </div>
</section>
CSS
css
.sticky-steps {
  min-height: 100dvh;
  position: relative;
  overflow: clip;
}

.sticky-steps__container {
  max-width: 74em;
  margin-left: auto;
  margin-right: auto;
  padding-left: 1.5em;
  padding-right: 1.5em;
}

.sticky-steps__collection {
  min-height: 100dvh;
  display: flex;
  position: relative;
}

.sticky-steps__list {
  grid-column-gap: 30dvh;
  grid-row-gap: 30dvh;
  flex-flow: column;
  flex: 1;
  padding-top: calc(50dvh - 7.5em);
  padding-bottom: calc(50dvh - 7.5em);
  display: flex;
}

.sticky-steps__text {
  grid-column-gap: 2em;
  grid-row-gap: 2em;
  flex-flow: column;
  width: 50%;
  padding-right: 6em;
  display: flex;
}

.sticky-steps__eyebrow {
  opacity: .5;
  text-transform: uppercase;
  font-size: 1.3125em;
  font-weight: 700;
}

.sticky-steps__h2 {
  letter-spacing: -.04em;
  margin-top: 0;
  margin-bottom: 0;
  font-size: min(5.5em, 15vw);
  font-weight: 500;
  line-height: .9;
}

.sticky-steps__p {
  opacity: .6;
  margin-bottom: 0;
  font-size: min(1.4375em, 5vw);
  line-height: 1.4;
}

.sticky-steps__media {
  width: 50%;
  height: 100%;
  padding-left: 3em;
  position: absolute;
  top: 0;
  right: 0;
}

.sticky-steps__sticky {
  align-items: center;
  width: 100%;
  min-height: 100dvh;
  display: flex;
  position: sticky;
  top: 0;
}

.sticky-steps__visual {
  aspect-ratio: 3 / 4;
  border-radius: 500em;
  width: 100%;
  position: relative;
}

.sticky-steps__cover-image {
  object-fit: cover;
  border-radius: inherit;
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

/* Desktop — status-driven opacity */
@media screen and (min-width: 992px) {
  [data-sticky-steps-item-status] .sticky-steps__visual {
    transition: opacity 0.5s ease-in-out, visibility 0.5s ease-in-out;
    opacity: 0;
    visibility: hidden;
  }

  [data-sticky-steps-item-status="before"] .sticky-steps__visual,
  [data-sticky-steps-item-status="active"] .sticky-steps__visual {
    opacity: 1;
    visibility: visible;
  }

  [data-sticky-steps-item-status] .sticky-steps__text {
    transition: opacity 0.5s ease-in-out;
    opacity: 0.25;
  }

  [data-sticky-steps-item-status="active"] .sticky-steps__text {
    opacity: 1;
  }
}

/* Tablet */
@media screen and (max-width: 991px) {
  .sticky-steps__list {
    grid-column-gap: 7.5em;
    grid-row-gap: 7.5em;
    padding-top: 10em;
    padding-bottom: 10em;
  }

  .sticky-steps__text {
    width: 100%;
    padding-bottom: 5em;
    padding-right: 0;
  }

  .sticky-steps__sticky {
    min-height: auto;
    position: relative;
    top: auto;
  }

  .sticky-steps__media {
    width: 100%;
    height: auto;
    padding-left: 0;
    position: relative;
    top: auto;
    right: auto;
  }
}

/* Mobile */
@media screen and (max-width: 767px) {
  .sticky-steps__text {
    grid-column-gap: 1.5em;
    grid-row-gap: 1.5em;
  }
}
JavaScript
javascript
function initStickyStepsBasic() {
  const containers = document.querySelectorAll("[data-sticky-steps-init]");
  if (!containers.length) return;

  containers.forEach((container) => {
    const items = [...container.querySelectorAll("[data-sticky-steps-item]")];
    if (!items.length) return;

    function updateSteps() {
      const viewportCenter = window.innerHeight / 2;
      let closestIndex = 0;
      let closestDistance = Infinity;

      items.forEach((item, index) => {
        const anchor = item.querySelector("[data-sticky-steps-anchor]");
        if (!anchor) return;

        const rect = anchor.getBoundingClientRect();
        const anchorCenter = rect.top + rect.height / 2;
        const distance = Math.abs(viewportCenter - anchorCenter);

        if (distance < closestDistance) {
          closestDistance = distance;
          closestIndex = index;
        }
      });

      items.forEach((item, index) => {
        let status = "active";
        if (index < closestIndex) status = "before";
        if (index > closestIndex) status = "after";
        item.setAttribute("data-sticky-steps-item-status", status);
      });
    }

    window.addEventListener("scroll", updateSteps);
    window.addEventListener("resize", updateSteps);
    requestAnimationFrame(updateSteps);
  });
}

document.addEventListener('DOMContentLoaded', function () {
  initStickyStepsBasic();
});

Attributes

NameTypeDefaultDescription
[data-sticky-steps-init]attributeThe main wrapper. The script scopes all queries inside this element, so multiple independent instances can coexist on the same page.
[data-sticky-steps-item]attributeEach step wrapper. Receives the [data-sticky-steps-item-status] attribute on every scroll/resize event.
[data-sticky-steps-item-status]attributeactive / before / after"active" = the step whose anchor is closest to the viewport center. "before" = steps above it. "after" = steps below it. Use these in CSS to drive all visual state changes.
[data-sticky-steps-anchor]attributeA child of [data-sticky-steps-item] that marks the non-sticky text block. The script measures this element's position for viewport-center calculations — it must not be the sticky element.

Notes

  • No external dependencies — uses native scroll events and getBoundingClientRect.
  • The sticky media element uses CSS position: sticky. Make sure its parent (.sticky-steps__media) has height: 100% and the grandparent has position: relative.
  • On mobile (< 992px) the layout switches to a stacked column — position: sticky is removed and all status-based CSS is disabled so every step shows at full opacity.
  • Multiple [data-sticky-steps-init] instances on the same page are fully supported — each is initialised independently.

Guide

Container

Use [data-sticky-steps-init] on the main wrapper that contains the full sticky steps section. The script only checks and updates items inside that specific element, so multiple instances on a page work independently.

Item & Status

Use [data-sticky-steps-item] together with an initial [data-sticky-steps-item-status] on each step wrapper. On scroll the script sets "before" for items above the active step, "active" for the item whose anchor is closest to the viewport center, and "after" for items below.

Anchor

Use [data-sticky-steps-anchor] on the non-sticky text block inside each item. The script measures this element's center position for the viewport calculations — it must not be the sticky element.

Minimal structure

<section data-sticky-steps-init>
  <div data-sticky-steps-item data-sticky-steps-item-status="before">
    <div data-sticky-steps-anchor><!-- Text (scrolls) --></div>
    <div><!-- Sticky element --></div>
  </div>
  <div data-sticky-steps-item data-sticky-steps-item-status="active">
    <div data-sticky-steps-anchor><!-- Text (scrolls) --></div>
    <div><!-- Sticky element --></div>
  </div>
  <div data-sticky-steps-item data-sticky-steps-item-status="after">
    <div data-sticky-steps-anchor><!-- Text (scrolls) --></div>
    <div><!-- Sticky element --></div>
  </div>
</section>

Animation

The status attributes are pure styling hooks. The demo uses opacity, but you can animate scale, blur, position, rotation, filters, or any CSS property. The CSS transitions run automatically whenever the attribute changes.

GSAP ScrollTrigger version

If you are already using GSAP ScrollTrigger, you can replace the scroll/resize listeners with ScrollTrigger.create() for more efficient updates.

function initStickyStepsBasic() {
  const containers = document.querySelectorAll("[data-sticky-steps-init]");
  if (!containers.length) return;

  containers.forEach((container) => {
    const items = [...container.querySelectorAll("[data-sticky-steps-item]")];
    if (!items.length) return;

    function setActiveStep(activeIndex) {
      items.forEach((item, index) => {
        let status = "active";
        if (index < activeIndex) status = "before";
        if (index > activeIndex) status = "after";
        item.setAttribute("data-sticky-steps-item-status", status);
      });
    }

    items.forEach((item, index) => {
      const anchor = item.querySelector("[data-sticky-steps-anchor]");
      if (!anchor) return;

      ScrollTrigger.create({
        trigger: anchor,
        start: "center center",
        onEnter: () => setActiveStep(index),
        onEnterBack: () => setActiveStep(index),
      });
    });

    setActiveStep(0);
  });
}

document.addEventListener('DOMContentLoaded', function () {
  initStickyStepsBasic();
});