# Step Swipe

> Step-by-step content advanced by swipe gestures — onboarding and stories with crisp horizontal pacing.

**Definition:** Step Swipe is a scroll UI animation from collections.html. It is built with HTML, CSS, JavaScript, and GSAP, ships with a live preview, and is free to copy into personal and commercial projects.

## Facts
- Category: Scroll
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-08-01
- Canonical page: https://html.bosconvertpro.com/collection/step-swipe
- Full-page preview: https://html.bosconvertpro.com/collection/step-swipe/full
- Live demo: https://html.bosconvertpro.com/demos/step-swipe/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/step-swipe/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/step-swipe/full to preview the animation in full screen.
2. Copy index.html, styles.css, and script.js from the code tabs on https://html.bosconvertpro.com/collection/step-swipe.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Step Swipe?

Step Swipe is a scroll UI animation from collections.html. Step-by-step content advanced by swipe gestures — onboarding and stories with crisp horizontal pacing.

### What is Step Swipe built with?

Step Swipe is built with HTML, CSS, JavaScript, and GSAP. The source for every file is shown on the animation page and can be copied straight into your project.

### How do I use Step Swipe in my project?

Open the animation page (https://html.bosconvertpro.com/collection/step-swipe), preview it live, then copy index.html, styles.css, and script.js from the code tabs. Paste the files into your project and adjust timing, easing, and content as needed. A standalone demo runs at https://html.bosconvertpro.com/demos/step-swipe/index.html.

### Is Step Swipe free to use?

Yes. The Step Swipe demo code may be used in personal and commercial projects. You may not resell or redistribute the collection itself as a competing template pack. Full terms: https://html.bosconvertpro.com/terms.

## Source — index.html
````html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Step Swipe — Cities</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link rel="preconnect" href="https://images.unsplash.com" />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,300..600&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <!--
      Step Swipe — Cities
      Tall scroll track with a sticky stage. As you scroll, background + card
      layers wipe in, titles character-roll, numbers odometer, and copy lines
      transition between cities.
    -->
    <section
      class="step-swipe"
      data-step-swipe
      data-duration="0.9"
      data-ease="expo.out"
      data-stagger="0.00875"
      data-hold-height="90"
      data-transition-height="55"
      data-card-width="47.5vw"
      data-zoom="1.1"
    >
      <!-- Declarative step data (hidden). JS builds the sticky stage. -->
      <div class="step-swipe__data" data-step-swipe-data hidden>
        <div
          data-step-swipe-item
          data-title="Tokyo"
          data-number="01"
          data-description="Neon veins through midnight streets. Where ancient temples and tomorrow’s skyline share the same train line home."
          data-background="https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?ixlib=rb-4.0.3&auto=format&fit=crop&w=2400&q=80"
          data-card="https://images.unsplash.com/photo-1542051841857-5f90071e7989?ixlib=rb-4.0.3&auto=format&fit=crop&w=1600&q=80"
        ></div>

        <div
          data-step-swipe-item
          data-title="Paris"
          data-number="02"
          data-description="Stone that remembers every century. Cafés still open when the light turns gold along the river’s edge."
          data-background="https://images.unsplash.com/photo-1502602898657-3e91760cbb34?ixlib=rb-4.0.3&auto=format&fit=crop&w=2400&q=80"
          data-card="https://images.unsplash.com/photo-1499856871958-5b9627545d1a?ixlib=rb-4.0.3&auto=format&fit=crop&w=1600&q=80"
        ></div>

        <div
          data-step-swipe-item
          data-title="New York"
          data-number="03"
          data-description="A grid that never sleeps. Ambition stacked floor by floor until the skyline itself feels like a promise."
          data-background="https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?ixlib=rb-4.0.3&auto=format&fit=crop&w=2400&q=80"
          data-card="https://images.unsplash.com/photo-1485871981521-5b1fd3805eee?ixlib=rb-4.0.3&auto=format&fit=crop&w=1600&q=80"
        ></div>

        <div
          data-step-swipe-item
          data-title="London"
          data-number="04"
          data-description="Rain on river stone. History worn soft under centuries of footfall, still moving with quiet resolve."
          data-background="https://images.unsplash.com/photo-1513635269975-59663e0ac1ad?ixlib=rb-4.0.3&auto=format&fit=crop&w=2400&q=80"
          data-card="https://images.unsplash.com/photo-1486299267070-83823f5448dd?ixlib=rb-4.0.3&auto=format&fit=crop&w=1600&q=80"
        ></div>
      </div>

      <!-- Stage shell: layers, progress, titles, number, description -->
      <div class="step-swipe__stage" data-step-swipe-stage></div>
    </section>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* ============================================
   Step Swipe — layout + components
   ============================================ */

*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    "Helvetica Neue", sans-serif;
  background: #8a8a8a;
  color: #0a0a0a;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* --------------------------------------------
   Root track
   -------------------------------------------- */

.step-swipe {
  --step-swipe-bg: #8a8a8a;
  --step-swipe-fg: rgba(255, 255, 255, 0.92);
  --step-swipe-fg-soft: rgba(255, 255, 255, 0.78);
  --step-swipe-line: rgba(255, 255, 255, 0.18);
  --step-swipe-fill: rgba(255, 255, 255, 0.85);
  --step-swipe-margin: 40px;
  --step-swipe-font: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
  --step-swipe-columns: 4;
  --step-swipe-card-width: 47.5vw;
  --step-swipe-zoom: 1.1;
  --step-swipe-track-height: 500vh;

  position: relative;
  width: 100%;
  height: var(--step-swipe-track-height);
  font-family: var(--step-swipe-font);
  -webkit-font-smoothing: antialiased;
  background: var(--step-swipe-bg);
}

.step-swipe__data {
  display: none;
}

/* Sticky viewport stage */
.step-swipe__stage {
  position: sticky;
  top: 0;
  width: 100%;
  height: 100vh;
  overflow: hidden;
  background: var(--step-swipe-bg);
}

/* --------------------------------------------
   Background stack
   -------------------------------------------- */

.step-swipe__background {
  position: absolute;
  inset: 0;
  z-index: 1;
}

.step-swipe__background-layer {
  position: absolute;
  inset: 0;
  /* Wipe from bottom upward as --step-swipe-clip rises 0 → 100 */
  clip-path: inset(0% 0% calc(var(--step-swipe-clip, 0) * 1%) 0%);
  will-change: clip-path;
}

.step-swipe__background-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(var(--step-swipe-zoom, 1.1));
  transform-origin: 50% 50%;
  filter: brightness(0.65) saturate(0.92);
}

