Motion patterns Get motion.css

Patterns

Bars and count-up

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.

Open the demo alone
0.00s

Timeline loads with the demo.

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

<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="bars/style.css">
<script src="play.js"></script>

<ol class="bars" data-play aria-label="Cups sold per hour">
  <li style="--v: .53; --to: 112">
    <span class="bar-col">
      <span class="bar-fill"></span>
      <span class="bar-tag"><span class="bar-num"><span class="visually-hidden">112 cups</span></span></span>
    </span>
    <span class="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 propertyWhereWhat it does
.barsListVertical bars. Height from --bars-h (200px), gap from --bar-gap (8px)
.bars-hList, with .barsHorizontal bars; the label rides the end of the bar. Thickness from --bar-thick (14px)
--v, --toEach liBar length (0 to 1) and the number to count to
--bar-colorList or liFill colour, currentColor by default. Mark one key bar, not many
--bars-delayListHolds the whole list, to start a second chart after the first
--iEach liStagger step. Set for you where sibling-index() exists; set it by hand for other browsers
.bar-markInside a .bars-plot wrapper, after the listA 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: 0 50%; 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: 0 50%; pointer-events: none; }

@keyframes bar-grow   { from { scale: 1 0; } }
@keyframes bar-ride   { from { translate: 0 100%; } }
@keyframes bar-grow-x { from { scale: 0 1; } }
@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: 0 1; 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; }
}