Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/tldraw/tldraw/llms.txt

Use this file to discover all available pages before exploring further.

Tools define how users interact with the canvas. Each tool (select, draw, erase, etc.) is implemented as a hierarchical state machine that responds to pointer, keyboard, and other input events.

Overview

The tool system in tldraw is built on:
  • StateNode: Base class for all tools and tool states
  • State hierarchy: Tools can have child states (e.g., Select tool has Idle, Pointing, Dragging, etc.)
  • Event handlers: Methods for pointer, keyboard, and other events
  • Transitions: Moving between states based on user input

StateNode architecture

Every tool extends the StateNode class:
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'

export class MyTool extends StateNode {
  static override id = 'my-tool'
  static override initial = 'idle'
  static override children(): TLStateNodeConstructor[] {
    return [Idle, Pointing, Dragging]
  }
  
  onEnter() {
    // Called when tool becomes active
    this.editor.setCursor({ type: 'cross' })
  }
  
  onExit() {
    // Called when tool becomes inactive
  }
}

State hierarchy

Tools are organized as trees of states:
root
├── select (branch)
│   ├── idle (leaf)
│   ├── pointing (leaf)
│   ├── brushing (leaf)
│   ├── translating (leaf)
│   └── rotating (leaf)
├── draw (branch)
│   ├── idle (leaf)
│   └── drawing (leaf)
└── eraser (branch)
    ├── idle (leaf)
    ├── pointing (leaf)
    └── erasing (leaf)

Creating a simple tool

Here’s a complete example of a custom tool:
1
Define the tool class
2
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { Idle } from './Idle'
import { Drawing } from './Drawing'

export class StampTool extends StateNode {
  static override id = 'stamp'
  static override initial = 'idle'
  static override children(): TLStateNodeConstructor[] {
    return [Idle, Drawing]
  }
  
  override onEnter() {
    this.editor.setCursor({ type: 'cross', rotation: 0 })
  }
  
  override onExit() {
    this.editor.setCursor({ type: 'default' })
  }
}
3
Create child states
4
Idle state (waiting for input):
5
import { StateNode, TLPointerEventInfo } from '@tldraw/editor'

export class Idle extends StateNode {
  static override id = 'idle'
  
  override onPointerDown(info: TLPointerEventInfo) {
    // Transition to drawing state
    this.parent.transition('drawing', info)
  }
  
  override onKeyDown(info: TLKeyboardEventInfo) {
    if (info.key === 'Escape') {
      // Return to select tool
      this.editor.setCurrentTool('select')
    }
  }
}
6
Drawing state (actively creating shape):
7
import { StateNode, TLPointerEventInfo, createShapeId } from '@tldraw/editor'

export class Drawing extends StateNode {
  static override id = 'drawing'
  
  private shapeId = createShapeId()
  
  override onEnter(info: TLPointerEventInfo) {
    const { currentPagePoint } = info
    
    // Create the shape
    this.editor.createShape({
      id: this.shapeId,
      type: 'stamp',
      x: currentPagePoint.x - 50,
      y: currentPagePoint.y - 50,
      props: {
        w: 100,
        h: 100
      }
    })
  }
  
  override onPointerMove(info: TLPointerEventInfo) {
    // Update shape as pointer moves
    const { currentPagePoint } = info
    
    this.editor.updateShape({
      id: this.shapeId,
      type: 'stamp',
      x: currentPagePoint.x - 50,
      y: currentPagePoint.y - 50
    })
  }
  
  override onPointerUp() {
    // Finish drawing, return to idle
    this.parent.transition('idle')
  }
  
  override onCancel() {
    // Delete shape and return to idle
    this.editor.deleteShapes([this.shapeId])
    this.parent.transition('idle')
  }
}
8
Register the tool
9
import { Tldraw } from 'tldraw'
import { StampTool } from './StampTool'
import { StampShapeUtil } from './StampShapeUtil'

function App() {
  return (
    <Tldraw
      shapeUtils={[StampShapeUtil]}
      tools={[StampTool]}
      onMount={(editor) => {
        // Activate the tool
        editor.setCurrentTool('stamp')
      }}
    />
  )
}

Event handlers

StateNode provides handlers for various input events:

