Sizing an SVG to its parent’s aspect ratio

I’ve run into this before, but when I ran into it this yesterday I couldn’t remember the solution. You’re welcome, future self, for writing it down.

Sometimes, you want an SVG to perfectly scale to the size of its parent, regardless of any intrinsic aspect ratio the SVG and its viewBox might have.

You drop in your SVG and tell it to fill its container:

svg {
display: block;
width: 100%;
height: 100%;
}

And…it doesn’t work quite right. It this case, the viewBox has an intrinsic aspect ratio of 1:1. The browser lets it go below that, so it’s fine when the container is taller than it is wide, but when the container’s aspect ratio goes above the intrinsic aspect ratio (i.e., wider than it is tall), it adds extra height to force it to that intrinsic ratio. So in this case, the SVG is forced, and forces its parent, to be a square when the container should be wider than it is tall.

See the Pen Sizing an SVG to its parent – bad example by Noah (@noleli) on CodePen.

Thankfully, the fix is simple: add overflow: hidden to the container.

See the Pen Sizing an SVG to its parent – good example by Noah (@noleli) on CodePen.

🎉

Now, it’s not always a perfect fix because sometimes you want an SVG to be allowed to overflow, especially for showing strokes near the edges. If anyone has any better suggestions, let me know!

App Defaults 2023

Ok why not, I’ll play, too.

  • 📨 Mail Client: Mail.app
  • 📮 Mail Server: Fastmail (referral link)
  • 📝 Notes: Bear
  • To-Do: Things
  • 📷 Photo Shooting: iPhone 13 Mini (though it’s still too big)
  • 🟦 Photo Management: Photos.app/iCloud
  • 📆 Calendar: Calendar.app/iCloud
  • 📁 Cloud File Storage: iCloud Drive/Backblaze (referral link)
  • 📖 RSS: NetNewsWire
  • 🌦 Weather: Weather Strip and RadarScope
  • 🙍🏻‍♂️ Contacts: Contacts.app/iCloud
  • 🌐 Browser: Firefox
  • 💬 Chat: iMessage, Signal, Discord
  • 🔖 Bookmarks: Pinboard
  • 📑 Read It Later: Pocket
  • 📜 Word Processing: Pages, Bear
  • 📈 Spreadsheets: Excel (or Pandas)
  • 📊 Presentations: Keynote
  • 🛒 Shopping Lists: shared Notes.app note
  • 💰 Budgeting and Personal Finance: Banktivity
  • 📰 News: WBEZ/NPR, Washington Post, New York Times
  • 🎵 Music: Apple Music
  • 🎤 Podcasts: Overcast
  • 🔐 Password Management: 1Password
  • 🧑‍💻 Code Editor: Nova
  • 🌐 Web Hosting: Dreamhost (yes, I’m old fashioned)
  • ✈️ VPN: Proton VPN

See also: Moving In: What makes a computer feel like home

Drawing with CSS: useful, or clever trick?

Last week, Set Studio published the second installment of their excellent Reality Check series. It’s a great post, including a very useful trick with setting font size on a parent to be able to use typographic units like ex in clever ways.

In it, Andy uses an SVG to create a curved border. It’s easy, it looks great, and it uses the preserveAspectRatio="none" trick I’ve been so fond of lately. But my initial reaction was that it seemed like a simple enough shape (nearly an ellipse) that it could be achieved with a border radius.

I took a stab at it, and while it’s not perfect, it’s pretty good! I like it because it keeps the markup cleaner and lets the CSS alone focus on the visual design.

See the Pen Set Studio Reality Check #2 – 404 page with border-radius instead of SVG by Noah (@noleli) on CodePen.

That said, it took some fiddling to get the border radii right (or reasonably close to right — see lines 204–210 in the Pen’s CSS). That has me wondering where the line is between drawing with CSS being actually useful, and when it becomes a little too clever. (Don’t get me wrong, I love all of the CSS art out there.) In this particular case, with a simple shape and without resorting to a million stacked gradients in background-image and mask-image, I feel very comfortable with either the CSS-based approach or the SVG-based approach.

