apps/docs/content/sdk-features/overlay-utils.mdx
Overlay utils render ephemeral UI on the canvas: selection handles, brush rectangles, snap lines, scribbles, and shape handles. They draw directly to a <canvas> element using the Canvas 2D API and can optionally provide hit-test geometry for pointer interactions.
Each overlay util defines a specific type of canvas UI. The editor queries all registered overlay utils reactively: when the editor state changes, overlay utils determine whether they are active, produce overlay instances, and render them.
An OverlayUtil is an abstract class with four responsibilities:
isActive() returns whether the overlay should render right nowgetOverlays() returns the current set of overlay objectsrender() draws overlays into a canvas 2D contextgetGeometry() returns geometry for interactive overlaysThe editor calls these methods reactively. When isActive() returns false, the overlay is skipped entirely. When it returns true, getOverlays() re-runs whenever the editor state it reads changes, and render() re-runs on that plus camera movement and viewport resizes.
Paint order across utils comes from options.zIndex: higher numbers paint on top and are hit-tested first, ties keep registration order, and the default is 0. Built-in utils use values like 50, 100, 200, and 300 so custom utils can slot between them.
The tldraw package includes these overlay utils by default:
| Overlay util | Purpose |
|---|---|
| ShapeIndicatorOverlayUtil | Selection, hover, and hint outlines on shapes |
| SelectionForegroundOverlayUtil | Selection box, resize handles, corners |
| ShapeHandleOverlayUtil | Shape handles (arrows, lines, etc.) |
| BrushOverlayUtil | Selection brush rectangle |
| ZoomBrushOverlayUtil | Zoom brush rectangle |
| SnapIndicatorOverlayUtil | Snap alignment guides |
| ScribbleOverlayUtil | Eraser and lasso scribbles |
| ArrowHintOverlayUtil | Target hints while drawing arrows |
| ArrowBindingHintOverlayUtil | Binding hints when dragging arrow handles |
| CollaboratorBrushOverlayUtil | Remote users' brush rectangles |
| CollaboratorScribbleOverlayUtil | Remote users' scribbles |
| CollaboratorHintOverlayUtil | Remote users' viewport-edge hint arrows |
| CollaboratorShapeIndicatorOverlayUtil | Remote users' selection indicators |
These are exported as defaultOverlayUtils from tldraw.
Extend the OverlayUtil class and implement at least isActive(), getOverlays(), and render():
import { OverlayUtil, TLOverlay } from 'tldraw'
interface MyHighlightOverlay extends TLOverlay {
props: {
x: number
y: number
radius: number
}
}
class HighlightOverlayUtil extends OverlayUtil<MyHighlightOverlay> {
static override type = 'highlight'
override isActive(): boolean {
// Active when there's exactly one selected shape
return this.editor.getSelectedShapeIds().length === 1
}
override getOverlays(): MyHighlightOverlay[] {
const shape = this.editor.getOnlySelectedShape()
if (!shape) return []
const bounds = this.editor.getShapePageBounds(shape)
if (!bounds) return []
return [
{
id: 'highlight',
type: 'highlight',
props: {
x: bounds.midX,
y: bounds.midY,
radius: Math.max(bounds.width, bounds.height) / 2 + 20,
},
},
]
}
override render(ctx: CanvasRenderingContext2D, overlays: MyHighlightOverlay[]): void {
const zoom = this.editor.getZoomLevel()
for (const overlay of overlays) {
const { x, y, radius } = overlay.props
ctx.beginPath()
ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.strokeStyle = 'dodgerblue'
ctx.lineWidth = 2 / zoom
ctx.stroke()
}
}
}
Each overlay is a plain object with id, type, and props:
interface TLOverlay {
id: string // Unique identifier for this instance
type: string // Matches the overlay util's static type
props: Record<string, unknown> // Data needed for rendering and hit testing
}
Define a custom interface extending TLOverlay to type your props. The id must be globally unique across all overlay utils — hit testing and hover lookup use the id alone. Namespace your ids to avoid collisions: a fixed string like 'highlight' works for single-instance overlays, while per-item overlays should include the item's identity (e.g. 'handle:<shapeId>:<handleId>').
The render() method receives a CanvasRenderingContext2D already transformed to page space (camera offset and zoom applied). Scale line widths and radii by 1 / zoom to keep them constant on screen:
override render(ctx: CanvasRenderingContext2D, overlays: MyOverlay[]): void {
const zoom = this.editor.getEfficientZoomLevel()
ctx.lineWidth = 1 / zoom
// ...draw your overlays
}
By default, overlays are non-interactive. To make an overlay respond to pointer events, implement getGeometry() to return a Geometry2d in page coordinates:
import { Circle2d, Geometry2d, TLCursorType } from 'tldraw'
class MyInteractiveOverlayUtil extends OverlayUtil<MyOverlay> {
// ...isActive, getOverlays, render...
override getGeometry(overlay: MyOverlay): Geometry2d | null {
return new Circle2d({
x: overlay.props.x - 10,
y: overlay.props.y - 10,
radius: 10,
isFilled: true,
})
}
override getCursor(): TLCursorType | undefined {
return 'pointer'
}
}
While the select tool is idle, it hit-tests overlays on every pointer move, records the result with OverlayManager#setHoveredOverlay, and applies the cursor from getCursor(). Overlays take priority over shapes for hover. To handle clicks yourself, implement onPointerDown(overlay, info); it runs before the default routing, and returning false falls through to the default behavior.
Pass your overlay utils through the overlayUtils prop on Tldraw or TldrawEditor:
import { Tldraw } from 'tldraw'
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw overlayUtils={[HighlightOverlayUtil]} />
</div>
)
}
When using the Tldraw component, your custom overlay utils are merged with the defaults, so you don't need to re-include them. If your custom overlay util has the same type as a default one, it replaces the default. TldrawEditor has no defaults; pass defaultOverlayUtils yourself if you want them.
To replace a default overlay, extend it and override the methods you want to change. Give it the same static type so it replaces the built-in:
import { Tldraw, BrushOverlayUtil, type TLBrushOverlay } from 'tldraw'
class BlueBrushOverlayUtil extends BrushOverlayUtil {
override render(ctx: CanvasRenderingContext2D, overlays: TLBrushOverlay[]): void {
const overlay = overlays[0]
if (!overlay) return
const { x, y, w, h } = overlay.props
const zoom = this.editor.getZoomLevel()
ctx.beginPath()
ctx.rect(x, y, w, h)
ctx.fillStyle = 'rgba(0, 0, 255, 0.1)'
ctx.fill()
ctx.lineWidth = 1 / zoom
ctx.strokeStyle = 'blue'
ctx.stroke()
}
}
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw overlayUtils={[BlueBrushOverlayUtil]} />
</div>
)
}
Overlay utils can define an options property for configuration. Keep zIndex in it, since the base type is { zIndex?: number }. Use the static OverlayUtil#configure method to create a customized version without subclassing:
class MyOverlayUtil extends OverlayUtil<MyOverlay> {
static override type = 'my_overlay'
override options = { zIndex: 50, color: 'red', radius: 10 }
// ...use this.options.color and this.options.radius in render()
}
// Create a variant with different options
const BlueOverlay = MyOverlayUtil.configure({ color: 'blue' })
The built-in utils expose their colors and line widths as theme-aware display values, so you can recolor one with configure({ getCustomDisplayValues }) instead of overriding render(). See BrushOverlayUtilOptions for an example.
Use Editor#overlays to interact with the overlay system:
// Get a specific overlay util
const brushUtil = editor.overlays.getOverlayUtil<BrushOverlayUtil>('brush')
// Get all currently active overlays
const activeOverlays = editor.overlays.getCurrentOverlays()
// Hit test at a page point
const overlay = editor.overlays.getOverlayAtPoint({ x: 100, y: 200 })
// Check what's hovered
const hoveredId = editor.overlays.getHoveredOverlayId()