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.
Source
"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>
);
}