NEMAWASHI LAB

Horizontal Card

CSS 3D

A horizontal card belt: 40 cards are placed around a CSS 3D ring, each at its angular slice, and the whole scene tilts on the X axis via a configurable sceneTilt. Drag or flick rotates the ring, with velocity decaying at 0.92 friction. Off-centre cards pick up progressive Gaussian blur (absAngle * 8 px) and opacity fade, while an 11-slider tuner panel exposes radius, perspective, tilt gain, card width, fade range, lift, spring frequency, damping, and drag sensitivity.

AgPhantomDisplay Serif
RrSignalBold Sans
WwWhisperLight Italic
IiIndigoMono Wide
DdObsidianExtra Bold
BbBoneThin Condensed
CcCrimsonMedium Slab
HzHazeRegular Round
SgSageBook Weight
AmAmberSemi Bold
AgPhantomDisplay Serif
RrSignalBold Sans
WwWhisperLight Italic
IiIndigoMono Wide
DdObsidianExtra Bold
BbBoneThin Condensed
CcCrimsonMedium Slab
HzHazeRegular Round
SgSageBook Weight
AmAmberSemi Bold
AgPhantomDisplay Serif
RrSignalBold Sans
WwWhisperLight Italic
IiIndigoMono Wide
DdObsidianExtra Bold
BbBoneThin Condensed
CcCrimsonMedium Slab
HzHazeRegular Round
SgSageBook Weight
AmAmberSemi Bold
AgPhantomDisplay Serif
RrSignalBold Sans
WwWhisperLight Italic
IiIndigoMono Wide
DdObsidianExtra Bold
BbBoneThin Condensed
CcCrimsonMedium Slab
HzHazeRegular Round
SgSageBook Weight
AmAmberSemi Bold
Belt Tuner
Radius560px
Tilt gain2.50
Perspective2200px
Card width190px
Gap36px
Fade range6.00 cards
Lift0px
Frequency2.40 Hz
Damping1.00
Sensitivity160 px/card
Scene tilt-14°

Source

HorizontalBelt.tsx410 lines
"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import ScrambleLink from "../ScrambleLink";

