Kind Pattern, data
Starts Plays in view
Reduced motion Shows the finished state
Built with CSS, SVG
Tokens --ease-inout --ease-spring --ease-paper --dur-draw --dur-base
Source Original to this collection
One pen draws the whole donut. The segments are static; a mask circle draws round once, so the sweep is one continuous stroke that lifts at every gap, and the total in the middle counts on the same clock and curve. A gauge needle swings past its value and settles while the arc fills. No script.
Use
<link rel ="stylesheet" href ="tokens.css" >
<link rel ="stylesheet" href ="donut/style.css" >
<script src ="play.js" ></script >
<figure data-play>
<svg class ="donut" viewBox ="0 0 200 200" role ="img" aria-label ="Spending by category" >
<defs >
<mask id ="sweep" maskUnits ="userSpaceOnUse" x ="0" y ="0" width ="200" height ="200" >
<circle class ="donut-pen" cx ="100" cy ="100" r ="80" pathLength ="100" />
</mask >
</defs >
<g transform ="rotate(-90 100 100)" >
<circle class ="donut-track" cx ="100" cy ="100" r ="80" pathLength ="100" />
<g mask ="url(#sweep)" >
<circle class ="donut-seg" style ="--v: 60; --at: 0; --c: #1e2229" cx ="100" cy ="100" r ="80" pathLength ="100" />
<circle class ="donut-seg donut-key" style ="--v: 40; --at: 60; --c: #ee3b2b" cx ="100" cy ="100" r ="80" pathLength ="100" />
</g >
<circle class ="donut-tip" cx ="180" cy ="100" r ="4.5" />
</g >
</svg >
<figcaption >RM <span class ="count count-k" style ="--to: 3200" ><span class ="count-text" >3,200</span ></span ></figcaption >
</figure >
Every circle keeps pathLength="100", so --v and --at are plain percentages of the ring.
Options
Class or property Does Notes .donut-seg with --v, --at, --cOne segment: its share, where it starts (the sum of the shares before it), its colour Shares should add to 100 .donut-penThe mask that draws the ring once on --ease-inout over --dur-draw Required for the sweep .donut-keyThickens one segment on the spring once the pen has passed One per chart .donut-tipA nib that rides the front of the pen, then lifts off Optional --donut-w, --donut-gapRing thickness (22) and the gap between segments (0.9) On .donut .gauge with --vHalf ring and needle; .gauge-pen fills the arc, .gauge-needle swings to --v --gauge-delay, --gauge-dur to sequence it.count with --toCounts from 0 to --to with no script --count-dur, --count-ease, --count-delay to share a clock.count-kAdds the thousands comma Needs CSS round() and mod()
Do
Give the count the same duration and curve as the thing it measures. The demo's gauge count uses the needle's curve, so the number reads the needle. Use .donut-key for the one segment the text is about, and nothing else.
Do not
Put --ease-spring on the gauge. A needle that reads 82 on its way to 68 looks like a wrong number; the gauge uses --ease-paper, a slight overshoot. Draw more than about 7 segments. Group the small ones into "Other".
Browser notes
The count needs @property (all current browsers). Without it the number jumps to the end. .count-k needs CSS round() and mod(). Without them the number shows without the comma.
Reduced motion
The ring, the arc and the needle show in their final place, the key segment is already thick, and the counts show their final number.
style.css
Copy
/*
* donut: one pen draws the whole ring. The segments are static; a mask circle draws round once on
* --ease-inout, so the sweep is one continuous stroke that lifts at every gap. The centre total
* (.count) counts on the same clock and curve, so the number is always where the pen is.
* Each segment takes --v (its share, 0 to 100) and --at (the sum of the shares before it).
*/
.donut { display : block; overflow : visible; }
.donut-track,
.donut-seg { fill : none; stroke-width : var(--donut-w , 22 ); }
.donut-track { stroke : var(--donut-track , currentColor); opacity : var(--donut-track-o , .1 ); }
.donut-seg {
stroke : var(--c , currentColor);
stroke-dasharray : calc(var(--v ) - var(--donut-gap , .9 )) 100 ;
stroke-dashoffset : calc(var(--at ) * -1 );
}
.donut-pen { fill : none; stroke : #fff; stroke-width : calc(var(--donut-w , 22 ) + 24 ); stroke-dasharray : 100 100 ; }
.js .donut-pen { stroke-dashoffset : 100 ; }
.js .go .donut-pen,
.js .go.donut .donut-pen { animation : pen var(--dur-draw ) var(--ease-inout ) both; }
@keyframes pen { from { stroke-dashoffset : 100 ; } to { stroke-dashoffset : 0 ; } }
/* donut-tip: optional nib that rides the front of the pen and lifts off when the ring closes. Starts at 3 o'clock in the ring's own space. */
.donut-tip { transform-box : view-box; transform-origin : 100px 100px ; opacity : 0 ; }
.js .go .donut-tip,
.js .go.donut .donut-tip { animation : donut-tip var(--dur-draw ) var(--ease-inout ) both; }
@keyframes donut-tip {
from { transform : rotate(0deg ); opacity : 1 ; }
92% { opacity : 1 ; }
to { transform : rotate(360deg ); opacity : 0 ; }
}
/* donut-key: the one segment the reader should look at. It thickens on the spring once the pen has passed it. */
.donut-key { stroke-width : calc(var(--donut-w , 22 ) + 8 ); }
.js .donut-key { stroke-width : var(--donut-w , 22 ); }
.js .go .donut-key,
.js .go.donut .donut-key { animation : donut-key var(--dur-base ) var(--ease-spring ) var(--dur-draw ) both; }
@keyframes donut-key {
from { stroke-width : var(--donut-w , 22 ); }
to { stroke-width : calc(var(--donut-w , 22 ) + 8 ); }
}
/*
* gauge: a half ring that fills while a needle swings to --v (0 to 100) and lands with a slight
* overshoot (--ease-paper). Not the full spring: a needle that reads 82 on its way to 68 looks like a wrong number. The fill, the needle and a .count beside it share one clock.
* Start it after something else with --gauge-delay.
*/
.gauge { display : block; overflow : visible; }
.gauge-track,
.gauge-fill { fill : none; stroke-width : var(--gauge-w , 14 ); }
.gauge-track { stroke : var(--gauge-track , currentColor); opacity : .1 ; }
.gauge-fill { stroke : var(--c , currentColor); stroke-dasharray : var(--v ) 100 ; }
.gauge-pen { fill : none; stroke : #fff; stroke-width : calc(var(--gauge-w , 14 ) + 12 ); stroke-dasharray : 100 100 ; stroke-dashoffset : calc(100 - var(--v )); }
.gauge-needle { transform-box : view-box; transform-origin : 100px 100px ; transform : rotate(calc(var(--v ) * 1.8deg - 90deg )); }
.js .gauge-pen { stroke-dashoffset : 100 ; }
.js .gauge-needle { transform : rotate(-90deg ); }
.js .go .gauge-pen,
.js .go.gauge .gauge-pen { animation : gauge-pen var(--gauge-dur , 1200ms ) var(--ease-paper ) var(--gauge-delay , 0ms ) both; }
.js .go .gauge-needle,
.js .go.gauge .gauge-needle { animation : gauge-needle var(--gauge-dur , 1200ms ) var(--ease-paper ) var(--gauge-delay , 0ms ) both; }
@keyframes gauge-pen { from { stroke-dashoffset : 100 ; } to { stroke-dashoffset : calc(100 - var(--v )); } }
@keyframes gauge-needle {
from { transform : rotate(-90deg ); }
to { transform : rotate(calc(var(--v ) * 1.8deg - 90deg )); }
}
/*
* count: a number that counts up to --to with no script. The digits are a CSS counter driven by a
* registered integer property. The real number stays in the markup (.count-text) for screen
* readers and copy; the counted copy has empty alt text. Match the clock with --count-dur,
* --count-ease and --count-delay. Browsers without @property jump straight to the end.
*/
@property --count { syntax : "<integer>"; inherits : false; initial-value : 0 ; }
.count { position : relative; }
.count-text {
position : absolute; width : 1px ; height : 1px ; overflow : hidden;
clip-path : inset(50 %); white-space : nowrap;
}
.count::after { --count : var(--to , 0 ); counter-reset : count var(--count ); content : counter(count) / ""; }
.js .count::after { --count : 0 ; }
.js .go .count::after,
.js .count.go::after { animation : count var(--count-dur , var(--dur-draw )) var(--count-ease , var(--ease-inout )) var(--count-delay , 0ms ) both; }
@keyframes count { from { --count : 0 ; } to { --count : var(--to , 0 ); } }
/* count-k: thousands get a comma (3,200). Needs CSS round() and mod(); older browsers show 3200. */
@counter-style count-blank { system : cyclic; symbols : "\200B"; }
@counter-style count-lead { system : extends decimal; range : 1 infinite; fallback : count-blank; }
@counter-style count-comma { system : cyclic; symbols : ","; range : 1 infinite; fallback : count-blank; }
@counter-style count-plain { system : extends decimal; range : 0 infinite; fallback : count-blank; }
@counter-style count-pad3 { system : extends decimal; pad : 3 "0 "; range : 0 infinite; fallback : count-blank; }
@supports (z-index: mod(7 , 3 )) {
.count-k::after {
--k : round(down, var(--count ) / 1000 );
--r : mod(var(--count ), 1000 );
--on : min(var(--k ), 1 );
counter-reset : k var(--k ) lo calc(var(--r ) - var(--on ) * 100000 ) pad calc(var(--r ) - (1 - var(--on )) * 100000 );
content : counter(k, count-lead) counter(k, count-comma) counter(lo, count-plain) counter(pad, count-pad3) / "";
}
}
@media (prefers-reduced-motion: reduce) {
.js .donut-pen, .js .go .donut-pen, .js .go.donut .donut-pen { animation : none; stroke-dashoffset : 0 ; }
.js .donut-key, .js .go .donut-key, .js .go.donut .donut-key { animation : none; stroke-width : calc(var(--donut-w , 22 ) + 8 ); }
.js .gauge-pen, .js .go .gauge-pen, .js .go.gauge .gauge-pen { animation : none; stroke-dashoffset : calc(100 - var(--v )); }
.js .gauge-needle, .js .go .gauge-needle, .js .go.gauge .gauge-needle { animation : none; transform : rotate(calc(var(--v ) * 1.8deg - 90deg )); }
.js .go .donut-tip, .js .go.donut .donut-tip { animation : none; }
.js .count::after, .js .go .count::after, .js .count.go::after { animation : none; --count : var(--to , 0 ); }
}