SVG-free graphs with D3 and CSS layouts
I love D3, but much of it was designed for SVG. When drawing a data visualization with SVG, you need to tell it exactly where and how big each item needs to be. That’s why D3 has layout utilities like scaleBand.bandwidth
and stack()
, and why you need to explicitly handle resizing. And when using D3 axis, it directly draws its own SVG.
But you don’t have to use SVG. You can take advantage of CSS’s grid and flex layouts to create many types of visualizations. I recently made an SVG-free bar chart that uses CSS to lay itself out as its container is resized and as data is added and removed — all without calculating any sizes using JavaScript.
See the Pen
SVG-free bar graph with CSS layouts and D3 by Noah (@noleli)
on CodePen.
So let’s make a flexible bar graph with axes, axis labels, bars, and bar labels without using SVG.
The markup and styling
Layout
Here’s the overall layout we’re going for.

We can achieve this with a basic grid layout.
<div class="graph" id="graphContainer">
<div class="axis-label x-axis-label">x axis label</div>
<div class="axis x-axis">x axis</div>
<div class="axis-label y-axis-label">y axis label</div>
<div class="axis y-axis">y axis</div>
<div class="graph-area">graph area</div>
</div>
The axes and axis labels should take up only as much space as they need, while the area for the graph itself takes up the rest of the place. That means giving axis and axis label rows and columns a dimension of auto
, and letting the graph area take up the rest of the space with 1fr
.
.graph {
display: grid;
grid-template-columns: auto auto 1fr;
grid-template-rows: 1fr auto auto;
grid-template-areas:
"y-axis-label y-axis graph-area"
". . x-axis"
". . x-axis-label";
}
(As an aside, I normally don’t reach for grid-template-areas
, preferring to use grid-area: <start-row> / <start-col> / <end-row> / <end-col>
, but in this case I think it’s nicely illustrative.)
We can now assign each of the graph elements to its respective grid area.
.graph .x-axis-label {
grid-area: x-axis-label;
}
.graph .x-axis {
grid-area: x-axis;
}
.graph .y-axis-label {
grid-area: y-axis-label;
}
.graph .y-axis {
grid-area: y-axis;
}
.graph .graph-area {
grid-area: graph-area;
aspect-ratio: 5 / 4;
}
Note that I gave the graph-area
an aspect ratio of 5:4. Since, as basically a block element, the inline size (a.k.a. width in left-to-right and right-to-left languages) of the .graph
element is determined by its container, we can set the aspect ratio to ensure that it has a nicely proportional block size (a.k.a. height). The exact value is a matter of taste, but 5:4 works for me. Depending on your needs, you might size the container differently.
Axes
Now that we have our skeleton, let’s quickly take care of the axis labels before moving on to the axes themselves. I’m going to make them bold and align them center, and also add a bit of margin between the labels and the axes.
.graph .axis-label {
--margin: 8px;
font-weight: bold;
text-align: center;
}
.graph .x-axis-label {
grid-area: x-axis-label;
margin-top: var(--margin);
}
Then, I’m going to rotate the y axis label so it’s vertical. Sadly, only Firefox supports writing-mode: sideways-lr
, so we’re stuck with
.graph .y-axis-label {
grid-area: y-axis-label;
margin-block-end: var(--margin);
writing-mode: vertical-lr;
transform: rotate(180deg);
}
That gives us:

