Coin
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-coin" role="status" aria-label="Loading">
<span class="loader-coin__world" aria-hidden="true">
<span class="loader-coin__shadow"></span>
<span class="loader-coin__coin">
<span class="loader-coin__ring" style="transform:translateZ(-1.6px)"></span>
<span class="loader-coin__ring" style="transform:translateZ(-0.8px)"></span>
<span class="loader-coin__ring" style="transform:translateZ(0px)"></span>
<span class="loader-coin__ring" style="transform:translateZ(0.8px)"></span>
<span class="loader-coin__ring" style="transform:translateZ(1.6px)"></span>
<span class="loader-coin__face">1</span>
<span class="loader-coin__face loader-coin__face--back">★</span>
</span>
</span>
</div>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: 62px; top: 90px; width: 76px; height: 76px; border-radius: 50%; box-shadow: inset 0 0 0 38px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); filter: blur(4px); translate: 0 3px; animation: loader-coin-shadow 4.2s infinite; }
.loader-coin__coin { position: absolute; left: 62px; top: 90px; width: 76px; height: 76px; transform-style: preserve-3d; animation: loader-coin-spin 4.2s 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 { 0.00% { transform: translateY(-37.8px) rotateY(0deg) rotateX(6.0deg); } 2.14% { transform: translateY(-37.8px) rotateY(51deg) rotateX(6.0deg); } 4.29% { transform: translateY(-37.8px) rotateY(103deg) rotateX(6.1deg); } 6.43% { transform: translateY(-37.8px) rotateY(158deg) rotateX(6.2deg); } 8.57% { transform: translateY(-37.8px) rotateY(214deg) rotateX(6.5deg); } 10.71% { transform: translateY(-37.7px) rotateY(272deg) rotateX(6.9deg); } 12.86% { transform: translateY(-37.7px) rotateY(332deg) rotateX(7.5deg); } 15.00% { transform: translateY(-37.6px) rotateY(394deg) rotateX(8.2deg); } 17.14% { transform: translateY(-37.5px) rotateY(459deg) rotateX(9.1deg); } 19.29% { transform: translateY(-37.4px) rotateY(527deg) rotateX(10.2deg); } 21.43% { transform: translateY(-37.2px) rotateY(597deg) rotateX(11.5deg); } 23.57% { transform: translateY(-37.0px) rotateY(670deg) rotateX(13.0deg); } 25.71% { transform: translateY(-36.7px) rotateY(747deg) rotateX(14.8deg); } 27.86% { transform: translateY(-36.4px) rotateY(828deg) rotateX(16.9deg); } 30.00% { transform: translateY(-35.9px) rotateY(913deg) rotateX(19.2deg); } 32.14% { transform: translateY(-35.3px) rotateY(1003deg) rotateX(21.8deg); } 34.29% { transform: translateY(-34.5px) rotateY(1097deg) rotateX(24.7deg); } 36.43% { transform: translateY(-33.6px) rotateY(1198deg) rotateX(27.9deg); } 38.57% { transform: translateY(-32.4px) rotateY(1304deg) rotateX(31.4deg); } 40.71% { transform: translateY(-31.1px) rotateY(1419deg) rotateX(35.2deg); } 42.86% { transform: translateY(-29.4px) rotateY(1542deg) rotateX(39.4deg); } 45.00% { transform: translateY(-27.4px) rotateY(1674deg) rotateX(43.9deg); } 47.14% { transform: translateY(-25.1px) rotateY(1819deg) rotateX(48.7deg); } 49.29% { transform: translateY(-22.4px) rotateY(1977deg) rotateX(54.0deg); } 51.43% { transform: translateY(-19.2px) rotateY(2152deg) rotateX(59.6deg); } 53.57% { transform: translateY(-15.7px) rotateY(2347deg) rotateX(65.6deg); } 55.71% { transform: translateY(-11.8px) rotateY(2569deg) rotateX(72.0deg); } 57.86% { transform: translateY(-7.4px) rotateY(2826deg) rotateX(78.8deg); } 60.00% { transform: translateY(-2.7px) rotateY(3130deg) rotateX(86.0deg); } 64.00% { transform: translateY(-0.5px) rotateY(3391deg) rotateX(89.2deg); } 66.50% { transform: translateY(-0.5px) rotateY(3501deg) rotateX(90.7deg); } 68.50% { transform: translateY(-0.3px) rotateY(3560deg) rotateX(89.6deg); } 70.00% { transform: translateY(-0.2px) rotateY(3587deg) rotateX(90.3deg); } 72.00% { transform: translateY(-0.0px) rotateY(3600deg) rotateX(90.0deg); } 84.00% { transform: translateY(-0.0px) rotateY(3600deg) rotateX(90.0deg); animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1); } 92.00% { transform: translateY(-56.0px) rotateY(3800deg) rotateX(228.0deg); animation-timing-function: cubic-bezier(0.6, 0, 0.8, 0.4); } 100.00% { transform: translateY(-37.8px) rotateY(3960deg) rotateX(366.0deg); } }
@keyframes loader-coin-shadow { 0.00% { transform: rotateY(0deg) rotateX(90deg) scale(0.91, 0.10); opacity: 1; } 2.14% { transform: rotateY(51deg) rotateX(90deg) scale(0.91, 0.10); opacity: 1; } 4.29% { transform: rotateY(103deg) rotateX(90deg) scale(0.91, 0.11); opacity: 1; } 6.43% { transform: rotateY(158deg) rotateX(90deg) scale(0.91, 0.11); opacity: 1; } 8.57% { transform: rotateY(214deg) rotateX(90deg) scale(0.91, 0.11); opacity: 1; } 10.71% { transform: rotateY(272deg) rotateX(90deg) scale(0.91, 0.12); opacity: 1; } 12.86% { transform: rotateY(332deg) rotateX(90deg) scale(0.91, 0.13); opacity: 1; } 15.00% { transform: rotateY(394deg) rotateX(90deg) scale(0.91, 0.14); opacity: 1; } 17.14% { transform: rotateY(459deg) rotateX(90deg) scale(0.92, 0.16); opacity: 1; } 19.29% { transform: rotateY(527deg) rotateX(90deg) scale(0.92, 0.18); opacity: 1; } 21.43% { transform: rotateY(597deg) rotateX(90deg) scale(0.92, 0.20); opacity: 1; } 23.57% { transform: rotateY(670deg) rotateX(90deg) scale(0.92, 0.23); opacity: 1; } 25.71% { transform: rotateY(747deg) rotateX(90deg) scale(0.93, 0.26); opacity: 1; } 27.86% { transform: rotateY(828deg) rotateX(90deg) scale(0.93, 0.29); opacity: 1; } 30.00% { transform: rotateY(913deg) rotateX(90deg) scale(0.93, 0.33); opacity: 1; } 32.14% { transform: rotateY(1003deg) rotateX(90deg) scale(0.94, 0.37); opacity: 1; } 34.29% { transform: rotateY(1097deg) rotateX(90deg) scale(0.94, 0.42); opacity: 1; } 36.43% { transform: rotateY(1198deg) rotateX(90deg) scale(0.95, 0.47); opacity: 1; } 38.57% { transform: rotateY(1304deg) rotateX(90deg) scale(0.95, 0.52); opacity: 1; } 40.71% { transform: rotateY(1419deg) rotateX(90deg) scale(0.96, 0.58); opacity: 1; } 42.86% { transform: rotateY(1542deg) rotateX(90deg) scale(0.96, 0.63); opacity: 1; } 45.00% { transform: rotateY(1674deg) rotateX(90deg) scale(0.97, 0.69); opacity: 1; } 47.14% { transform: rotateY(1819deg) rotateX(90deg) scale(0.98, 0.75); opacity: 1; } 49.29% { transform: rotateY(1977deg) rotateX(90deg) scale(0.98, 0.81); opacity: 1; } 51.43% { transform: rotateY(2152deg) rotateX(90deg) scale(0.99, 0.86); opacity: 1; } 53.57% { transform: rotateY(2347deg) rotateX(90deg) scale(0.99, 0.91); opacity: 1; } 55.71% { transform: rotateY(2569deg) rotateX(90deg) scale(1.00, 0.95); opacity: 1; } 57.86% { transform: rotateY(2826deg) rotateX(90deg) scale(1.00, 0.98); opacity: 1; } 60.00% { transform: rotateY(3130deg) rotateX(90deg) scale(1.00, 1.00); opacity: 1; } 64.00% { transform: rotateY(3391deg) rotateX(90deg) scale(1.00, 1.00); opacity: 1; } 66.50% { transform: rotateY(3501deg) rotateX(90deg) scale(1.00, 1.00); opacity: 1; } 68.50% { transform: rotateY(3560deg) rotateX(90deg) scale(1.00, 1.00); opacity: 1; } 70.00% { transform: rotateY(3587deg) rotateX(90deg) scale(1.00, 1.00); opacity: 1; } 72.00% { transform: rotateY(3600deg) rotateX(90deg) scale(1.00, 1.00); opacity: 1; } 84.00% { transform: rotateY(3600deg) rotateX(90deg) scale(1.00, 1.00); opacity: 1; } 92.00% { transform: rotateY(3800deg) rotateX(90deg) scale(0.54, 0.41); opacity: 0.5; } 100.00% { transform: rotateY(3960deg) rotateX(90deg) scale(0.91, 0.10); opacity: 1; } }
@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); }
}Goal: Create a "Coin" loader.
Visual behavior: A spun coin wobbles lower and faster until it rattles flat, then is flicked up onto its edge to spin again.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The table is seen from a little above in preserve-3d. The coin is two faces with a stack of thin discs between them for the milled edge.
- The spin keyframes are generated like a real spinning disc: the lean grows faster and faster while the wobble speeds up, then a few chattering bounces as it slaps flat.
- Its shadow is sampled from the same frames, widening as the coin leans, and a flick sends it up a flip and a half to land on its edge again.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Coin" loader.
Visual behavior: A spun coin wobbles lower and faster until it rattles flat, then is flicked up onto its edge to spin again.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The table is seen from a little above in preserve-3d. The coin is two faces with a stack of thin discs between them for the milled edge.
- The spin keyframes are generated like a real spinning disc: the lean grows faster and faster while the wobble speeds up, then a few chattering bounces as it slaps flat.
- Its shadow is sampled from the same frames, widening as the coin leans, and a flick sends it up a flip and a half to land on its edge again.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.