I built an interactive 3D coin component that flips on click, has curved SVG text and a stacked-edge illusion that gives depth. This article explains everything — the core 3D system, how the coin edge is built, SVG curved text fitting, and the click/flip timing — with clear, copy-able snippets so you (or any reader) can recreate it.
Live demo (interactive):
Project overview (what lives where)
-
HTML: semantic container
.scene
,.coin
,.coin-face
for front/back; SVGs for curved text;.inner-content
for central circle. -
CSS: CSS variables for colors and timing, perspective,
transform-style: preserve-3d
,backface-visibility
, small animations (star pulse, ripple), responsive tweaks. -
JS: helpers that:
- programmatically create the visual coin edge (
buildCoinEdges
), - fit SVG curved text to the arc (
fitTextToArc
), - create particles (decorative),
- handle click ripple + flip with careful timing and state control.
- programmatically create the visual coin edge (
Minimal HTML skeleton (core structure)
<div id="scene" class="scene" role="img" aria-label="Interactive 3D Commemorative Coin">
<div id="coin" class="coin">
<div class="coin-face coin-front"> ... front ... </div>
<div class="coin-face coin-back"> ... back ... </div>
</div>
</div>
.scene gives a local perspective (distance to the viewer).
.coin is a 3D object (children keep their 3D transforms).
.coin-face are circles, absolutely positioned, with backface-visibility: hidden so the back of each face is invisible when rotated away.
♿ Accessibility: role + aria-label
We add ARIA attributes so assistive tech treats the coin as an image-like element. That way, a screen reader won’t just read out a bunch of <div>
s and <svg>
paths.
<div id="scene"
class="scene"
role="img"
aria-label="Interactive 3D Commemorative Coin">
...
</div>
- role="img" tells screen readers this is a visual graphic.
- aria-label gives a human-readable description.
- For production, you can localize or customize this description.
Key CSS pieces — the 3D foundation
:root { --flip-duration: 2.8s; }
body { perspective: 1200px; } /* global depth context */
.scene { perspective: 1000px; } /* local depth for coin area */
.coin {
transform-style: preserve-3d;
transition: transform var(--flip-duration) ease-in-out;
}
.coin.is-flipped {
transform: rotateY(180deg) !important;
}
.coin-face {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
backface-visibility: hidden;
transform-style: preserve-3d;
}
Why these matter
- perspective provides depth; lower values make the element appear more foreshortened.
- transform-style: preserve-3d keeps children transformed in 3D space (instead of flattening them).
- backface-visibility: hidden prevents mirrored text from showing when a face rotates away.
- The .is-flipped class toggles a rotateY(180deg) on the .coin to flip it (CSS transition animates the flip).
Deep dive: core building blocks
1) 3D transform system
This tiny set of CSS + DOM rules is the backbone. Without correct perspective, transform-style, and backface-visibility, you won't get a convincing coin.
- body { perspective: 1200px } — sets the viewer-camera distance; larger → weaker perspective.
- .scene { perspective: 1000px } — sets perspective for the coin area specifically.
- .coin performs rotations. When you rotateY(180deg) the whole coin, front/back switch.
Common gotcha: If backface-visibility is omitted, the backside of a face can be visible mirrored when rotated.
2) Coin edge illusion: buildCoinEdges() (why it matters)
A coin is thin, but visually convincing if you programmatically place many thin layers (.coin-edge) along Z. The project fakes thickness by creating many concentric rings each translated by small translateZ offsets.
Key function (full):
function buildCoinEdges(coinSelector, edgeCount = 60, step = 1.2) {
const coinElement = document.querySelector(coinSelector);
if (!coinElement) return;
// remove old edges
coinElement.querySelectorAll('.coin-edge').forEach(edge => edge.remove());
for (let i = 0; i < edgeCount; i++) {
const edge = document.createElement('div');
edge.classList.add('coin-edge');
// alternate gradient for visual texture
if (i % 3 === 0) {
edge.style.background = 'linear-gradient(90deg, #777 0%, #555 50%, #777 100%)';
} else if (i % 3 === 1) {
edge.style.background = 'linear-gradient(90deg, #666 0%, #444 50%, #666 100%)';
} else {
edge.style.background = 'linear-gradient(90deg, #777 0%, #555 50%, #777 100%)';
}
edge.style.boxShadow = 'inset 0 0 8px rgba(0,0,0,0.7)';
edge.style.border = '1px solid #444';
const offset = (i - edgeCount / 2) * step;
edge.style.transform = `translateZ(${offset}px)`;
coinElement.appendChild(edge);
}
// push front/back faces forward so they sit outside the edge stack
const maxOffset = (edgeCount / 2) * step;
const front = coinElement.querySelector('.coin-front');
const back = coinElement.querySelector('.coin-back');
if (front) front.style.transform = `rotateY(0deg) translateZ(${maxOffset}px)`;
if (back) back.style.transform = `rotateY(180deg) translateZ(${maxOffset}px)`;
}
Why translateZ?
Each .coin-edge is the same size as the coin but moved slightly on Z. Stacking many creates the visual ridged edge.
Numeric example
If edgeCount = 24 and step = 1.2:
- edgeCount / 2 = 24 / 2 = 12.
- maxOffset = 12 * 1.2 = 14.4 (units are px in the style string). So front/back faces are translated translateZ(14.4px) to sit just outside the center of the stack.
Pitfalls
- Too many edges → performance hit. Use 16–36 for a good tradeoff.
- Using fractional translateZ is fine; browsers accept decimal px.
3) SVG curved text + fitTextToArc() (line-by-line)
Curved text is achieved using an SVG and . To make the text fit the curve regardless of viewport changes, compute the path length and set textLength + lengthAdjust.
Key function:
function fitTextToArc(pathElement, textPathElement){
if (!pathElement || !textPathElement) return;
const pathLen = pathElement.getTotalLength();
const padding = 5; // small breathing room
const available = pathLen + padding;
textPathElement.setAttribute('lengthAdjust','spacingAndGlyphs');
textPathElement.setAttribute('textLength', available.toFixed(1));
}
How it works
- pathElement.getTotalLength() returns the path arc length in px.
- textLength tells the browser how much space the text should occupy along the path.
- lengthAdjust="spacingAndGlyphs" allows the browser to scale spacing/glyphs so the string fits visually along that length.
Do this on:
- window.load (initial layout)
- window.resize (when layout changes)
Pitfall: If you have duplicate SVG ids (e.g., same badgeGradient or path id on front/back), you may get unpredictable rendering. Give unique ids for each <svg> in the document.
4) Click ripple + flip: state & timing
The click handler needs to:
- visually show a ripple centered on the click point,
- toggle the flip class,
- disable hover tilt while flip animation runs, and
- re-enable it once the flip is complete.
Core snippet:
scene.addEventListener('click', function(e) {
// create ripple
const clickEffect = document.createElement('div');
clickEffect.classList.add('click-effect');
const rect = scene.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
clickEffect.style.left = `${x}px`;
clickEffect.style.top = `${y}px`;
scene.appendChild(clickEffect);
setTimeout(() => clickEffect.style.animation = 'clickRipple 0.8s forwards', 10);
setTimeout(() => scene.removeChild(clickEffect), 800);
// flip logic
const flipDuration = parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue('--flip-duration'));
// If --flip-duration: 2.8s => parseFloat(...) = 2.8
// multiply by 1000 => 2800 ms
coin.style.transform = ''; // clear any hover transform
coin.classList.toggle('is-flipped'); // trigger flip animation
isHovering = false;
setTimeout(() => { isHovering = true; }, flipDuration * 1000);
});
✨ Particles (decorative extras)
Particles add subtle depth and motion. They aren’t required for the coin effect, but they enhance the polish.
function spawnParticles(container, count = 20) {
for (let i = 0; i < count; i++) {
const p = document.createElement('div');
p.classList.add('particle');
p.style.left = Math.random() * 100 + '%';
p.style.top = Math.random() * 100 + '%';
container.appendChild(p);
// fade out & remove
setTimeout(() => p.remove(), 4000 + Math.random() * 2000);
}
}
// Example usage: run every few seconds
setInterval(() => spawnParticles(document.getElementById('scene')), 3000);
And in CSS:
.particle {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background: radial-gradient(circle, #fff 0%, transparent 70%);
opacity: 0.7;
animation: float 4s linear forwards;
}
@keyframes float {
from { transform: translateY(0) scale(1); opacity: 0.7; }
to { transform: translateY(-60px) scale(0.4); opacity: 0; }
}
snippets & layout hints
CSS variables (control look & timing from one place)
:root {
--flip-duration: 2.8s;
--coin-gradient-start: #173A7A;
--coin-gradient-end: #66B7E1;
--ripple-color: rgba(56, 189, 248, 0.6);
}
SVG curved text (example):
<svg viewBox="0 0 380 380">
<defs>
<path id="circle-top-front" d="M 28,190 A 162,162 0 0,1 352,190" />
</defs>
<text class="curved-text-top">
<textPath id="topTextPathFront" href="#circle-top-front" startOffset="50%">
20 YEARS ORLANDO & SURROUNDING CITIES
</textPath>
</text>
</svg>
countUp (brief):
function countUp(el, target, duration) {
const startTime = performance.now();
function update(now) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const current = Math.floor(progress * target);
el.textContent = current;
if (progress < 1) requestAnimationFrame(update);
else el.textContent = target;
}
requestAnimationFrame(update);
}
📱 Responsive tweaks
On small screens, the coin should shrink so it doesn’t overflow. A quick media query keeps it centered and contained:
@media (max-width: 768px) {
.scene {
width: 85vw;
max-width: 320px;
}
.curved-text-top {
font-size: 32px !important;
letter-spacing: 3px !important;
}
.curved-stars-bottom {
font-size: 20px !important;
letter-spacing: 6px !important;
}
.twenty {
font-size: 4.5rem !important;
margin-top: -20px !important;
margin-bottom: -15px !important;
}
.years {
font-size: 2rem !important;
margin-bottom: -5px !important;
margin-top: -10px !important;
}
.location {
font-size: 0.9rem !important;
}
.inner-content-wrapper {
width: 180px !important;
height: 180px !important;
}
.inner-content {
width: 190px !important;
height: 190px !important;
}
.badge-container {
width: 90% !important;
}
}
This ensures the coin scales gracefully without breaking curved text.
Demo & full source
👉 Interactive demo on CodePen:
https://codepen.io/Shahibur-Rahman/pen/zxrqpGz
👉 Full source code on GitHub:
https://github.com/d5b94396feba3/3D-Flipping-Interactive-Coin
Final thoughts
This project shows how a handful of CSS properties (perspective, transform-style, backface-visibility) plus small JS utilities (edge stacking, text fitting, hover mapping) can produce a polished interactive component.
Start with the 3D foundation (perspective + flip), add edge stacking for realism, then layer on curved text and hover/click polish.
It’s surprisingly little code for a very convincing effect.
Top comments (3)
amazing post thanks
Thanks, Yalda! Glad you found it useful 🙌
⭐✨