CSS-based scatter plots

The simplest way to show the relationship of two variables in a data set, a scatter plot is also a perfect chance to talk about how CSS positioning works: how to place an element precisely within a space, with a consistent visual shape, regardless of screen size. HTML and CSS turn out to be well-suited for this task, even in comparison to SVG and canvas.

Once complete, our very basic demo chart (measuring "joy" versus "dogs", for what it's worth) should look something like this:

CSS position basics

The easiest way to think of the "position" style is that it sets an element's frame of reference for layout. By default, every element in the browser starts with the style position: static. That means that as long as it's not floated, it positions itself according to the standard document flow: it's either inline with text, or it's one of many blocks "stacked" vertically in the page.

By setting an element's position to "relative," it still initially places itself in document flow. But now, if you set one of the additional offset styles ("top", "bottom", "left", or "right"), you can shift the element visually by the given amount. This is only a visual shift—it still leaves a "hole" behind in the layout where it was actually placed. Using relative positioning this way should be fairly rare. However, it becomes more important in conjunction with the next value.

An "absolute" value for CSS position means that you're removing that element from document flow entirely. It will no longer count for layout of other elements, text won't flow around it, and it no longer has the standard block size of 100% page width. Instead, it takes its size and position cues from the first ancestor element with a non-static position.

That means that a common arrangement for data visualizations, where we want to precisely place dots or icons on a "field" within our page, is to make a container that's position: relative to serve as the new coordinate system reference (or "origin") for position: absolute elements inside. Which, if you inspect it, is exactly how our dots are placed above.

There are two other position values that you might use, albeit more rarely than these other three. A "fixed" position is much like an absolute, but its offset and dimensions are determined by the window's viewport, not another element. On the other hand, a "sticky" element lives in normal document flow until it crosses a boundary within the viewport, at which point it becomes fixed (you often see this used for sidebar menus that attach themselves to the frame so that they're always visible).

Placing our dots

Back to our scatter plot, we start with one element that will serve as our chart's background. It is positioned relative, so that our dots can be placed inside using absolute positioning. A negative margin of half the dot's height and width will "center" it on the desired coordinate.

.scatter-area {
  position: relative;
  background: #eee;
  border-bottom: 1px solid black;
  border-left: 1px solid black;
  max-width: 400px;
  margin: auto;
  height: 400px;
}

.scatter-area .dot {
  border-radius: 100%;
  background: rgba(0, 0, 128, .4);
  border: 1px solid darkblue;
  position: absolute;
  width: 20px;
  height: 20px;
  margin: -10px;
}

Creating our dots is just a matter of looping through our data, generating an element for each dot, and adding it to the ".scatter-area" container. The scaleX and scaleY used below should look familiar from the section on scaling functions, with one change: scaleY has to flip its axis by subtracting the scaled value from 100%. That's because the Y value of CSS starts at zero and increases as we move down, but our scatter plot puts zero at the bottom and increases as values move up.

It's useful to have our scaling functions as common infrastructure, because they're useful for drawing more than just dots. If this were a real graphic, I would want to add grid lines to it, and those are just 1px wide/tall elements positioned behind the dots, using the same scaling as the scatter plot.

var scaleX = v => v / bounds.x * 100;
var scaleY = v => 100 - (v / bounds.y * 100);

data.forEach(function(d, i) {
  var dot = document.createElement("div");
  dot.className = "dot";
  dot.style.left = scaleX(d.joy) + "%";
  dot.style.top = scaleY(d.dogs) + "%";
  grid.appendChild(dot);
});

We can add tooltips to each dot by placing inserting HTML into the dot with a ".tooltip" class, but hiding it unless the dot matches the :hover pseudo-class. Because the tooltips are also positioned absolutely, they take their positioning cues from the dot that contain them, but they don't distort its size (since position: absolute removes an element from layout calculation). The pointer-events: none also stops them from being too "sticky" in browsers that support it.

.scatter-area .tooltip {
  position: absolute;
  background: white;
  padding: 8px;
  box-shadow: 0 8px 16px -8px rgba(0, 0, 0, .2);
  font-size: 15px;
  width: 100px;
  display: none;
  top: 50%;
  left: 50%;
  z-index: 999;
  pointer-events: none;
}

.scatter-area .dot:hover .tooltip {
  display: block;
}

The code shown above is not entirely complete, and you should use your developer tools to explore some of the other niceties (such as the tooltips that "flip" from left to right when they cross the x-axis). You can also adapt this technique any time that you need to place elements within a defined space—I like to use it, along with percentage-based widths and heights, to create "annotated" images, similar to the facial tagging seen on many social networks.

Still, one more detail remains to be explained: because our dots are positioned using percentages, this graph is fully responsive, yet if you resize the window the example plot keeps its perfectly square shape no matter what. In the styles I've written above, it's defined as 400px high (which means it would distort on small screens), so clearly there's something else setting the graph's height. What's the change that makes a constant aspect ratio possible?

Defining an aspect ratio

Some HTML elements have aspect ratios built in, by virtue of their content. Images, for example, "know" how high and wide they should be, and that relationship holds constant even if you resize one axis. The same is true for videos, and for canvas tags with the width or height attributes set.

Other HTML elements, unfortunately, do not know how big they "should" be. If you change the width of these elements, by resizing the browser or placing them in a container, the height does not maintain a constant relationship. For the most part, this makes sense: you wouldn't want your paragraphs to get shorter just because they got skinnier, since that would cut off the text inside (or shrink it, which is equally bad). But sometimes we genuinely do want a non-image element, like an iframe containing a third-party video, to stay "frozen" at a certain aspect ratio, and there isn't an obvious style to make it happen.

The trick, such as it is, comes from the "padding" property. In what seems like a bizarre (but helpful) choice, if you set an element's padding to be a percentage, the actual size of the padding will be determined by the width of the parent element. In other words, padding-bottom: 100% is equal to the width of your element's parent.

Take the following markup:

<div class="aspect-container">
  <div class="aspect-inner">
    <iframe src="https://www.youtube.com/embed/a1Y73sPHKxw"></iframe>
  </div>
</div>

The iframe doesn't know how tall it wants to be, and if you set the width and height on it manually (as YouTube does by default), it'll either overflow small screens, or (if the max-width style is set) it won't resize the height when the width is restricted. However, setting the following styles will cause it to "lock" to 16:9, by positioning it absolutely within its container, and defining that container's height using its child's bottom padding.

.aspect-container { position: relative }

/* 56.25% is 9 / 16, or 16:9 aspect ratio */
.aspect-inner { padding-bottom: 56.25% }

.aspect-container iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

Here's the live demo:

Even better, in the case of our scatter plot, we can use the ::before pseudo-element to play the role of the inner padding, so that we don't need to add an additional tag to our markup just for some empty padding:

.scatter-area {
  position: relative;
  /* ... */
}

.scatter-area::before {
  display: block;
  content: "";
  padding-bottom: 100%;
}

100% padding is 100% of the container's width, creating a perfectly square block no matter how big or small the page gets (up to the container's max-width of 400px, of course). Once you start using this technique, it's not just good for plots and videos, but also for maps, canvas elements, and SVG graphics.

Finally, a great advantage of setting sizes this way is that padding can be overridden from a media query, based either on screen width or (a little-known property) on the window's own aspect ratio. So small portrait screens can get a taller scatter plot, while big and wide screens get one that uses more horizontal space, all just by adjusting a single CSS property. So while it's a bizarre choice to match padding and parent width this way, it works out well for us in the end.