How I built a realistic coin flip tool with 3D CSS transforms, parabolic arc physics, and streak tracking — zero dependencies, 150 lines of JS.

The Problem
I needed a coin flip tool for ToolKnit that felt physical — not just a random number with a boring text swap. The goal: a 3D coin that launches into the air, spins realistically, lands with a bounce, and tracks your statistics. All running 100% in the browser with zero dependencies.
Here's how I built it.
Architecture: 3 Layers of Animation
The trick to realistic coin physics is separating concerns into three independent CSS animations that play simultaneously:
┌─────────────────────────────────┐
│ .coin-wrapper → Y trajectory │ (parabolic arc up/down)
│ └── .coin → 3D spin │ (rotateY for flip)
│ .coin-shadow → ground shadow │ (scale pulse)
└─────────────────────────────────┘
Each layer handles one axis of movement. Combined, they create convincing physics without any JavaScript animation libraries.
Layer 1: The Parabolic Arc (Y-axis)
The wrapper handles the vertical trajectory — launch up, hang at apex, fall back down with a small bounce:
@keyframes coinToss {
0% { transform: translateY(0); }
8% { transform: translateY(10px); } /* crouch before launch */
35% { transform: translateY(-200px); } /* rising */
50% { transform: translateY(-220px); } /* apex — slight hang */
65% { transform: translateY(-180px); } /* falling */
88% { transform: translateY(-10px); } /* impact */
93% { transform: translateY(5px); } /* bounce 1 */
96% { transform: translateY(-3px); } /* bounce 2 */
100% { transform: translateY(0); } /* settle */
}
Key insight: The 8% crouch before launch sells the "weight" of the coin. The micro-bounces at 88–100% prevent it from feeling like it stops dead.
Layer 2: The 3D Spin
This is where the magic happens. CSS rotateY with transform-style: preserve-3d gives us true 3D flipping:
/* Heads: lands face-up (even half-turns = 3240° = 9 full rotations) */
@keyframes spinHeads {
0% { transform: rotateY(0deg) rotateX(0deg); }
35% { transform: rotateY(1080deg) rotateX(15deg); }
65% { transform: rotateY(2160deg) rotateX(-10deg); }
88% { transform: rotateY(2800deg) rotateX(5deg); }
100% { transform: rotateY(3240deg) rotateX(0deg); } /* 3240 / 360 = 9 */
}
/* Tails: lands face-down (odd half-turns = 3060° = 8.5 rotations) */
@keyframes spinTails {
0% { transform: rotateY(0deg) rotateX(0deg); }
35% { transform: rotateY(1080deg) rotateX(15deg); }
65% { transform: rotateY(2160deg) rotateX(-10deg); }
88% { transform: rotateY(2800deg) rotateX(5deg); }
100% { transform: rotateY(3060deg) rotateX(0deg); } /* 3060 / 360 = 8.5 */
}
Why this works:
- Heads lands on an even number of half-turns (front face visible)
- Tails lands on an odd number of half-turns (back face visible, thanks to
backface-visibility: hidden) - The subtle
rotateXwobble at 35%/65% simulates the coin not spinning on a perfectly flat axis — just like real physics
Layer 3: Ground Shadow
A radial-gradient ellipse that shrinks when the coin is airborne and expands on landing:
@keyframes shadowPulse {
0% { width: 120px; opacity: 1; }
10% { width: 80px; opacity: 0.5; }
30% { width: 50px; opacity: 0.2; } /* coin at height */
50% { width: 40px; opacity: 0.15; } /* apex */
70% { width: 60px; opacity: 0.3; } /* falling */
85% { width: 100px; opacity: 0.7; }
92% { width: 130px; opacity: 1; } /* impact overshoot */
96% { width: 110px; opacity: 0.9; }
100% { width: 120px; opacity: 1; } /* settle */
}
This shadow grounds the coin in 3D space. Without it, the coin feels like it's floating.
The JavaScript: 150 Lines, Zero Dependencies
The entire logic is a self-contained IIFE:
(function () {
'use strict';
const ANIM_DURATION = 1450; // matches CSS 1.4s + buffer
let heads = 0, tails = 0, streak = 0, best = 0;
let lastResult = null, isFlipping = false, history = [];
function flipCoin() {
if (isFlipping) return;
isFlipping = true;
// Fair 50/50 random
const result = Math.random() < 0.5 ? 'heads' : 'tails';
// Reset animations (remove class → force reflow → add class)
coin.classList.remove('spin-heads', 'spin-tails');
coinWrapper.classList.remove('tossing');
void coin.offsetWidth; // force reflow — critical!
// Trigger all 3 animation layers simultaneously
coinWrapper.classList.add('tossing');
coinShadow.classList.add('airborne');
coin.classList.add(result === 'heads' ? 'spin-heads' : 'spin-tails');
// Update state after animation completes
setTimeout(() => {
result === 'heads' ? heads++ : tails++;
// Streak tracking
streak = (result === lastResult) ? streak + 1 : 1;
lastResult = result;
if (streak > best) best = streak;
history.push(result);
updateStats();
isFlipping = false;
}, ANIM_DURATION);
}
// Keyboard shortcut: Space to flip
document.addEventListener('keydown', (e) => {
if (e.code === 'Space' && !e.repeat) {
e.preventDefault();
flipCoin();
}
});
})();
Key decisions:
void coin.offsetWidth— Forces a browser reflow between removing and re-adding animation classes. Without this, the browser optimizes away the class change and the animation won't replay.isFlippingguard — Prevents spamming the button during animation. CSS animations can stack weirdly if retriggered mid-flight.Math.random() < 0.5— Simple and fair. The browser's PRNG is seeded from OS entropy, making it perfectly adequate for a coin flip (this isn't cryptography).Streak tracking — A single variable that resets on result change. Elegant and zero-allocation.
The Coin Face Structure (HTML)
<div class="coin-container">
<div class="coin-shadow" id="coin-shadow"></div>
<div class="coin-wrapper" id="coin-wrapper">
<div class="coin" id="coin">
<div class="coin-face coin-heads">
<div class="coin-shine"></div>
H
</div>
<div class="coin-face coin-tails">
<div class="coin-shine"></div>
T
</div>
</div>
</div>
</div>
The .coin-tails face has transform: rotateY(180deg) so it's initially hidden by backface-visibility: hidden. The shine overlay is a CSS gradient that gives the coin a metallic reflection.
Stats & Probability Bar
A dead-simple probability visualization:
function updateStats() {
const total = heads + tails;
const hp = total > 0 ? Math.round((heads / total) * 100) : 50;
headsBar.style.width = hp + '%';
tailsBar.style.width = (100 - hp) + '%';
}
Two <div>s inside a flex container with transition: width 0.5s — the bar animates smoothly as the probability shifts with each flip. Over 100+ flips, you watch it converge to 50/50 (law of large numbers in real time).
Try It Live
👉 ToolKnit Coin Flip — free, no signup, runs entirely in your browser.
The full source is visible in-browser (View Source) since it's all client-side HTML/CSS/JS. Feel free to learn from it, fork the approach, or adapt it for your own projects.
What I'd Do Differently
- Web Animations API instead of CSS keyframes — more control over playback speed, easing, and cancellation
- Canvas/WebGL for truly custom coin textures (country flags, custom images)
-
Haptic feedback on mobile via
navigator.vibrate()
But for a zero-dependency, 150-line solution that works on every browser? Pure CSS animations are hard to beat.
Built as part of ToolKnit — a free, open-source collection of 60+ browser-based tools. No uploads, no accounts, works offline.
Top comments (0)