DEV Community

Cover image for Building a 3D Coin Flip with Pure CSS Animations & Vanilla JS (Open Source)
Zihang Dong 董子航
Zihang Dong 董子航

Posted on

Building a 3D Coin Flip with Pure CSS Animations & Vanilla JS (Open Source)

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)
└─────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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 rotateX wobble 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 */
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    });
})();
Enter fullscreen mode Exit fullscreen mode

Key decisions:

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

  2. isFlipping guard — Prevents spamming the button during animation. CSS animations can stack weirdly if retriggered mid-flight.

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

  4. 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>
Enter fullscreen mode Exit fullscreen mode

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) + '%';
}
Enter fullscreen mode Exit fullscreen mode

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)