Computing bounding boxes

On an infinite canvas, every interaction raises a spatial question. What did you click? Which shapes are currently visible? What area surrounds a group of selected shapes?
As in any graphical interface that responds to fast spatial queries, we answer these questions with bounding boxes.
Here’s how they’re computed in tldraw, from a single shape to a rotated selection.
Why axis-aligned?
A bounding box is the smallest axis-aligned rectangle that can contain a shape. "Axis-aligned" means the edges stay horizontal and vertical relative to the canvas, even when the shape is rotated.

This constraint makes bounding boxes fast. When checking if two boxes overlap, we don't need to make expensive calculations. Instead, we just make four simple comparisons between the edges of the two boxes:
// packages/editor/src/lib/primitives/Box.ts:421-423
static Collides(A: Box, B: Box) {
return !(A.maxX < B.minX || A.minX > B.maxX || A.maxY < B.minY || A.minY > B.maxY)
}Rotating boxes
What if a shape gets rotated? Say you have a 100×50px rectangle at the origin. Its bounding box is:
{x: 0, y: 0, w: 100, h: 50}If you rotate the rectangle by 45°, what would the new bounding box be? A first step would be to apply the rotation matrix directly to the vertices of the original box:
const corners = [
{ x: 0, y: 0 }, // top-left
{ x: 100, y: 0 }, // top-right
{ x: 100, y: 50 }, // bottom-right
{ x: 0, y: 50 }, // bottom-left
]
const rotated = corners.map((p) => {
const cos = Math.cos(Math.PI / 4)
const sin = Math.sin(Math.PI / 4)
return {
x: p.x * cos - p.y * sin,
y: p.x * sin + p.y * cos,
}
})
// Results: [{x: 0, y: 0}, {x: 70.7, y: 70.7}, {x: 35.4, y: 106.1}, {x: -35.4, y: 35.4}]The rotated points describe the shape's new position, but the edges are no longer aligned with the axes of the canvas. If we tried to use these directly for collision detection, we'd need to compute projections using dot products (according to the separating axis theorem), which is more expensive than just comparing min/max values.
Making a new bounding box
We want the best of both worlds: the speed of axis-aligned box comparisons, but correct results for rotated shapes. The solution is to let the shape rotate, then compute a new axis-aligned box that wraps the rotated result. We take the shape's bounding box vertices in its own coordinate system, transform them to page space, and find the tightest axis-aligned box around those transformed points.
// packages/editor/src/lib/editor/Editor.ts:4838-4847 (simplified)
getShapePageBounds(shape: TLShape): Box | undefined {
const pageTransform = this.getShapePageTransform(shape)
if (!pageTransform) return undefined
return Box.FromPoints(
pageTransform.applyToPoints(this.getShapeGeometry(shape).boundsVertices)
)
}The Box.FromPoints function finds the minimum and maximum x and y values across all points, giving us the tightest possible axis-aligned box:
// packages/editor/src/lib/primitives/Box.ts:390-406
static FromPoints(points: VecLike[]): Box {
if (points.length === 0) return new Box()
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (let i = 0, n = points.length; i < n; i++) {
const point = points[i]
minX = Math.min(point.x, minX)
minY = Math.min(point.y, minY)
maxX = Math.max(point.x, maxX)
maxY = Math.max(point.y, maxY)
}
return new Box(minX, minY, maxX - minX, maxY - minY)
}Take our 100×50px rectangle above as an example: we calculated its original bounding box, then we applied the 45° rotation to each corner.
After this, we find the min and max (x, y) values among those rotated points. The result is a new bounding box that is roughly 106×106px. This is bigger than the original box since it's stretched to contain all four rotated corners.
Nested transform
Shapes in tldraw can be nested inside frames, which can themselves be rotated and positioned anywhere. If a rectangle sits inside a frame that's rotated 30°, the rectangle's position on the page depends on both its own transform, and that of its parent.
Each transform (position, rotation, scale) is represented as a matrix. To get the shape's final position on the page, we need to apply the parent's transform first, then the shape's local transform on top of that. Matrix multiplication does this by combining two transforms into one:
// packages/editor/src/lib/editor/Editor.ts:4784-4798 (simplified)
getShapePageTransform(shape: TLShape): Mat {
if (isPageId(shape.parentId)) {
return this.getShapeLocalTransform(shape)
}
const parentTransform = this.getShapePageTransform(shape.parentId)
return Mat.Compose(parentTransform, this.getShapeLocalTransform(shape))
}If a shape sits directly on the page, its page transform is just its local transform. Otherwise, we get the parent's page transform and compose it with the shape's local transform. This recurses up through parents, handling arbitrary nesting.
Rotating selection boxes
When you rotate multiple shapes in tldraw, you might notice that the selection box and handles rotate with the group.
This might seem confusing given everything we covered above—shouldn't the selection bounds always be aligned with the canvas?
In actuality, tldraw uses two different kinds of bounds. For viewport culling calculations, we only use axis-aligned bounding boxes. But for the selection UI, we also compute a rotated selection box that matches the orientation of the shapes.
If you select two shapes and rotate them by the same angle, you should be able to resize them along their rotated axes. An axis-aligned selection box would only let you resize horizontally and vertically relative to the page, which would distort the shapes. The rotated selection box allows you to drag a corner handle and scale the shapes along their actual orientation.
If the selected shapes are rotated by different amounts, there's no single angle that makes sense for the selection box, so we fall back to using an axis-aligned box for selection.
To compute this, we un-rotate all corner points by the negative selection rotation, find an axis-aligned bounding box in that un-rotated space, then rotate the box position back to get the final result:
// packages/editor/src/lib/editor/Editor.ts:2140-2180 (simplified)
getShapesRotatedPageBounds(shapeIds: TLShapeId[]): Box | undefined {
const selectionRotation = this.getShapesSharedRotation(shapeIds)
// If shapes have different rotations, fall back to axis-aligned bounds
if (selectionRotation === 0) {
return this.getShapesPageBounds(shapeIds) ?? undefined
}
// Collect all corners, un-rotate them, find the bounding box, then re-rotate
const boxFromRotatedVertices = Box.FromPoints(
shapeIds
.flatMap((id) => {
const pageTransform = this.getShapePageTransform(id)
if (!pageTransform) return []
return pageTransform.applyToPoints(this.getShapeGeometry(id).bounds.corners)
})
.map((p) => p.rot(-selectionRotation)) // Un-rotate all corners
)
boxFromRotatedVertices.point = boxFromRotatedVertices.point.rot(selectionRotation)
return boxFromRotatedVertices
}Tradeoffs
Every bounding box in this article is an approximation. A rotated rectangle's axis-aligned bounding box is always larger than the shape itself because it stretches to fit all four corners into an axis-aligned rectangle. That means we sometimes check shapes that weren't actually clicked, or render shapes that aren't quite visible at the edge of the viewport.
The alternative—testing against exact shape geometry—requires the expensive math we use only when absolutely necessary (e.g. hit-testing shapes close up). The extra false positives are cheap to filter out in a second pass, and for the vast majority of spatial queries, the four-comparison AABB check is all we need.
We’d love your feedback. Get in touch with us on our Discord or follow us on X. If you'd like to put a canvas like this in your own product, check out our docs or starter kits.
