Creating Bar Charts Using Only HTML5 and CSS3

26th November 2021

I am mildly obsessed with trying to do as much as I can with just HTML and CSS, and with HTML5 and CSS3 specifically, anything feels possible if you ignore best-practice enough. On this site, with the exception of one page, I do not use any JavaScript or cookies, or any other technology other than HTML5 and CSS. This isn't to prove a point or anything; I just personally enjoy the richness available in HTML5 and CSS3. As you can probably see, I don't use my site as a portfolio or for clout really, I just enjoy mucking around with HTML. It gives me the same fuzzy feeling as writing in a BulletJournal (a cooler way of saying "diary / to do list"). This leads to many hours on CSS-Tricks and StackOverflow, as well as the excellent Mozilla Developer Network and W3 Schools as I try and string together various things that would honestly be far simpler in JavaScript. However, I manage, making this site and several for personal projects, tooling, and friends.

However, one issue that I have repeatedly come across though is that of bar charts. I have written a few tools as part of my job, and being able to clearly display output data in a bar chart is a nice bonus. Naturally, if you want to get dirty, it's easy enough to set max-width manually, allow 'unsafe-inline' and call it a day. But you know what is nice? Not having to write custom CSS for each and every bar chart one creates. I'm happy to commit myself to writing HTML - that's how I'm writing this blog. How I wrote this blog. Tense. But I have a single CSS file for the majority of my site, and I dislike the mess of having small sections within it, or small supplementary CSS files for each and every page with a wee bar chart in it.

However, in the process of writing my blog on Windows UI Desktop Technologies, I revisited this old nemesis. I'd been playing with CSS Grids on another project, and the thought occured that I could use grids to act as the major guide lines, leaning on the ability to overlay grids to then display bar atop these neat layers. Perhaps I could also do something clever with widths to allow bars to be displayed? To be honest, I should come back to that thought. But I ended up getting something working and thought the process of how I got there might be interesting.

So, the goal:

Is it possible to create a bar chart using only HTML5 and CSS3, with all dynamic content residing solely within the HTML?

Surprisingly, yes! I'd thought I'd be stymied by various CSS iniquities, but it ended up working out enough for me to be briefly happy with it.

I should note, I'm not trying to pretend that I ended up with a) the neatest solution (it can't possibly be, as I wrote it) b) the best solution or c) the first time that this behaviour has been documented. But it's new to me, and I've enjoyed the evening I spent on it. So there.

