A vertical scroll-snapped stack of cards that tilt in 3D as they move through the viewport. Each card's rotateX is proportional to its distance from the viewport centre (dist * -75 deg), with a slight alternating rotateZ for lateral sway. Cards far from centre pick up RGB chromatic-aberration box-shadows -- the red and blue offsets scale with distance, maxing out at about 4px and 0.2 alpha. No JS animation library; the rAF loop just reads bounding rects.
Source
"use client";
import { useEffect, useRef } from "react";
import ScrambleLink from "../ScrambleLink";
const CARDS = [
{ bg: "#1a1a2e", color: "#fff", title: "Phantom", subtitle: "Display Serif", letter: "Ag" },
{ bg: "#e74c3c", color: "#fff", title: "Signal", subtitle: "Bold Sans", letter: "Rr" },
{ bg: "#f5f0e8", color: "#1a1a2e", title: "Whisper", subtitle: "Light Italic", letter: "Ww" },
{ bg: "#2d2b6b", color: "#fff", title: "Indigo", subtitle: "Mono Wide", letter: "Ii" },
{ bg: "#1a1a1a", color: "#fff", title: "Obsidian", subtitle: "Extra Bold", letter: "Dd" },
{ bg: "#e8e4dc", color: "#1a1a2e", title: "Bone", subtitle: "Thin Condensed", letter: "Bb" },
{ bg: "#8b1a1a", color: "#fff", title: "Crimson", subtitle: "Medium Slab", letter: "Cc" },
{ bg: "#f0e6ff", color: "#2d2b6b", title: "Haze", subtitle: "Regular Round", letter: "Hz" },
{ bg: "#d4edda", color: "#1a1a2e", title: "Sage", subtitle: "Book Weight", letter: "Sg" },
{ bg: "#fff3cd", color: "#1a1a2e", title: "Amber", subtitle: "Semi Bold", letter: "Am" },
];
const CARD_W = 420;
const CARD_H = 290;
export default function ScrollGallery() {
const scrollRef = useRef<HTMLDivElement>(null);
const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const scroller = scrollRef.current;
if (!scroller) return;
function update() {
const viewH = window.innerHeight;
const centerY = viewH / 2;
cardsRef.current.forEach((el, i) => {
if (!el) return;
const rect = el.getBoundingClientRect();
const cardCenterY = rect.top + rect.height / 2;
const dist = (cardCenterY - centerY) / viewH;
const rotateX = dist * -75;
const rotateZ = dist * 12 * (i % 2 === 0 ? 1 : -1);
const absD = Math.abs(dist);
const chroma = Math.min(absD * 6, 4);
const chromaAlpha = Math.min(absD * 0.4, 0.2);
el.style.transform =
`perspective(900px) rotateX(${rotateX.toFixed(2)}deg) rotateZ(${rotateZ.toFixed(2)}deg)`;
el.style.boxShadow = chroma > 0.3
? `${chroma.toFixed(1)}px ${(-chroma * 0.6).toFixed(1)}px 0 rgba(255,20,60,${chromaAlpha.toFixed(3)}), ` +
`${(-chroma).toFixed(1)}px ${(chroma * 0.6).toFixed(1)}px 0 rgba(30,80,255,${chromaAlpha.toFixed(3)}), ` +
`0 4px 24px rgba(0,0,0,0.08)`
: `0 4px 24px rgba(0,0,0,0.08)`;
});
rafRef.current = requestAnimationFrame(update);
}
rafRef.current = requestAnimationFrame(update);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<div
ref={scrollRef}
style={{
position: "fixed",
inset: 0,
background: "#e8e5e0",
overflowY: "auto",
overflowX: "hidden",
scrollSnapType: "y mandatory",
}}
>
{/* Fixed left label */}
<div
style={{
position: "fixed",
left: 40,
top: "50%",
transform: "translateY(-50%)",
zIndex: 10,
pointerEvents: "none",
}}
>
<div
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 0",
fontSize: 22,
color: "#1a1a2e",
letterSpacing: "-0.01em",
}}
>
Giragira Lab
</div>
<div
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11,
color: "rgba(26,26,46,0.4)",
letterSpacing: "0.1em",
textTransform: "uppercase",
marginTop: 4,
}}
>
SCROLL GALLERY 2026
</div>
</div>
{/* Card column */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: `calc(50vh - ${CARD_H / 2}px)`,
paddingBottom: `calc(50vh - ${CARD_H / 2}px)`,
gap: 120,
}}
>
{CARDS.map((card, i) => (
<div
key={i}
ref={(el) => { cardsRef.current[i] = el; }}
style={{
flexShrink: 0,
width: CARD_W,
height: CARD_H,
borderRadius: 14,
backgroundColor: card.bg,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
willChange: "transform",
transformOrigin: "center center",
overflow: "hidden",
scrollSnapAlign: "center",
}}
>
<span
style={{
fontFamily: "var(--font-flux), Georgia, serif",
fontVariationSettings: "'wght' 300, 'SRIF' 500",
fontSize: CARD_W * 0.28,
color: card.color,
lineHeight: 1,
}}
>
{card.letter}
</span>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 600, 'SRIF' 0",
fontSize: 15,
color: card.color,
marginTop: 18,
letterSpacing: "0.04em",
opacity: 0.9,
}}
>
{card.title}
</span>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11,
color: card.color,
marginTop: 4,
opacity: 0.45,
letterSpacing: "0.06em",
textTransform: "uppercase",
}}
>
{card.subtitle}
</span>
</div>
))}
</div>
{/* Nav */}
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink
from="SCROLL"
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>
);
}