On the other hand, I’ve been in situations where being too clever made things more difficult. For example, when someone relied on pseudoelements to create the visual representation of a toggle switch component out of a single div, it was difficult to maintain and even more difficult to style (e.g., a pseudo is a child of its main element, so can’t be more opaque than the element). Thankfully, it was part of a component’s shadow DOM and we had full control over the markup, so I encouraged them to rework it with separate elements for the nub and the track. The resulting CSS was much simpler.

So it seems to me that drawing with CSS can be a great exercise, and is a great way to stretch your skills with CSS. But for most cases in the real world, unless it’s very simple, it seems that sticking with tools that are actually designed for drawing is the better choice.

Documents, apps, Tailwind, and The Web

Call me old fashioned, but the web is not an app development platform, it is a document display platform.

Me on Twitter in 2010

The web is, fundamentally, for documents. HTML gives those documents structure. Anything new you might want to do for layout or interactivity is added on top of that structure. Tailwind, on the other hand, mimics apps, not documents. That makes it appealing for app developers and out of place on the web.

If you think in a document-oriented way when authoring HTML, you’re first going to think about the page you’re working on as a document. What are the headings? What are the lists? What order should they go in? What other semantics do we need to consider? This approach results in a document structure — like, for example, a scientific paper. Only once the markup is structured do you start laying it out and styling it[1].

Compare that with something like Flutter, Google’s modern app UI framework. It, along with Apple’s similar SwiftUI, are how the two companies who own the biggest app platforms out there think about structuring apps:

Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(color: Colors.white),
child: const Center(
child: Text(
'Hello World',
textDirection: TextDirection.ltr,
style: TextStyle(
fontSize: 32,
color: Colors.black87,
),
),
),
);
}
Source

Everything is a widget, and every widget serves some purpose for layout or style. Yes, there is a hierarchical structure that is arguably analogous to the DOM, but it is not, and never was, meant to describe a document. Much of the structure is directly intended to describe the layout; the widget tree structure and the layout on screen are completely inseparable.

And the reality is that many apps aren’t documents; they’re (gasp) apps. I have no idea how accessibility works in Flutter or SwiftUI, but I appreciate that this approach does make a lot of sense for declarative app UI. This, I think is the appeal of Tailwind: it lets someone author for the web as if they’re writing an app: with semantically meaningless widgets whose purpose is just to provide layout and styling (i.e., <div>s with utility classes).

For those of us who are web-minded people, though, this feels weird and uncomfortable. It absolutely cuts against the grain of the web, and throws away so much of the progressive enhancement and accessibility that comes with the web platform for free. To us — and everyone who benefits from that progressive enhancement and accessibility — this is not a good thing.

I think these two philosophies of the web — web as app development platform and web as document distribution platform — are exactly why Tailwind gets so much love and so much hate from different factions.

I’ll leave with a provocation for those of us who are squarely in the Document camp: how do layout-only utility Web Components (e.g., Nord’s Stack) fit into this picture? Are they at home on the web? Are they like Flutter and SwiftUI’s layout-only widgets? And can they be both?


  1. Of course this isn’t strictly how the real world always works, but it’s an ideal. ↩︎

Making a plucky web component

I recently decided to redo the plucky underlines with zero dependencies, namely removing GSAP and D3. My main goal was to reduce the weight of my JavaScript in order to speed up page download times, it also seemed like a good opportunity to encapsulate the JavaScript and CSS by making it a Web Component (aka custom element).

You can see the result on its project page, but I wanted to share a few experiences from building it.

Less JavaScript

First, I absolutely achieved my primary goal of reducing how much JavaScript I’m shipping to the client. Vite bundles all the JavaScript together, so it’s hard to know out exactly how much was the underlines and how much was the wavy header, but just the update to the underlines cut the client-side JS from 103kB (39kB gzip) to 47kB (17kB gzip).

Screenshot of Firefox dev tools Network tab showing downloaded JavaScript before any modification. 103kB (39kB gzip)
Before: 103.05 kB (39.20 kB compressed)
Screenshot of Firefox dev tools Network tab showing downloaded JavaScript with the new plucky web component. 47kB (17kB gzip)
With updated Plucky: 47.03 kB (17.08 kB compressed)

