Back to the library

Chart — donut sweep

Variant: Donut

Four segments spending one ring in 1.6s at a single angular rate, so each takes exactly its share of that budget — 736ms for the 46%, 128ms for the 8% — and how long a segment holds your eye is the number it is reporting. It starts at twelve o’clock and every segment picks up where the last one stopped; nothing in a part-to-whole chart may start from nowhere. The readout lands 75ms after the ring closes, so it reads as a conclusion drawn from the chart rather than a caption printed on it.

datachartentrancesvgloop

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 — donut sweep</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;
        --round: 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);
        --_faint: color-mix(in srgb, var(--slate) 24%, transparent);

        /* Clock — 5s, of which the ring is spent in 1.6s. One turn, one budget:
           the four segments share that 1.6s in proportion to their values, so no
           segment has a duration of its own. */
        --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);
      }

      .tile {
        width: min(320px, 90vw);
        display: grid;
        gap: 10px;
        justify-items: center;
        padding: 18px 22px 20px;
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
      }

      .label {
        justify-self: start;
        font-size: 12px;
        font-weight: 600;
        letter-spacing: 0.14em;
        text-transform: uppercase;
        color: var(--_muted);
      }

      svg {
        display: block;
        width: clamp(132px, 46vw, 160px);
        height: auto;
      }

      /* The empty ring is the whole the parts are spent out of, so it is drawn
         before anything is spent and stays behind the segments afterwards. */
      .track {
        fill: none;
        stroke: var(--oat);
        stroke-width: 18;
        opacity: 0.55;
      }

      .seg {
        fill: none;
        stroke-width: 18;
        transform-origin: 80px 80px;
        animation-duration: var(--t);
        animation-timing-function: linear;
        animation-iteration-count: infinite;
      }

      /* A circle's path starts at three o'clock, so every segment carries -90deg
         plus the share already spent before it. Twelve o'clock is where a reader
         starts a dial, and each segment picks up exactly where the last stopped
         — nothing in a part-to-whole chart is allowed to start from nowhere. */
      .s1 {
        stroke: var(--clay);
        stroke-dasharray: 0.452 1;
        transform: rotate(-90deg);
        animation-name: s1;
      }

      .s2 {
        stroke: var(--clay-d);
        stroke-dasharray: 0.282 1;
        transform: rotate(75.6deg);
        animation-name: s2;
      }

      .s3 {
        stroke: var(--olive);
        stroke-dasharray: 0.162 1;
        transform: rotate(180deg);
        animation-name: s3;
      }

      .s4 {
        stroke: var(--_faint);
        stroke-dasharray: 0.072 1;
        transform: rotate(241.2deg);
        animation-name: s4;
      }

      /* Linear, and each window is sized by the segment's share: 736ms for 46%,
         128ms for 8%. One angular rate across the whole turn means the time a
         segment holds your eye is the number it is reporting. */
      @keyframes s1 {
        0%,
        8% {
          stroke-dashoffset: 0.452;
        }
        22.72%,
        93% {
          stroke-dashoffset: 0;
        }
        93.5%,
        100% {
          stroke-dashoffset: 0.452;
        }
      }

      @keyframes s2 {
        0%,
        22.72% {
          stroke-dashoffset: 0.282;
        }
        32%,
        93% {
          stroke-dashoffset: 0;
        }
        93.5%,
        100% {
          stroke-dashoffset: 0.282;
        }
      }

      @keyframes s3 {
        0%,
        32% {
          stroke-dashoffset: 0.162;
        }
        37.44%,
        93% {
          stroke-dashoffset: 0;
        }
        93.5%,
        100% {
          stroke-dashoffset: 0.162;
        }
      }

      @keyframes s4 {
        0%,
        37.44% {
          stroke-dashoffset: 0.072;
        }
        40%,
        93% {
          stroke-dashoffset: 0;
        }
        93.5%,
        100% {
          stroke-dashoffset: 0.072;
        }
      }

      .value {
        font-size: 28px;
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        fill: var(--slate);
      }

      .caption {
        font-size: 13px;
        font-weight: 600;
        letter-spacing: 0.12em;
        fill: var(--_muted);
      }

      /* The readout lands 75ms after the ring closes, not with it: an annotation
         that arrives alongside its evidence reads as a caption printed on the
         chart rather than a conclusion drawn from it. */
      .readout {
        animation: readout var(--t) linear infinite;
      }

      @keyframes readout {
        0%,
        41.5% {
          opacity: 0;
          transform: translateY(6px);
          animation-timing-function: var(--ease-out);
        }
        46.5%,
        93% {
          opacity: 1;
          transform: translateY(0px);
        }
        93.5%,
        100% {
          opacity: 0;
          transform: translateY(6px);
        }
      }

      /* The re-arm window: the ring empties under opacity 0 at 93.5% and fades
         back up already empty, so the sweep is never seen running backwards. */
      .ink {
        animation: ink-cycle var(--t) linear infinite;
      }

      @keyframes ink-cycle {
        0%,
        88% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        93%,
        96% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        100% {
          opacity: 1;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The ring is simply spent — no sweep, no re-arm. Only the readout
           still cycles, gently, so the card is not frozen. */
        .seg {
          animation: none;
          stroke-dashoffset: 0;
        }

        .ink {
          animation: none;
          opacity: 1;
        }

        .readout {
          animation: readout-still calc(var(--t) * 1.6) var(--ease-out) infinite;
          transform: none;
        }

        @keyframes readout-still {
          0%,
          41.5% {
            opacity: 0.3;
          }
          46.5%,
          93% {
            opacity: 1;
          }
          93.5%,
          100% {
            opacity: 0.3;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="tile">
      <span class="label">Traffic by source</span>
      <svg
        viewBox="0 0 160 160"
        role="img"
        aria-label="A donut chart whose four segments sweep in around the dial in sequence, forty-six percent direct"
      >
        <circle class="track" cx="80" cy="80" r="56" />
        <g class="ink">
          <circle class="seg s1" cx="80" cy="80" r="56" pathLength="1" />
          <circle class="seg s2" cx="80" cy="80" r="56" pathLength="1" />
          <circle class="seg s3" cx="80" cy="80" r="56" pathLength="1" />
          <circle class="seg s4" cx="80" cy="80" r="56" pathLength="1" />
          <g class="readout">
            <text class="value" x="80" y="82" text-anchor="middle">46%</text>
            <text class="caption" x="80" y="101" text-anchor="middle">DIRECT</text>
          </g>
        </g>
      </svg>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.