Pure CSS Text effects #loop #text

Text ring in pure CSS

A word wrapped around a ring, one character per slice, standing on a rim you can see all the way round.

  • 55 lines of CSS
  • 13 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. One <span> per character, all stacked in the same spot.
  2. Each gets rotateY(i × 360° / count) translateZ(radius) — the carousel formula again.
  3. backface-visibility: hidden hides the far side, where letters would otherwise show mirrored.
  4. A monospace font keeps the spacing even. Pick the radius so that 2πr ≈ count × character width.
  5. Every length is a multiple of one base unit, --u: the radius is 52 of them, a character cell 36 × 64, so the ring is the same share of a gallery card, the editor and a recording canvas.
  6. The ring is as wide as it is allowed to be, so the number of characters decides how big each one is. Nine of them, CSS 3D •, are big enough to read on a gallery card; the thirteen and the twenty-four it had before were not.
  7. The tilt is the other half of the height. At rotateX(-32deg) you look down on the ring, so the letter in front drops well below the ones at the sides and the word curves round instead of running across.
  8. The letters stand on a rim: a ::after circle of the same radius, laid flat with rotateX(90deg). The letters only show their near half, but the rim shows all of it, so the back of the ring is still there to see. It is drawn nearly as solid as the letters: it is what holds the ring’s full height in every pose, so even a paused ring, with only three letters in front, stands as tall as the whole ring.
  9. What is drawn sits low — the near half of the letters and the front of the rim both drop below the middle — so translateY lifts the ring by part of that, which puts it back in the middle of the box. The spin starts at -40deg, so a paused ring faces you with CSS.

Key techniques

  • rotateY(i × 360° / n) translateZ(r)
  • backface-visibility: hidden
  • monospace for even spacing
  • a rotateX(90deg) rim for the far side

Copy-paste code

HTML 13 lines

<div class="scene">
  <div class="ring" style="--n:9" aria-label="CSS 3D •">
    <span style="--i:0" aria-hidden="true">C</span>
    <span style="--i:1" aria-hidden="true">S</span>
    <span style="--i:2" aria-hidden="true">S</span>
    <span style="--i:3" aria-hidden="true">&nbsp;</span>
    <span style="--i:4" aria-hidden="true">3</span>
    <span style="--i:5" aria-hidden="true">D</span>
    <span style="--i:6" aria-hidden="true">&nbsp;</span>
    <span style="--i:7" aria-hidden="true">•</span>
    <span style="--i:8" aria-hidden="true">&nbsp;</span>
  </div>
</div>

CSS 55 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the ring is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.82vmin;
  perspective: calc(800 * var(--u));
}

/* 52 units of radius against a 36-unit character cell: 2πr is a little more than the nine
   characters need, so the band of text closes without the letters touching */
.ring {
  --r: calc(52 * var(--u));
  position: relative;
  width: calc(36 * var(--u));
  height: calc(64 * var(--u));
  font: 800 calc(52 * var(--u))/calc(64 * var(--u)) 'JetBrains Mono', ui-monospace, monospace;
  transform-style: preserve-3d;
  animation: ring-spin 12s linear infinite;
}

.ring span {
  position: absolute;
  inset: 0;
  text-align: center;
  /* a teal of middle lightness, 3:1 or more on both stages (large text): #2ee6d6 was 1.4:1 on the
     light one */
  color: oklch(62% 0.13 185);
  backface-visibility: hidden;
  transform: rotateY(calc(var(--i) * 360deg / var(--n))) translateZ(var(--r));
}

/* the rim the letters stand on: a flat circle of the same radius, laid down with
   rotateX(90deg) and centred on their feet. Unlike the letters it shows all the way round, so the
   far half of the ring is still there to see */
.ring::after {
  content: '';
  position: absolute;
  top: calc(100% - 52 * var(--u) - 14 * var(--u));
  left: calc(50% - var(--r));
  width: calc(2 * var(--r));
  height: calc(2 * var(--r));
  box-sizing: border-box;
  border: calc(1.5 * var(--u)) solid color-mix(in srgb, #2ee6d6 80%, transparent);
  border-radius: 50%;
  transform: rotateX(90deg);
}

/* The tilt is what lets you see the rim as a ring rather than a line, and it drops the letter in
   front below the ones at the sides, so the word reads as going round. At 32deg the rim stands
   over 40vmin tall, so the ring is tall enough at any turn, paused included. The lift puts what is
   drawn back in the middle; starting at -40deg puts CSS, not the bullet, in front of a paused
   ring. */
@keyframes ring-spin {
  from { transform: translateY(calc(-14 * var(--u))) rotateX(-32deg) rotateY(-40deg); }
  to   { transform: translateY(calc(-14 * var(--u))) rotateX(-32deg) rotateY(-400deg); }
}
Standalone snippet · plain CSS, no build step, no dependencies.