I then went further and removed several unnecessary D3 dependencies from the wavy header, resulting in total client-side JavaScript of 14kB (5.49kB gzip). I’m very pleased with the size reduction!

Screenshot of Firefox dev tools Network tab showing downloaded JavaScript after updated plucky and wavy header. 14kB (5.49kB gzip)
After: 14kB (5.49kB compressed)

Layout and CSS

An improvement I made to the previous implementation was getting rid of the need to measure the size of the element being underlined. I did this in two ways: first, I made the coordinate system go from 0 to 1 in both dimensions rather than relying on pixels by using viewBox="0 0 1 1', preserveAspectRatio="none", and vector-effect: non-scaling-stroke. (Here’s an example pen that shows the technique in action.)

And second, I made the entire layout entirely scaled around em typographic units. If you overlay text and a 1em high SVG with the SVG centered, the bottom of the SVG will be (from what I can tell; there may be exceptions?) at the baseline of the text. By combining those two things, there’s no need to know how big anything is in pixels.

Screenshot of text "Example" with a purple labeled bounding box around the text, and a light blue dashed box going from the baseline of the text to 1em above it labeled SVG box

The thickness of the underline is also sized in em. By default, though, I didn’t want the stroke to be thinner than 1px. Manual Matuzovic recently asked on Mastodon for interesting uses of the CSS max() function. Well, here’s an example: the default stroke width is max(.053em, 1px).

Dousing Lit with vanilla

For the last year or two I’ve been using a lot of Lit to build Web Components. Lit is great, but I really wanted this to have no dependencies. In order to help get my head back into vanilla Web Components, I referenced two examples: Zach Leatherman’s table-saw, and Chris Ferdinandi’s Web Component boilerplate. I didn’t follow either of them to a T, but both were helpful in remembering how to create a Web Component without additional tooling.

One difference is how I handled stylesheets. Chris’s boilerplate predates fairly widespread support for constructable stylesheets, and Zach’s cuts the mustard and doesn’t have a fallback for not using constructable stylesheets. Since global adoption is around 89%, I used them, but provided a fallback.

I also used private class members. This was maybe a poor choice, but I like it in theory at least!

Animation

At first, this seemed like a good opportunity to use the Animation API. In fact, I did the whole thing using it. But ultimately I redid it using a trusty old requestAnimationFrame() loop for two reasons. One is that you can’t make a totally custom easing function, which meant I had to sample the decay function[1] to make keyframes, then have it linearly interpolate between those keyframes. Not exactly elegant.

But the real fatal problem is that the Animation API can only animate CSS properties, and Safari somehow still doesn’t support using SVG presentation attributes (like a path d string) as CSS properties.

On the other hand, while custom easing functions may be more straightforward with an animation loop because you can use a JavaScript function, you lose any browser-provided (or GSAP-provided) easing functions. This meant having to look at what I had been using (power2.out) and trying to approximate it with math.

Maybe one day I’ll have a new creative idea, but in the mean time, I hope you enjoyed yet another blog post about the waves on this website.


  1. See the original post for too many details on that. ↩︎

(Old) hypertext in (new) hypertext

The Talmud is an early form of hypertext, with multiple texts and links between them. A daf (page) of Talmud has a unique layout. The Mishna and Gemara are in the center, with further discussion and commentary around it.

A daf of Talmud. Source: https://kottke.org/18/09/putting-the-talmud-online

A couple weeks ago, @emilyaviva@tech.lgbt posed the following challenge on Mastodon:

All right, tech folks, listen up. I want to make a web page that has a central div of text where two divs flow around it from top, sides, and bottom, just like @esther_alter@mastodon.social’s brilliant Talmudifier, but in CSS only. You’d think this would be possible, but after several hours of screwing around with floats and flexbox, I’m not sure it is. Any brilliant ideas?

Challenge accepted.

Challenge solved — almost. (It required a tiny bit of JavaScript.)

See the Pen Talmud daf layout by Noah (@noleli) on CodePen.

Let’s dig into how.

Semantics

