Pure CSS Data & tools #loop #chart #data #donut

3D donut chart in pure CSS

A conic-gradient disc repeated in twelve translateZ layers. Lower layers are darkened, which reads as a solid wall, and a radial mask on each layer cuts the hole.

  • 99 lines of CSS
  • 29 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. One disc: a conic-gradient with hard stops at the running totals (40%, 65%, 85%). Repeat it in twelve layers, each translateZ(var(--i) * 1.5 units) higher.
  2. Tilt the stack. The rims of the lower layers peek out under the top one and together read as a solid side wall. A dark overlay whose alpha grows as --i shrinks shades that wall.
  3. The hole is a radial-gradient mask on each layer. A mask on the spinning parent would flatten all twelve layers into one picture.
  4. Spin a wrapper with rotateZ inside the tilt, so every slice passes the front in turn.
  5. The donut is written in one base unit, --u, so it is the same share of a gallery card, the editor and a recording canvas. The legend under it is in plain vmin: it sits in the same control zone, at the same size, as every other model's.

Key techniques

  • conic-gradient segments
  • stacked translateZ layers
  • darkening by --i
  • mask on the leaf layers only

Copy-paste code

HTML 29 lines

<div class="band">
  <div class="view">
    <div class="tilt">
      <div class="spin">
        <i style="--i:0"></i>
        <i style="--i:1"></i>
        <i style="--i:2"></i>
        <i style="--i:3"></i>
        <i style="--i:4"></i>
        <i style="--i:5"></i>
        <i style="--i:6"></i>
        <i style="--i:7"></i>
        <i style="--i:8"></i>
        <i style="--i:9"></i>
        <i style="--i:10"></i>
        <i style="--i:11"></i>
      </div>
    </div>
  </div>
  <div class="controls">
    <p class="caption">Sprint time by stage, %</p>
    <ul class="row legend">
      <li style="--c:#8b6cff">Design 40</li>
      <li style="--c:#2ee6d6">Build 25</li>
      <li style="--c:#ff4d9d">Test 20</li>
      <li style="--c:#ffb547">Ship 15</li>
    </ul>
  </div>
</div>

CSS 99 lines

/* the model box and the legend stand in one stack, so the legend is the same distance under
   the donut in every model that has a control zone */
.band {
  display: grid;
  justify-items: center;
  gap: 4vmin;
}

/* the model box: one base unit, and every length of the donut is a multiple of it */
.view {
  --u: 0.44vmin;
  display: grid;
  place-items: center;
  height: 50vmin;
  perspective: calc(800 * var(--u));
}

.tilt {
  position: relative;
  width: calc(136 * var(--u));
  height: calc(136 * var(--u));
  margin: calc(-60 * var(--u)) 0; /* the tilted disc is much flatter than its box */
  transform-style: preserve-3d;
  transform: rotateX(60deg);
}

.spin {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  animation: spin 18s linear infinite;
}

.spin i {
  --shade: rgb(0 0 0 / calc((11 - var(--i)) * 0.05)); /* 0.55 at the bottom, 0 on top */
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background:
    linear-gradient(var(--shade), var(--shade)),
    conic-gradient(#8b6cff 0 40%, #2ee6d6 0 65%, #ff4d9d 0 85%, #ffb547 0);
  /* the hole: on each layer, never on a parent */
  mask: radial-gradient(closest-side, transparent 49.5%, #000 50%);
  transform: translateZ(calc(var(--i) * 1.5 * var(--u)));
}

/* top layer: a sheen and thin white slice edges. The edges fade in and out over a hair
   instead of hard stops: a hard hairline wedge on the tilted disc steps and reads as a dashed line. */
.spin i:last-child {
  background:
    radial-gradient(circle at 30% 25%, rgb(255 255 255 / 0.35), transparent 55%),
    conic-gradient(#fff 0 0.1%, #8b6cff 0.6% 39.2%, #fff 39.7% 40.1%, #2ee6d6 40.6% 64.2%, #fff 64.7% 65.1%, #ff4d9d 65.6% 84.2%, #fff 84.7% 85.1%, #ffb547 85.6% 99.2%, #fff 99.7%);
}

/* the control zone: the same object, at the same size, in every model that has one — so it is
   written in plain vmin and not in the donut's own unit. Here it holds the legend. */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

.controls .caption {
  margin: 0;
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  opacity: 0.7;
}

.controls .row {
  display: flex;
  gap: 2.5vmin;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* a legend entry reports, it is not pressed: the row's height and text, no pill */
.legend li {
  display: flex;
  align-items: center;
  gap: 1.2vmin;
  height: 8vmin;
  font: 600 4vmin Inter, system-ui, sans-serif;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.legend li::before {
  content: '';
  width: 2.8vmin;
  height: 2.8vmin;
  border-radius: 0.8vmin;
  background: var(--c);
}

@keyframes spin {
  to { transform: rotateZ(360deg); }
}
Standalone snippet · plain CSS, no build step, no dependencies.