Fixing Treemap Tile Overlap and Padding
Your D3 treemap renders tiles that touch with no gap, bleed a pixel into each other, or reshuffle wildly every time the data updates.
Three separate causes hide behind one symptom, and fixing the wrong one wastes an afternoon. This is a focused companion to hierarchical and network layouts in D3: here we isolate padding, sub-pixel rounding, and tiling-method choice, each of which produces overlap or misalignment in a distinct way.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: default padding, fractional coords, unstable tiling on update.
import { treemap, hierarchy } from 'd3-hierarchy';
function layoutBroken(data: Datum, w: number, h: number) {
const root = hierarchy(data).sum((d) => d.value);
const t = treemap<Datum>().size([w, h]); // paddingInner defaults to 0 → no gaps
return t(root); // x0/x1 are floats → 0.5px seams read as overlap;
// treemapSquarify (the default) reflows tiles on every update
}
// ✅ FIXED: explicit padding, integer rounding, stable tiling method.
import { treemap, treemapResquarify, hierarchy, type HierarchyRectangularNode } from 'd3-hierarchy';
interface Datum { name: string; value: number; children?: Datum[]; }
function layoutFixed(data: Datum, w: number, h: number): HierarchyRectangularNode<Datum> {
const root = hierarchy<Datum>(data)
.sum((d) => d.value) // REQUIRED: gives tiles their area
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0)); // stable sibling order
const t = treemap<Datum>()
.size([w, h])
.tile(treemapResquarify) // PERF: preserves tile order across updates → tiles resize, not jump
.paddingInner(2) // 2px gap BETWEEN sibling tiles
.paddingOuter(4) // 4px inset from each parent's edge
.paddingTop(18) // room for a parent label strip
.round(true); // snap x0/y0/x1/y1 to integers → no sub-pixel seams
return t(root);
}
// A11Y: guard against negative dimensions when padding exceeds a tiny tile.
function tileWidth(d: HierarchyRectangularNode<Datum>): number {
return Math.max(0, d.x1 - d.x0);
}
The two lines that fix the “overlap” report are .paddingInner(2) and .round(true). Overlap is almost never true overlap — it is either a missing gap (tiles legitimately share an edge because paddingInner is 0) or a sub-pixel seam where two fractional edges land on the same physical pixel and anti-aliasing smears them together. Padding creates real separation; rounding makes the shared edges land on exact pixel boundaries.
Step-by-step fix
import { select, type Selection } from 'd3-selection';
import { type HierarchyRectangularNode } from 'd3-hierarchy';
function draw(
g: Selection<SVGGElement, unknown, null, undefined>,
root: HierarchyRectangularNode<Datum>,
): void {
g.selectAll<SVGRectElement, HierarchyRectangularNode<Datum>>('rect.tile')
.data(root.leaves(), (d) => d.data.name) // stable key for smooth updates
.join('rect')
.attr('class', 'tile')
.attr('x', (d) => d.x0)
.attr('y', (d) => d.y0)
.attr('width', (d) => Math.max(0, d.x1 - d.x0)) // never negative
.attr('height', (d) => Math.max(0, d.y1 - d.y0))
.attr('fill', '#2563eb')
.attr('aria-label', (d) => `${d.data.name}: ${d.value}`); // A11Y: value per tile
}
Verification
// No two sibling leaves should overlap after padding + rounding.
function overlaps(a: HierarchyRectangularNode<Datum>, b: HierarchyRectangularNode<Datum>): boolean {
return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
}
const leaves = root.leaves();
for (let i = 0; i < leaves.length; i++) {
for (let j = i + 1; j < leaves.length; j++) {
console.assert(!overlaps(leaves[i], leaves[j]), `tiles ${i} and ${j} overlap`);
}
}
In DevTools, inspect two adjacent <rect> elements and confirm their x/width values differ by at least your paddingInner, and that all four coordinates are whole integers. A gap that exists in the numbers but not on screen means your stroke width, not the layout, is covering it.
Squarify vs resquarify on updating data
The tiling method is the least-known cause of “misaligned” treemaps, and it only shows up once the data changes. treemapSquarify — the default — recomputes the best aspect-ratio arrangement from scratch each time, so a small change in one value can send tiles leaping to entirely new positions because the algorithm found a different locally-optimal packing. For a static chart that is fine. For a chart that updates on a timer or a filter, it is disorienting: the user loses track of which tile is which. treemapResquarify solves this by remembering the row structure from the previous layout and only resizing tiles within their existing rows, so a value change makes tiles grow and shrink smoothly in place while their relative positions stay put. The trade is a slightly worse aspect ratio on any single frame in exchange for stability across frames, which is almost always the right call for interactive dashboards. Pair it with a stable key on the data join so D3 can animate each tile from its old rectangle to its new one.
Edge cases & gotchas
- Padding larger than a tile. When a leaf’s natural width is smaller than
2 × paddingInner,x1 - x0goes negative and the browser drops the rect or throws. Clamp withMath.max(0, ...)and consider filtering out sub-pixel leaves. - Rounding drift on nested levels.
.round(true)rounds every level independently, so a parent and its children can round in opposite directions and leave a 1px overhang. Prefer rounding once at the leaf draw step if precise nesting matters more than crisp edges. paddingTopwithout a label. ReservingpaddingTopfor a parent label you never draw leaves a mysterious empty strip. Only add it when you render the parent’s name in that band.- Stroke faking padding. A
strokeon each tile visually separates them but does not change the layout, so labels still collide and hit-testing overlaps. Use realpaddingInner, not a border, for genuine separation. - Forgetting
.sum()after a data swap. Rebuilding the hierarchy without re-calling.sum()leavesvalueundefined and every tile collapses to zero. The aggregation is per-hierarchy, not cached across rebuilds. - Non-leaf tiles double-drawing. Binding
root.descendants()instead ofroot.leaves()draws parents and children, so children paint on top of parents and look like overlap. Draw leaves for the tiles and parents only for label bands.
Frequently Asked Questions
Why do my treemap tiles touch with no gap?
Because paddingInner defaults to 0, so the layout places sibling tiles edge-to-edge by design. Set .paddingInner(px) to insert a real gap between siblings, and .paddingOuter(px) to inset children from their parent’s border. The gap comes from the layout coordinates, not from a CSS border or stroke.
What causes the one-pixel overlap or fuzzy seams between tiles?
Fractional tile coordinates. When x0/x1 land on values like 120.5, two adjacent edges can fall on the same physical pixel and anti-aliasing smears them into an apparent overlap. Call .round(true) so all tile edges snap to integer pixel boundaries and the seams become crisp.
How do I stop tiles jumping around when the data updates?
Switch the tiling method from the default treemapSquarify to treemapResquarify, which preserves the previous row structure and only resizes tiles in place. Combined with a stable key on your data join, that lets each tile animate smoothly from its old size to its new one instead of leaping to a new position.
Related
- Hierarchical and network layouts in D3 — the overview covering the sum/sort lifecycle these tiles depend on.
- Building a collapsible tree with d3.hierarchy — a sibling hierarchy layout with interaction.
- Enter-update-exit pattern mastery — keying the tile join so updates animate in place.
- Stabilizing a jittery force-directed graph — the network side of this guide.