First things first: semantics. The way I thought about it, the whole daf is an <article>. The Mishna/Gemara are the main body of the article (but gets wrapped in a <div> with a class to be unambiguous). Then, the commentary around it is an <aside>. Within the commentary, there are two distinct sections: the Rashi (set here in a sans serif font, following Talmudifier’s convention) is first. (Remember that while this text is faux Latin, Hebrew and Aramaic are written from right to left.) Then, the tosafot wrap around it. As they’re both part of the <aside>, the Rashi is also in a <div>.

So, the whole markup looks like this:

<article class="daf">
<div class="mishna"><p>The Mishna/Gemara text</p></div>
<aside>
<div class="rashi"><p>The Rashi text</p></div>
<p>The tosafot text</p>
</aside>
</article>

Since the original challenge referenced Talmudifier, I pasted in the lorem ipsum from there.

Layout

Now, let’s put things where they need to be. I used a simple grid layout with 3 rows and 3 columns. That might be a little counterintuitive because it looks at first like the commentary is in two columns, but it’s really not. We’ll handle that in a moment.

The center/Mishna text is in the center of the 3 × 3 grid, so the height of first row of the grid layout determines how high or low on the page it starts. Since the center text’s width is set by the width of the .mishna element and its height is intrinsic based on the length of the text, the grid looks like this:

.daf {
width: 80ch;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: 6rem auto 1fr;
}

Then the Mishna is placed in the center grid area, and the aside (commentary and Rashi) are put in the entire grid area (plus a bit of styling).

.mishna {
grid-area: 2 / 2 / 3 / 3;
font-weight: bold;
width: 25ch;
}

aside {
grid-area: 1 / 1 / -1 / -1;
}

.rashi {
font-family: sans-serif;
}

That leaves us with this delightful mess, in which the Rashi and tosafot are regular paragraphs and the Mishna is thrown on top:

Two paragraphs of text with a third paragraph stuck on top, narrow and in the center

Now let’s ignore the Mishna and shift gears to the <aside>. While it initially looks like a two-column layout at the top, if you look down below you can see that the tosafot wraps around the Rashi. So, this is really a straightforward floating layout in which the Rashi is floated to the right, with the tosafot wrapping around it. The main thing here is that the two “columns” are of equal width, and there’s a gap between the columns.

We’ll need to use the column gap value a bunch of times, so let’s add a custom property to the .daf:

.daf {
/* ... */
--column-gap: 1.125rem;
}

The Rashi needs to be approximately 50% of the width of its parent, but not exactly. To keep the two (apparent) columns the same width, the column gap needs to be split between the width of the tosafot and the width of the Rashi. Therefore, the width of the Rashi is 50% the width of its parent minus half of the column gap. The gap itself is applied as a left margin on the Rashi[1].

.rashi {
float: right;
font-family: sans-serif;
width: calc(50% - var(--column-gap)/2);
margin-left: var(--column-gap);
}

Hiding the Mishna/Gemara, we get this:

Rashi floated right in tosafot

And with the Mishna added back in,

layout without wrapping around mishna

Perfect! Except for that one big problem….

Wrapping around the Mishna

There’s no good way to wrap text around other things using CSS if you need anything more complicated than a simple float. This is a known shortcoming, and several attempts at solving it (and related problems) have been proposed over the years. Regions, Shapes Level 2, and — most promisingly here — Exclusions. Alas, we need to hack something now.

Floats are generally positioned inline with some text. That means that in order to have a float not be at the top of the text, but at a specific point along the block axis, you need to know exactly where to insert that float inline into your text’s markup. That’s not going to work here. You also can’t float something to the middle, or have multiple elements wrap around a single float.

So here’s what we’re going to do: we’re going to use JavaScript to insert two empty floating divs, size them appropriately, and “place” them by messing with their shape-outside. Why JavaScript? Two reasons, one required and one aesthetic: first, we need to measure the size of the Mishna container; and second, it lets us keep the input markup semantically clean.

Each of the two floated divs takes care of half of the mishna text. Let’s deal with the righthand side first. Here, the Rashi is our main block of text, so let’s add a div to the top of the Rashi element, give it a fixed size, and float it left.

const rashi = aside.querySelector(".rashi");
const rashiFloater = document.createElement("div");

rashiFloater.style = `
float: left;
width: 110px;
height: 130px;
`
;

rashi.prepend(rashiFloater);

