NEMAWASHI LAB

Vertical Card

CSS 3D

Six cards sit on the faces of a CSS 3D cylinder that auto-rotates around the X axis at 360 deg per 9 seconds. Mouse hover tilts the Y axis with a lerped parallax (gain 0.08), and drag overrides velocity with direct mouse-delta input that decays through friction (0.95) on release. Perspective is 800px, backface-visibility hidden, so only one or two faces are ever visible.

GIRAGIRAGIRAGIRAGIRAGIRA
gPhantom SoftAg
!
D

Source

CardCarousel.tsx242 lines
"use client";

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

const CARD_COUNT = 6;
const ROTATION_DURATION = 9;

const cards = [
  {
    bg: "#1a1a2e",
    content: (
      <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 0 }}>
        {[0, 1, 2].map((i) => (
          <span key={i} style={{ fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 800, 'SRIF' 0", fontSize: "clamp(24px, 5vw, 48px)", lineHeight: 1.05, color: "#fff", letterSpacing: "-0.02em" }}>
            GIRAGIRA
          </span>
        ))}
      </div>
    ),
  },
  {
    bg: "#2d2b6b",
    content: (
      <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", position: "relative" }}>
        <span style={{ fontFamily: "var(--font-flux), Georgia, serif", fontVariationSettings: "'wght' 300, 'SRIF' 500", fontSize: "clamp(80px, 16vw, 160px)", color: "#fff", lineHeight: 1 }}>
          g
        </span>
        <span style={{ position: "absolute", top: 16, left: 20, fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 400, 'SRIF' 100", fontSize: 10, color: "rgba(255,255,255,0.4)", letterSpacing: "0.08em", textTransform: "uppercase" as const }}>
          Phantom Soft
        </span>
        <span style={{ position: "absolute", top: 16, right: 20, fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 400, 'SRIF' 100", fontSize: 10, color: "rgba(255,255,255,0.4)" }}>
          Ag
        </span>
      </div>
    ),
  },
  {
    bg: "#8b1a1a",
    content: (
      <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", background: "radial-gradient(circle at 30% 40%, #c0392b 0%, #6b1010 70%)" }}>
        <div style={{ width: "60%", height: "60%", borderRadius: "50%", background: "radial-gradient(circle at 40% 35%, #e74c3c 0%, #8b1a1a 80%)", boxShadow: "inset 0 -20px 40px rgba(0,0,0,0.3)" }} />
      </div>
    ),
  },
  {
    bg: "#f5f0e8",
    content: (
      <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
        <span style={{ fontFamily: "var(--font-flux), Georgia, serif", fontVariationSettings: "'wght' 600, 'SRIF' 500", fontSize: "clamp(60px, 12vw, 120px)", color: "#1a1a2e", lineHeight: 1 }}>
          !
        </span>
      </div>
    ),
  },
  {
    bg: "#e8e4dc",
    content: (
      <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%" }}>
        <div style={{ width: 4, height: "40%", background: "linear-gradient(to bottom, transparent, #b8a88a 20%, #b8a88a 80%, transparent)", borderRadius: 2 }} />
        <div style={{ width: 40, height: 40, borderRadius: "50%", background: "radial-gradient(circle at 45% 40%, #d4c8b0, #a89878)", marginTop: -2 }} />
      </div>
    ),
  },
  {
    bg: "#1a1a1a",
    content: (
      <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", position: "relative" }}>
        <span style={{ fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 700, 'SRIF' 0", fontSize: "clamp(60px, 12vw, 120px)", color: "#fff", lineHeight: 1 }}>
          D
        </span>
      </div>
    ),
  },
];

const LERP = 0.08;
const DRAG_SENSITIVITY = 0.8;
const FRICTION = 0.95;

export default function CardCarousel() {
  const containerRef = useRef<HTMLDivElement>(null);
  const hitRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef<number | null>(null);

  const state = useRef({
    hovering: false,
    dragging: false,
    rotation: 0,
    velocity: (360 / ROTATION_DURATION) / 60,
    targetYTilt: 0,
    currentYTilt: 0,
    lastMouseY: 0,
    mouseX: 0.5,
  });

  useEffect(() => {
    const container = containerRef.current;
    const hit = hitRef.current;
    if (!container || !hit) return;

    function tick() {
      const s = state.current;

      if (!s.dragging) {
        if (s.hovering) {
          s.velocity *= FRICTION;
        } else {
          const autoSpeed = (360 / ROTATION_DURATION) / 60;
          s.velocity += (autoSpeed - s.velocity) * 0.02;
        }
      }

      s.rotation += s.velocity;

      if (s.hovering) {
        s.targetYTilt = (s.mouseX - 0.5) * -30;
      } else {
        s.targetYTilt = 0;
      }
      s.currentYTilt += (s.targetYTilt - s.currentYTilt) * LERP;

      if (container) {
        container.style.transform =
          `rotateY(${s.currentYTilt.toFixed(2)}deg) rotateX(${s.rotation.toFixed(2)}deg)`;
      }

      rafRef.current = requestAnimationFrame(tick);
    }

    const onEnter = (e: MouseEvent) => {
      const s = state.current;
      s.hovering = true;
    };

    const onMove = (e: MouseEvent) => {
      const rect = hit.getBoundingClientRect();
      const s = state.current;
      s.mouseX = (e.clientX - rect.left) / rect.width;

      if (s.dragging) {
        const dy = e.clientY - s.lastMouseY;
        s.velocity = -dy * DRAG_SENSITIVITY;
        s.lastMouseY = e.clientY;
      }
    };

    const onDown = (e: MouseEvent) => {
      const s = state.current;
      s.dragging = true;
      s.lastMouseY = e.clientY;
      s.velocity = 0;
      hit.style.cursor = "grabbing";
    };

    const onUp = () => {
      const s = state.current;
      s.dragging = false;
      hit.style.cursor = "grab";
    };

    const onLeave = () => {
      const s = state.current;
      s.hovering = false;
      s.dragging = false;
      hit.style.cursor = "grab";
    };

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

    rafRef.current = requestAnimationFrame(tick);

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

  const angleStep = 360 / CARD_COUNT;
  const radius = 260;

  return (
    <div className="fixed inset-0 bg-[#f0eeeb] flex flex-col items-center justify-center select-none">
      <div
        ref={hitRef}
        style={{
          perspective: 800,
          perspectiveOrigin: "center center",
          padding: "80px 120px",
          cursor: "grab",
        }}
      >
        <div
          ref={containerRef}
          style={{
            position: "relative",
            width: "clamp(180px, 28vw, 280px)",
            height: "clamp(180px, 28vw, 280px)",
            transformStyle: "preserve-3d",
          }}
        >
          {cards.map((card, i) => (
            <div
              key={i}
              style={{
                position: "absolute",
                inset: 0,
                borderRadius: 20,
                overflow: "hidden",
                backgroundColor: card.bg,
                transform: `rotateX(${i * angleStep}deg) translateZ(${radius}px)`,
                backfaceVisibility: "hidden",
                boxShadow: "0 4px 24px rgba(0,0,0,0.12)",
              }}
            >
              {card.content}
            </div>
          ))}
        </div>
      </div>

      <div className="fixed bottom-6 left-6" style={{ zIndex: 10 }}>
        <ScrambleLink
          from="CARDS"
          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