AnimationsAdvancedApril 8, 2026

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.

View Full Demo →
index.jsx
"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
        )}
    </>
  );
}
styles.module.css
.section {
  color: #2b2b2b;
  background-color: #c9ccc5;
  width: 100%;
  height: 100dvh;
  overflow: hidden;
  position: relative;
}

.collection {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
}

.item {
  width: 100%;
}

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

.heading {
  text-align: center;
  letter-spacing: -0.05em;
  text-transform: uppercase;
  white-space: nowrap;
  margin: 0;
  font-size: 7.5vw;
  line-height: 0.9;
  transition: color 0.2s ease;
}

.itemActive .heading {
  color: #6b6b6b;
  mix-blend-mode: difference;
}

/* ── Portal overlay ── */

.mediaPortal {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 9999;
}

.media {
  aspect-ratio: 3 / 4;
  width: 17.5vw;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  overflow: hidden;
  opacity: 0;
  --po: 1.5em;
  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))
  );
  transition:
    clip-path 1.2s cubic-bezier(0.16, 1, 0.3, 1),
    opacity 0.15s ease;
}

.mediaActive {
  clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
  opacity: 1;
}

.ratio32 {
  aspect-ratio: 3 / 2;
  width: 25vw;
}

.ratio23 {
  aspect-ratio: 2 / 3;
  width: 16.5vw;
}

.ratio11 {
  aspect-ratio: 1;
  width: 20vw;
}

.img {
  object-fit: cover;
}

.mediaLabel {
  -webkit-backdrop-filter: blur(1em);
  backdrop-filter: blur(1em);
  color: #f4f4f4;
  text-align: center;
  white-space: nowrap;
  background-color: rgba(32, 29, 29, 0.2);
  margin: 0;
  padding: 0.25em;
  font-family: monospace;
  font-size: 0.75em;
  position: absolute;
  bottom: 2em;
  left: 50%;
  transform: translateX(-50%);
}

@media (max-width: 991px) {
  .heading {
    font-size: 11vw;
  }

  .media {
    width: 52.5vw;
  }

  .ratio32 {
    width: 75vw;
  }

  .ratio23 {
    width: 49.5vw;
  }

  .ratio11 {
    width: 60vw;
  }
}
  • lenis

Apr 27, 2026

ANIMATIONS

Cursor Hover Label

A custom cursor label that follows the mouse and fades in when hovering trigger elements. Uses GSAP quickTo for smooth tracking. Trigger elements use data-cursor-label attributes to set label text. Inspired by portfolio/agency sites like Studio PIC.

Apr 27, 2026

ANIMATIONS

Section Transition 01

GSAP ScrollTrigger-based section transition system. Sibling sections opt into parallax, pin, or reveal modes via data attributes. Supports y offset, overlay opacity, and overlay color per section. Mobile strategy simplifies motion on smaller screens.

Apr 27, 2026

ANIMATIONS

Text Reveal 01

GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.