/* --------------------------------------------
   Center card
   -------------------------------------------- */

.step-swipe__card {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: min(var(--step-swipe-card-width, 47.5vw), 88vw);
  aspect-ratio: 3 / 2;
  border-radius: 12px;
  overflow: hidden;
  z-index: 2;
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
}

.step-swipe__card-layer {
  position: absolute;
  inset: 0;
  clip-path: inset(0% 0% calc(var(--step-swipe-clip, 0) * 1%) 0%);
  will-change: clip-path;
}

.step-swipe__card-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* --------------------------------------------
   Progress line
   -------------------------------------------- */

.step-swipe__progress {
  position: absolute;
  top: 28px;
  left: var(--step-swipe-margin);
  right: var(--step-swipe-margin);
  height: 1px;
  background: var(--step-swipe-line);
  z-index: 3;
}

.step-swipe__progress-fill {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: var(--step-swipe-fill);
  transform: scaleX(0);
  transform-origin: left center;
  will-change: transform;
}

/* --------------------------------------------
   Title grid (one column per step)
   -------------------------------------------- */

.step-swipe__titles {
  position: absolute;
  top: 44px;
  left: var(--step-swipe-margin);
  right: var(--step-swipe-margin);
  display: grid;
  grid-template-columns: repeat(var(--step-swipe-columns), minmax(0, 1fr));
  z-index: 3;
  pointer-events: none;
  overflow: visible;
}

