April 8, 2026AdvancedText 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.
Preview
Source
"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
)}
</>
);
}
Dependencies
lenis