April 8, 2026/Advanced/Text Effects

Big Typo Scroll Preview

Large-type scrollable project list where hovering (desktop) or scrolling to center (touch) reveals a clipped image preview. Infinite scroll via Lenis with a polygon clip-path reveal animation.

"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import styles from "./styles.module.css";

const ASPECT_MAP = {
  "3/2": styles.ratio32,
  "2/3": styles.ratio23,
  "1/1": styles.ratio11,
};

// Render 4 copies of items so Lenis infinite scroll loops seamlessly
function buildList(items) {
  return [...items, ...items, ...items, ...items];
}

export default function TypoScrollPreview({ items = [] }) {
  const wrapperRef = useRef(null);
  const collectionRef = useRef(null);
  const [activeIndex, setActiveIndex] = useState(-1);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    const collection = collectionRef.current;
    if (!wrapper || !collection || !items.length) return;

    let lenis;
    let rafId;

    const isTouchDevice =
      "ontouchstart" in window || navigator.maxTouchPoints > 0;

    (async () => {
      const { default: Lenis } = await import("lenis");
      lenis = new Lenis({
        wrapper,
        content: collection,
        autoRaf: true,
        infinite: true,
        syncTouch: true,
      });
      document.fonts?.ready.then(() => lenis?.resize());
    })();

    // Touch: RAF proximity check — highlight item closest to viewport center
    if (isTouchDevice) {
      const tick = () => {
        const centerY = window.innerHeight / 2;
        const rect = wrapper.getBoundingClientRect();

        if (centerY < rect.top || centerY > rect.bottom) {
          setActiveIndex(-1);
          rafId = requestAnimationFrame(tick);
          return;
        }

        let closest = null;
        let minDist = Infinity;
        wrapper.querySelectorAll("[data-item-index]").forEach((el) => {
          const r = el.getBoundingClientRect();
          if (r.bottom < 0 || r.top > window.innerHeight) return;
          const dist = Math.abs(centerY - (r.top + r.height / 2));
          if (dist < minDist) {
            minDist = dist;
            closest = el;
          }
        });

        const idx = closest
          ? parseInt(closest.dataset.itemIndex, 10)
          : -1;
        setActiveIndex(idx);
        rafId = requestAnimationFrame(tick);
      };
      rafId = requestAnimationFrame(tick);
    }

    return () => {
      lenis?.destroy();
      cancelAnimationFrame(rafId);
    };
  }, [items]);

  const allItems = buildList(items);

  return (
    <>
      <section
        ref={wrapperRef}
        className={styles.section}
        onMouseLeave={() => setActiveIndex(-1)}
      >
        <div ref={collectionRef} className={styles.collection}>
          {allItems.map((item, i) => {
            const realIdx = i % items.length;
            const isActive = realIdx === activeIndex;
            return (
              <div
                key={i}
                className={`${styles.item} ${isActive ? styles.itemActive : ""}`}
                data-item-index={realIdx}
                onMouseEnter={() => setActiveIndex(realIdx)}
              >
                <a href={item.href} className={styles.link}>
                  <h3 className={styles.heading}>{item.label}</h3>
                </a>
              </div>
            );
          })}
        </div>
      </section>

      {/* Portal keeps the fixed overlay outside any transformed ancestor */}
      {mounted &&
        createPortal(
          <div className={styles.mediaPortal} aria-hidden="true">
            {items.map((item, i) => (
              <div
                key={i}
                className={`${styles.media} ${ASPECT_MAP[item.aspect] ?? ""} ${
                  i === activeIndex ? styles.mediaActive : ""
                }`}
              >
                <Image
                  src={item.image}
                  alt=""
                  fill
                  className={styles.img}
                  sizes="25vw"
                />
                <p className={styles.mediaLabel}>[ OPEN CASE ]</p>
              </div>
            ))}
          </div>,
          document.body
        )}
    </>
  );
}
  • lenis

Related Components

1MO AGO
Text Flipping Boardtext-effects
3MO AGO
Text Reveal Systemtext-effects
3MO AGO
Highlight Texttext-effects