CSS Bar Chart
Animated bar chart using CSS custom properties for heights and @keyframes grow animation.
A CSS bar chart is a flex row of columns anchored to a baseline, where each bar's height comes from a custom property written into the markup and the growth animation comes from a single shared @keyframes rule. The data lives in the HTML, the presentation lives in the stylesheet, and nothing needs a chart library or a canvas.
The chart below loops rather than playing once, and the reason is worth reading before you copy it. Two more builds follow: horizontal bars driven by scaleX, and stacked columns where one animation on the wrapper grows the whole stack at once. For shares of a whole rather than a comparison, the CSS pie chart is the better encoding.
Seven bars, one keyframe
HTML
<div class="chart-area">
<p class="chart-title">Weekly visitors</p>
<div class="bar-chart">
<div class="bar-col">
<span class="bar-val">65</span>
<div class="bar" style="--h: 65%"></div>
<span class="bar-label">Mon</span>
</div>
<div class="bar-col">
<span class="bar-val">45</span>
<div class="bar" style="--h: 45%"></div>
<span class="bar-label">Tue</span>
</div>
<div class="bar-col">
<span class="bar-val">80</span>
<div class="bar" style="--h: 80%"></div>
<span class="bar-label">Wed</span>
</div>
<div class="bar-col">
<span class="bar-val">55</span>
<div class="bar" style="--h: 55%"></div>
<span class="bar-label">Thu</span>
</div>
<div class="bar-col">
<span class="bar-val">90</span>
<div class="bar" style="--h: 90%"></div>
<span class="bar-label">Fri</span>
</div>
<div class="bar-col">
<span class="bar-val">70</span>
<div class="bar" style="--h: 70%"></div>
<span class="bar-label">Sat</span>
</div>
<div class="bar-col">
<span class="bar-val">30</span>
<div class="bar" style="--h: 30%"></div>
<span class="bar-label">Sun</span>
</div>
</div>
</div>
CSS
.chart-area {
display: flex;
flex-direction: column;
gap: .75rem;
}
.chart-title {
font-size: .8rem;
color: #88888f;
font-weight: 500;
}
/* flex-end anchors every bar to the baseline */
.bar-chart {
display: flex;
align-items: flex-end;
gap: .5rem;
height: 160px;
padding: 0 .25rem;
border-bottom: 1px solid #2a2a2d;
border-left: 1px solid #2a2a2d;
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: .4rem;
height: 100%;
}
/* Height comes from the inline --h on each bar */
.bar {
width: 100%;
height: var(--h);
min-height: 4px;
border-radius: 4px 4px 0 0;
transform-origin: bottom;
animation: bar-grow 4.5s cubic-bezier(0.16, 1, 0.3, 1) infinite;
animation-delay: var(--delay, 0s);
}
/* Color and stagger, one line per column */
.bar-col:nth-child(1) .bar { background: #b8ff57; --delay: 0s; }
.bar-col:nth-child(2) .bar { background: #38bdf8; --delay: .05s; }
.bar-col:nth-child(3) .bar { background: #c084fc; --delay: .10s; }
.bar-col:nth-child(4) .bar { background: #ff6b6b; --delay: .15s; }
.bar-col:nth-child(5) .bar { background: #57d9a3; --delay: .20s; }
.bar-col:nth-child(6) .bar { background: #ffd444; --delay: .25s; }
.bar-col:nth-child(7) .bar { background: #ff9040; --delay: .30s; }
.bar-label {
font-size: .7rem;
color: #88888f;
font-weight: 500;
}
.bar-val {
font-size: .7rem;
color: #88888f;
font-family: "DM Mono", "Fira Code", Consolas, monospace;
}
/* Looped with a hold rather than played once. A one-shot entrance is
over before most visitors scroll down to it, so the chart they arrive
at is a still image of an animation they never saw. */
@keyframes bar-grow {
0% { transform: scaleY(0); }
20% { transform: scaleY(1); }
85% { transform: scaleY(1); }
100% { transform: scaleY(0); }
}
Other ways to build it
Horizontal bars for long labels
Turning the chart on its side changes two things: the rows stack instead of sharing a row, and the animation becomes scaleX growing from transform-origin: left. Everything else carries over. This is the right shape whenever the category names are longer than a word, because a vertical chart with seven word long labels ends up rotating them 45 degrees, which is much harder to read than a plain list running down the left edge. Watch the right hand end of each bar as it grows: the rounded cap is squashed flat while the scale is low, which is the visible cost of scaling rather than resizing.
HTML
<div class="bc-h-row">
<span class="bc-h-label">Chrome</span>
<div class="bc-h-track"><div class="bc-h-bar" style="--w: 68%"></div></div>
<span class="bc-h-val">68</span>
</div>
CSS
.bc-h-track {
flex: 1 1 auto;
height: 14px;
border-radius: 7px;
background: #1c1c1e;
overflow: hidden;
}
.bc-h-bar {
width: var(--w);
height: 100%;
border-radius: 7px;
background: #b8ff57;
/* left, not bottom: the bar now grows sideways */
transform-origin: left;
animation: bar-grow-h 4.5s cubic-bezier(0.16, 1, 0.3, 1) infinite;
animation-delay: var(--delay, 0s);
}
.bc-h-row:nth-child(2) .bc-h-bar { background: #38bdf8; --delay: .06s; }
.bc-h-row:nth-child(3) .bc-h-bar { background: #c084fc; --delay: .12s; }
.bc-h-row:nth-child(4) .bc-h-bar { background: #ff9040; --delay: .18s; }
/* full width from 20% to 85% of the cycle, so the chart spends most
of its time showing the actual data */
@keyframes bar-grow-h {
0% { transform: scaleX(0); }
20% { transform: scaleX(1); }
85% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.bc-h-bar { animation: none; }
}
Stacked columns, animated as one piece
Each column here is a flex stack of three segments whose heights are percentages of the column, and the column itself carries the height that represents the total. One scaleY on the wrapper grows the whole stack, which keeps the segment boundaries in proportion the entire way up: animating each segment separately would make the parts arrive at different times and briefly show gaps between them. It also reuses the same bar-grow keyframes as the chart at the top of this page, since the movement is identical and only the element it is applied to has changed.
HTML
<div class="bc-stack-col">
<div class="bc-stack" style="--h: 62%">
<div class="bc-seg bc-seg-a" style="--p: 40%"></div>
<div class="bc-seg bc-seg-b" style="--p: 35%"></div>
<div class="bc-seg bc-seg-c" style="--p: 25%"></div>
</div>
<span class="bar-label">Q1</span>
</div>
CSS
/* uses @keyframes bar-grow from the main example above; the rules here are
what changes, not the whole file */
/* the total goes on the stack, the split goes on the segments */
.bc-stack {
display: flex;
flex-direction: column;
width: 100%;
height: var(--h);
min-height: 4px;
border-radius: 4px 4px 0 0;
overflow: hidden;
transform-origin: bottom;
/* the same keyframes the vertical chart above uses */
animation: bar-grow 4.5s cubic-bezier(0.16, 1, 0.3, 1) infinite;
animation-delay: var(--delay, 0s);
}
.bc-seg { height: var(--p); }
.bc-seg-a { background: #b8ff57; }
.bc-seg-b { background: #38bdf8; }
.bc-seg-c { background: #c084fc; }
.bc-stack-col:nth-child(2) .bc-stack { --delay: .07s; }
.bc-stack-col:nth-child(3) .bc-stack { --delay: .14s; }
.bc-stack-col:nth-child(4) .bc-stack { --delay: .21s; }
@media (prefers-reduced-motion: reduce) {
.bc-stack { animation: none; }
}
How it works
Each bar takes height: var(--h) with --h set inline, for example style="--h: 65%". The @keyframes rule animates scaleY from 0 to 1 with transform-origin: bottom so the bars grow upward from the axis. A --delay custom property applied through nth-child selectors staggers them. The container's align-items: flex-end anchors every bar to the baseline, and cubic-bezier(0.16, 1, 0.3, 1) gives the growth its fast start and long settle.
Keeping the value in the markup is what makes this a chart rather than a picture. style="--h: 65%" is the only thing that differs between one bar and the next, so a template writes seven numbers and the stylesheet handles everything else. min-height: 4px sits alongside the percentage height for the case that percentage is zero: a bar with no height is indistinguishable from a missing bar, and a four pixel stub reads as a real value of zero.
The animation scales rather than resizing. Animating height from 0 to 65% would force a layout pass across the whole chart on every frame, and with seven bars running at once on a phone that is enough to drop frames. transform: scaleY() with transform-origin: bottom runs on the compositor instead and costs nothing per frame. The tradeoff is that scaling distorts: the rounded cap on top of each bar is squashed flat while the bar is short, and any text placed inside a scaled bar stretches with it. Put labels outside the bar, as this chart does.
The chart loops with a long hold, and that decision came from a real problem. A one shot entrance animation is finished within a second of the page loading, which means it has already ended before most visitors have scrolled far enough to see the chart at all. What they arrive at is a still image of an animation that nobody watched. Looping fixes it, but a naive loop that grows and shrinks continuously is worse than no animation: the chart is unreadable most of the time. The keyframes here reach full height at 20% and stay there until 85%, so roughly two thirds of every 4.5 second cycle is a chart sitting still at its real values. The same correction is on the animated donut chart.
The stagger is a second custom property. Each nth-child selector sets --delay along with the bar's color, and the shared rule reads it as animation-delay: var(--delay, 0s). The fallback in var() is the part worth copying: add an eighth column and forget its selector, and the bar still animates on time rather than not at all. Fifty milliseconds between columns is enough to read as a sweep across the chart without the last bar arriving noticeably late.
The axes are borders. border-bottom and border-left on the flex row draw both lines with no extra elements, and align-items: flex-end is what stands the bars on the bottom one. Each column carries flex: 1, so seven columns divide the row into seven equal shares and eleven would divide it into eleven, with no width arithmetic anywhere. Column width and bar height are handled by completely separate mechanisms, which is why adding a data point does not disturb the layout.
CSS properties used
custom properties--hper bar for the value and--delayper column for the stagger. Both are read by one shared rule, which keeps the data in the markup.transformscaleY()for the growth. Composited, unlike an animatedheight, which would force layout on every frame.transform-originbottommakes the bar grow up from the axis. Leave it at the default center and the bar expands from its middle in both directions.animation-delay- Read from
var(--delay, 0s). The fallback keeps an unlisted column animating on time instead of not at all. align-itemsflex-endon the row stands every bar on the baseline regardless of its height.flex1on each column, so the row divides itself evenly no matter how many data points there are.min-height- A few pixels, so a bar with a value of zero is still drawn. Without it, no data and zero look identical.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
custom properties | 49 | 31 | 10 | 16 |
animation | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
flexbox | 21 | 28 | 6.1 | 12 |
gap on flexbox | 84 | 63 | 14.1 | 84 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Figures come from Can I Use. The one to watch is gap in a flex container, which Safari only supported from 14.1 and which is doing the spacing between columns here. On anything older the bars touch each other rather than the chart breaking, and a margin on the columns is the fallback if that matters. Everything else has been safe for years.
Accessibility notes
A chart made of empty div elements is nothing to a screen reader. Put the numbers in the markup as text, which this chart does through the value above each bar and the label below it, and consider wrapping the whole thing in a <figure> with a <figcaption> that states what the chart shows. For anything beyond a handful of points, a real <table> marked hidden from sighted readers but exposed to assistive technology is more useful than any ARIA on the bars.
An animation that never stops competes with the text around it. The loop here is defensible because the chart holds still for most of its cycle, but it is still movement in the reader's peripheral vision. Under prefers-reduced-motion: reduce, hold the bars at full height and drop the animation entirely, which is exactly the state a reader wants anyway.
Color alone should not carry meaning. Seven colors across seven days is decoration rather than a data encoding, which is fine, but the moment a color means a category it needs a label or a pattern beside it. Check the bars against the chart background at 3:1 rather than 4.5:1, since a bar is a graphical object rather than text.
What you can build with it
- Traffic and usage summaries. Seven days across a week is the case this shape was made for, and it fits in a dashboard tile without a library.
- Poll and survey results. Horizontal bars with the option text alongside, which is the second variant below and reads far better than vertical bars with rotated labels.
- Budget breakdowns over time. Stacked columns where each month splits into categories, using one animation on the stack rather than one per segment.
- Skill and rating displays. A short row of horizontal bars on a profile or a case study, where the values are static and the markup is written by hand.
- Sparkline style tiles. The same chart at 40px tall with the labels removed, repeated across a grid of metrics.
Mistakes worth avoiding
- Animating
heightinstead oftransform. Every frame relayouts the flex row, and the chart stutters on exactly the low powered devices where it matters. - Playing the entrance once. It finishes long before a visitor scrolls to the chart, so almost nobody sees it and the effort is invisible. Loop it with a hold, or trigger it with a scroll driven animation.
- Looping without a hold. A chart that grows and shrinks continuously is unreadable, because the bars only show their real values for an instant on each pass.
- Leaving
transform-originat its default. The bar scales from its center, so it grows downward through the axis as well as upward, and the baseline stops meaning anything. - Putting text inside a scaled bar. The
scaleYstretches the characters vertically, and at the start of the animation they are compressed to nothing. Labels belong outside the animated element.
Frequently asked questions
How do you make a bar chart with only CSS?
align-items: flex-end, one column per data point with flex: 1, and a bar inside each column whose height comes from an inline custom property. Animate transform: scaleY() with transform-origin: bottom rather than animating the height.Why does my bar chart animation only play once?
animation-iteration-count: infinite and hold the full height for most of the cycle.Should I animate height or transform?
height is a layout property, so changing it reflows the containing flex row every frame. scaleY is composited and costs nothing. The only reason to prefer height is if the bar contains text, which would be stretched by a scale.How do I stagger the bars?
--delay custom property per column with nth-child selectors and read it with animation-delay: var(--delay, 0s). Around 50ms per column reads as a sweep. Include the fallback in var() so an unstyled column still animates.Can I make the bars horizontal?
scaleY with transform-origin: bottom for scaleX with transform-origin: left. Horizontal bars are the better choice whenever the category labels are longer than a word.