Bars spring up from the baseline and each value rides the top of its own bar while it counts up. Vertical and horizontal, with an optional marked rule.
Bars spring up from the baseline, and each value label rides the top of its own bar while it counts up. The fill and the label share one duration, one delay and one curve, so the number stays on the bar through the overshoot instead of chasing it.
Use
<linkrel="stylesheet"href="tokens.css">
<linkrel="stylesheet"href="bars/style.css">
<scriptsrc="play.js"></script>
<olclass="bars" data-play aria-label="Cups sold per hour">
<listyle="--v: .53; --to: 112">
<spanclass="bar-col">
<spanclass="bar-fill"></span>
<spanclass="bar-tag"><spanclass="bar-num"><spanclass="visually-hidden">112 cups</span></span></span>
</span>
<spanclass="bar-label">7 am</span>
</li>
<!-- one li per bar -->
</ol>
Bring your own visually-hidden class for the real number (the demo calls it .vh).
--v is the bar's share of the plot height, 0 to 1. Keep the tallest bar near .8: the spring overshoots by about a fifth, and the label needs room above it. --to is the whole number the label counts to.
Variants and options
Class or property
Where
What it does
.bars
List
Vertical bars. Height from --bars-h (200px), gap from --bar-gap (8px)
.bars-h
List, with .bars
Horizontal bars; the label rides the end of the bar. Thickness from --bar-thick (14px)
--v, --to
Each li
Bar length (0 to 1) and the number to count to
--bar-color
List or li
Fill colour, currentColor by default. Mark one key bar, not many
--bars-delay
List
Holds the whole list, to start a second chart after the first
--i
Each li
Stagger step. Set for you where sibling-index() exists; set it by hand for other browsers
.bar-mark
Inside a .bars-plot wrapper, after the list
A dashed rule across the plot at its own --v, drawn after the bars land (--mark-delay, 1400ms)
Do
Mark the one bar the reader should look at: a colour on its fill and a larger number.
Start a second chart with --bars-delay so the eye finishes the first one.
Do not
Count decimals or thousands with this: a CSS counter is a plain integer with no separators. Use the odometer pattern for money and large numbers.
Put the spring on a bar that fills the plot: the overshoot will cross the top edge.
Reduced motion
Bars, labels and the rule show at their final size and value with no animation. The real numbers are in the markup the whole time; the drawn counter carries an empty alt text, so screen readers read each value once.
style.css
/*
* bars: bars grow from the baseline on the spring, and each value label rides the top of its own
* bar while it counts up. The fill and the label share one duration, one delay and one curve, so the
* label stays glued to the bar through the overshoot instead of chasing it.
*
* Per bar: --v (0..1, the bar's share of the plot) and --to (the integer the label counts to).
* Optional: --i (stagger step, set for you where sibling-index() exists), --bars-delay on the list.
*/@property--bar-n { syntax: "<integer>"; inherits: false; initial-value: 0; }
.bars { --bar-label-h: 1.9em; display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: var(--bar-gap, 8px); height: var(--bars-h, 200px); }
.bars > li { --bar-delay: calc(var(--bars-delay, 0ms) + var(--i, 0) * var(--stagger)); display: grid; grid-template-rows: 1fr var(--bar-label-h); min-width: 0; }
@supports (animation-delay: calc(sibling-index() * 1ms)) {
.bars > li { --i: calc(sibling-index() - 1); }
}
.bar-col { position: relative; }
.bar-fill { position: absolute; inset: 0; transform-origin: 50% 100%; scale: 1 var(--v); background: var(--bar-color, currentColor); }
/* the tag is a column-tall box; moving it by (1 - v) of its own height puts its top edge on the bar's top */.bar-tag { position: absolute; inset: 0; translate: 0 calc((1 - var(--v)) * 100%); pointer-events: none; }
.bar-num { --bar-n: var(--to); counter-reset: bar-n var(--bar-n); display: block; translate: 0 calc(-100% - 4px); text-align: center; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* the counter is drawn, the real number is in the markup for screen readers (an empty alt hides the drawn one) */.bar-num::before { content: counter(bar-n); content: counter(bar-n) / ""; }
.bar-label { align-self: end; text-align: center; white-space: nowrap; }
/* bars-h: the same parts, growing to the right; the label rides the end of the bar */.bars-h { display: grid; grid-auto-flow: row dense; grid-auto-columns: auto; grid-template-columns: max-content 1fr; height: auto; gap: var(--bar-gap, 8px) 12px; }
.bars-h > li { display: contents; }
.bars-h .bar-label { grid-column: 1; align-self: center; text-align: left; }
.bars-h .bar-col { grid-column: 2; }
.bars-h .bar-col { height: var(--bar-thick, 14px); margin-right: 3.5em; }
.bars-h .bar-fill { transform-origin: 050%; scale: var(--v) 1; }
.bars-h .bar-tag { translate: calc((var(--v) - 1) * 100%) 0; display: flex; justify-content: flex-end; align-items: center; }
.bars-h .bar-num { translate: calc(100% + 6px) 0; }
/* bar-mark: an optional rule across the plot at one bar's height (set --v on it), drawn after the bars land */.bars-plot { position: relative; }
.bar-mark { position: absolute; left: 0; right: 0; bottom: calc(var(--bar-label-h, 1.9em) + var(--v) * (var(--bars-h, 200px) - var(--bar-label-h, 1.9em))); border-top: 1px dashed currentColor; transform-origin: 050%; pointer-events: none; }
@keyframes bar-grow { from { scale: 10; } }
@keyframes bar-ride { from { translate: 0100%; } }
@keyframes bar-grow-x { from { scale: 01; } }
@keyframes bar-ride-x { from { translate: -100% 0; } }
@keyframes bar-count { from { --bar-n: 0; } }
@keyframes bar-show { from { opacity: 0; } }
@keyframes bar-mark { from { scale: 01; opacity: 0; } 1% { opacity: 1; } }
/* Hidden start states only with play.js; the bars start when it adds .go to them or an ancestor */.js .bars .bar-fill { animation: bar-grow var(--dur-slow) var(--ease-spring) var(--bar-delay) both paused; }
.js .bars .bar-tag { animation: bar-ride var(--dur-slow) var(--ease-spring) var(--bar-delay) both paused; }
.js .bars .bar-num { animation: bar-count var(--dur-slow) var(--ease-out) var(--bar-delay) both paused, bar-show var(--dur-quick) linear var(--bar-delay) both paused; }
.js .bars-h .bar-fill { animation-name: bar-grow-x; }
.js .bars-h .bar-tag { animation-name: bar-ride-x; }
.js .bar-mark { animation: bar-mark var(--dur-base) var(--ease-inout) var(--mark-delay, 1400ms) both paused; }
.js .go .bars :is(.bar-fill, .bar-tag, .bar-num),
.js .bars.go :is(.bar-fill, .bar-tag, .bar-num),
.js .go .bar-mark { animation-play-state: running; }
@media (prefers-reduced-motion: reduce) {
.js .bars :is(.bar-fill, .bar-tag, .bar-num),
.js .go .bars :is(.bar-fill, .bar-tag, .bar-num),
.js .bars.go :is(.bar-fill, .bar-tag, .bar-num),
.js .bar-mark,
.js .go .bar-mark { animation: none; }
}