Keyring

PSRW
API keysFour on the ring. Pick one.
No dependencies
Code format
React + Tailwind
import { useEffect, useRef, useState } from 'react';

const KEYS = [
  { name: 'Production', mark: 'P', value: 'mv_prod_7Q2k9XwL4f2a', bow: 'round', tone: '--kit-200', rest: 58 },
  { name: 'Staging', mark: 'S', value: 'mv_stag_c81PzR0dTe6b', bow: 'square', tone: '--kit-400', rest: 20 },
  { name: 'Read only', mark: 'R', value: 'mv_read_Hn3vY5sGu19c', bow: 'hex', tone: '--kit-300', rest: -20 },
  { name: 'Webhooks', mark: 'W', value: 'mv_hook_m0JfB7aKq42d', bow: 'clover', tone: '--kit-500', rest: -58 },
];
const RING = { x: 134, y: 40, r: 24 };
// Where the other three hang while one key is chosen, per chosen key.
const ASIDE = [
  [76, 44, -56],
  [76, -44, -72],
  [76, 44, -72],
  [76, 40, -40],
];
const mask = (v: string) => `${v.slice(0, 8)}••••${v.slice(-4)}`;

// The blade: every notch comes from a character of the key, so its shape is its value.
function blade(value: string) {
  const code = value.slice(-11);
  let d = 'M-3.5 24 L-3.5 94 L0 99 L3.5 96';
  [...code].forEach((ch, i) => {
    const y = 92 - i * 5.4;
    const depth = 3.5 + (ch.charCodeAt(0) % 4) * 2.4;
    d += ` L${depth.toFixed(1)} ${(y - 1.4).toFixed(1)} L${depth.toFixed(1)} ${(y - 3.6).toFixed(1)} L3.5 ${(y - 5).toFixed(1)}`;
  });
  return `${d} L3.5 24 Z`;
}
const BOWS: Record<string, string> = {
  round: 'M0 -1 A14 14 0 1 1 0 27 A14 14 0 1 1 0 -1 Z',
  square: 'M-11 0 H11 A3 3 0 0 1 14 3 V22 A3 3 0 0 1 11 25 H-11 A3 3 0 0 1 -14 22 V3 A3 3 0 0 1 -11 0 Z',
  hex: 'M0 -2 L13.5 5.5 L13.5 20.5 L0 28 L-13.5 20.5 L-13.5 5.5 Z',
  clover: 'M0 -1 A7.6 7.6 0 0 1 8 8 A7.8 7.8 0 1 1 5 21 L0 26 L-5 21 A7.8 7.8 0 1 1 -8 8 A7.6 7.6 0 0 1 0 -1 Z',
};