To see the finished article (haha), you can either visit the aforementioned page (Windows Desktop Application Technologies and play directly with my CSS in the development console or open my whole site's CSS directly). Or you could scroll down.

Setting the scene

First off, to fit my lovely '98 aesthetic, a canvas to play in and some CSS to match it:

HTML:

<span class="bar-chart-title">Windows UI Desktop Technologies, Self Reported Market Share</span>
<div class="horizontal-bar-chart">
</div>

CSS:

.horizontal-bar-chart {
	margin: 15px;
	padding: 15px;
	border: 2px solid var(--dark-grey);
	border-bottom: 2px solid aliceblue;
	border-right: 2px solid aliceblue;
	background-color: floralwhite;
}

.bar-chart-title {
	display: table;
	margin: 0 auto;
	text-decoration: underline;
}

This rendered thusly:

Windows UI Desktop Technologies, Self Reported Market Share

Wow

I just really enjoy the aesthetic of clear boxes. If you want to read my limp justifications beyond "I just think it's neat", have a read through ... my limp justifications in a post.

But yes, nothing fancy as of yet beyond my typical web styling. So let's add in some CSS Grids to handle the Spanish section - the underlay, underlay.

In some brief, panicked searching, I found an excellent blog by Audree Steinberg, with a rather nice walkthrough of animated text. This perfectly fit what I sought to achieve. I'd seen on MDN that it is possible to format the grid areas, so all I needed now was to grasp the grid layout, which mostly seems like arcane magic.

As Audree explains, one must first create a <div> to hold each layer, and then a final <div> to encompass the lot. We then get to the magic - we declare our wrapper to use ... the grid display property and then make both our layers share the same space:

From Audree's blog:

.wrapper {
  display: grid;
}

.bottom-layer, .top-layer {
  grid-area: 1 / 1;
}

I'll grant you, this is fairly straight forward to see this written out, but I'd royally knotted my brain trying to understand the whole thing, and with such a nice example I enjoyed the blog that simply spelled it out.

As my goal is ease of use, I'd quite like to have each of my bars as a simple item to insert. HTML comes with simple ways to organise a series of items - the unordered list. Two of those will do nicely as our layers and we can use the div from earlier as our wrapper.

So with our overlays sorted, we can now start to get some lines sorted. I found an older method whic h relied on setting max heights, but these grids seem fun to use. So lets just use them raw, and wriggling, by directly styling them. The magic of grids is that they go all the way down. I simply declare that my bar-chart-lines element also subscribes to griddian thought and define how many columns I feel it should have.

Since I want to cover from 0% to 50%, I need five sections. Well, six as I also need an empty dummy, but I shall explain why eventually. If you were looking to introduce some automation, especially on the server side, this would be a perfect place for Jekyll to step in or SCSS. Either way, the grid columns:

.bar-chart-lines {
	list-style-type: none;
	padding: 0;
	display: grid;
	grid-template-columns: auto auto auto auto auto 0px;
	counter-reset: line;
}

I quite like using the counter method within CSS. It feels wrong - it's like the opposite of Node, one feels that you actually should be using JavaScript at that point.1

So counters are pretty simple - you declare them, and every time they hit the thing you want to count, you iterate them. Handily, iterating a non-existent counter also declares it, saving a line and much effort. The counter can then be called (with some caveats) to stand in where needed. In this instance, I shall be using it within the ::before pseudo element, so I can have some nice percentage markings on my bar chart.

::before and it's pseudo element brethren are really quite interesting. From a security perspective, it is fascinating seeing how CSS can interact with a page, and how various methods are implemented to try and stop CSS from being clever enough to be dangerous. For instance, loading SVG images from a URL and dynamically adding them to the page - perfectly possible, without a grain of JavaScript. Very interesting.

Unfortunately, simply including the ::before tag element is not enough; we shall also need to move it out of the way of the graph using some transformations. A note - you must do all your transformations on the same line, else CSS will overwrite your earlier transformations with later ones and you'll be confused for a few minutes, as I was. This is also why I need to add in a final dummy 0px column - it's to ensure that the ::before labels line up, as they will be left-aligned and I don't want to faff and potentially break responsiveness by forcing the Brownites right when they are comfortable being vaguely left and not hurting anyone.

We also need to ensure that we add lines for all sections of the graph, but that we do not double up. So the simplest way is to solely draw a line on the right hand side of each grid section and draw an additional line on the left hand side of the first grid section only. Again, thanks to CSS' magic, this time the selector, this is fairly trivial. I'll also make this line a little bolder, give it some confidence. While we're on the subject, I should also remove the right border from the final child as well, so we don't get any fuzzy confusing lines that didn't baffle me for a moment or so. Charitably.

Finally, we add in some handling for the bars themselves. As the grid covers the full area of the graph, or a percentage thereof, we don't have to faff. The counter is a special case - you cannot use counter() methods within calc() or other fun functions. Presumably you could do something naughty with it, like easily make a bar chart. Or something about loading in unverified third-party content given a width. One of the two. Either way, we can thankfully include the counter() within the content property of a pseudo element, and we can concatenate it with strings to boot! This sets the stage for our nice values to be included later.

So we now have (pre-populating with the labels for the bars I'll be later adding):

HTML:

<span class="bar-chart-title">Windows UI Desktop Technologies, Self Reported Market Share</span>
<div class="horizontal-bar-chart">
	<ul class="bar-chart-bars">
		<li class="bar-chart-bar"><span>WPF</span></li>
		<li class="bar-chart-bar"><span>Windows Forms</span></li>
		<li class="bar-chart-bar"><span>UWP</span></li>
		<li class="bar-chart-bar"><span>Electron</span></li>
		<li class="bar-chart-bar"><span>Console</span></li>
		<li class="bar-chart-bar"><span>Other</span></li>
	</ul>
	<ul class="bar-chart-lines" data-start="0" data-end="100" data-major-interval="10">
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
	</ul>
</div>

CSS:

.horizontal-bar-chart {
	margin: 15px;
	padding: 15px;
	border: 2px solid var(--dark-grey);
	border-bottom: 2px solid aliceblue;
	border-right: 2px solid aliceblue;
	background-color: floralwhite;
	display: grid;
}

.bar-chart-title {
	display: table;
	margin: 0 auto;
	text-decoration: underline;
}

.bar-chart-bars, .bar-chart-lines {
	grid-area: 1 / 1;
	margin-top: 2rem;
	margin-left: 1rem;
	margin-right: 1rem;
}

.bar-chart-bars {
	list-style-type: none;
	padding: 0;
}

.bar-chart-lines {
	list-style-type: none;
	padding: 0;
	display: grid;
	grid-template-columns: auto auto auto auto auto 0px;
	counter-reset: line;
}

.bar-chart-line:not(:last-child) {
	border-right: 1px solid gray;
}

.bar-chart-line:first-child {
	border-left: 2px solid gray;
}


.bar-chart-line::before {
	content: "0%";
	position: absolute;
	transform: translateY(-100%) translateX(-30%);
}


.bar-chart-line:not(:first-child)::before {
	counter-increment: line +2;
	content: counter(line)"0%";
}

And in glorious technicolour:

Windows UI Desktop Technologies, Self Reported Market Share

As a final cosmetic touch, I have also set up the axis labels, as you can see above. The following CSS defines a new counter, writes "0%", and as I want to have a chart out of 100 in hops of 20, I increment the bar by 2 for each <li> tag and cheekily adding the string "0%", smoothly going from "0%" to "100%".

So where are we now? Well:

Which leaves us with two final bits to complete:

However, these are deceptively aggravating to solve, as CSS' kinda whole deal is that you load the CSS and it provides you with styling. It's not meant to facilitate two-way communication, or at least wasn't originally really intended to be much more than a look-up for a browser to theme an HTML file. However, over the years various helper functions have been added that should allow us to messily bastardise this original intent.

So far, aside from having to define the columns which make up our scale, the rest of the chart can be defined entirely from our HTML, meaning that we can re-use the CSS across the site, without requiring any major changes (again, aside from scales being set to a pre-determined amount3.

Meaning that the only thing to do is our strong-arming. If I step down my security headers (for the specified pages which require it, naturally), I can allow 'unsafe-inline'. Now, as the name suggests, this is generally not best-practice. Thankfully, if you do take this step, you can hash or Prince Andrew the content to ensure that it is the same when processed by the client as it was when leaving the server. Or I suppose technically, it will ensure that if it differs, it will not be processed at all by the client.

We could cut to the chase - within the <li> tag we could straight up set the width attribute to be a percentage. As the <li> tag is within our bar-chart-bars layer, itself within our bar chart wrapper, this will translate 1:1 to the correct percentage of the available space. However, I'm not too happy with this, as I would also like to have the option to display the data point label to indicate the exact percentage, especially as with the bar chart I'm trying to display, including minor steps would just be more visual clutter. And I don't want to declare the same item twice like an animal.

So is there a way to use one value multiple times? CSS doesn't let you pass on items, or read the width of an element within the DOM, since that's not it's department. But we can set variables within CSS: I use them myself to keep consistent colour schemes. While this might be obvious to you, the reader, it was surprising (and obvious in hindsight), that declaring the same variable but changing the value re-applies for each of the list items. With a brief review of to remind myself of the basics of CSS this is obvious. As CSS is ... cascading, and my style sheet is properly loaded at the top of the page, earlier values are, if redeclared, overwritten by later values. However, this applies as each element is evaluated. Meaning that a list item that redeclares, say, the width of a bar to be 96% will not change the width of all bars within the document, provided that subsequent bars also declare. As variables inherit, children of our bar chart bars, such as ::before, can also inherit this value!

So essentially we have arrived at a very simple answer4: adding style="--bar-width: &ound;percentage" should allow us to neatly write the percentage a single time, but use it repeatedly.

So our bars in the HTML, with percentages set as intended, should appear like so:

<div class="horizontal-bar-chart">
	<ul class="bar-chart-bars">
		<li class="bar-chart-bar" style="--bar-width: 46"><span>WPF</span></li>
		<li class="bar-chart-bar" style="--bar-width: 42"><span>Windows Forms</span></li>
		<li class="bar-chart-bar" style="--bar-width: 8"><span>UWP</span></li>
		<li class="bar-chart-bar" style="--bar-width: 1"><span>Electron</span></li>
		<li class="bar-chart-bar" style="--bar-width: 1"><span>Console</span></li>
		<li class="bar-chart-bar" style="--bar-width: 2"><span>Other</span></li>
	</ul>
	<ul class="bar-chart-lines" data-start="0" data-end="100" data-major-interval="10">
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
	</ul>
</div>

Nice. With the HTML squared away, two further simple declarations along the lines of width: var(--bar-width)%; to set the bar width and content: var(--bar-width)"%"; should see us complete?

Not quite, unfortunately. If we had just set the width, such as width: 46% within the bar, that would be enough. But then we could not print out the value within our content header. The inverse is also true, as we cannot append a % symbol to a string, even if that string is a number, to act as our width. So sadly, as we want to use our value as both a string and an integer, and CSS doesn't allow you to re-cast values, we're stuck with what we have, like the last season of That 70's Show.

Essentially, we need to get CSS to read our variable and cast it willy-nilly to complete this final step. The solution?

A return to our friend from earlier, the counter() method. We can, as set out by darrylyeo in their StackOverflow answer, stock arbitrary integers within the counter() declaration, from a variable.

.bar-chart-bar {
	height: 2.25rem;
	counter-increment: percentage var(--bar-width);
	counter-reset: percentage;
}

.bar-chart-bar::before {
	content: counter(percentage)"%";
	[ . . . snip . . . ]
}

So the bar is now appropriately labelled as we have converted our integer to a string. So now all that remains is to convert our integer to a percentage. While I'm sure I could have done something clever with calc() to work out the % of the page width, the method escaped me as the variable would not be processed when added to calc(), seemingly as I could not add a % to a px. But perhaps we can use the calc() method indirectly? According to W3, it is not possible to use integers directly within calc. However, integers are permitted within multiplication and division. As such, it is possible to directly use our inline variable as is to set the width of the element, so long as we use it as an operand to an already-extant percentage. (As I write this, I have realised this is also the solution that goodship11 recommended, which I missed while searching for a solution.)

So, with some simple mathematics, and a small correction for my 2px Y axis we can convert the integer to a percentage (and you can see how happily CSS will subtract 2 pixels from a percentage, but baulks at implicit casting):

.bar-chart-bar::before {
	--width: calc(1% * var(--bar-width) - 2px);
	width: var(--width);
}

And there we have it:

Rendered:

Windows UI Desktop Technologies, Self Reported Market Share

HTML:

<span class="bar-chart-title">Windows UI Desktop Technologies, Self Reported Market Share</span>
<div class="horizontal-bar-chart">
	<ul class="bar-chart-bars">
		<li class="bar-chart-bar" style="--bar-width: 46"><span>WPF</span></li>
		<div class="dropdown-content"><span>Some bullshit</span></div>

		<li class="bar-chart-bar" style="--bar-width: 42"><span>Windows Forms</span></li>
		<li class="bar-chart-bar" style="--bar-width: 8"><span>UWP</span></li>
		<li class="bar-chart-bar" style="--bar-width: 1"><span>Electron</span></li>
		<li class="bar-chart-bar" style="--bar-width: 1"><span>Console</span></li>
		<li class="bar-chart-bar" style="--bar-width: 2"><span>Other</span></li>
	</ul>
	<ul class="bar-chart-lines" data-start="0" data-end="100" data-major-interval="10">
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
		<li class="bar-chart-line"></li>
	</ul>
</div>

CSS:

/* credit to https://audreesteinberg.medium.com/overlapping-html-elements-using-css-grid-f401262a4486 for the method of overlaying */
.horizontal-bar-chart {
	margin: 15px;
	padding: 15px;
	border: 2px solid var(--dark-grey);
	border-bottom: 2px solid aliceblue;
	border-right: 2px solid aliceblue;
	background-color: floralwhite;
	display: grid;
}

.bar-chart-title {
	display: table;
	margin: 0 auto;
	text-decoration: underline;
}

.bar-chart-bars, .bar-chart-lines {
	grid-area: 1 / 1;
	margin-top: 2rem;
	margin-left: 1rem;
	margin-right: 1rem;
}

.bar-chart-bars {
	list-style-type: none;
	padding: 0;
}

.bar-chart-lines {
	list-style-type: none;
	padding: 0;
	display: grid;
	grid-template-columns: auto auto auto auto auto 0px;
	counter-reset: line;
}

.bar-chart-line:not(:last-child) {
	border-right: 1px solid gray;
}

.bar-chart-line:first-child {
	border-left: 2px solid gray;
}


.bar-chart-line::before {
	content: "0%";
	position: absolute;
	transform: translateY(-100%) translateX(-30%);	
}


.bar-chart-line:not(:first-child)::before {
	counter-increment: line +2;
	content: counter(line)"0%";
}

.bar-chart-bar {
	height: 2.25rem;
	counter-increment: percentage var(--bar-width);
	counter-reset: percentage;
}

.bar-chart-bar::before {
	--width: calc(1% * var(--bar-width) - 2px);
	content: counter(percentage)"%";
	background-color: var(--red);
	width: var(--width);
	display: block;
	height: 1.5rem;
	transform: translateY(100%) translateX(2px);
	z-index: -1;
	text-indent: 4px;
	text-shadow: 0px 0px 5px #ffffff;
	font-weight: bold;
}

/* thanks to https://stackoverflow.com/a/24866952/8947779 for solving the stacking issue */
.bar-chart-bar span {
	display: block;
	position: relative;
	color: var(--main-text-colour);
	font-weight: bold;
	text-align: right;
	transform: translateX(-1%);
	text-shadow: 0px 0px 5px #ffffff;
}

.bar-chart-bar:last-child {
	margin-bottom: 2.25rem;
}

The full CSS for this graph can also be found on a right-click inspect, but the really important things are setting the grid, defining the exact columns and rows that the layers should occupy, and juggling the variable from counter() to calc() to force CSS to cast them via multiplication. Sadly as this is solely limited to passing around integers, I don't think there is much opportunity for exploitation, beyond what already exists within more eyecatching methods such as using url() to load an SVG where the ability to modify CSS is present. Regardless, it was a fun evening's escapade and I hope you found some value or enjoyment from my retelling of it.

Footnotes

  1. As a note to self, examining the client-side performance impact of CSS counters versus a JavaScript for loop might be an interesting review of bullshit optimisation (by which I mean, optimisation that looks faster but in reality saves no time.2).
  2. Like making all if statements into ternary ifs because they look cooler and take up less lines.
  3. And to be honest, I think I shan't need all that many, but it would be nice to revisit this. The blocker is that without something like SCSS this is a bit of a pain.
  4. Not that I necessarily worked this out. This is more of a backwards explanation. I just tried it and it worked and worked out why afterwards5, after I got confused as to how it could continually access the variable within other selectors, until I realised while they were other selectors, they were also pseudo-elements and thus children of where the variable was formally set.
  5. Like the accidental discovery of saccharin, after Constantin Fahlberg spilled chemicals on his hand and accidentally ate them for dinner.