# Index Wipe

> Directional polygon wipes between full-bleed images with character rolls and odometer counters.

**Definition:** Index Wipe 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/index-wipe
- Full-page preview: https://html.bosconvertpro.com/collection/index-wipe/full
- Live demo: https://html.bosconvertpro.com/demos/index-wipe/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/index-wipe/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/index-wipe/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/index-wipe.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Index Wipe?

Index Wipe is a scroll UI animation from collections.html. Directional polygon wipes between full-bleed images with character rolls and odometer counters.

### What is Index Wipe built with?

Index Wipe 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 Index Wipe in my project?

Open the animation page (https://html.bosconvertpro.com/collection/index-wipe), 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/index-wipe/index.html.

### Is Index Wipe free to use?

Yes. The Index Wipe 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>Index Wipe — 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 rel="preconnect" href="https://wsrv.nl" />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <!--
      Index Wipe
      Hidden item data is read by JS to build media layers, list, and odometer.
      Hover a row → photo wipes in from cursor direction + character roll on text.
    -->
    <section
      class="index-wipe"
      data-index-wipe
      data-duration="1.0"
      data-ease="indexWipeInOut"
      data-rest-index="0"
    >
      <div class="index-wipe__data" data-index-wipe-data hidden>
        <div
          data-index-wipe-item
          data-word="Tokyo"
          data-bg="https://wsrv.nl/?url=images.unsplash.com%2Fphoto-1540959733332-eab4deabeeaf&w=1920&h=1280&fit=cover&q=82&output=webp"
        ></div>
        <div
          data-index-wipe-item
          data-word="Paris"
          data-bg="https://wsrv.nl/?url=images.unsplash.com%2Fphoto-1502602898657-3e91760cbb34&w=1920&h=1280&fit=cover&q=82&output=webp"
        ></div>
        <div
          data-index-wipe-item
          data-word="New York"
          data-bg="https://wsrv.nl/?url=images.unsplash.com%2Fphoto-1496442226666-8d4d0e62e6e9&w=1920&h=1280&fit=cover&q=82&output=webp"
        ></div>
        <div
          data-index-wipe-item
          data-word="London"
          data-bg="https://wsrv.nl/?url=images.unsplash.com%2Fphoto-1513635269975-59663e0ac1ad&w=1920&h=1280&fit=cover&q=82&output=webp"
        ></div>
        <div
          data-index-wipe-item
          data-word="Dubai"
          data-bg="https://wsrv.nl/?url=images.unsplash.com%2Fphoto-1512453979798-5ea266f8880c&w=1920&h=1280&fit=cover&q=82&output=webp"
        ></div>
        <div
          data-index-wipe-item
          data-word="Singapore"
          data-bg="https://wsrv.nl/?url=images.unsplash.com%2Fphoto-1525625293386-3f8f99389edd&w=1920&h=1280&fit=cover&q=82&output=webp"
        ></div>
      </div>

      <!-- Stage: JS builds media stack, list, badge, counter -->
      <div class="index-wipe__stage" data-index-wipe-stage></div>
    </section>

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

## Source — styles.css
````css
/* ==========================================================================
   Index Wipe — BEM class names
   ========================================================================== */

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

html,
body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #101010;
}

.index-wipe {
  --index-wipe-bg: #101010;
  --index-wipe-fg: rgba(255, 250, 242, 0.92);
  --index-wipe-fg-soft: rgba(255, 255, 255, 0.78);
  --index-wipe-fg-dim: rgba(255, 255, 255, 0.4);
  --index-wipe-line: rgba(255, 255, 255, 0.2);
  --index-wipe-margin: 40px;
  --index-wipe-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;

  position: relative;
  width: 100%;
  height: 100dvh;
  overflow: hidden;
  background: var(--index-wipe-bg);
  font-family: var(--index-wipe-font);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Declarative data only — never shown */
.index-wipe__data {
  display: none;
}

.index-wipe__stage {
  position: relative;
  width: 100%;
  height: 100%;
}

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

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

.index-wipe__layer {
  position: absolute;
  inset: 0;
  will-change: clip-path;
}

.index-wipe__image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top;
  transform: scale(var(--index-wipe-img-scale, 1));
  transform-origin: 50% 50%;
  filter: brightness(0.72) saturate(0.92);
  will-change: transform;
  user-select: none;
  -webkit-user-drag: none;
}