Pointer events

class MyState extends StateNode {
  override onPointerDown(info: TLPointerEventInfo) {
    const { currentPagePoint, button, altKey, ctrlKey, shiftKey } = info
    
    if (button === 0) { // Left click
      // Handle left click
    }
  }
  
  override onPointerMove(info: TLPointerEventInfo) {
    // Handle pointer movement
  }
  
  override onPointerUp(info: TLPointerEventInfo) {
    // Handle pointer release
  }
  
  override onDoubleClick(info: TLClickEventInfo) {
    // Handle double-click
  }
  
  override onTripleClick(info: TLClickEventInfo) {
    // Handle triple-click
  }
}

Keyboard events

class MyState extends StateNode {
  override onKeyDown(info: TLKeyboardEventInfo) {
    switch (info.key) {
      case 'Escape':
        this.editor.setCurrentTool('select')
        break
      case 'Enter':
        this.complete()
        break
      case 'z':
        if (info.ctrlKey) this.editor.undo()
        break
    }
  }
  
  override onKeyUp(info: TLKeyboardEventInfo) {
    // Handle key release
  }
  
  override onKeyRepeat(info: TLKeyboardEventInfo) {
    // Handle key held down
  }
}

Other events

class MyState extends StateNode {
  // Mouse wheel
  override onWheel(info: TLWheelEventInfo) {
    // Handle scroll
  }
  
  // Animation frame
  override onTick(elapsed: number) {
    // Called every frame while active
  }
  
  // State transitions
  override onInterrupt() {
    // Called when state is interrupted
  }
  
  override onCancel() {
    // Called when user cancels (ESC, etc.)
  }
  
  override onComplete() {
    // Called when action completes
  }
}

State transitions

Navigate between states using transition():
class Idle extends StateNode {
  static override id = 'idle'
  
  override onPointerDown(info: TLPointerEventInfo) {
    // Transition to sibling state
    this.parent.transition('pointing', info)
  }
}

class Pointing extends StateNode {
  static override id = 'pointing'
  
  override onPointerMove(info: TLPointerEventInfo) {
    const { originPagePoint, currentPagePoint } = info
    const distance = Vec.dist(originPagePoint, currentPagePoint)
    
    if (distance > 10) {
      // Transition to another sibling state
      this.parent.transition('dragging', info)
    }
  }
  
  override onPointerUp() {
    // Return to idle
    this.parent.transition('idle')
  }
}

Real-world example: Eraser tool

The built-in eraser tool demonstrates the pattern:
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { Erasing } from './childStates/Erasing'
import { Idle } from './childStates/Idle'
import { Pointing } from './childStates/Pointing'

export class EraserTool extends StateNode {
  static override id = 'eraser'
  static override initial = 'idle'
  static override isLockable = false
  
  static override children(): TLStateNodeConstructor[] {
    return [Idle, Pointing, Erasing]
  }
  
  override onEnter() {
    this.editor.setCursor({ type: 'cross', rotation: 0 })
  }
}
Its child states:
// Idle.ts
export class Idle extends StateNode {
  static override id = 'idle'
  
  override onPointerDown() {
    this.parent.transition('pointing')
  }
  
  override onCancel() {
    this.editor.setCurrentTool('select')
  }
}

// Pointing.ts
export class Pointing extends StateNode {
  static override id = 'pointing'
  
  override onPointerMove(info: TLPointerEventInfo) {
    if (this.editor.inputs.isDragging) {
      this.parent.transition('erasing', info)
    }
  }
  
  override onPointerUp() {
    this.parent.transition('idle')
  }
}

// Erasing.ts
export class Erasing extends StateNode {
  static override id = 'erasing'
  private shapesToErase = new Set<TLShapeId>()
  
  override onEnter() {
    this.updateErasingShapes()
  }
  
  override onPointerMove() {
    this.updateErasingShapes()
  }
  
  override onPointerUp() {
    this.editor.deleteShapes(Array.from(this.shapesToErase))
    this.parent.transition('idle')
  }
  
