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