.step-swipe__title {
  grid-row: 1;
  position: relative;
  z-index: 1;
  font-size: clamp(24px, 3.4vw, 44px);
  font-weight: 400;
  letter-spacing: -0.04em;
  line-height: 1.25;
  color: #ffffff;
  margin: 0;
  padding: 0;
  white-space: nowrap;
  overflow: visible;
  width: max-content;
  max-width: none;
  text-shadow: 0 2px 16px rgba(0, 0, 0, 0.35);
}

.step-swipe__title[data-column]:last-child {
  justify-self: end;
  text-align: right;
}

.step-swipe__title:has(.step-swipe__title-char) {
  z-index: 2;
}

.step-swipe__title-line {
  position: relative;
  display: block;
  /* Mask for char roll — extra room so descenders (y, g) aren't cut off */
  overflow: hidden;
  padding: 0.12em 0.08em 0.38em;
  margin: -0.12em -0.08em -0.38em;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}

.step-swipe__title-char {
  position: relative;
  display: inline-block;
  backface-visibility: hidden;
}

/* --------------------------------------------
   Step number (odometer)
   -------------------------------------------- */

.step-swipe__number {
  position: absolute;
  left: var(--step-swipe-margin);
  bottom: 32px;
  display: inline-flex;
  align-items: flex-start;
  gap: 0;
  font-size: clamp(52px, 8.5vw, 88px);
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1, "lnum" 1;
  letter-spacing: -0.03em;
  line-height: 1;
  color: #ffffff;
  z-index: 3;
  margin: 0;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: geometricPrecision;
  text-shadow: 0 2px 18px rgba(0, 0, 0, 0.35);
}

.step-swipe__digit {
  position: relative;
  display: block;
  flex: 0 0 auto;
  overflow: hidden;
  height: 1em;
  width: 0.62em;
  vertical-align: top;
}

.step-swipe__digit-roller {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  display: flex;
  flex-direction: column;
}

.step-swipe__digit-roller span {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  height: 1em;
  width: 100%;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1, "lnum" 1;
}

/* --------------------------------------------
   Description
   -------------------------------------------- */

.step-swipe__description {
  position: absolute;
  right: var(--step-swipe-margin);
  bottom: 40px;
  width: min(340px, calc(100% - 2 * var(--step-swipe-margin)));
  font-size: 15px;
  font-weight: 380;
  line-height: 1.55;
  color: var(--step-swipe-fg-soft);
  z-index: 3;
  pointer-events: none;
  margin: 0;
}

.step-swipe__description-line {
  position: relative;
  will-change: transform;
}

/* Mask wrappers created by SplitText */
.step-swipe__description .step-swipe__description-line,
.step-swipe__description [class*="line"] {
  overflow: hidden;
}

/* --------------------------------------------
   Mobile
   -------------------------------------------- */

@media (max-width: 767px) {
  .step-swipe {
    --step-swipe-margin: 20px;
  }

  .step-swipe__titles {
    display: block;
    top: 40px;
    left: var(--step-swipe-margin);
    right: var(--step-swipe-margin);
  }

  .step-swipe__title {
    position: absolute;
    left: 0;
    top: 0;
    right: auto;
    font-size: clamp(28px, 9vw, 40px);
    justify-self: start;
    text-align: left;
    width: max-content;
    max-width: calc(100vw - 2 * var(--step-swipe-margin) - 64px);
  }

  .step-swipe__title[data-column]:last-child {
    justify-self: start;
    text-align: left;
  }

  .step-swipe__number {
    left: auto;
    right: var(--step-swipe-margin);
    top: 40px;
    bottom: auto;
    font-size: 44px;
  }

  .step-swipe__card {
    width: calc(100% - 2 * var(--step-swipe-margin));
  }

  .step-swipe__description {
    left: var(--step-swipe-margin);
    right: auto;
    top: auto;
    bottom: 24px;
    width: calc(100% - 2 * var(--step-swipe-margin));
    max-width: 360px;
    font-size: 14px;
  }
}
````