const CARD_DATA = [
  { 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" },
];

interface TunerConfig {
  radius: number;
  tiltGain: number;
  perspective: number;
  cardWidth: number;
  gap: number;
  fadeRange: number;
  lift: number;
  frequency: number;
  damping: number;
  sensitivity: number;
  sceneTilt: number;
}

const DEFAULTS: TunerConfig = {
  radius: 560,
  tiltGain: 2.5,
  perspective: 2200,
  cardWidth: 190,
  gap: 36,
  fadeRange: 6,
  lift: 0,
  frequency: 2.4,
  damping: 1.0,
  sensitivity: 160,
  sceneTilt: -14,
};

const SLIDER_DEFS: { key: keyof TunerConfig; label: string; min: number; max: number; step: number; unit: string }[] = [
  { key: "radius", label: "Radius", min: 100, max: 1200, step: 10, unit: "px" },
  { key: "tiltGain", label: "Tilt gain", min: 0, max: 3, step: 0.05, unit: "" },
  { key: "perspective", label: "Perspective", min: 400, max: 4000, step: 50, unit: "px" },
  { key: "cardWidth", label: "Card width", min: 120, max: 600, step: 10, unit: "px" },
  { key: "gap", label: "Gap", min: 0, max: 100, step: 2, unit: "px" },
  { key: "fadeRange", label: "Fade range", min: 1, max: 10, step: 0.25, unit: " cards" },
  { key: "lift", label: "Lift", min: -100, max: 100, step: 5, unit: "px" },
  { key: "frequency", label: "Frequency", min: 0.5, max: 8, step: 0.1, unit: " Hz" },
  { key: "damping", label: "Damping", min: 0.1, max: 2, step: 0.05, unit: "" },
  { key: "sensitivity", label: "Sensitivity", min: 40, max: 400, step: 10, unit: " px/card" },
  { key: "sceneTilt", label: "Scene tilt", min: -30, max: 30, step: 1, unit: "°" },
];

function formatVal(v: number, unit: string) {
  if (unit === "" ) return v.toFixed(2);
  if (unit === " cards") return v.toFixed(2) + unit;
  if (unit === " Hz") return v.toFixed(2) + unit;
  if (unit === " px/card") return v + unit;
  if (unit === "°") return v + unit;
  return v + unit;
}

const FRICTION = 0.92;
const LERP = 0.1;
const DEG = 180 / Math.PI;
const RENDER_COUNT = 40;
const RENDERED_CARDS = Array.from({ length: RENDER_COUNT }, (_, i) => CARD_DATA[i % CARD_DATA.length]);

export default function HorizontalBelt() {
  const [cfg, setCfg] = useState<TunerConfig>({ ...DEFAULTS });
  const [dark, setDark] = useState(true);
  const beltRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef<number | null>(null);

  const state = useRef({
    angle: 0,
    velocity: 0,
    dragging: false,
    lastX: 0,
    hovering: false,
    mouseXNorm: 0.5,
    currentTiltX: 0,
    sceneTilt: 0,
  });

  const cfgRef = useRef(cfg);
  cfgRef.current = cfg;
  const updateSlider = useCallback((key: keyof TunerConfig, val: number) => {
    setCfg((prev) => ({ ...prev, [key]: val }));
  }, []);

  useEffect(() => {
    if (!beltRef.current) return;
    const belt = beltRef.current as HTMLDivElement;

    const parent = belt.parentElement!;

    function tick() {
      const s = state.current;
      const c = cfgRef.current;
      const N = RENDER_COUNT;

      if (!s.dragging) {
        s.velocity *= FRICTION;
        s.angle += s.velocity;
      }

      const tiltTarget = s.hovering ? (s.mouseXNorm - 0.5) * c.tiltGain * 15 : 0;
      s.currentTiltX += (tiltTarget - s.currentTiltX) * LERP;

      s.sceneTilt += (c.sceneTilt - s.sceneTilt) * LERP;
      belt.style.transform = `rotateX(${s.sceneTilt.toFixed(2)}deg)`;

      const sliceAngle = (2 * Math.PI) / N;

      const children = belt.children;
      for (let i = 0; i < children.length; i++) {
        const el = children[i] as HTMLElement;

        const cardAngle = s.angle + i * sliceAngle;
        let visible = ((cardAngle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
        if (visible > Math.PI) visible -= 2 * Math.PI;

        const angleDeg = visible * DEG;
        const absAngle = Math.abs(visible);

        const fadeStart = (c.fadeRange * 0.4) * sliceAngle;
        const fadeEnd = (c.fadeRange) * sliceAngle;
        const rawOpacity = absAngle < fadeStart ? 1 : absAngle > fadeEnd ? 0 : 1 - (absAngle - fadeStart) / (fadeEnd - fadeStart);
        const opacity = Math.max(0.35, rawOpacity);

        const liftY = -Math.abs(visible / sliceAngle) * c.lift * 0.5;

        el.style.transform =
          `rotateY(${(angleDeg * c.tiltGain + s.currentTiltX).toFixed(2)}deg) translateZ(${c.radius}px) translateY(${liftY.toFixed(1)}px)`;
        el.style.opacity = Math.max(0, opacity).toFixed(3);
        el.style.width = c.cardWidth + "px";
        el.style.zIndex = String(100 - Math.round(absAngle * 10));

        const blur = Math.max(0, (absAngle - 0.15) * 8);
        el.style.filter = blur > 0.5 ? `blur(${blur.toFixed(1)}px)` : "";
      }

      rafRef.current = requestAnimationFrame(tick);
    }

    const onEnter = () => { state.current.hovering = true; };
    const onLeave = () => {
      state.current.hovering = false;
      state.current.dragging = false;
      parent.style.cursor = "grab";
    };
    const onMove = (e: MouseEvent) => {
      const rect = parent.getBoundingClientRect();
      state.current.mouseXNorm = (e.clientX - rect.left) / rect.width;
      if (state.current.dragging) {
        const dx = e.clientX - state.current.lastX;
        const c = cfgRef.current;
        const anglePerPx = 1 / c.sensitivity;
        state.current.velocity = dx * anglePerPx;
        state.current.angle += dx * anglePerPx;
        state.current.lastX = e.clientX;
      }
    };
    const onDown = (e: MouseEvent) => {
      state.current.dragging = true;
      state.current.lastX = e.clientX;
      state.current.velocity = 0;
      parent.style.cursor = "grabbing";
    };
    const onUp = () => {
      state.current.dragging = false;
      parent.style.cursor = "grab";
    };

    parent.addEventListener("mouseenter", onEnter);
    parent.addEventListener("mouseleave", onLeave);
    parent.addEventListener("mousemove", onMove);
    parent.addEventListener("mousedown", onDown);
    window.addEventListener("mouseup", onUp);

    rafRef.current = requestAnimationFrame(tick);

    return () => {
      parent.removeEventListener("mouseenter", onEnter);
      parent.removeEventListener("mouseleave", onLeave);
      parent.removeEventListener("mousemove", onMove);
      parent.removeEventListener("mousedown", onDown);
      window.removeEventListener("mouseup", onUp);
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  return (
    <div
      className="fixed inset-0 flex items-center justify-center select-none overflow-hidden"
      style={{
        background: dark ? "#0a0a0a" : "#f8f7f4",
        transition: "background 0.4s ease",
      }}
    >
      {/* Belt area */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          cursor: "grab",
          zIndex: 1,
          perspective: cfg.perspective,
          perspectiveOrigin: "center center",
        }}
      >
        <div
          ref={beltRef}
          style={{
            position: "absolute",
            left: "50%",
            top: "50%",
            transformStyle: "preserve-3d",
          }}
        >
          {RENDERED_CARDS.map((card, i) => (
            <div
              key={i}
              style={{
                position: "absolute",
                height: cfg.cardWidth * 1.2,
                borderRadius: 16,
                backgroundColor: card.bg,
                boxShadow: "0 8px 32px rgba(0,0,0,0.1)",
                display: "flex",
                flexDirection: "column",
                alignItems: "center",
                justifyContent: "center",
                marginLeft: -(cfg.cardWidth / 2),
                marginTop: -(cfg.cardWidth * 1.2) / 2,
                backfaceVisibility: "visible",
                willChange: "transform, opacity",
              }}
            >
              <span
                style={{
                  fontFamily: "var(--font-flux), Georgia, serif",
                  fontVariationSettings: "'wght' 300, 'SRIF' 500",
                  fontSize: cfg.cardWidth * 0.35,
                  color: card.color,
                  lineHeight: 1,
                }}
              >
                {card.letter}
              </span>
              <span
                style={{
                  fontFamily: "var(--font-flux), sans-serif",
                  fontVariationSettings: "'wght' 600, 'SRIF' 0",
                  fontSize: 14,
                  color: card.color,
                  marginTop: 16,
                  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.5,
                  letterSpacing: "0.06em",
                  textTransform: "uppercase",
                }}
              >
                {card.subtitle}
              </span>
            </div>
          ))}
        </div>
      </div>

      {/* Belt Tuner */}
      <div
        style={{
          position: "fixed",
          right: 24,
          top: "50%",
          transform: "translateY(-50%)",
          zIndex: 20,
          width: 260,
          background: dark ? "rgba(20,20,20,0.92)" : "rgba(255,255,255,0.92)",
          backdropFilter: "blur(12px)",
          borderRadius: 16,
          padding: "20px 22px",
          boxShadow: dark ? "0 2px 20px rgba(0,0,0,0.3)" : "0 2px 20px rgba(0,0,0,0.06)",
          border: dark ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(0,0,0,0.06)",
          transition: "all 0.4s ease",
        }}
      >
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
          <span
            style={{
              fontFamily: "var(--font-flux), sans-serif",
              fontVariationSettings: "'wght' 500, 'SRIF' 100",
              fontSize: 12,
              letterSpacing: "0.1em",
              textTransform: "uppercase",
              color: dark ? "#fff" : "#1a1a2e",
            }}
          >
            Belt Tuner
          </span>
          <button
            onClick={() => setCfg({ ...DEFAULTS })}
            style={{
              fontFamily: "var(--font-flux), sans-serif",
              fontVariationSettings: "'wght' 300, 'SRIF' 100",
              fontSize: 11,
              color: dark ? "rgba(255,255,255,0.35)" : "rgba(26,26,46,0.35)",
              background: "none",
              border: "none",
              cursor: "pointer",
            }}
          >
            Reset
          </button>
        </div>

        <button
          onClick={() => setDark((v) => !v)}
          style={{
            width: "100%",
            padding: "8px 0",
            marginBottom: 16,
            borderRadius: 8,
            border: dark ? "1px solid rgba(255,255,255,0.2)" : "1px solid rgba(26,26,46,0.1)",
            background: dark ? "rgba(255,255,255,0.1)" : "rgba(26,26,46,0.04)",
            color: dark ? "#fff" : "#1a1a2e",
            fontFamily: "var(--font-flux), sans-serif",
            fontVariationSettings: "'wght' 500, 'SRIF' 100",
            fontSize: 11,
            letterSpacing: "0.08em",
            textTransform: "uppercase" as const,
            cursor: "pointer",
            transition: "all 0.3s ease",
          }}
        >
          {dark ? "Dark" : "Light"}
        </button>

        {SLIDER_DEFS.map((sd) => (
          <div key={sd.key} style={{ marginBottom: 14 }}>
            <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
              <span style={{
                fontFamily: "var(--font-flux), sans-serif",
                fontVariationSettings: "'wght' 400, 'SRIF' 0",
                fontSize: 11,
                color: dark ? "#fff" : "#1a1a2e",
              }}>
                {sd.label}
              </span>
              <span style={{
                fontFamily: "var(--font-flux), sans-serif",
                fontVariationSettings: "'wght' 300, 'SRIF' 100",
                fontSize: 11,
                color: dark ? "rgba(255,255,255,0.4)" : "rgba(26,26,46,0.45)",
              }}>
                {formatVal(cfg[sd.key], sd.unit)}
              </span>
            </div>
            <input
              type="range"
              min={sd.min}
              max={sd.max}
              step={sd.step}
              value={cfg[sd.key]}
              onChange={(e) => updateSlider(sd.key, parseFloat(e.target.value))}
              style={{
                width: "100%",
                accentColor: "#1a1a2e",
                height: 2,
              }}
            />
          </div>
        ))}
      </div>

      {/* Nav */}
      <div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
        <ScrambleLink
          from="HORIZONTAL"
          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