.index-wipe__scrim {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: linear-gradient(
    to right,
    rgba(10, 10, 10, 0.75) 0%,
    rgba(10, 10, 10, 0.35) 45%,
    rgba(10, 10, 10, 0.15) 100%
  );
}

/* --------------------------------------------------------------------------
   Word list
   -------------------------------------------------------------------------- */

.index-wipe__list {
  position: absolute;
  left: var(--index-wipe-margin);
  top: 50%;
  z-index: 4;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
}

.index-wipe__item {
  position: relative;
  display: block;
  border: none;
  background: transparent;
  padding: 0;
  margin: 0;
  cursor: pointer;
  text-align: left;
  -webkit-tap-highlight-color: transparent;
  font: inherit;
  color: inherit;
}

.index-wipe__word {
  position: relative;
  display: block;
  overflow: visible;
  padding-block: 4px;
}

.index-wipe__label {
  position: relative;
  display: inline-block;
  white-space: nowrap;
  font-size: clamp(40px, 7vw, 96px);
  font-weight: 300;
  letter-spacing: -0.03em;
  line-height: 1.02;
  color: var(--index-wipe-fg-dim);
  opacity: 0.55;
}

.index-wipe__label.is-active {
  color: var(--index-wipe-fg);
  opacity: 1;
}

/* Character roll cells (text hover animation) */
.index-wipe__char {
  position: relative;
  display: inline-block;
  overflow-x: visible;
  overflow-y: hidden;
  padding-bottom: 0.1em;
  margin-bottom: -0.1em;
  vertical-align: top;
}

.index-wipe__char-inner {
  position: relative;
  display: inline-block;
  will-change: transform;
}

.index-wipe__char-face {
  display: block;
}

.index-wipe__char-face--next {
  position: absolute;
  left: 0;
  top: 0;
  transform: translateY(110%);
}

.index-wipe__char-space {
  display: inline-block;
  white-space: pre;
}

.index-wipe__item:focus-visible {
  outline: none;
}

.index-wipe__item:focus-visible .index-wipe__label {
  color: var(--index-wipe-fg);
  opacity: 1;
}

/* --------------------------------------------------------------------------
   Index odometer + badge
   -------------------------------------------------------------------------- */

.index-wipe__counter {
  position: absolute;
  left: var(--index-wipe-margin);
  bottom: var(--index-wipe-margin);
  z-index: 4;
  display: flex;
  align-items: baseline;
  gap: 2px;
  font-size: 13px;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--index-wipe-fg-soft);
  pointer-events: none;
}

.index-wipe__digit {
  position: relative;
  display: inline-block;
  overflow-x: visible;
  overflow-y: hidden;
  height: 1em;
  width: 0.62em;
  vertical-align: top;
}

.index-wipe__digit-roller {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  flex-direction: column;
  will-change: transform;
}

.index-wipe__digit-roller span {
  display: block;
  height: 1em;
  line-height: 1;
}

.index-wipe__counter-sep {
  color: var(--index-wipe-line);
  padding-inline: 1px;
}

.index-wipe__counter-total {
  color: var(--index-wipe-fg-dim);
}

.index-wipe__badge {
  position: absolute;
  right: var(--index-wipe-margin);
  top: var(--index-wipe-margin);
  z-index: 4;
  font-size: 13px;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--index-wipe-fg-soft);
  pointer-events: none;
}

/* --------------------------------------------------------------------------
   Responsive
   -------------------------------------------------------------------------- */

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

  .index-wipe__label {
    font-size: clamp(32px, 11vw, 56px);
  }

  .index-wipe__scrim {
    background: linear-gradient(
      to top,
      rgba(10, 10, 10, 0.8) 0%,
      rgba(10, 10, 10, 0.3) 55%,
      rgba(10, 10, 10, 0.15) 100%
    );
  }
}
````