Now for the axes themselves, I’m going to make an important assumption: all ticks are evenly spaced. That means that what I’m about to do won’t work if you want to show certain specific ticks or are using a nonlinear (e.g., log) axis scale.
With that stated, let’s add some ticks to our markup. Later on, we’ll have D3 take care of this, but let’s do it manually for now so we can get them laid out and styled.
<div class="axis x-axis">
<div class="tick">Category 1</div>
<div class="tick">Category 2</div>
<div class="tick">Category 3</div>
<div class="tick">Category 4</div>
</div>
<div class="axis y-axis">
<div class="tick">0</div>
<div class="tick">100</div>
<div class="tick">200</div>
<div class="tick">300</div>
<div class="tick">400</div>
<div class="tick">500</div>
<div class="tick">600</div>
<div class="tick">700</div>
<div class="tick">800</div>
</div>
Because this is a bar graph, meaning that the x values are categorical and the y values are continuous, we’re going to treat the x and y axes somewhat differently. But we know they’ll have some things in common, so we can set those things up once.
To begin with, each axis is going to be a flex layout, but they get different flex directions. Similarly, both will have the same border, but they’re oriented differently, so the border will be along different edges, so all we can do is define a custom property to be assigned later.
.graph .axis {
display: flex;
font-size: .8rem;
--border: 1px solid black;
}
The ticks themselves will also have many things in common, like font size, tick size, and tick color. We’ll also put a small gap between the tick label and the tick line.
.graph .tick {
--tick-lengeth: 10px;
--tick-thickness: 1px;
--tick-color: darkgray;
display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
Now, for the the y axis, let’s lay the ticks out vertically, starting at the bottom:
.graph .y-axis {
grid-area: y-axis;
flex-direction: column-reverse;
justify-content: space-between;
align-items: end;
border-inline-end: var(--border);
}
Let’s also add some tick marks to the ticks themselves:
.graph .y-axis .tick::after {
content: "";
height: var(--tick-thickness);
width: var(--tick-lengeth);
background-color: var(--tick-color);
}
That gives us this:

But there’s a problem! The flex layout is taking into account the height of the tick labels themselves. To fix it, we have to give the tick a height of 0. That way, from a layout perspective a tick can be placed exactly where it’s supposed to be, but the tick content will happily overflow its 0-height container and look fine.
.graph .y-axis .tick {
height: 0;
}
The x axis is similar, but being categorical, it’s typical to not have the bars go edge to edge like space-between
; rather, they get laid out with space-around
.

.graph .x-axis {
grid-area: x-axis;
justify-content: space-around;
border-top: var(--border);
}
.graph .x-axis .tick {
width: 0;
flex-direction: column;
text-align: center;
}
.graph .x-axis .tick::before {
content: "";
width: var(--tick-thickness);
height: var(--tick-lengeth);
background-color: var(--tick-color);
}
One other thing with the x axis is that when there’s a lot of data (or the graph is small) the tick labels can overlap. That isn’t always the case, but we can add an option to let someone turn the labels vertical by setting data-orientation="vertical"
on the axis element.
.graph .x-axis[data-orientation=vertical] .tick {
writing-mode: vertical-lr;
flex-direction: row-reverse;
transform: rotate(180deg);
}

Neat.
Graph data
How about we add some data to our graph, eh?
Each data point is going to be an element inside the .graph-area
with two children: a bar and a value label (optional):
<div class="datum">
<div class="value">336</div>
<div class="bar" style="height: 42%"></div>
</div>
Thinking a bit about the width and horizontal position of each data point, when there’s only a small number of data points, they should use the same space-around
justification as the x axis labels (the data had better line up with the labels!), but should only grow to be so wide when there aren’t that many of them (we don’t want bars the width of the graph). But when there is a lot of data, the bars need to get narrower so more of them can fit.
To do this, we can have a grid create as many columns as there are data points, letting them get arbitrarily (well, 1 pixel) narrow, but only allowed to grow to 25% of the graph area. (Because there are no other columns, the remaining width and the total width are the same. That’s why .25fr
is 25% of the total width.)
.graph .graph-area {
grid-area: graph-area;
aspect-ratio: 5 / 4;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(1px, .25fr));
justify-content: space-around;
}
Remember from the markup above that each .datum
has two children: a .value
, where the text value goes (though this is optional), and a .bar
. The value sits on top of the bar, so let’s use a column flex layout for the .datum
s (data?). Notice that each grid column in .graph-area
is the full height of the graph area. That means that each .datum
will also be the full height of the graph area. So, we can use justify-content: end
to send the bar and value label to the bottom.
I also added a bit of inline margin here. This is what makes sure there’s space between the bars even when there are a lot of them. I used margin instead of a grid gap for a couple reasons. First, by keeping the spacing as part of each .datum
rather than part of the layout we don’t have to compensate for it in the axis; everything stays nicely lined up. Second, we can use a percent value, which means that as you add more bars, the space gets narrower. This uses space more efficiently and feels more natural to me.
.graph .datum {
margin-inline: 5%;
display: flex;
flex-direction: column;
justify-content: end;
}
For the bar itself, you probably noticed in the .datum
markup that its height is set on each bar. Beyond that, the bars get two additional styles. A color, of course (although that could also be set based on the data), and, importantly, flex-shrink: 0
. We need that because if a bar gets tall enough that the value text hits the top of the graph area, by default the browser will squish the bar so the text fits. We don’t want that because the height represents a specific numeric value.
.graph .datum .bar {
flex-shrink: 0;
background-color: rebeccapurple;
}
The value itself is pretty simple: center it with the datum. I also gave it a z index so that if the bars get close enough, and the value gets wide enough, the value will be on top of the adjacent bars. That said, if you find yourself in this situation, I’d recommend hiding the value label except when a bar is hovered over.
.graph .datum .value {
align-self: center;
z-index: 1;
}

