Big Typo Scroll Preview (Infinite)

A large-typography scroll list where hovering or centering an item reveals a fixed preview image with a clip-path reveal animation. Supports infinite Lenis scrolling by duplicating the list, with separate active-state logic for touch and non-touch devices.

lenisscrollinfinitetypographyhoverpreviewtouch

Setup — External Scripts

Setup: External Scripts
html
<!-- Lenis CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lenis@1.2.3/dist/lenis.css">

<!-- Lenis JS -->
<script src="https://cdn.jsdelivr.net/npm/lenis@1.2.3/dist/lenis.min.js"></script>

Code

index.html
html
<section data-typo-scroll-init="" data-typo-scroll-infinite="true" class="typo-scroll">
  <div class="typo-scroll__collection">
    <div data-typo-scroll-list="" class="typo-scroll__list">
      <div data-typo-scroll-item="" class="typo-scroll__item">
        <a href="#" class="typo-scroll__link">
          <h3 class="typo-scroll__h">OSMO SUPPLY</h3>
          <div class="typo-scroll__media">
            <img src="https://cdn.prod.website-files.com/693a7f8f14a0becb25db9e8f/693a879acaa2379a19c9dbc1_image%2026.avif" loading="lazy" alt="" class="typo-scroll__img">
            <p class="typo-scroll__p">[ OPEN CASE ]</p>
          </div>
        </a>
      </div>
      <div data-typo-scroll-item="" class="typo-scroll__item">
        <a href="#" class="typo-scroll__link">
          <h3 class="typo-scroll__h">Mara Lynt</h3>
          <div class="typo-scroll__media is--3-2">
            <img src="https://cdn.prod.website-files.com/693a7f8f14a0becb25db9e8f/693a879a40b0a52832601f51_image%2017.avif" loading="lazy" alt="" class="typo-scroll__img">
            <p class="typo-scroll__p">[ OPEN CASE ]</p>
          </div>
        </a>
      </div>
      <div data-typo-scroll-item="" class="typo-scroll__item">
        <a href="#" class="typo-scroll__link">
          <h3 class="typo-scroll__h">Kavirö</h3>
          <div class="typo-scroll__media is--2-3">
            <img src="https://cdn.prod.website-files.com/693a7f8f14a0becb25db9e8f/693a879a6755543b199a941a_image%2021.avif" loading="lazy" alt="" class="typo-scroll__img">
            <p class="typo-scroll__p">[ OPEN CASE ]</p>
          </div>
        </a>
      </div>
      <div data-typo-scroll-item="" class="typo-scroll__item">
        <a href="#" class="typo-scroll__link">
          <h3 class="typo-scroll__h">Solara Works</h3>
          <div class="typo-scroll__media is--1-1">
            <img src="https://cdn.prod.website-files.com/693a7f8f14a0becb25db9e8f/693a8799fb46f896d7c81f9b_image%2030.avif" loading="lazy" alt="" class="typo-scroll__img">
            <p class="typo-scroll__p">[ OPEN CASE ]</p>
          </div>
        </a>
      </div>
      <!-- More items -->
    </div>
  </div>
</section>
styles.css
css
.typo-scroll {
  color: #2b2b2b;
  background-color: #c9ccc5;
  width: 100vw;
  position: relative;
  overflow: clip;
}

.typo-scroll__collection {
  flex-flow: column;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
}

.typo-scroll__list {
  flex-flow: column;
  width: 100%;
  display: flex;
}

.typo-scroll__item {
  width: 100%;
}

.typo-scroll__link {
  color: inherit;
  justify-content: center;
  width: 100%;
  text-decoration: none;
  display: flex;
}

.typo-scroll__h {
  text-align: center;
  letter-spacing: -.05em;
  text-transform: uppercase;
  white-space: nowrap;
  margin-top: 0;
  margin-bottom: 0;
  font-size: 7.5vw;
  line-height: .9;
}

[data-typo-scroll-item="active"] .typo-scroll__h {
  z-index: 2;
  color: #6B6B6B;
  mix-blend-mode: difference;
}

.typo-scroll__media {
  aspect-ratio: 3 / 4;
  pointer-events: none;
  width: 17.5vw;
  position: fixed;
  top: 50%;
  left: 50%;
  overflow: hidden;
  transform: translate(-50%, -50%);
  --po: 1.5em;
  transition: clip-path 1.2s cubic-bezier(0.16, 1, 0.3, 1);
  clip-path: polygon(calc(0% + var(--po)) calc(0% + var(--po)), calc(100% - var(--po)) calc(0% + var(--po)), calc(100% - var(--po)) calc(100% - var(--po)), calc(0% + var(--po)) calc(100% - var(--po)));
  opacity: 0;
}

[data-typo-scroll-item="active"] .typo-scroll__media {
  clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
  opacity: 1;
}

@media (hover: none) and (pointer: coarse) {
  [data-typo-scroll-item="active"] .typo-scroll__media {
    pointer-events: all;
  }
}

.typo-scroll__media.is--3-2 {
  aspect-ratio: 3 / 2;
  width: 25vw;
}

.typo-scroll__media.is--2-3 {
  aspect-ratio: 2 / 3;
  width: 16.5vw;
}