## Source — script.js
````javascript
/**
 * Index Wipe
 *
 * - Full media layer stack (one photo per row)
 * - Polygon wipe from cursor travel direction (with soft edge lag)
 * - Per-character roll on the active word (dual face)
 * - Odometer digit rollers for 01 / 06
 * - Restores rest-index on mouseleave
 */

(() => {
  "use strict";

  function whenReady(fn, attempt) {
    attempt = attempt || 0;
    if (typeof gsap === "undefined" || typeof CustomEase === "undefined") {
      if (attempt < 100) {
        setTimeout(() => whenReady(fn, attempt + 1), 50);
      } else {
        console.error("[Index Wipe] gsap / CustomEase failed to load.");
      }
      return;
    }
    fn();
  }

  whenReady(() => {
    gsap.registerPlugin(CustomEase);
    CustomEase.create("indexWipe", "M0,0 C0.3,0.9 0.1,1 1,1");
    CustomEase.create("indexWipeInOut", "M0,0 C0.7,0 0.16,1 1,1");

    const instances = [];
    document.querySelectorAll("[data-index-wipe]").forEach((root) => {
      const api = initIndexWipe(root);
      if (api) instances.push(api);
    });

    window.IndexWipe = {
      refresh() {
        instances.forEach((i) => i.refresh());
      },
      goTo(index) {
        instances.forEach((i) => i.goTo(index));
      },
    };
  });

  /* ---------------------------------------------------------------------- */

  function initIndexWipe(root) {
    const dataHost = root.querySelector("[data-index-wipe-data]");
    const stage = root.querySelector("[data-index-wipe-stage]");
    const itemNodes = dataHost
      ? Array.from(dataHost.querySelectorAll("[data-index-wipe-item]"))
      : [];

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

    const duration = parseFloat(root.dataset.duration || "1.0");
    const ease = root.dataset.ease || "indexWipeInOut";
    const restIndex = Math.min(
      Math.max(parseInt(root.dataset.restIndex || "0", 10), 0),
      itemNodes.length - 1
    );

    const items = itemNodes.map((node) => ({
      word: node.dataset.word || "",
      bg: node.dataset.bg || "",
    }));

    let current = restIndex;
    let lastDir = 1;
    let zBump = 0;
    let wipeSeq = 0;
    /** @type {null | { layers: HTMLElement[], rowRefs: any[], counterCurrent: HTMLElement }} */
    let ui = null;

    /* ---- helpers ------------------------------------------------------- */

    function pad2(n) {
      return String(n + 1).padStart(2, "0");
    }

    /**
     * Polygon wipe clip.
     * dir > 0 → reveal from bottom; dir < 0 → reveal from top.
     * `lead` / `trail` allow a soft lagging edge while wiping.
     */
    function setWipeClip(layer, dir, lead, trail) {
      if (trail === undefined) trail = lead;

      if (lead <= 0 && trail <= 0) {
        layer.style.clipPath = "none";
        layer.style.visibility = "";
        return;
      }

      if (lead >= 100 && trail >= 100) {
        layer.style.clipPath = "none";
        layer.style.visibility = "hidden";
        return;
      }

      layer.style.visibility = "";
      if (dir > 0) {
        layer.style.clipPath =
          "polygon(0% " +
          lead +
          "%, 100% " +
          trail +
          "%, 100% 100%, 0% 100%)";
      } else {
        layer.style.clipPath =
          "polygon(0% 0%, 100% 0%, 100% " +
          (100 - lead) +
          "%, 0% " +
          (100 - trail) +
          "%)";
      }
    }

    function buildDigit(digitChar) {
      const wrap = document.createElement("span");
      wrap.className = "index-wipe__digit";

      const roller = document.createElement("span");
      roller.className = "index-wipe__digit-roller";

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

      wrap.appendChild(roller);
      const value = parseInt(digitChar, 10);
      wrap._digit = value;
      wrap._roller = roller;
      gsap.set(roller, { y: -value + "em" });
      return wrap;
    }

    function renderOdometer(host, valueStr) {
      host.innerHTML = "";
      String(valueStr)
        .split("")
        .forEach((ch) => {
          host.appendChild(buildDigit(ch));
        });
    }

    function rollOdometer(host, valueStr) {
      const digits = String(valueStr).split("");
      const wraps = Array.from(host.querySelectorAll(".index-wipe__digit"));

      if (wraps.length !== digits.length) {
        renderOdometer(host, valueStr);
        return;
      }

      wraps.forEach((wrap, i) => {
        const next = parseInt(digits[i], 10);
        wrap._digit = next;
        gsap.to(wrap._roller, {
          y: -next + "em",
          duration: 0.6,
          ease: "expo.inOut",
          force3D: true,
          overwrite: true,
        });
      });
    }

    function splitWord(labelEl, word, charInnersOut) {
      word.split("").forEach((ch) => {
        if (ch === " ") {
          const space = document.createElement("span");
          space.className = "index-wipe__char-space";
          space.textContent = " ";
          labelEl.appendChild(space);
          return;
        }

        const char = document.createElement("span");
        char.className = "index-wipe__char";

        const inner = document.createElement("span");
        inner.className = "index-wipe__char-inner";

        const face = document.createElement("span");
        face.className = "index-wipe__char-face";
        face.textContent = ch;

        const faceNext = document.createElement("span");
        faceNext.className = "index-wipe__char-face index-wipe__char-face--next";
        faceNext.textContent = ch;
        faceNext.setAttribute("aria-hidden", "true");

        inner.appendChild(face);
        inner.appendChild(faceNext);
        char.appendChild(inner);
        labelEl.appendChild(char);
        charInnersOut.push(inner);
      });
    }

    /* ---- UI build ------------------------------------------------------ */

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

      const media = document.createElement("div");
      media.className = "index-wipe__media";

      const layers = items.map((item, i) => {
        const layer = document.createElement("div");
        layer.className = "index-wipe__layer";
        layer.style.zIndex = String(i);
        setWipeClip(layer, 1, i === current ? 0 : 100);

        const img = document.createElement("img");
        img.className = "index-wipe__image";
        img.src = item.bg;
        img.alt = "";
        img.setAttribute("aria-hidden", "true");
        img.draggable = false;

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

      const scrim = document.createElement("div");
      scrim.className = "index-wipe__scrim";
      scrim.setAttribute("aria-hidden", "true");

      const list = document.createElement("div");
      list.className = "index-wipe__list";
      list.setAttribute("role", "list");

      const rowRefs = items.map((item, i) => {
        const btn = document.createElement("button");
        btn.type = "button";
        btn.className = "index-wipe__item";
        btn.setAttribute("role", "listitem");
        btn.setAttribute("aria-label", item.word);
        btn.dataset.index = String(i);

        const word = document.createElement("span");
        word.className = "index-wipe__word";

        const label = document.createElement("span");
        label.className = "index-wipe__label";
        if (i === current) {
          label.classList.add("is-active");
        }

        const charInners = [];
        splitWord(label, item.word, charInners);

        word.appendChild(label);
        btn.appendChild(word);
        list.appendChild(btn);

        return { btn, label, charInners };
      });

      const badge = document.createElement("div");
      badge.className = "index-wipe__badge";
      badge.textContent = "Index";

      const counter = document.createElement("div");
      counter.className = "index-wipe__counter";

      const counterCurrent = document.createElement("span");
      counterCurrent.className = "index-wipe__counter-current";

      const sep = document.createElement("span");
      sep.className = "index-wipe__counter-sep";
      sep.textContent = "/";

      const total = document.createElement("span");
      total.className = "index-wipe__counter-total";
      total.textContent = String(items.length).padStart(2, "0");

      renderOdometer(counterCurrent, pad2(current));
      counter.append(counterCurrent, sep, total);

      stage.append(media, scrim, list, badge, counter);

      ui = { layers, rowRefs, counterCurrent };
      return rowRefs;
    }

    /* ---- transitions --------------------------------------------------- */

    function setActiveWord(index) {
      ui.rowRefs.forEach((ref, i) => {
        if (i === index) {
          gsap.to(ref.label, {
            opacity: 1,
            duration: 0.4,
            ease,
            force3D: true,
            overwrite: "auto",
          });
          ref.label.classList.add("is-active");
        } else {
          gsap.to(ref.label, {
            opacity: 0.55,
            duration: 0.5,
            ease,
            force3D: true,
            overwrite: "auto",
          });
          ref.label.classList.remove("is-active");
        }
      });
    }

    /** Character roll — dual-face slot motion. */
    function rollChars(rowRef) {
      if (!rowRef || !rowRef.charInners.length) return;
      const inners = rowRef.charInners;
      gsap.killTweensOf(inners);
      gsap.set(inners, { yPercent: 0, y: 0 });
      gsap.to(inners, {
        yPercent: -110,
        y: 0,
        duration: 0.55,
        ease: "indexWipeInOut",
        stagger: 0.02,
        force3D: true,
        onComplete() {
          gsap.set(inners, { yPercent: 0, y: 0 });
        },
      });
    }

    function goTo(index, dir) {
      if (index === current) return;

      lastDir = dir;
      current = index;
      root.setAttribute("aria-label", items[index].word);

      setActiveWord(index);
      rollChars(ui.rowRefs[index]);

      const layer = ui.layers[index];
      zBump += 1;
      layer.style.zIndex = String(items.length + zBump);

      const img = layer.querySelector(".index-wipe__image");
      setWipeClip(layer, dir, 100);
      gsap.set(img, { scale: 1.2 });

      const seq = ++wipeSeq;
      const tl = gsap.timeline();
      const progress = { v: 100 };

      tl.to(
        progress,
        {
          v: 0,
          duration,
          ease,
          onUpdate() {
            const p = (100 - progress.v) / 100;
            const soft = Math.min(1, p * (1 + 0.3 * (1 - p)));
            setWipeClip(layer, dir, 100 * (1 - soft), progress.v);
          },
        },
        0
      );

      tl.to(
        img,
        {
          scale: 1,
          duration,
          ease,
          force3D: true,
        },
        0
      );

      tl.call(
        () => {
          if (seq !== wipeSeq) return;
          ui.layers.forEach((ly, i) => {
            if (i === index) {
              setWipeClip(ly, 1, 0);
              ly.style.zIndex = String(items.length);
            } else {
              setWipeClip(ly, 1, 100);
              ly.style.zIndex = String(i);
            }
          });
        },
        null,
        duration
      );

      rollOdometer(ui.counterCurrent, pad2(index));
    }

    /* ---- wire up ------------------------------------------------------- */

    const rowRefs = build();

    rowRefs.forEach((ref, i) => {
      const activate = () => {
        if (i === current) return;
        const dir = i > current ? 1 : i < current ? -1 : lastDir;
        goTo(i, dir);
      };
      ref.btn.addEventListener("mouseenter", activate);
      ref.btn.addEventListener("focus", activate);
    });

    function onLeave() {
      if (current === restIndex) return;
      goTo(restIndex, restIndex > current ? 1 : -1);
    }

    function onKey(e) {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        goTo(Math.min(current + 1, items.length - 1), 1);
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        goTo(Math.max(current - 1, 0), -1);
      }
    }

    root.addEventListener("mouseleave", onLeave);
    root.addEventListener("keydown", onKey);
    root.setAttribute("role", "region");
    root.setAttribute("aria-label", items[current].word);
    root.tabIndex = 0;

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

    let resizeTimer = null;
    function onResize() {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {}, 150);
    }

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

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

    return {
      refresh() {},
      goTo(index) {
        if (index === current || index < 0 || index >= items.length) return;
        goTo(index, index > current ? 1 : -1);
      },
      kill() {
        window.removeEventListener("resize", onResize);
        document.removeEventListener("visibilitychange", onVisibility);
        root.removeEventListener("keydown", onKey);
        root.removeEventListener("mouseleave", onLeave);
      },
    };
  }
})();
````
