Five layers of scattered shapes -- large translucent squares in the back, small bright circles in the front -- each scroll at a different fraction of the page speed (0.1x to 1.0x) via ScrollTrigger with scrub. Text headlines fade in from 60px below when they cross 80% of the viewport, then reverse out on scroll-up. The depth illusion comes from coupling size, opacity, and count: background layers have fewer, larger, dimmer shapes, foreground layers have many small bright ones.
Source
"use client";
import { useRef, useEffect, useState } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import ScrambleLink from "../ScrambleLink";
gsap.registerPlugin(ScrollTrigger);
const LAYERS = [
{ speed: 0.1, color: "rgba(255,255,255,0.03)", size: 200, count: 5 },
{ speed: 0.3, color: "rgba(255,255,255,0.05)", size: 120, count: 8 },
{ speed: 0.5, color: "rgba(255,255,255,0.08)", size: 80, count: 12 },
{ speed: 0.7, color: "rgba(255,255,255,0.12)", size: 50, count: 18 },
{ speed: 1.0, color: "rgba(255,255,255,0.2)", size: 24, count: 25 },
];
function seededRandom(seed: number) {
const x = Math.sin(seed * 9301 + 49297) * 49297;
return x - Math.floor(x);
}
export default function ParallaxLayers() {
const containerRef = useRef<HTMLDivElement>(null);
const [dark] = useState(true);
useEffect(() => {
document.documentElement.classList.add("allow-scroll");
return () => document.documentElement.classList.remove("allow-scroll");
}, []);
useEffect(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
const layers = gsap.utils.toArray<HTMLElement>(".plx-layer");
layers.forEach((layer) => {
const speed = parseFloat(layer.dataset.speed || "0.5");
gsap.to(layer, {
yPercent: -50 * speed,
ease: "none",
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "bottom top",
scrub: true,
},
});
});
const texts = gsap.utils.toArray<HTMLElement>(".plx-text");
texts.forEach((text) => {
gsap.from(text, {
opacity: 0,
y: 60,
duration: 0.8,
ease: "power3.out",
scrollTrigger: {
trigger: text,
start: "top 80%",
toggleActions: "play none none reverse",
},
});
});
}, containerRef);
return () => ctx.revert();
}, []);
return (
<div
ref={containerRef}
className="relative"
style={{ background: "#0a0a0a", minHeight: "400vh" }}
>
{/* Parallax layers */}
{LAYERS.map((layer, li) => (
<div
key={li}
className="plx-layer"
data-speed={layer.speed}
style={{
position: "fixed",
inset: 0,
pointerEvents: "none",
zIndex: li,
}}
>
{Array.from({ length: layer.count }, (_, i) => (
<div
key={i}
style={{
position: "absolute",
width: layer.size,
height: layer.size,
borderRadius: layer.size < 60 ? "50%" : 12,
background: dark ? layer.color : layer.color.replace("255,255,255", "26,26,46"),
left: `${seededRandom(li * 100 + i) * 100}%`,
top: `${seededRandom(li * 100 + i + 50) * 120}%`,
}}
/>
))}
</div>
))}
{/* Content sections */}
<div style={{ position: "relative", zIndex: 10 }}>
<div className="h-screen flex items-center justify-center">
<p
className="plx-text text-center"
style={{
fontFamily: "var(--font-flux), Georgia, serif",
fontVariationSettings: "'wght' 600, 'SRIF' 500",
fontSize: "clamp(32px, 6vw, 72px)",
color: "#fff",
lineHeight: 1.1,
}}
>
Parallax
</p>
</div>
{["Depth", "Layers", "Motion", "Space", "Infinite"].map((word, i) => (
<div key={word} className="h-screen flex items-center justify-center">
<p
className="plx-text"
style={{
fontFamily: "var(--font-flux), Georgia, serif",
fontVariationSettings: `'wght' ${200 + i * 100}, 'SRIF' ${i * 100}`,
fontSize: "clamp(24px, 5vw, 56px)",
color: `rgba(255,255,255,${0.3 + i * 0.15})`,
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{word}
</p>
</div>
))}
</div>
{/* Nav */}
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink from="PARALLAX" to="← LAB" href="/lab" className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors" style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }} />
</div>
</div>
);
}