  private updateErasingShapes() {
    const { currentScreenPoint } = this.editor.inputs
    const erasing = this.editor.getShapesAtPoint(currentScreenPoint)
    
    for (const shape of erasing) {
      this.shapesToErase.add(shape.id)
    }
    
    this.editor.setErasingShapes(Array.from(this.shapesToErase))
  }
}

BaseBoxShapeTool

For tools that create rectangular shapes, extend BaseBoxShapeTool:
import { BaseBoxShapeTool } from '@tldraw/editor'

export class CardTool extends BaseBoxShapeTool {
  static override id = 'card'
  static override initial = 'idle'
  override shapeType = 'card'
}
This provides built-in states for:
  • Idle: Waiting for input
  • Pointing: Mouse down but not dragging yet
  • Creating: Dragging to define shape size

Advanced patterns

Accessing parent tool

class ChildState extends StateNode {
  override onComplete() {
    // Access parent tool
    const tool = this.parent as MyTool
    tool.someToolMethod()
    
    // Transition in parent
    this.parent.transition('idle')
  }
}

Sharing data between states

export class DrawTool extends StateNode {
  static override id = 'draw'
  
  // Shared data accessible to child states
  currentShapeId?: TLShapeId
  initialPoint?: VecLike
  
  setCurrentShape(id: TLShapeId) {
    this.currentShapeId = id
  }
}

class Drawing extends StateNode {
  override onEnter() {
    const tool = this.parent as DrawTool
    tool.setCurrentShape(newShapeId)
  }
}

Using tick for animations

class Animating extends StateNode {
  private startTime = Date.now()
  
  override onTick() {
    const elapsed = Date.now() - this.startTime
    const progress = Math.min(elapsed / 1000, 1)
    
    // Update shape based on progress
    this.editor.updateShape({
      id: this.shapeId,
      type: 'animated',
      props: { progress }
    })
    
    if (progress >= 1) {
      this.complete()
    }
  }
}

Handling interruptions

class Drawing extends StateNode {
  private shapeId: TLShapeId
  
  override onInterrupt() {
    // Clean up when interrupted by another tool
    this.editor.deleteShapes([this.shapeId])
  }
  
  override onCancel() {
    // User pressed ESC
    this.editor.deleteShapes([this.shapeId])
    this.parent.transition('idle')
  }
}

Tool configuration

Tool options

export class MyTool extends StateNode {
  static override id = 'my-tool'
  static override initial = 'idle'
  
  // Prevent locking when tool is active
  static override isLockable = false
  
  // Use coalesced events for better performance
  static override useCoalescedEvents = true
}

Dynamic tool behavior

class Pointing extends StateNode {
  override onPointerMove(info: TLPointerEventInfo) {
    // Check modifier keys
    if (info.shiftKey) {
      // Constrain to axis
      this.constrainToAxis()
    }
    
    if (info.altKey) {
      // Draw from center
      this.drawFromCenter()
    }
    
    if (info.ctrlKey) {
      // Snap to grid
      this.snapToGrid()
    }
  }
}

Testing tools

import { TestEditor } from '@tldraw/editor'
import { MyTool } from './MyTool'

describe('MyTool', () => {
  let editor: TestEditor
  
  beforeEach(() => {
    editor = new TestEditor()
    editor.registerTool(MyTool)
  })
  
  it('creates shape on click', () => {
    editor.setCurrentTool('my-tool')
    editor.pointerDown(100, 100)
    editor.pointerUp(100, 100)
    
    expect(editor.getCurrentPageShapes()).toHaveLength(1)
  })
  
  it('cancels on escape', () => {
    editor.setCurrentTool('my-tool')
    editor.pointerDown(100, 100)
    editor.keyDown('Escape')
    
    expect(editor.getCurrentToolId()).toBe('select')
  })
})

Best practices

Keep states focused: Each state should have a single, clear responsibility. Use multiple states for complex interactions.
Handle cancellation: Always implement onCancel() and onInterrupt() to clean up when the tool is interrupted.
Use transactions: Wrap shape operations in editor.run() to create proper undo/redo entries.
Cursor feedback: Update the cursor in onEnter() to give users visual feedback about the active tool.
  • Shapes - Creating shapes that tools interact with
  • Editor - Using the Editor API in tools
  • StateNode API - Complete StateNode reference

Build docs developers (and LLMs) love