At this arbitrary size, we get this (giving it a background color and again hiding the Mishna):

An empty div floated left in the Rashi

Now we need to set it to the right size. The width needs to be the distance from the center of the column break to the edge of the Mishna. In other words, it’s half the width of the Mishna minus half the width of the column gap. The height needs to take our floater from where it starts at the top of the daf to the bottom of the Mishna.

// get the mishna measurements
const mishnaRect = document.querySelector(".mishna").getBoundingClientRect();
// but we'll need to shift it into local rather than viewport coordinates
const { top: dafTop } = document.querySelector(".daf").getBoundingClientRect();

rashiFloater.style = `
float: left;
width: calc((
${mishnaRect.width}px - var(--column-gap))/2);
height:
${mishnaRect.height + mishnaRect.y - dafTop}px;
margin-right: var(--column-gap);
`
;

Add in a right margin (column gap), and now you can really see where this is going.

An empty div floated left in the Rashi that's sized to start at the top and perfectly clear the Mishna

So close! But we still need to not have the float punching out the Rashi all the way to the top of the daf. For that, we’ll use shape-outside. shape-outside lets you give the browser an effective shape (for wrapping purposes) for a floating element. Using the inset() shape function, we can inset the top starting point of the wrapping to the distance between the top of the daf and the top of the Mishna.

rashiFloater.style = `
float: left;
width: calc((
${mishnaRect.width}px - var(--column-gap))/2);
height:
${mishnaRect.height + mishnaRect.y - dafTop}px;
shape-outside: inset(
${mishnaRect.y - dafTop}px 0 0 0);
margin-right: var(--column-gap);
`
;

And that’s it for the right side! Remember that shape-outside doesn’t actually move or clip the floated element, as you can see with the background color, but the wrapping works exactly the way we need it to.

An empty div floated left in the Rashi where the Rashi wraps around the Mishna, both above and below, but the color of the div goes all the way to the top

The left side is basically the same. The main differences are where to put the floater in the DOM, and which direction to float it. The <aside> already has something floated right (the Rashi itself), so the empty floater needs to go after the Rashi. Remember from the bad old days before flex layouts that if you have multiple items floated in the same direction, they “pile up” so that the first floated item goes the farthest, and the next floated item runs into that. By putting the floater right after the Rashi, the Rashi is farthest right, and the floater is to the left of that.

const aside = document.querySelector("aside");

const tosafotFloater = document.createElement("div");
tosafotFloater.style = `
float: right;
width: calc((
${mishnaRect.width}px - var(--column-gap))/2);
height:
${mishnaRect.height + mishnaRect.y - dafTop}px;
shape-outside: inset(
${mishnaRect.y - dafTop}px 0 0 0);
margin-left: var(--column-gap);
`
;

rashi.after(tosafotFloater);

Both left and right floats with the Rashi and Tosafot wrapping around them with background colors

That’s the whole thing! Get rid of the background colors from the floats and here’s what we’ve got. Success!

A completed page of lorem ipsum layed out like a daf of talmud


  1. You’d think this would be a perfect place to use logical properties, what with Hebrew and Aramaic being right-to-left languages. However, the reference image from Talmudifier has English text laid out in the same orientation as the original RTL pages, so using absolute directions seemed to be the way to go. ↩︎

A brief history of מה אשיב/Ma Ashiv

Over Pesach there was some debate during Hallel over the time signature of the beginning of a melody to מה אשיב/Ma Ashiv. Some people do the beginning in 3/4 time, and some do it in 4/4.

I had to find out who was right (though I was pretty sure it was 4/4 because that’s consistent with the rest of the song), so did a bit of digging. Here’s what I was able to find.

It was composed by Aviezer Wolfson and first recorded by Leibele Haschel. I couldn’t find the year or full recording, but FAU has a 45 second sample available. There seems to have been a rerelease of that album in 2007, so here are other 30 second samples (jump to 30 seconds in, because it’s track 2 in one concatenated file). In terms of the exact year, it must have been after 1978 because the liner notes say (if I’m correctly making out extremely blurry text) that the arranger, Moshe Laufer, was named composer of the year (by…someone) in 1978.

