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.