Chart entrance — bars from the baseline
Variant: Bars from the baseline
The frame lands before any datum does: the axis rules itself out from the left, and only then do six bars grow up off it, 60ms apart. That order is the whole argument — a bar’s length means nothing until there is a baseline to measure it from, so a chart that fades in whole spends its first 400ms showing marks with no scale under them. The bars leave at 6% rather than zero, because a column rising off the axis should read as a thing that was already there. The cascade is the flourish and the growth is the data, which is why reduced motion keeps the second and drops the first.
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 entrance — bars from the baseline</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);
--_grid: color-mix(in srgb, var(--oat) 78%, transparent);
--_axis: color-mix(in srgb, var(--slate) 26%, transparent);
/* Clock — 5.4s: 216ms for the frame, a 300ms stagger over six bars,
421ms of growth each, then a long settled hold. A chart entrance is
mostly hold; the reveal is a fifth of the loop. */
--cycle: 5.4s;
--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);
}
.chart {
width: min(288px, 88vw);
display: grid;
gap: 10px;
animation: frame var(--t) infinite;
}
.label {
font-size: clamp(11px, 3.2vw, 12px);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--_muted);
}
.plot {
position: relative;
height: clamp(96px, 27vw, 118px);
}
/* The frame lands before any datum does. A bar's length only means
something measured against a baseline, so the baseline has to exist
first — otherwise the first 400ms are marks with no scale under them. */
.grid {
position: absolute;
inset: 0;
display: grid;
grid-template-rows: repeat(3, 1fr);
}
.grid span {
border-top: 1px solid var(--_grid);
}
.axis {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: var(--_axis);
transform-origin: left center;
animation: rule var(--t) infinite;
}
.bars {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: clamp(5px, 1.8vw, 8px);
}
.bar {
flex: 1;
height: var(--_h);
background: var(--clay);
border-radius: calc(2px * var(--round)) calc(2px * var(--round)) 0 0;
transform-origin: bottom center;
/* Matches the 0% keyframe so the bars sit flat on the axis during the
delay rather than snapping down when their animation picks up. */
transform: scaleY(0.06);
animation: grow var(--t) var(--ease-out) infinite;
animation-delay: calc((0.36s + var(--_i) * 0.06s) / var(--speed));
}
.bar.now {
background: var(--clay-d);
}
.days {
display: flex;
gap: clamp(5px, 1.8vw, 8px);
font-size: clamp(10px, 3vw, 11px);
color: var(--_muted);
}
.days span {
flex: 1;
text-align: center;
}
/* 6% is a sliver, not zero — the bar leaves the axis as a thing that was
already there. It collapses again at 85-87%, four hundred milliseconds
after the chart has faded to nothing, so the re-arm is never on screen. */
@keyframes grow {
0% {
transform: scaleY(0.06);
}
7.8%,
85% {
transform: scaleY(1);
}
87%,
100% {
transform: scaleY(0.06);
}
}
@keyframes rule {
0% {
transform: scaleX(0);
animation-timing-function: var(--ease-out);
}
6%,
100% {
transform: scaleX(1);
}
}
@keyframes frame {
0% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
4%,
87% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
91%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The growth stays: a bar's length is the value, and a chart that never
draws its values makes no claim at all. The cascade goes — that is the
flourish, not the data — and the whole loop stretches to 9.2s. Because
every duration and delay scales by the same 1.7, the collapse still
lands inside the dark window and nothing rewinds on screen. */
.chart,
.bar {
animation-duration: calc(var(--t) * 1.7);
}
.bar {
animation-delay: calc(0.612s / var(--speed));
}
.axis {
animation: none;
transform: scaleX(1);
}
}
</style>
</head>
<body>
<div
class="chart"
role="img"
aria-label="A bar chart arriving: the baseline and gridlines draw first, then six bars grow up from the axis one after another"
>
<span class="label">Deploys / day</span>
<div class="plot">
<div class="grid"><span></span><span></span><span></span></div>
<div class="bars">
<div class="bar" style="--_h: 38%; --_i: 0"></div>
<div class="bar" style="--_h: 61%; --_i: 1"></div>
<div class="bar" style="--_h: 47%; --_i: 2"></div>
<div class="bar" style="--_h: 79%; --_i: 3"></div>
<div class="bar" style="--_h: 68%; --_i: 4"></div>
<div class="bar now" style="--_h: 92%; --_i: 5"></div>
</div>
<div class="axis"></div>
</div>
<div class="days">
<span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.