Album cover: Leibale Haschel, מה אשיב לה׳, Music by Freilach Orchestra, Songs composed by Aviezer Wolfson and Moshe Laufer

The songs seems to have had its breakout moment when Morechai Ben David recorded it in 1987 on a compilation album with Avraham Fried, The Piamentas, and Dov Levine. It’s not available on YouTube or Apple Music, but it is on Spotify.

Album cover: Suki & Ding Present: Morechai Ben David, Avraham Fried, The Piamentas, Dov Levine. Hallel, featuring מה אשיב. Original recordings musically and digitally enhanced.

MBD has rerecorded it at least twice since, once in 2002 (Kumzits), and one other time (on an album that contains such titles as “Beethoven In Birdland”, “The Malach Piano Concerto In C”, and “The Bach Suite #2 In B Minor”).

All of these versions were in 4/4.

I did find one recording that starts in 3/4, which is an instrumental/classical guitar arrangement played by a guy who also happens to be a small plane pilot.

Simplenote to Bear importer

With Bear 2 seemingly around the corner and the beta being great (though still very much a beta), I decided it was time to move from Simplenote.

Exporting from Simplenote gives you two things, neither of which is directly usable by Bear:

  • A file for each note with correct creation and modification dates and tags appended at the end as not-hashtags
  • A JSON file containing each note and its metadata (UUID, tags, creation date, and modification date)

What I needed for Bear was a file for each note with correct creation and modification dates and tags at the end as hashtags. So I wrote a quick Python script that reads in the JSON file and — with some help from SetFile — outputs a file in the format I needed.

from subprocess import run
import os
import json
from datetime import datetime

# path to input JSON
in_path = os.path.join(os.environ['HOME'], 'Downloads/notes/source/notes.json')

# path to output directory. it must exist.
out_path = os.path.join(os.environ['HOME'], 'Downloads/for-bear')

# read the data
with open(in_path) as f:
note_data = json.load(f)

datetime_format = '%m/%d/%Y %H:%M:%S.%f'
def create_note_file(datum):
# the note text
content = datum['content']

# if there are tags, append them as space-separated hashtags at the end of the text
if 'tags' in datum:
tags_string = '\n\n'
tags_string += ' '.join(['#' + tag for tag in datum['tags']])
content += tags_string

# convert times to local timezone (Simplenote is in UTC) and format for SetFile
creation_date = datetime.fromisoformat(datum['creationDate']).astimezone()
creation_date_str = creation_date.strftime(datetime_format)

modification_date = datetime.fromisoformat(datum['lastModified']).astimezone()
modification_date_str = modification_date.strftime(datetime_format)

# write out the file
outfile_path = os.path.join(out_path, datum['id'] + '.md')
with open(outfile_path, 'w+') as outfile:
outfile.write(content)

# set the creation and modification dates
run(['SetFile', '-d', creation_date_str, outfile_path])
run(['SetFile', '-m', modification_date_str, outfile_path])

# iterate through the notes
for note in note_data['activeNotes']:
create_note_file(note)

Note: Using datetime.fromisoformat() with the date format Simplenote uses requires Python 3.11+.

After running the script, import them into Bear with these settings:

  • ☑️ Keep original creation and modification date
  • ☑️ Use first line as title (txt, md, textbundle)
  • 🔲 Use filename as title (txt, md, textbundle)
  • 🔲 Escape involuntary tags

Possible improvements

It would be nice to \ escape all incidental hashtags, but it wouldn’t be trivial. For example, you shouldn’t escape # followed by a non-space character inside a Markdown `#code block`.

Here’s a Gist.

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.

Graph layout with an area for the y axis label, y axis, x axis label, x axis, and a space for the graph itself to go

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:

Graph skeleton with axis labels

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:

Work-in-progress screenshot where the y axis looks mostly correct, but the height of the tick text is affecting where the ticks themselves are.

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 with x and y axes and labels but no actual graph content

.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);
}

Empty graph with axes like above, but with vertical x axis ticks

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 .datums (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;
}

Value text overlapping adjacent bars

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:

A bar graph with 4 bars at 200, 400, 600, and 800, with values on top, axes with labels, and ticks

🎉

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.

The finished graph of building heights

As always, the full demo is on CodePen.