DEV Community

Shahibur Rahman
Shahibur Rahman

Posted on • Edited on

Build a 3D Flipping Coin with HTML, CSS & JavaScript — deep dive

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.

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

.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>
Enter fullscreen mode Exit fullscreen mode
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

Why these matter

  1. perspective provides depth; lower values make the element appear more foreshortened.
  2. transform-style: preserve-3d keeps children transformed in 3D space (instead of flattening them).
  3. backface-visibility: hidden prevents mirrored text from showing when a face rotates away.
  4. 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)`;
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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 (5)

Collapse
 
yaldakhoshpey profile image
Yalda Khoshpey • Edited

amazing post thanks

Collapse
 
shahibur_rahman_6670cd024 profile image
Shahibur Rahman

Thanks, Yalda! Glad you found it useful 🙌

Collapse
 
yaldakhoshpey profile image
Yalda Khoshpey

⭐✨

Collapse
 
kibotu profile image
Jan Rabe

thanks for sharing! you can still see through the coin when the side is at the front. you could move a square behind it make it less apparent.

another thing is that the performance is not great on mobile sadly since it has no hardware acceleration which kind of limits its uses a bit sadly. any recommendations how to improve the performance?

Collapse
 
shahibur_rahman_6670cd024 profile image
Shahibur Rahman

Hi Jan, thanks for the detailed feedback!

You’re right about the see-through effect — it’s a limitation of stacking flat layers to fake the edge. Adding a solid backing layer (or inner disc) can help mask it from certain angles.

For mobile, reducing the edge count (e.g. ~12–20) and simplifying the edge styles (gradients/shadows) helps a lot, since both the number of layers and paint cost add up quickly.