Back to Tldraw

Overlay utils

apps/docs/content/sdk-features/overlay-utils.mdx

5.4.09.7 KB
Original Source

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.

How it works

An OverlayUtil is an abstract class with four responsibilities:

  1. ActivationisActive() returns whether the overlay should render right now
  2. Overlay instancesgetOverlays() returns the current set of overlay objects
  3. Renderingrender() draws overlays into a canvas 2D context
  4. Hit testing (optional) — getGeometry() returns geometry for interactive overlays

The 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.

Default overlay utils

The tldraw package includes these overlay utils by default:

Overlay utilPurpose
ShapeIndicatorOverlayUtilSelection, hover, and hint outlines on shapes
SelectionForegroundOverlayUtilSelection box, resize handles, corners
ShapeHandleOverlayUtilShape handles (arrows, lines, etc.)
BrushOverlayUtilSelection brush rectangle
ZoomBrushOverlayUtilZoom brush rectangle
SnapIndicatorOverlayUtilSnap alignment guides
ScribbleOverlayUtilEraser and lasso scribbles
ArrowHintOverlayUtilTarget hints while drawing arrows
ArrowBindingHintOverlayUtilBinding hints when dragging arrow handles
CollaboratorBrushOverlayUtilRemote users' brush rectangles
CollaboratorScribbleOverlayUtilRemote users' scribbles
CollaboratorHintOverlayUtilRemote users' viewport-edge hint arrows
CollaboratorShapeIndicatorOverlayUtilRemote users' selection indicators

These are exported as defaultOverlayUtils from tldraw.

Creating an overlay util

Extend the OverlayUtil class and implement at least isActive(), getOverlays(), and render():

tsx
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()
		}
	}
}

The overlay interface

Each overlay is a plain object with id, type, and props:

tsx
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>').

Rendering

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:

tsx
override render(ctx: CanvasRenderingContext2D, overlays: MyOverlay[]): void {
    const zoom = this.editor.getEfficientZoomLevel()
    ctx.lineWidth = 1 / zoom
    // ...draw your overlays
}

Hit testing

By default, overlays are non-interactive. To make an overlay respond to pointer events, implement getGeometry() to return a Geometry2d in page coordinates:

tsx
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.

Registering overlay utils

Pass your overlay utils through the overlayUtils prop on Tldraw or TldrawEditor:

tsx
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.

Customizing default overlays

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:

tsx
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>
	)
}

Options via configure

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:

tsx
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.

Accessing overlay utils at runtime

Use Editor#overlays to interact with the overlay system:

tsx
// 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()
  • Shapes - The shape system that overlays interact with
  • Indicators - Shape indicators rendered alongside overlays
  • Scribble - Scribble system rendered via overlay utils