A bunch of hexagon tiles

Let's cut to the chase: if you want to render a hex grid on the web, you are better off using SVG or Canvas. There is a decent amount of material out there about how to do that.

But this post is about how I got as close as I could (so far) to a decent experience making hex grids out of DOM.

Why DOM is the wrong choice

Unlike Canvas (and to some extent, SVG), the rendering behavior of the DOM is largely abstracted away. This makes it very hard to consistently optimize draw-heavy UI, even if you understand how the GPU works and how to optimize in theory.

Hex grids highlight this problem particularly, because the bounding boxes of hexes overlap. To explain why that's a problem, let's do a shallow dive into compositing.

Compositing and layers, pragmatic version

Ignoring many details (and obscuring my own ignorance), let's talk layers.

When the browser wants to render pixels, it first has to decide which groups of elements to combine into rectangular textures.

Why rectangles? That's just how graphics works, at least in this case. Even if you render a circle--or a hexagon--you're ultimately drawing transparent pixels to fill in the rest of the rectangle.

Curious about how that pans out? Open your browser dev tools and switch to the Rendering panel from triple-dot menu > More tools. Then check Layer borders. The elements the browser grouped into layer rectangles will be outlined in orange.

Luckily for the web, we are mostly in the business of drawing pretty boxes, so this aligns pretty well and we don't have to worry about it.

The most naive way you could think of compositing a website would be to render every single HTML element as an individual composited rectangle. Start from the lowest z-index, keep overlaying them till you get to the largest. If you were building a toy browser you might start there.

But it's way more efficient to batch elements into larger rectangles because of how GPUs work, and that's what the browser does. It uses some heuristics to decide how to group things. The logic for which elements to group together is not, to my knowledge, consistent between vendors or publicly documented. Maybe you can find it if you're good at reading specs.

Why hex grids ruin everything

In hexagon grids, the cells inherently overlap one another, and one surefire way to get an element booted to its own layer (its own personal rectangle) is to overlap another layer. Do this for hundreds of hexagon cells, and you're now making hundreds of sorted draw calls. Frames start dropping fast.

A side by side screenshot of a hex grid and browser dev tools. The hex grid is annotated with layer borders, and every single cell has its own layer. In the dev tools you can see confirmation of this with a list of hundreds of layers in the layer viewer.

Above: the hex grid on the left shows orange layer borders around every cell. On the right is another browser dev tool, the layer view, which confirms we have 252 individual layers.

Now, in a perfect world, we could explicitly tell the browser to composite certain cells together on a specific layer. But browsers do not afford this to us (yes I know about will-change; it's good for making more layers but not for forcing things into fewer layers, in my experience).

For mere mortals, we must simply try to play along with whatever the browser is doing.

So you're really doing it? Here's how.

I will assume you have already read and digested this excellent Red Blob Games primer on how to actually do the math and rendering portions of hex grids, and applied that to DOM to position your cell elements on the page.

So we know the problem: overlapping elements get pushed to their own layer, and every hex in a hex grid overlaps other elements. And browsers provide no explicit way of circumventing this.

Instead of explicit, then, we go implicit. The trick is to group our hexes under different parents so that none of the siblings in one parent overlaps another. In theory, this will encourage browsers to rasterize (render to rectangle) their common parent instead of each grouped tile individually.

Theoretically you could do this by splitting into two groups, the odd and even rows. This ought to work as hexes on the same row butt up flat against one another without overlapping. But something about Chrome's layer algorithm trips up on this too. So I've opted for a two-tiered separation: every other cell from every other row.

Finally, with this configuration, we seem to have convinced the browser to group cells together in a much smaller number of batches. Curiously, we still get a couple stragglers here and there. But this is still pretty much as good as I've managed to get it.

Another side-by-side view with the hex grid and layer viewer. There are now just a handful of layers, most of the grid is devoid of orange borders.

Above, see how few orange layer borders we have now! Strangely we still get some near the outer corners of the map. But overall, this is a massive improvement.

As I'm using React, I wanted to ensure that the developer actually constructing a grid never had to worry about these performance optimizations, so I've turned to registering elements as portal targets in a context provider. This turns out pretty well!

Here's some example React code, types omitted for brevity.

const HexRenderContext = createContext({
  wrap: (child, coordinate) => child, // default
});

function HexMap({ children }) {
  const layer1Ref = useRef(null);
  const layer2Ref = useRef(null);
  const layer3Ref = useRef(null);
  const layer4Ref = useRef(null);

  const layerGroups = [
    [layer1Ref, layer2Ref],
    [layer3Ref, layer4Ref],
  ];

  const wrapCell = (child, coordinate) => {
    const groupI = Math.abs(coordinate[0]) % layerGroups.length;
    const groupJ = Math.abs(coordinate[1]) % layerGroups[groupI].length;
    const layer = layerGroups[groupI][groupJ];
    if (layer.current) {
      return createPortal(child, layer.current);
    }
    // fallback
    return child;
  };

  return (
    <HexRenderContext.Provider value={{ wrap: wrapCell }}>
      <div className="hex-map">
        {children}
        <div ref={layer1Ref} />
        <div ref={layer2Ref} />
        <div ref={layer3Ref} />
        <div ref={layer4Ref} />
      </div>
    </HexRenderContext>
  );
}

function HexCell({ children, coordinate }) {
  // up to you to do this math, this post is only concerned
  // with compositing optimization.
  const { top, left } = getPosition(coordinate);

  const { wrap } = use(HexRenderContext);

  return wrap(
    <div style={{ top, left, position: 'absolute' }}>
      {children}
    </div>,
    coordinate
  );
}

// Usage: you don't have to know about the layers at all.
function ExampleMap() {
  return (
    <HexMap>
      <HexCell coordinate={[0, 0]} />
      <HexCell coordinate={[1, 0]} />
      {/* ... so on */}
    </HexMap>
  );
}

The hex map wrapper renders 4 divs, one for each layer group. It also provides a wrapping function to be used in the cells. This function selectively injects their DOM content into one of the group divs based on their coordinate.

I wrote the logic here to more easily scale up and down the number of layer groups to play around with optimization, but I find this 2x2 setup works just fine for me.

A massive grid of hex tiles, marked with colors. Odd rows show every other hex colored either yellow or green. Even rows show every other hex colored white or gray.

Above: a visual coloring of which hexes are grouped together under different parent divs. As you can see, no two cells of the same color come close to overlapping, which seems to be enough for the browser to optimize compositing from there.

Help: I tried this and they're still all different layers

You might be using transform instead of top/left for cell positioning? Usually transform is the more performant way to position things, but that's because usually you actually want positioned elements on their own layer, so they don't affect the rasterization of things around them as they move. In this case we really don't want that--so absolute positioning is the way to go.

The letdown

Even with all of this, if you try to render more than a hundred or so cells onscreen, expect to dip below 60FPS. DOM is just not a great fit for this.

But if you, like me, have your reasons... this will at least get you reasonable performance. I recommend keeping the number of onscreen hexes low if possible.

That's all for now, but if I find another trick I'll add it here.