BlogProduct

Computing bounding boxes

Ani Krishnan

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.

About the author

  • Ani Krishnan

    Ani Krishnan

    Software Engineer at tldraw

    Ani Krishnan is a developer at tldraw, interested in human-centred design and how agents fit on the canvas. Off the clock, you’ll find him lost in a jam session or making games.

Trusted by these companies

  • AlAI
  • bigpi
  • CADChat
  • Google
  • Replit
  • BlackRock
  • Loveable
  • ClickUp
  • Autodesk
  • Google Stitch
  • Luma
  • Runway
  • SchoolAI
  • Honeycomb
  • Padlet
  • Genio
  • JAM
  • Mobbin
  • Brisk
  • Aries
  • Dirac