Back to the library

Chart entrance — donut accumulating

Variant: Donut accumulating

Three arcs from twelve o’clock sharing one angular rate — 960ms for the whole ring, split between them in proportion to their shares — so it reads as a single pen going round and changing colour at each boundary rather than three arcs racing to their own finish lines. Each key row lands the instant its own arc closes, which makes the legend something the drawing writes instead of a block fading in beside it. Nothing translates or scales anywhere in the piece: the sweep is the motion, and the total waits for the ring to shut because it is the sum and stating it early is answering before the working is shown.

datachartentrancesvg

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Chart entrance — donut accumulating</title>
    <style>
      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        --_muted: color-mix(in srgb, var(--slate) 45%, transparent);
        --_track: color-mix(in srgb, var(--oat) 62%, transparent);

        /* Clock — 5.0s: 200ms for the track, 960ms for one lap of the ring,
           250ms for the key, then a settled hold. */
        --cycle: 5s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .chart {
        width: min(288px, 88vw);
        display: flex;
        align-items: center;
        gap: clamp(10px, 3.5vw, 16px);
        animation: frame var(--t) infinite;
      }

      .ring {
        position: relative;
        flex: 0 0 auto;
        width: clamp(108px, 34vw, 124px);
      }

      .ring svg {
        width: 100%;
        height: auto;
        display: block;
      }

      circle {
        fill: none;
        stroke-width: 13;
      }

      .track {
        stroke: var(--_track);
      }

      /* Every arc turns at the same angular rate — 960ms for the whole ring,
         split between the three in proportion to their share. That is what makes
         it read as one pen going round changing colour at each boundary, rather
         than three arcs racing each other to their own finish lines. Linear,
         because a constant rate is the entire claim. */
      .arc {
        transform-origin: 66px 66px;
      }

      /* Each arc is rotated to start exactly where the previous one ended, so
         the three dasharrays tile the ring with no gap to fake or account for. */
      .arc.a {
        stroke: var(--clay);
        stroke-dasharray: 138.7 301.6;
        transform: rotate(-90deg);
        animation: arc-a var(--t) infinite;
      }

      .arc.b {
        stroke: var(--olive);
        stroke-dasharray: 93.5 301.6;
        transform: rotate(75.6deg);
        animation: arc-b var(--t) infinite;
      }

      .arc.c {
        stroke: var(--clay-d);
        stroke-dasharray: 69.4 301.6;
        transform: rotate(187.2deg);
        animation: arc-c var(--t) infinite;
      }

      .centre {
        position: absolute;
        inset: 0;
        display: grid;
        place-content: center;
        justify-items: center;
        gap: 1px;
        text-align: center;
        animation: total var(--t) infinite;
      }

      .total {
        font-size: clamp(18px, 5.6vw, 22px);
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        letter-spacing: -0.02em;
      }

      .unit {
        font-size: clamp(9px, 2.7vw, 10px);
        letter-spacing: 0.1em;
        text-transform: uppercase;
        color: var(--_muted);
      }

      .key {
        flex: 1 1 auto;
        display: grid;
        gap: clamp(7px, 2.4vw, 10px);
        min-width: 0;
      }

      .row {
        display: grid;
        grid-template-columns: 9px 1fr auto;
        align-items: center;
        gap: 8px;
        font-size: clamp(11px, 3.2vw, 12px);
        opacity: 0;
      }

      .row i {
        width: 9px;
        height: 9px;
        border-radius: 50%;
      }

      .row b {
        font-weight: 400;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      .row em {
        font-style: normal;
        font-variant-numeric: tabular-nums;
        color: var(--_muted);
      }

      /* Each key row lands the instant its own arc closes, so the legend is
         written by the drawing instead of fading in beside it. Fade only: the
         arcs are the motion in this piece, and a second travelling thing would
         just be competing with them. */
      .row.a {
        animation: key-a var(--t) infinite;
      }
      .row.a i {
        background: var(--clay);
      }

      .row.b {
        animation: key-b var(--t) infinite;
      }
      .row.b i {
        background: var(--olive);
      }

      .row.c {
        animation: key-c var(--t) infinite;
      }
      .row.c i {
        background: var(--clay-d);
      }

      @keyframes arc-a {
        0%,
        6% {
          stroke-dashoffset: 138.7;
          animation-timing-function: linear;
        }
        14.8%,
        92% {
          stroke-dashoffset: 0;
        }
        93%,
        100% {
          stroke-dashoffset: 138.7;
        }
      }

      @keyframes arc-b {
        0%,
        14.8% {
          stroke-dashoffset: 93.5;
          animation-timing-function: linear;
        }
        20.8%,
        92% {
          stroke-dashoffset: 0;
        }
        93%,
        100% {
          stroke-dashoffset: 93.5;
        }
      }

      @keyframes arc-c {
        0%,
        20.8% {
          stroke-dashoffset: 69.4;
          animation-timing-function: linear;
        }
        25.2%,
        92% {
          stroke-dashoffset: 0;
        }
        93%,
        100% {
          stroke-dashoffset: 69.4;
        }
      }

      @keyframes key-a {
        0%,
        14.8% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        18.6%,
        92% {
          opacity: 1;
        }
        93%,
        100% {
          opacity: 0;
        }
      }

      @keyframes key-b {
        0%,
        20.8% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        24.6%,
        92% {
          opacity: 1;
        }
        93%,
        100% {
          opacity: 0;
        }
      }

      @keyframes key-c {
        0%,
        25.2% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        29%,
        92% {
          opacity: 1;
        }
        93%,
        100% {
          opacity: 0;
        }
      }

      /* The total waits for the ring to close. It is the sum of the three
         shares, and putting it on screen before they exist would be stating an
         answer the chart has not shown its working for yet. */
      @keyframes total {
        0%,
        25.2% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        30.2%,
        92% {
          opacity: 1;
        }
        93%,
        100% {
          opacity: 0;
        }
      }

      @keyframes frame {
        0% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        4%,
        87% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        91%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Nothing here translates or scales, so there is no movement to remove —
           the accommodation is pace. Every clock stretches by the same 1.7, which
           keeps the three arcs sharing one angular rate and keeps the re-arm
           inside the dark window. */
        .chart,
        .arc,
        .row,
        .centre {
          animation-duration: calc(var(--t) * 1.7);
        }
      }
    </style>
  </head>
  <body>
    <div
      class="chart"
      role="img"
      aria-label="A donut chart arriving: three arcs sweep clockwise from twelve o'clock at one constant rate, each key row landing as its arc closes"
    >
      <div class="ring">
        <svg viewBox="0 0 132 132" xmlns="http://www.w3.org/2000/svg">
          <circle class="track" cx="66" cy="66" r="48" />
          <circle class="arc a" cx="66" cy="66" r="48" />
          <circle class="arc b" cx="66" cy="66" r="48" />
          <circle class="arc c" cx="66" cy="66" r="48" />
        </svg>
        <div class="centre">
          <span class="total">1.24M</span>
          <span class="unit">requests</span>
        </div>
      </div>
      <div class="key">
        <div class="row a"><i></i><b>Cache hit</b><em>46%</em></div>
        <div class="row b"><i></i><b>Origin</b><em>31%</em></div>
        <div class="row c"><i></i><b>Stale</b><em>23%</em></div>
      </div>
    </div>
  </body>
</html>