const css = `
  .card-keyring { flex: none; --paper: light-dark(#ebedf0, #f1f2f3); --print: light-dark(#373a40, #1a1c1f); --soft: color-mix(in oklab, var(--print) 80%, var(--paper)); position: relative; width: 268px; height: 224px; }
  .card-keyring svg { position: absolute; left: 0; top: 0; width: 268px; height: 170px; overflow: visible; }
  .card-keyring__screw { fill: light-dark(#6d7179, #7d8087); }
  .card-keyring__hook { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 3.4; stroke-linecap: round; }
  .card-keyring__ring { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 4; }
  .card-keyring__ring--hi { stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent); stroke-width: 1; }
  .card-keyring__key { cursor: pointer; outline: none; }
  .card-keyring__steel { fill-rule: evenodd; stroke: color-mix(in oklab, light-dark(#fbfcfd, #121417) 34%, transparent); stroke-width: 0.8; filter: drop-shadow(0 2px 2px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.32))); transition: opacity 0.25s; }
  .card-keyring__key:focus-visible .card-keyring__steel { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.8; }
  .card-keyring__mark { fill: light-dark(#fbfcfd, #121417); font: 800 10.5px system-ui, sans-serif; text-anchor: middle; pointer-events: none; }
  .card-keyring__groove { fill: none; stroke: color-mix(in oklab, light-dark(#fbfcfd, #121417) 34%, transparent); stroke-width: 1; pointer-events: none; }
  .card-keyring[data-picked='true'] .card-keyring__key:not([aria-pressed='true']) .card-keyring__steel { opacity: 0.5; }
  .card-keyring__tagwrap { position: absolute; left: 12px; right: 12px; bottom: 0; filter: drop-shadow(0 3px 5px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); }
  .card-keyring__tag { position: relative; display: flex; align-items: center; gap: 10px; box-sizing: border-box; height: 46px; padding: 0 8px 0 24px; border-radius: 6px 10px 10px 6px; background: var(--paper); color: var(--print); clip-path: polygon(13px 0, 100% 0, 100% 100%, 13px 100%, 0 72%, 0 28%); }
  .card-keyring__eyelet { position: absolute; left: 9px; top: 50%; width: 7px; height: 7px; margin-top: -3.5px; border-radius: 50%; background: var(--soft); }
  .card-keyring__text { min-width: 0; flex: 1; }
  .card-keyring__name { display: block; font-size: 13px; font-weight: 700; line-height: 1.2; }
  .card-keyring__value { display: block; overflow: hidden; color: var(--soft); font: 600 10px/1.3 'Courier New', Courier, monospace; text-overflow: ellipsis; white-space: nowrap; }
  .card-keyring__copy { flex: none; min-width: 50px; height: 28px; padding: 0 10px; border: 0; border-radius: 7px; background: var(--print); color: var(--paper); font: inherit; font-size: 11px; font-weight: 700; cursor: pointer; }
  .card-keyring__copy:focus-visible { outline: 2px solid var(--print); outline-offset: 2px; }
  .card-keyring__tag--empty { padding-right: 16px; }
`;

