Coin

No dependencies
Code format
React + Tailwind
const R = 38; // coin radius
const T = 4.2;
const rad = (d: number) => (d * Math.PI) / 180;
type F = { u: number; spin: number; lean: number };
// Spinning down: the lean grows faster and faster while the wobble speeds up (an Euler's disk).
const SPIN_END = 0.6;
const K = 1650;
const frames: F[] = Array.from({ length: 29 }, (_, i) => {
  const u = i / 28;
  return { u: u * SPIN_END, lean: 6 + 80 * u ** 2.6, spin: -K * Math.log(1 - 0.85 * u) };
});
const SPIN_A = frames[frames.length - 1].spin;
const REST = Math.ceil((SPIN_A + 220) / 360) * 360;
// The last chatter as it slaps flat, the wobble running down to a stop face up.
[
  [0.64, 89.2],
  [0.665, 90.7],
  [0.685, 89.6],
  [0.7, 90.3],
  [0.72, 90],
  [0.84, 90],
].forEach(([u, lean]) => {
  const p = Math.min(1, (u - SPIN_END) / 0.12);
  frames.push({ u, lean, spin: SPIN_A + (REST - SPIN_A) * (1 - (1 - p) ** 2) });
});
// Flicked: up off the table, a flip and a half, and down onto its edge spinning again.
frames.push({ u: 0.92, lean: 228, spin: REST + 200 }, { u: 1, lean: 366, spin: REST + 360 });

const height = (f: F) => (f.u > 0.84 && f.u < 1 ? -56 : -R * Math.abs(Math.cos(rad(f.lean))));
const pct = (u: number) => `${(u * 100).toFixed(2)}%`;
const coinFrames = frames
  .map((f) => {
    const ease = f.u === 0.84 ? ' animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1);' : f.u === 0.92 ? ' animation-timing-function: cubic-bezier(0.6, 0, 0.8, 0.4);' : '';
    return `${pct(f.u)} { transform: translateY(${height(f).toFixed(1)}px) rotateY(${f.spin.toFixed(0)}deg) rotateX(${f.lean.toFixed(1)}deg);${ease} }`;
  })
  .join(' ');
const shadowFrames = frames
  .map((f) => {
    const k = Math.max(0.1, Math.abs(Math.sin(rad(f.lean))));
    const away = f.u > 0.84 && f.u < 1 ? 0.55 : 1;
    return `${pct(f.u)} { transform: rotateY(${f.spin.toFixed(0)}deg) rotateX(90deg) scale(${(away * (0.9 + 0.1 * k)).toFixed(2)}, ${(away * k).toFixed(2)}); opacity: ${away === 1 ? 1 : 0.5}; }`;
  })
  .join(' ');

const css = `
  .loader-coin { --hi: light-dark(#fbfcfd, #f1f2f3); --metal: light-dark(#cbced3, #a7abb2); --lo: light-dark(#5f626a, #393c41); --edge: color-mix(in oklab, var(--lo) 80%, light-dark(#fbfcfd, #121417)); position: relative; width: 200px; height: 170px; perspective: 520px; perspective-origin: 50% 20%; }
  /* The table is seen from a little above, so a coin lying flat still shows its face. */
  .loader-coin__world { position: absolute; inset: 0; transform-style: preserve-3d; transform-origin: 100px 128px; transform: rotateX(-30deg); }
  .loader-coin__shadow { position: absolute; left: ${100 - R}px; top: ${128 - R}px; width: ${2 * R}px; height: ${2 * R}px; border-radius: 50%; box-shadow: inset 0 0 0 ${R}px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); filter: blur(4px); translate: 0 3px; animation: loader-coin-shadow ${T}s infinite; }
  .loader-coin__coin { position: absolute; left: ${100 - R}px; top: ${128 - R}px; width: ${2 * R}px; height: ${2 * R}px; transform-style: preserve-3d; animation: loader-coin-spin ${T}s infinite; }
  /* Two faces and a stack of thin discs between them for the milled edge. */
  .loader-coin__face, .loader-coin__ring { position: absolute; inset: 0; border-radius: 50%; }
  .loader-coin__ring { background: var(--edge); }
  .loader-coin__face { display: grid; place-items: center; background: radial-gradient(circle at 34% 28%, var(--hi), var(--metal) 52%, var(--lo)); box-shadow: inset 0 0 0 2.5px color-mix(in oklab, var(--lo) 80%, light-dark(#fbfcfd, #121417)), inset 0 0 0 5px var(--metal), inset 1px 1px 0 6px color-mix(in oklab, var(--hi) 60%, transparent); color: var(--metal); font: 700 36px/1 Georgia, 'Times New Roman', serif; text-shadow: -1px -1px 0 color-mix(in oklab, var(--hi) 80%, transparent), 1px 1px 0 color-mix(in oklab, var(--lo) 80%, light-dark(#fbfcfd, #121417)); backface-visibility: hidden; transform: translateZ(2px); }
  .loader-coin__face::before { content: ''; position: absolute; inset: 10px; border-radius: 50%; border: 1.5px dotted color-mix(in oklab, var(--lo) 85%, light-dark(#fbfcfd, #121417)); }
  .loader-coin__face--back { transform: rotateY(180deg) translateZ(2px); font-size: 30px; }
  @keyframes loader-coin-spin { ${coinFrames} }
  @keyframes loader-coin-shadow { ${shadowFrames} }
  @media (prefers-reduced-motion: reduce) {
    .loader-coin * { animation: none !important; }
    .loader-coin__coin { transform: translateY(-1px) rotateY(20deg) rotateX(84deg); }
    .loader-coin__shadow { transform: rotateY(20deg) rotateX(90deg); }
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-coin" precedence="kit">
        {css}
      </style>
      <div className="loader-coin" role="status" aria-label="Loading">
        <span className="loader-coin__world" aria-hidden="true">
          <span className="loader-coin__shadow" />
          <span className="loader-coin__coin">
            {[-1.6, -0.8, 0, 0.8, 1.6].map((z) => (
              <span key={z} className="loader-coin__ring" style={{ transform: `translateZ(${z}px)` }} />
            ))}
            <span className="loader-coin__face">1</span>
            <span className="loader-coin__face loader-coin__face--back">★</span>
          </span>
        </span>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/loader-coin
Source on GitHub

About this component

A spun coin wobbles lower and faster until it rattles flat, then is flicked up onto its edge to spin again.

Dependencies
None
Difficulty
Advanced
Triggers
continuous, loop, 3d
Techniques
keyframes, preserve 3d, 3d transform, perspective
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the coin rests almost flat, just settling.

More loaders

See all
Connect
Countdown
Elevator
Fan