.typo-scroll__media.is--1-1 {
  aspect-ratio: 1;
  width: 20vw;
}

.typo-scroll__img {
  object-fit: cover;
  width: 100%;
  height: 100%;
  max-height: 100%;
}

.typo-scroll__img.is--bw {
  filter: grayscale(1);
}

.typo-scroll__p {
  -webkit-backdrop-filter: blur(1em);
  backdrop-filter: blur(1em);
  color: #f4f4f4;
  text-align: center;
  white-space: nowrap;
  background-color: #201d1d33;
  margin-bottom: 0;
  padding: .25em;
  font-family: monospace;
  font-size: .75em;
  position: absolute;
  bottom: 2em;
  left: 50%;
  transform: translate(-50%);
}

@media screen and (max-width: 991px) {
  .typo-scroll__h {
    font-size: 11vw;
  }

  .typo-scroll__media {
    width: 52.5vw;
  }

  .typo-scroll__media.is--3-2 {
    width: 75vw;
  }

  .typo-scroll__media.is--2-3 {
    width: 49.5vw;
  }

  .typo-scroll__media.is--1-1 {
    width: 60vw;
  }
}
script.js
javascript
/* Lenis */
var lenis = null;

function initTypoScrollPreview() {
  var containers = document.querySelectorAll('[data-typo-scroll-init]');
  if (!containers.length) return;

  var hasInfinite = false;

  containers.forEach(function (container) {
    var isInfinite =
      container.getAttribute('data-typo-scroll-infinite') === 'true';

    if (isInfinite) {
      hasInfinite = true;

      var list = container.querySelector('[data-typo-scroll-list]');
      if (list) {
        var clone = list.cloneNode(true);
        clone.style.overflow = 'hidden';
        clone.style.height = '100dvh';
        container.appendChild(clone);
      }
    }
  });

  lenis = new Lenis({
    autoRaf: true,
    infinite: hasInfinite,
    syncTouch: hasInfinite
  });

  if ('fonts' in document && document.fonts.ready) {
    document.fonts.ready.then(function () {
      if (lenis) {
        lenis.resize();
      }
    });
  }

  var isTouchDevice =
    ('ontouchstart' in window) ||
    (navigator.maxTouchPoints > 0) ||
    (navigator.msMaxTouchPoints > 0);

  if (isTouchDevice) {
    function updateActiveItems() {
      var viewportCenterY = window.innerHeight / 2;

      containers.forEach(function (container) {
        var items = container.querySelectorAll('[data-typo-scroll-item]');
        if (!items.length) return;

        var containerRect = container.getBoundingClientRect();

        if (viewportCenterY < containerRect.top || viewportCenterY > containerRect.bottom) {
          items.forEach(function (item) {
            item.setAttribute('data-typo-scroll-item', '');
          });
          return;
        }

        var closestItem = null;
        var closestDistance = Infinity;

        items.forEach(function (item) {
          var rect = item.getBoundingClientRect();
          if (rect.bottom < 0 || rect.top > window.innerHeight) return;

          var itemCenterY = rect.top + rect.height / 2;
          var distance = Math.abs(viewportCenterY - itemCenterY);

          if (distance < closestDistance) {
            closestDistance = distance;
            closestItem = item;
          }
        });

        if (!closestItem) {
          items.forEach(function (item) {
            item.setAttribute('data-typo-scroll-item', '');
          });
          return;
        }

        items.forEach(function (item) {
          item.setAttribute(
            'data-typo-scroll-item',
            item === closestItem ? 'active' : ''
          );
        });
      });

      requestAnimationFrame(updateActiveItems);
    }

    requestAnimationFrame(updateActiveItems);
  } else {
    containers.forEach(function (container) {
      var items = container.querySelectorAll('[data-typo-scroll-item]');
      if (!items.length) return;

      function setActive(target) {
        items.forEach(function (item) {
          item.setAttribute(
            'data-typo-scroll-item',
            item === target ? 'active' : ''
          );
        });
      }

      function clearActive() {
        items.forEach(function (item) {
          item.setAttribute('data-typo-scroll-item', '');
        });
      }

      items.forEach(function (item) {
        item.addEventListener('mouseenter', function () {
          setActive(item);
        });
      });

      container.addEventListener('mouseleave', function () {
        clearActive();
      });
    });
  }
}

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

Guide

Container

Use [data-typo-scroll-init] to mark the element that manages scroll detection, active state logic, and optional infinite duplication of its list.

Infinite

Use [data-typo-scroll-infinite="true"] to enable Lenis infinite scrolling and list duplication inside the container.

List

Use [data-typo-scroll-list] to wrap all scrollable items, allowing the script to duplicate this group when infinite mode is active.

Item

Use [data-typo-scroll-item] to register each element as a selectable entry, giving the script a target for setting its active state.

Active

Use [data-typo-scroll-item="active"] to indicate which item currently aligns with the viewport center on touch devices or sits under the cursor on non-touch devices.

Touch vs Non-Touch Devices

On touch devices the script highlights the item closest to the center of the screen and clears all highlights when the center moves outside the container. On non-touch devices it highlights the item under the cursor and removes all highlights when the cursor leaves the container.