Orbit cards
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Cards orbit on a ring; drag sideways to spin them, hover to slow down.
Code
"use client";
import { useEffect, useRef } from "react";
import { prefersReducedMotion, Surface } from "../ui";
const CARDS = [
{ label: "Design", glyph: "◐", bg: "#151515", fg: "#fff" },
{ label: "Motion", glyph: "↻", bg: "#2B46B8", fg: "#fff" },
{ label: "Type", glyph: "Aa", bg: "#ffffff", fg: "#151515" },
{ label: "Colour", glyph: "●", bg: "#f2b33d", fg: "#151515" },
{ label: "Sound", glyph: "♪", bg: "#e9e8e3", fg: "#151515" },
{ label: "Code", glyph: "{}", bg: "#ffffff", fg: "#2B46B8" },
{ label: "Grid", glyph: "#", bg: "#151515", fg: "#f2b33d" },
];
export default function OrbitCards() {
const host = useRef<HTMLDivElement>(null);
const cards = useRef<(HTMLDivElement | null)[]>([]);
const state = useRef({ angle: 0, vel: 0.004, drag: false, lastX: 0, hover: false });
useEffect(() => {
const el = host.current;
if (!el) return;
const still = prefersReducedMotion();
let raf = 0;
const loop = () => {
const s = state.current;
const r = el.getBoundingClientRect();
if (!s.drag) {
const cruise = s.hover ? 0.0015 : 0.004;
s.vel += (cruise - s.vel) * 0.02;
s.angle += s.vel;
}
const rx = r.width * 0.34;
const ry = r.height * 0.16;
CARDS.forEach((_, i) => {
const c = cards.current[i];
if (!c) return;
const a = s.angle + (i / CARDS.length) * Math.PI * 2;
const depth = (Math.sin(a) + 1) / 2; // 0 back … 1 front
const x = Math.cos(a) * rx;
const y = Math.sin(a) * ry;
const sc = 0.62 + depth * 0.48;
c.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${sc}) rotate(${Math.cos(a) * -6}deg)`;
c.style.zIndex = String(Math.round(depth * 100));
c.style.filter = `blur(${(1 - depth) * 1.6}px)`;
c.style.opacity = String(0.45 + depth * 0.55);
});
if (!still) raf = requestAnimationFrame(loop);
};
loop();
return () => cancelAnimationFrame(raf);
}, []);
return (
<Surface>
<div
ref={host}
className="absolute inset-0 cursor-grab touch-pan-y active:cursor-grabbing"
onPointerEnter={() => (state.current.hover = true)}
onPointerLeave={() => {
state.current.hover = false;
state.current.drag = false;
}}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
Object.assign(state.current, { drag: true, lastX: e.clientX, vel: 0 });
}}
onPointerMove={(e) => {
const s = state.current;
if (!s.drag) return;
const w = host.current!.getBoundingClientRect().width;
const d = ((e.clientX - s.lastX) / w) * 4;
s.angle -= d;
s.vel = -d;
s.lastX = e.clientX;
}}
onPointerUp={() => (state.current.drag = false)}
onPointerCancel={() => (state.current.drag = false)}
>
<div className="absolute left-1/2 top-1/2 h-[32%] w-[68%] -translate-x-1/2 -translate-y-1/2 rounded-[50%] border border-dashed border-[#d6d5d0]" />
{CARDS.map((c, i) => (
<div
key={c.label}
ref={(n) => void (cards.current[i] = n)}
className="absolute left-1/2 top-1/2 flex h-[6em] w-[5em] flex-col justify-between rounded-[0.9em] p-[0.6em] shadow-[0_10px_24px_-12px_rgba(0,0,0,0.35),0_0_0_1px_rgba(0,0,0,0.04)] will-change-transform"
style={{ background: c.bg, color: c.fg }}
>
<span className="text-[1.5em] leading-none">{c.glyph}</span>
<span className="text-[0.7em] font-medium">{c.label}</span>
</div>
))}
</div>
</Surface>
);
}
Prompt
Build "Orbit cards", a card animation. What it does: Cards orbit on a ring; drag sideways to spin them, hover to slow down. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).