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 →Preview
Source
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;
}
}
Dependencies
lenis