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

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

⭐✨