There is no language I find quite so humbling as D3.js. Just when I think I understand it, I get stumped for a whole day trying to do something as simple as adding a colored background to a text element so the letters are readable against the rainbow-colored monstrosity sitting behind it. Well, if you found this post amongst the sea of woefully inadequate advice that I had to wade through, hopefully you have finally found your answer. In short, we are going to look at how to use D3.js to:

  1. Add a rect element for each text element in a dataset.
  2. Set the size of that rect element to match the corresponding text element.
  3. Ensure the rect element sits behind the text element, not on top of it.

Note: for this explainer we are working with D3.js version 6.

The break through occurred for me after reading this comment from the man himself, Mike Bostock (inventor of D3.js in case you didn’t know). After reading through many posts on this topic, I was aware that I needed to use the getBBox method to get the dimensions of a given text element, but every time I checked what getBBox was returning, it was all zeros.

In short, what I had been missing up until I read the above comment was that getBBox will return zero unless the text elements have actually been added to the DOM. This of course makes sense, but also prompts the question “Well, how do we add the rect elements before the text elements when we have to add the text to get the dimensions for the rect elements?” Turns out there are two methods for doing this.

Method 1: Add, delete, re-add

The first method boils down to the following:

  • Add the text elements
  • Measure the sizes
  • Remove the text elements
  • Add the rect elements,
  • Re-add the text elements

Let’s take a look at how we can do this using a dataset containing different length text elements, which is what we will almost always be doing. Note, the following is a fully self contained example, you can download and open it with a browser to see the results.

<!DOCTYPE html>
<html>
<head>
    <title>D3.js Text Elements with Background | Brett Romero</title>

    <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
    <meta content="utf-8" http-equiv="encoding">

    <!-- D3.js -->
    <script src="https://d3js.org/d3.v6.js"></script>

    <script>
        var data = [
            {"name": "foo", "x": 10, "y": 20},
            {"name": "bar", "x": 30, "y": 60},
            {"name": "stuff", "x": 15, "y": 175},
            {"name": "something really long", "x": 40, "y": 212},
            {"name": "something even longer akhdihfi", "x": 2, "y": 300}
        ]
    </script>
</head>

<body>
    <div>
        <div id="chart-container">
        </div>
    </div>
    <script>
        const height = 0.8 * window.innerHeight;
        const width = document.getElementById("chart-container").clientWidth;
        const svg = d3.create("svg")
          .attr("viewBox", [0, 0, width, height]);

        // Create the text elements
        svg.selectAll("text")
          .data(data)
          .join("text")
            .style("font", "14px sans-serif")
            .text(d => d.name);

        // Add the elements to the DOM
        document.getElementById("chart-container").append(svg.node());

        // Save the dimensions of the text elements
        svg.selectAll("text")
          .each(function(d) { d.bbox = this.getBBox(); });

        // Remove the text elements
        d3.selectAll("text").remove();

        // Now add the rectangles, using the sizes we just added to the data
        const xMargin = 4
        const yMargin = 2
        svg.append("g")
          .selectAll("rect")
          .data(data)
          .join("rect")
            .attr("x", d => d.x)
            .attr("y", d => d.y)
            .attr("width", d => d.bbox.width + 2 * xMargin)
            .attr("height", d => d.bbox.height + 2 * yMargin)
            .attr('transform', function(d) {
                return `translate(-${xMargin}, -${d.bbox.height * 0.8 + yMargin})`
                })
            .style("fill", "black")
            .style("opacity", "0.5");
        
        // Re-add the text, this time with positioning
        svg.append("g")
            .style("font", "14px sans-serif")
            .style("fill", "#FFF")
          .selectAll("text")
          .data(data)
          .join("text")
            .attr("x", d => d.x)
            .attr("y", d => d.y)
            .text(d => d.name);

        // Add new elements to the DOM
        document.getElementById("chart-container").append(svg.node());
    </script>
</body>
</html>

I believe the code is fairly self-explanatory, but let’s walk through it:

  1. Create an svg object that will contain all our D3 elements.
  2. Create the text elements (using the play dataset included in the head section). Note that we do not need to worry about positioning and colors at this stage, but we do have to worry about the font type and size. This is because we are trying to find the dimensions of the text elements.
  3. Add the text elements to the DOM.
  4. Save the measurements of the text elements to the dataset using getBBox.
  5. Remove the text elements.
  6. Add the rect elements using the saved dimensions (plus margins if wanted). Note that we have to use transform to ensure the rectangles are positioned correctly.
  7. Add the text elements back in, this time with styling and positioning.

Method 2: Re-size rect elements

This one I came up with all by myself, although I assume I am not the first person to think of this. The underlying problem is that we need the dimensions of a text element to correctly size a rect element that needs to be underneath the corresponding text element. So we can do the “add, delete, add” dance as above, or we can add the rect elements first and update their sizes after we have measured the text elements. The following code shows how we could do this:

<!DOCTYPE html>
<html>
<head>
    <title>D3.js Text Elements with Background | Brett Romero</title>

    <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
    <meta content="utf-8" http-equiv="encoding">

    <!-- D3.js -->
    <script src="https://d3js.org/d3.v6.js"></script>

    <script>
        var data = [
            {"name": "foo", "x": 10, "y": 20},
            {"name": "bar", "x": 30, "y": 60},
            {"name": "stuff", "x": 15, "y": 175},
            {"name": "something really long", "x": 40, "y": 212},
            {"name": "something even longer akhdihfi", "x": 2, "y": 300}
        ]
    </script>
</head>

<body>
    <div>
        <div id="chart-container">
        </div>
    </div>
    <script>
        const height = 0.8 * window.innerHeight;
        const width = document.getElementById("chart-container").clientWidth;
        const svg = d3.create("svg")
          .attr("viewBox", [0, 0, width, height]);

        // Add the rect elements, these are placeholders
        svg.append("g")
          .selectAll("rect")
          .data(data)
          .join("rect")
            .attr("x", d => d.x)
            .attr("y", d => d.y)
            .style("fill", "black")
            .style("opacity", "0.5");

        // Add the text
        svg.append("g")
            .style("font", "14px sans-serif")
            .style("fill", "#FFF")
          .selectAll("text")
          .data(data)
          .join("text")
            .attr("x", d => d.x)
            .attr("y", d => d.y)
            .text(d => d.name);

        // Add the elements to the DOM
        document.getElementById("chart-container").append(svg.node());

        // Save the dimensions of the text elements
        svg.selectAll("text")
          .each(function(d) { d.bbox = this.getBBox(); });

        // Update the rectangles using the sizes we just added to the data
        const xMargin = 4
        const yMargin = 2
        svg.selectAll("rect")
          .data(data)
          .join("rect")
            .attr("width", d => d.bbox.width + 2 * xMargin)
            .attr("height", d => d.bbox.height + 2 * yMargin)
            .attr('transform', function(d) {
                return `translate(-${xMargin}, -${d.bbox.height * 0.8 + yMargin})`
            });
    </script>
</body>
</html>

If anything, this solution seems cleaner and more concise than Method 1 as we only add each element once and avoid having to add and delete. Potentially there could be problems if you have more than one type of rect element, but then you can use classes to differentiate between them.

Wrapping Up

After spending a couple of days tearing my hair out, the problem turned out to be part lack of understanding on my part, and part clunky interface that makes it complicated to do something that I would think is a very common use case. In the end though, I am happy to present you with two reasonable (and understandable) ways to add fitted backgrounds to your text elements in D3.js.