That’s it for the HTML and CSS. This markup:
<div class="graph" id="graphContainer">
<div class="axis-label x-axis-label">x axis label</div>
<div class="axis x-axis">
<div class="tick">Category 1</div>
<div class="tick">Category 2</div>
<div class="tick">Category 3</div>
<div class="tick">Category 4</div>
</div>
<div class="axis-label y-axis-label">y axis label</div>
<div class="axis y-axis">
<div class="tick">0</div>
<div class="tick">100</div>
<div class="tick">200</div>
<div class="tick">300</div>
<div class="tick">400</div>
<div class="tick">500</div>
<div class="tick">600</div>
<div class="tick">700</div>
<div class="tick">800</div>
</div>
<div class="graph-area">
<div class="datum">
<div class="value">200</div>
<div class="bar" style="height: 25%"></div>
</div>
<div class="datum">
<div class="value">400</div>
<div class="bar" style="height: 50%"></div>
</div>
<div class="datum">
<div class="value">600</div>
<div class="bar" style="height: 75%"></div>
</div>
<div class="datum">
<div class="value">800</div>
<div class="bar" style="height: 100%"></div>
</div>
</div>
</div>
will produce this graph:

🎉
The JavaScript
Did someone say D3? Ah yes, that was me a long time ago.
People think of D3 as a data visualization library, but that’s not quite right. It’s a library for binding data to DOM elements and manipulating those elements based on the data — and it happens to have a ton of utilities to help with visualization.
The class
So let’s build a JavaScript class that will build the DOM structure described above based on data.
To do this, we need a bit of D3. Not all of it, but we can import just the bits we need at the top. You’ll see how these all get used soon.
import { select, selectAll } from "d3-selection";
import { scaleLinear } from "d3-scale";
import { max } from "d3-array";
I like the pattern of passing a container element and an object of optional options into a class constructor, then doing everything else from inside the class. Here’s our basic class definition:
class BarGraph {
constructor(container, options) {
this.container = select(container);
}
}
Continuing in the constructor, the options
object will handle axis labels and orientation, with some logical defaults. If an axis label is omitted, it’s set to an empty string. This is great, because if you don’t want a label, there simply won’t be one. This is a nice advantage over SVG: since the browser automatically sizes the axis label containers based on their content, if that content is ""
it essentially collapses out of the layout. Compare this to SVG, where the idiomatic D3 way of leaving room for things like axis labels is manually adding some space to a margin = { top, right, bottom, left }
object.
this.xAxisLabel = options.xAxisLabel ?? "";
this.yAxisLabel = options.yAxisLabel ?? "";
this.xAxisOrientation = options.xAxisOrientation ?? "horizontal";
Then, our constructor builds the DOM. It gives the container a class of graph
, creates the DOM tree, and applies the three options we just set up. It also assigns the three elements whose content will be dynamically built — xAxis
, yAxis
, and graphArea
— to instance fields.
this.container.classed("graph", true);
this.container.html(`
<div class="axis-label x-axis-label">${this.xAxisLabel}</div>
<div class="axis x-axis" data-orientation=${this.xAxisOrientation}></div>
<div class="axis-label y-axis-label">${this.yAxisLabel}</div>
<div class="axis y-axis"></div>
<div class="graph-area"></div>
`);
this.xAxis = this.container.select(".x-axis");
this.yAxis = this.container.select(".y-axis");
this.graphArea = this.container.select(".graph-area");
Note that with this approach, you can’t change the options later. We could easily change this by applying the options in the update()
method I’ll describe below instead of here in the constructor.
Remember that the height of each bar is determined by a percent. The last thing the constructor does is create the only D3 scale we need: a scale that will map the values of our data to 0–100%.
this.y = scaleLinear().range([0, 100]);
Next, let’s get some data. Our class will provide a getter and setter for the data.
get data() {
return this._data;
}
set data(d) {
this._data = d;
this.y.domain([0, max(this.data, d => d.value)]).nice();
this.update();
}
The getter is simple enough: just return the data itself. The setter sets the data, of course, then does a couple more things. It sets the domain of our y
scale, and it calls the (yet-to-be-defined) update()
method.
There are two important things about setting the scale domain that I want to call out. First, rather than using d3-array’s extent()
, it goes from 0 to max
because you should make good bar charts. Second, notice the call to nice()
at the end. This is because we don’t necessarily want the top of the y axis to be the exact maximum value of our data; we want it to be a nice round number. That’s exactly what nice()
does for us.
Finally, we get to the heart of any D3 application: the update()
method. But first, let’s state what the data has to look like. It’ll be an array of objects, and each object has (at least) a category
and value
field.
const data = [
{ category: "Category 1", value: 200 },
{ category: "Category 2", value: 400 },
{ category: "Category 3", value: 600 },
{ category: "Category 4", value: 800 }
];
Now that we’ve clarified that, let’s get into update()
. It needs to do three things: set up the x axis, set up the y axis, and draw the bars.
For the x axis, we know we need a .tick
for each value in our data array.
this.xAxis.selectAll(".tick").data(this.data, d => d.category)
.join("div")
.attr("class", "tick")
.text(d => d.category);
If you’re not used to thinking with joins that might be a little confusing. Basically, this selects all elements matching .tick
in .x-axis
. (To start, there should be none.) It then binds the data to them with the data()
method. The second parameter to data()
is a key so that it knows which data point is which should the data update later on. It then uses the join()
method, which is a handy D3 shorthand for treating the enter and update selections the same. What that means is that for each row of data we’ll end up with a <div>
with the class tick
and text equal to the category — exactly what we want for the x axis.
The y axis is similar, but the data isn’t the data we’re trying to graph; we want a list of ticks. For that, we’ll rely on the D3 scale’s ticks()
method. It takes an optional argument for the number of ticks, so we could be clever and set it based on the height of the container, but we can just stick with the default, which is 10. So this
this.y.ticks()
returns a list of 10 nice, evenly spaced ticks across the y scale’s domain. From there, we can have it add those ticks to the DOM the same way we did for the x axis:
this.yAxis.selectAll(".tick").data(this.y.ticks())
.join("div")
.attr("class", "tick")
.text(d => d);
Finally — finally! — we can actually graph our data. Because each .datum
element has two children, it’s going to be a little different than how we did the x ticks. Rather than using the join()
shorthand, we need to handle the enter and update selections separately. Specifically, when a new data point shows up (the enter selection), we need to create the .datum
and its two children, .value
and .bar
. Then, for all data points, whether new or not (a merged enter and update selection), we want to update the value and bar height.
let datums = this.graphArea.selectAll(".datum").data(this.data, d => d.category);
const datumsEnter = datums.enter().append("div").attr("class", "datum");
datumsEnter.append("div").attr("class", "value");
datumsEnter.append("div").attr("class", "bar");
datums = datums.merge(datumsEnter);
datums.select(".value").text(d => d.value);
datums.select(".bar").style("height", d => `${this.y(d.value)}%`);
Using it
Now we can use our new class with some data. Let’s say we want to graph the heights of the world’s tallest buildings:
const data = [
{ building: "Burj Khalifa", height: 828 },
{ building: "Merdeka 118", height: 678.9 },
{ building: "Shanghai Tower",height: 632 },
{ building: "Abraj Al-Bait Clock Tower", height: 601 },
{ building: "Ping An International Finance Centre", height: 599.1 }
];
In order to use it, we need to map it to the right format, where the keys are category
and value
.
const formattedData = data.map(d => ({ category: d.building, value: d.height }));
Now all we need to to is instantiate a BarGraph
object with the options we want and set the data.
const myGraph = new BarGraph("#buildings", {
xAxisLabel: "Building",
yAxisLabel: "Height (m)",
xAxisOrientation: "vertical"
});
myGraph.data = formattedData;
And that’s it! <div id="buildings"></div>
now looks like this.

As always, the full demo is on CodePen.