## Source — script.js
````javascript
/**
 * Step Swipe
 * Scroll-scrubbed sticky stage:
 *  - Background + card layers wipe via clip-path
 *  - Titles character-roll in place on a grid
 *  - Number odometer rolls digits
 *  - Description lines mask-wipe in/out
 */

(() => {
  "use strict";

  if (typeof gsap === "undefined" || typeof ScrollTrigger === "undefined") {
    console.error("[Step Swipe] GSAP + ScrollTrigger are required.");
    return;
  }

  const plugins = [ScrollTrigger];
  if (typeof SplitText !== "undefined") plugins.push(SplitText);
  gsap.registerPlugin(...plugins);

  /* --------------------------------------------------------------------------
   * Class names (BEM)
   * -------------------------------------------------------------------------- */

  const CLS = {
    background: "step-swipe__background",
    backgroundLayer: "step-swipe__background-layer",
    backgroundImage: "step-swipe__background-image",
    card: "step-swipe__card",
    cardLayer: "step-swipe__card-layer",
    cardImage: "step-swipe__card-image",
    progress: "step-swipe__progress",
    progressFill: "step-swipe__progress-fill",
    titles: "step-swipe__titles",
    title: "step-swipe__title",
    titleLine: "step-swipe__title-line",
    titleChar: "step-swipe__title-char",
    number: "step-swipe__number",
    digit: "step-swipe__digit",
    digitRoller: "step-swipe__digit-roller",
    description: "step-swipe__description",
    descriptionLine: "step-swipe__description-line",
  };

  /* --------------------------------------------------------------------------
   * Helpers
   * -------------------------------------------------------------------------- */

  /** Split title text into per-character spans inside a masked line. */
  function splitTitleChars(el, text) {
    el.innerHTML = "";
    const line = document.createElement("span");
    line.className = CLS.titleLine;

    Array.from(text).forEach((ch) => {
      const span = document.createElement("span");
      span.className = CLS.titleChar;
      span.textContent = ch === " " ? "\u00A0" : ch;
      line.appendChild(span);
    });

    el.appendChild(line);
    return Array.from(line.querySelectorAll(`.${CLS.titleChar}`));
  }

  /** Kill tweens and fully remove title chars (no residual glyphs). */
  function clearTitle(el) {
    if (!el) return;
    const chars = el.querySelectorAll(`.${CLS.titleChar}`);
    if (chars.length) gsap.killTweensOf(chars);
    el.innerHTML = "";
  }

  /**
   * Split description into masked lines.
   * Prefers GSAP SplitText; falls back to a simple block wrap.
   */
  function splitDescriptionLines(el, text, prevSplit) {
    if (prevSplit && typeof prevSplit.revert === "function") {
      prevSplit.revert();
    }

    el.textContent = text;

    if (typeof SplitText !== "undefined" && SplitText.create) {
      const split = SplitText.create(el, {
        type: "lines",
        mask: "lines",
        linesClass: CLS.descriptionLine,
      });
      return { lines: split.lines, split };
    }

    const line = document.createElement("span");
    line.className = CLS.descriptionLine;
    line.style.display = "block";
    line.style.overflow = "hidden";
    line.textContent = text;
    el.textContent = "";
    el.appendChild(line);
    return { lines: [line], split: null };
  }

  /**
   * Clip wipe: 0 = fully visible, 100 = fully clipped from bottom.
   * Uses CSS var --step-swipe-clip consumed by clip-path in CSS.
   */
  function setClip(el, clip) {
    if (clip <= 0) {
      el.style.clipPath = "none";
      el.style.visibility = "";
      el.style.removeProperty("--step-swipe-clip");
      return;
    }
    if (clip >= 100) {
      el.style.clipPath = "";
      el.style.visibility = "hidden";
      el.style.setProperty("--step-swipe-clip", "101");
      return;
    }
    el.style.clipPath = "";
    el.style.visibility = "";
    el.style.setProperty("--step-swipe-clip", String(clip));
  }

  /**
   * Build a single odometer digit (0–9 roller).
   * Uses em-based Y offsets and 2D transforms so large digits stay sharp.
   */
  function createDigit(digitChar) {
    const wrap = document.createElement("span");
    wrap.className = CLS.digit;

    const roller = document.createElement("span");
    roller.className = CLS.digitRoller;

    for (let i = 0; i <= 9; i++) {
      const span = document.createElement("span");
      span.textContent = String(i);
      roller.appendChild(span);
    }

    wrap.appendChild(roller);

    const value = Number.parseInt(digitChar, 10);
    const safe = Number.isFinite(value) ? Math.min(9, Math.max(0, value)) : 0;
    wrap._digit = safe;
    wrap._targetY = `${-safe}em`;
    wrap._roller = roller;
    return wrap;
  }

  /** Snap roller to exact digit (crisp, no subpixel drift). */
  function setDigitExact(wrap, digit) {
    const safe = Math.min(9, Math.max(0, digit | 0));
    wrap._digit = safe;
    wrap._targetY = `${-safe}em`;
    gsap.set(wrap._roller, {
      y: wrap._targetY,
      force3D: false,
      clearProps: "filter",
    });
  }

  /** Animate roller to a digit, then snap for a clean final frame. */
  function rollDigit(wrap, digit, duration = 0.8) {
    const safe = Math.min(9, Math.max(0, digit | 0));
    wrap._digit = safe;
    wrap._targetY = `${-safe}em`;
    gsap.to(wrap._roller, {
      y: wrap._targetY,
      duration,
      ease: "expo.inOut",
      force3D: false,
      overwrite: true,
      onComplete: () => setDigitExact(wrap, safe),
    });
  }

  /* --------------------------------------------------------------------------
   * Instance
   * -------------------------------------------------------------------------- */

  function createStepSwipe(root) {
    const dataEl = root.querySelector("[data-step-swipe-data]");
    const stage = root.querySelector("[data-step-swipe-stage]");
    const itemNodes = dataEl
      ? Array.from(dataEl.querySelectorAll("[data-step-swipe-item]"))
      : [];

    if (!stage || !itemNodes.length) return null;

    const duration = parseFloat(root.dataset.duration || "0.9");
    const ease = root.dataset.ease || "expo.out";
    const stagger = parseFloat(root.dataset.stagger || "0.00875");
    const holdHeight = parseFloat(root.dataset.holdHeight || "90");
    const transitionHeight = parseFloat(root.dataset.transitionHeight || "55");
    const cardWidth = root.dataset.cardWidth || "47.5vw";
    const zoom = parseFloat(root.dataset.zoom || "1.1");

    root.style.setProperty("--step-swipe-card-width", cardWidth);
    root.style.setProperty("--step-swipe-zoom", String(zoom));
    root.style.setProperty("--step-swipe-columns", String(itemNodes.length));

    const charStagger = stagger;
    const lineStagger = 9.14 * stagger;

    const steps = itemNodes.map((node) => ({
      title: node.dataset.title || "",
      number: node.dataset.number || "",
      description: node.dataset.description || "",
      background: node.dataset.background || "",
      card: node.dataset.card || "",
    }));

    /**
     * Scroll rhythm (same for every slide):
     *   [HOLD 0] [TRANS 0→1] [HOLD 1] [TRANS 1→2] … [HOLD n-1]
     */
    const stepCount = steps.length;
    const scrollSegments = [];
    for (let i = 0; i < stepCount; i++) {
      scrollSegments.push({
        type: "hold",
        index: i,
        weight: Math.max(20, holdHeight),
      });
      if (i < stepCount - 1) {
        scrollSegments.push({
          type: "trans",
          from: i,
          to: i + 1,
          weight: Math.max(15, transitionHeight),
        });
      }
    }
    const totalScrollWeight = scrollSegments.reduce(
      (sum, seg) => sum + seg.weight,
      0
    );

    function mapScrollProgress(p) {
      const clamped = Math.min(1, Math.max(0, p));
      let pos = clamped * totalScrollWeight;

      for (let i = 0; i < scrollSegments.length; i++) {
        const seg = scrollSegments[i];
        const isLast = i === scrollSegments.length - 1;
        if (pos <= seg.weight || isLast) {
          const t =
            seg.weight <= 0
              ? 1
              : Math.min(1, Math.max(0, pos / seg.weight));
          return { seg, t };
        }
        pos -= seg.weight;
      }

      const last = scrollSegments[scrollSegments.length - 1];
      return { seg: last, t: 1 };
    }

    let timeline = null;
    let activeIndex = -1;
    let ui = null;
    let outgoingDescriptionClone = null;
    let descriptionSplit = null;
    let resizeTimer = null;

    function setTrackHeight() {
      const vh = window.innerHeight || document.documentElement.clientHeight;
      const trackPx = (totalScrollWeight / 100) * vh + vh;
      root.style.setProperty("--step-swipe-track-height", `${trackPx}px`);
    }

    function refreshDescriptionLayout() {
      if (!ui || activeIndex < 0) return;
      const result = splitDescriptionLines(
        ui.descriptionEl,
        steps[activeIndex].description,
        descriptionSplit
      );
      descriptionSplit = result.split;
      gsap.set(result.lines, { yPercent: 0, y: 0 });
    }

    function buildStage() {
      stage.innerHTML = "";

      const progress = document.createElement("div");
      progress.className = CLS.progress;
      const progressFill = document.createElement("div");
      progressFill.className = CLS.progressFill;
      progress.appendChild(progressFill);

      const titles = document.createElement("div");
      titles.className = CLS.titles;

      const numberEl = document.createElement("div");
      numberEl.className = CLS.number;
      numberEl.setAttribute("aria-hidden", "true");

      const descriptionEl = document.createElement("p");
      descriptionEl.className = CLS.description;

      const background = document.createElement("div");
      background.className = CLS.background;

      const card = document.createElement("div");
      card.className = CLS.card;

      const backgroundLayers = steps.map((step, i) => {
        const layer = document.createElement("div");
        layer.className = CLS.backgroundLayer;
        setClip(layer, i === 0 ? 0 : 100);
        layer.style.zIndex = String(i);

        const img = document.createElement("img");
        img.className = CLS.backgroundImage;
        img.src = step.background;
        img.alt = "";
        img.setAttribute("aria-hidden", "true");
        img.decoding = "async";
        img.loading = i === 0 ? "eager" : "lazy";

        layer.appendChild(img);
        background.appendChild(layer);
        return layer;
      });

      const cardLayers = steps.map((step, i) => {
        const layer = document.createElement("div");
        layer.className = CLS.cardLayer;
        setClip(layer, i === 0 ? 0 : 100);
        layer.style.zIndex = String(i);

        const img = document.createElement("img");
        img.className = CLS.cardImage;
        img.src = step.card;
        img.alt = step.title;
        img.decoding = "async";
        img.loading = i === 0 ? "eager" : "lazy";

        layer.appendChild(img);
        card.appendChild(layer);
        return layer;
      });

      const titleEls = steps.map((step, i) => {
        const heading = document.createElement("h3");
        heading.className = CLS.title;
        heading.setAttribute("data-column", String(i + 1));
        heading.style.gridColumn = String(i + 1);
        titles.appendChild(heading);
        return heading;
      });

      stage.appendChild(background);
      stage.appendChild(card);
      stage.appendChild(progress);
      stage.appendChild(titles);
      stage.appendChild(numberEl);
      stage.appendChild(descriptionEl);

      ui = {
        backgroundLayers,
        cardLayers,
        titleEls,
        numberEl,
        descriptionEl,
        progressFill,
      };

      const first = steps[0];
      const chars = splitTitleChars(ui.titleEls[0], first.title);
      gsap.set(chars, { yPercent: 0, y: 0 });

      ui.numberEl.innerHTML = "";
      first.number.split("").forEach((d) => {
        const digit = createDigit(d);
        setDigitExact(digit, digit._digit);
        ui.numberEl.appendChild(digit);
      });

      const result = splitDescriptionLines(
        ui.descriptionEl,
        first.description,
        null
      );
      descriptionSplit = result.split;
      gsap.set(result.lines, { yPercent: 0 });
      activeIndex = 0;
    }

    function goToStep(nextIndex, direction) {
      if (nextIndex === activeIndex) return;
      if (nextIndex < 0 || nextIndex >= steps.length) return;

      const prevIndex = activeIndex;
      activeIndex = nextIndex;

      const step = steps[nextIndex];
      const prevTitle = ui.titleEls[prevIndex];
      const nextTitle = ui.titleEls[nextIndex];

      ui.titleEls.forEach((el, i) => {
        if (i !== prevIndex && i !== nextIndex) clearTitle(el);
      });

      if (prevTitle && prevTitle !== nextTitle) {
        const outChars = Array.from(
          prevTitle.querySelectorAll(`.${CLS.titleChar}`)
        );
        const leavingIndex = prevIndex;
        if (outChars.length) {
          gsap.killTweensOf(outChars);
          gsap.to(outChars, {
            yPercent: direction > 0 ? -180 : 180,
            y: 0,
            duration: 0.55,
            ease: "expo.in",
            stagger: charStagger,
            force3D: false,
            overwrite: true,
            onComplete: () => {
              if (activeIndex !== leavingIndex) clearTitle(prevTitle);
            },
          });
        } else {
          clearTitle(prevTitle);
        }
      }

      gsap.killTweensOf(nextTitle.querySelectorAll(`.${CLS.titleChar}`));
      const inChars = splitTitleChars(nextTitle, step.title);
      gsap.fromTo(
        inChars,
        { yPercent: direction > 0 ? 180 : -180, y: 0 },
        {
          yPercent: 0,
          y: 0,
          duration: 1.05,
          ease: "expo.out",
          stagger: charStagger,
          force3D: false,
          overwrite: true,
          onComplete: () => {
            gsap.set(inChars, { yPercent: 0, y: 0, force3D: false });
          },
        }
      );

      const digitWraps = Array.from(
        ui.numberEl.querySelectorAll(`.${CLS.digit}`)
      );
      const digits = step.number.split("");

      if (digitWraps.length === digits.length) {
        digitWraps.forEach((wrap, i) => {
          const next = Number.parseInt(digits[i], 10);
          rollDigit(wrap, Number.isFinite(next) ? next : 0, 0.8);
        });
      } else {
        gsap.to(ui.numberEl, {
          opacity: 0,
          duration: 0.28,
          onComplete: () => {
            ui.numberEl.innerHTML = "";
            digits.forEach((d) => {
              const digit = createDigit(d);
              setDigitExact(digit, digit._digit);
              ui.numberEl.appendChild(digit);
            });
            gsap.to(ui.numberEl, { opacity: 1, duration: 0.45 });
          },
        });
      }

      if (outgoingDescriptionClone) {
        outgoingDescriptionClone.remove();
        outgoingDescriptionClone = null;
      }

      if (ui.descriptionEl.querySelector(`.${CLS.descriptionLine}`)) {
        const clone = ui.descriptionEl.cloneNode(true);
        clone.setAttribute("aria-hidden", "true");
        ui.descriptionEl.parentNode.appendChild(clone);
        outgoingDescriptionClone = clone;

        gsap.to(clone.querySelectorAll(`.${CLS.descriptionLine}`), {
          yPercent: direction > 0 ? -100 : 100,
          y: 0,
          duration: 0.4,
          ease: "power2.in",
          stagger: lineStagger,
          force3D: true,
          onComplete: () => {
            if (outgoingDescriptionClone === clone) {
              outgoingDescriptionClone = null;
            }
            clone.remove();
          },
        });
      }

      const result = splitDescriptionLines(
        ui.descriptionEl,
        step.description,
        descriptionSplit
      );
      descriptionSplit = result.split;

      gsap.fromTo(
        result.lines,
        { yPercent: direction > 0 ? 100 : -100, y: 0 },
        {
          yPercent: 0,
          y: 0,
          duration,
          ease,
          stagger: lineStagger,
          delay: 0.3,
          force3D: true,
          overwrite: true,
        }
      );
    }

    function setLayersForStep(visibleThrough, wipingIndex, wipeT) {
      ui.backgroundLayers.forEach((layer, i) => {
        if (i <= visibleThrough) setClip(layer, 0);
        else if (i === wipingIndex) setClip(layer, 100 * (1 - wipeT));
        else setClip(layer, 100);
      });
      ui.cardLayers.forEach((layer, i) => {
        if (i <= visibleThrough) setClip(layer, 0);
        else if (i === wipingIndex) setClip(layer, 100 * (1 - wipeT));
        else setClip(layer, 100);
      });
    }

    function bindScroll() {
      if (steps.length <= 1) return;

      const progress = { p: 0 };

      timeline = gsap.timeline({
        scrollTrigger: {
          trigger: root,
          start: "top top",
          end: "bottom bottom",
          scrub: 0.35,
          invalidateOnRefresh: true,
        },
      });

      timeline.to(progress, {
        p: 1,
        duration: 1,
        ease: "none",
        onUpdate: () => {
          gsap.set(ui.progressFill, { scaleX: progress.p });

          const { seg, t } = mapScrollProgress(progress.p);
          let textIndex;

          if (seg.type === "hold") {
            setLayersForStep(seg.index, -1, 0);
            textIndex = seg.index;
          } else {
            setLayersForStep(seg.from, seg.to, t);

            if (activeIndex <= seg.from) {
              textIndex = t >= 0.45 ? seg.to : seg.from;
            } else {
              textIndex = t <= 0.55 ? seg.from : seg.to;
            }
          }

          if (textIndex !== activeIndex) {
            goToStep(textIndex, textIndex > activeIndex ? 1 : -1);
          }
        },
      });
    }

    function onResize() {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        setTrackHeight();
        refreshDescriptionLayout();
        ScrollTrigger.refresh();
      }, 150);
    }

    function onVisibility() {
      if (document.hidden) gsap.globalTimeline.pause();
      else gsap.globalTimeline.resume();
    }

    gsap.matchMedia().add("(prefers-reduced-motion: reduce)", () => {
      gsap.globalTimeline.timeScale(20);
      return () => gsap.globalTimeline.timeScale(1);
    });

    buildStage();
    setTrackHeight();
    bindScroll();

    window.addEventListener("resize", onResize);
    document.addEventListener("visibilitychange", onVisibility);

    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(() => {
        refreshDescriptionLayout();
        ScrollTrigger.refresh();
      });
    }

    return {
      refresh() {
        setTrackHeight();
        ScrollTrigger.refresh();
      },
      kill() {
        window.removeEventListener("resize", onResize);
        document.removeEventListener("visibilitychange", onVisibility);
        clearTimeout(resizeTimer);
        if (descriptionSplit && typeof descriptionSplit.revert === "function") {
          descriptionSplit.revert();
          descriptionSplit = null;
        }
        if (outgoingDescriptionClone) {
          outgoingDescriptionClone.remove();
          outgoingDescriptionClone = null;
        }
        if (timeline) {
          timeline.kill();
          timeline = null;
        }
        ScrollTrigger.getAll().forEach((st) => {
          if (st.trigger === root) st.kill();
        });
      },
    };
  }

  /* --------------------------------------------------------------------------
   * Boot
   * -------------------------------------------------------------------------- */

  function boot() {
    const instances = [];
    document.querySelectorAll("[data-step-swipe]").forEach((el) => {
      const api = createStepSwipe(el);
      if (api) instances.push(api);
    });

    window.StepSwipe = {
      refresh() {
        instances.forEach((instance) => instance.refresh());
      },
      kill() {
        instances.forEach((instance) => instance.kill());
        instances.length = 0;
      },
    };
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot);
  } else {
    boot();
  }
})();
````
