NEMAWASHI LAB

Scroll Gallery

CSS 3D

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.

Giragira Lab
SCROLL GALLERY 2026
AgPhantomDisplay Serif
RrSignalBold Sans
WwWhisperLight Italic
IiIndigoMono Wide
DdObsidianExtra Bold
BbBoneThin Condensed
CcCrimsonMedium Slab
HzHazeRegular Round
SgSageBook Weight
AmAmberSemi Bold

Source

ScrollGallery.tsx202 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe