Baktiyar Ablaikhan

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).