export function KeyringCard() {
  const root = useRef<HTMLDivElement>(null);
  const [picked, setPicked] = useState<number | null>(null);
  const [copied, setCopied] = useState(false);
  const pickedRef = useRef<number | null>(null);
  const wake = useRef<() => void>(() => {});
  const copyTimer = useRef(0);

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
    const groups = [...el.querySelectorAll<SVGGElement>('.card-keyring__key')];
    const ang = KEYS.map((k) => k.rest);
    const vel = KEYS.map(() => 0);
    let raf = 0;
    let last = 0;
    let px = 0;
    let py = 0;

    const goalOf = (i: number) => {
      const p = pickedRef.current;
      if (p === null) return KEYS[i].rest;
      if (p === i) return 0;
      // The others swing out of the way, on their own side of the one you chose.
      const others = KEYS.map((_, k) => k).filter((k) => k !== p);
      return ASIDE[p][others.indexOf(i)];
    };
    const paint = () => {
      groups.forEach((g, i) => g.setAttribute('transform', `translate(${RING.x} ${RING.y}) rotate(${ang[i].toFixed(2)}) translate(0 ${RING.r})`));
    };
    const tick = (t: number) => {
      const dt = Math.min(0.033, (t - last) / 1000 || 0.016);
      last = t;
      let active = false;
      ang.forEach((a, i) => {
        // A pendulum with a little drag, so they ring on for a while like real keys.
        vel[i] += ((goalOf(i) - a) * 62 - vel[i] * 3.2) * dt;
        ang[i] += vel[i] * dt;
        if (Math.abs(vel[i]) > 0.4 || Math.abs(goalOf(i) - ang[i]) > 0.1) active = true;
      });
      paint();
      raf = active ? requestAnimationFrame(tick) : 0;
    };
    wake.current = () => {
      if (calm) {
        ang.forEach((_, i) => {
          ang[i] = goalOf(i);
        });
        paint();
        return;
      }
      if (!raf) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    };
    const brush = (e: PointerEvent) => {
      if (calm) return;
      const box = el.getBoundingClientRect();
      const x = e.clientX - box.left;
      const y = e.clientY - box.top;
      const sweep = x - px;
      const lift = y - py;
      px = x;
      py = y;
      const near = Math.hypot(x - RING.x, y - RING.y);
      if (near < 26 || near > 130 || y > 170) return;
      const at = (Math.atan2(x - RING.x, y - RING.y) * 180) / Math.PI;
      let touched = false;
      ang.forEach((a, i) => {
        // Screen angle of the key from the ring: rotate() goes the other way round from atan2.
        if (Math.abs(-a - at) < 12) {
          vel[i] += Math.max(-120, Math.min(120, -sweep * 9 + lift * 2));
          touched = true;
        }
      });
      if (touched) wake.current();
    };
    el.addEventListener('pointermove', brush);
    paint();
    return () => {
      cancelAnimationFrame(raf);
      window.clearTimeout(copyTimer.current);
      el.removeEventListener('pointermove', brush);
    };
  }, []);

  const choose = (i: number) => {
    const next = picked === i ? null : i;
    pickedRef.current = next;
    setPicked(next);
    setCopied(false);
    wake.current();
  };
  const copy = () => {
    if (picked === null) return;
    navigator.clipboard?.writeText(KEYS[picked].value).catch(() => {});
    setCopied(true);
    window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => setCopied(false), 1400);
  };

  return (
    <>
      <style>
        {css}
      </style>
      <div ref={root} className="card-keyring" data-picked={picked !== null}>
        <svg viewBox="0 0 268 170" role="group" aria-label="API keys">
          {KEYS.map((k, i) => (
            <g
              key={k.name}
              className="card-keyring__key"
              role="button"
              tabIndex={0}
              aria-label={`${k.name} key`}
              aria-pressed={picked === i}
              transform={`translate(${RING.x} ${RING.y}) rotate(${k.rest}) translate(0 ${RING.r})`}
              onClick={() => choose(i)}
              onKeyDown={(e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                  e.preventDefault();
                  choose(i);
                }
              }}
            >
              <path className="card-keyring__steel" fill={`var(${k.tone})`} d={`${BOWS[k.bow]} M0 3 a3.4 3.4 0 1 0 0.01 0 Z`} />
              <path className="card-keyring__steel" fill={`var(${k.tone})`} d={blade(k.value)} />
              <path className="card-keyring__groove" d="M-0.6 30 L-0.6 90" />
              <text className="card-keyring__mark" x="0" y="20">
                {k.mark}
              </text>
            </g>
          ))}
          <circle className="card-keyring__screw" cx={RING.x} cy="5" r="4" />
          <path className="card-keyring__hook" d={`M${RING.x} 5 L${RING.x} 19`} />
          <circle className="card-keyring__ring" cx={RING.x} cy={RING.y} r={RING.r} />
          <circle className="card-keyring__ring card-keyring__ring--hi" cx={RING.x} cy={RING.y} r={RING.r} />
        </svg>
        <div className="card-keyring__tagwrap">
        <div className={`card-keyring__tag${picked === null ? ' card-keyring__tag--empty' : ''}`}>
          <span className="card-keyring__eyelet" aria-hidden="true" />
          <span className="card-keyring__text" role="status">
            <span className="card-keyring__name">{picked === null ? 'API keys' : KEYS[picked].name}</span>
            <span className="card-keyring__value">{picked === null ? 'Four on the ring. Pick one.' : mask(KEYS[picked].value)}</span>
          </span>
          {picked !== null && (
            <button type="button" className="card-keyring__copy" onClick={copy}>
              {copied ? 'Copied' : 'Copy'}
            </button>
          )}
        </div>
        </div>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/card-keyring
Source on GitHub

About this component

API keys as real keys on a ring. Brush past and they jingle; pick one and it swings to the front. Its teeth are cut from the key itself, so no two look alike.

Dependencies
None
Difficulty
Advanced
Triggers
card, click, mouse tracking, spring
Techniques
raf, spring, event position, svg, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

Each key is an SVG group with role="button", tabindex, aria-pressed and a name, and takes Enter and Space. The tag reads the chosen key from a role="status" area and Copy is a real button. Under prefers-reduced-motion the keys move at once and do not jingle.

More cards

See all
Ana Kerr, Art director, 020 7946 0112
Rolodex

Reading list · 6 books

Bookshelf
Snow globe
Papilio machaonSwallowtail, plate IV
Specimen