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.

Bindings represent relationships between shapes on the canvas. They enable features like arrows that stay connected to shapes when moved, stickers attached to frames, and other dynamic shape interactions.

Overview

The binding system consists of:
  • Binding records: Data stored in the store linking two shapes
  • BindingUtil classes: Define behavior for each binding type
  • Lifecycle hooks: Respond to creation, changes, and deletion
  • Automatic updates: Bindings update automatically when connected shapes change

Anatomy of a binding

Every binding has two parts:

1. Binding record (data)

Stored in the Editor’s store:
interface TLArrowBinding extends TLBinding {
  type: 'arrow'
  // Shape the arrow originates from
  fromId: TLShapeId
  // Shape the arrow points to
  toId: TLShapeId
  props: {
    // Connection point on the target shape (0-1 normalized)
    terminal: 'start' | 'end'
    normalizedAnchor: { x: number; y: number }
    isExact: boolean
    isPrecise: boolean
  }
}

2. BindingUtil class (behavior)

Defines how the binding works:
import { BindingUtil, TLArrowBinding } from '@tldraw/editor'

export class ArrowBindingUtil extends BindingUtil<TLArrowBinding> {
  static override type = 'arrow' as const
  
  getDefaultProps(): Partial<TLArrowBinding['props']> {
    return {
      terminal: 'start',
      normalizedAnchor: { x: 0.5, y: 0.5 },
      isExact: false,
      isPrecise: false
    }
  }
  
  // Called when bound shape moves/changes
  onAfterChangeToShape({ binding, shapeAfter }: BindingOnShapeChangeOptions) {
    // Update arrow position to follow the shape
    updateArrowTerminal(this.editor, binding, shapeAfter)
  }
  
  // Called when bound shape is deleted
  onBeforeDeleteToShape({ binding, shape }: BindingOnShapeDeleteOptions) {
    // Remove the binding
    this.editor.deleteBinding(binding.id)
    
    // Update arrow to no longer point to deleted shape
    const arrow = this.editor.getShape(binding.fromId)
    if (arrow) {
      // Convert binding to fixed point
      updateArrowToFreePoint(this.editor, arrow, binding)
    }
  }
}

Creating a custom binding

Here’s a complete example of a sticker binding that attaches stickers to frames:
1
Define the binding type
2
import { TLBinding, TLBaseBinding, RecordProps, T } from '@tldraw/editor'

type StickerBinding = TLBaseBinding<
  'sticker',
  {
    // Position relative to frame (0-1)
    relativePosition: { x: number; y: number }
    // Whether sticker moves with frame
    locked: boolean
  }
>

const stickerBindingProps: RecordProps<StickerBinding> = {
  relativePosition: T.object({
    x: T.number,
    y: T.number
  }),
  locked: T.boolean
}
3
Create the BindingUtil class
4
import { 
  BindingUtil,
  BindingOnShapeChangeOptions,
  BindingOnShapeDeleteOptions 
} from '@tldraw/editor'

export class StickerBindingUtil extends BindingUtil<StickerBinding> {
  static override type = 'sticker' as const
  static override props = stickerBindingProps
  
  getDefaultProps(): Partial<StickerBinding['props']> {
    return {
      relativePosition: { x: 0.5, y: 0.5 },
      locked: false
    }
  }
  
  // Called when the frame (toShape) moves
  onAfterChangeToShape({ 
    binding, 
    shapeAfter 
  }: BindingOnShapeChangeOptions<StickerBinding>) {
    const sticker = this.editor.getShape(binding.fromId)
    const frame = shapeAfter
    
    if (!sticker || sticker.type !== 'sticker') return
    if (!frame || frame.type !== 'frame') return
    
    // Calculate sticker position based on frame
    const frameBounds = this.editor.getShapePageBounds(frame)
    if (!frameBounds) return
    
    const { relativePosition } = binding.props
    const newX = frameBounds.x + frameBounds.width * relativePosition.x
    const newY = frameBounds.y + frameBounds.height * relativePosition.y
    
    // Update sticker position
    this.editor.updateShape({
      id: sticker.id,
      type: 'sticker',
      x: newX - sticker.props.w / 2,
      y: newY - sticker.props.h / 2
    })
  }
  
  // Called when frame is deleted
  onBeforeDeleteToShape({ 
    binding 
  }: BindingOnShapeDeleteOptions<StickerBinding>) {
    // Delete the sticker when frame is deleted
    this.editor.deleteShapes([binding.fromId])
  }
  
  // Called when sticker is copied without frame
  onBeforeIsolateFromShape({ 
    binding, 
    removedShape 
  }: BindingOnShapeIsolateOptions<StickerBinding>) {
    // Convert to absolute positioning
    const sticker = this.editor.getShape(binding.fromId)
    if (sticker) {
      // Remove binding but keep sticker in place
      this.editor.deleteBinding(binding.id)
    }
  }
}
5
Register the binding
6
import { Tldraw } from 'tldraw'
import { StickerBindingUtil } from './StickerBindingUtil'

function App() {
  return (
    <Tldraw
      bindingUtils={[StickerBindingUtil]}
      onMount={(editor) => {
        // Create a frame and sticker
        const frameId = createShapeId()
        const stickerId = createShapeId()
        
        editor.createShapes([
          { id: frameId, type: 'frame', x: 0, y: 0, props: { w: 400, h: 300 } },
          { id: stickerId, type: 'sticker', x: 200, y: 150 }
        ])
        
        // Create binding
        editor.createBinding({
          type: 'sticker',
          fromId: stickerId,
          toId: frameId,
          props: {
            relativePosition: { x: 0.5, y: 0.5 },
            locked: false
          }
        })
      }}
    />
  )
}

BindingUtil lifecycle hooks

Creation hooks

class MyBindingUtil extends BindingUtil<MyBinding> {
  // Validate and modify binding before creation
  onBeforeCreate({ binding }: BindingOnCreateOptions<MyBinding>) {
    // Validate
    const fromShape = this.editor.getShape(binding.fromId)
    const toShape = this.editor.getShape(binding.toId)
    
    if (!fromShape || !toShape) return
    
    // Modify props if needed
    return {
      ...binding,
      props: {
        ...binding.props,
        computedValue: calculateValue(fromShape, toShape)
      }
    }
  }
  
  // Perform side effects after creation
  onAfterCreate({ binding }: BindingOnCreateOptions<MyBinding>) {
    // Update connected shapes
    const fromShape = this.editor.getShape(binding.fromId)
    if (fromShape) {
      this.editor.updateShape({
        id: fromShape.id,
        type: fromShape.type,
        meta: { ...fromShape.meta, hasBinding: true }
      })
    }
  }
}

Change hooks

class MyBindingUtil extends BindingUtil<MyBinding> {
  // Validate and modify changes
  onBeforeChange({ 
    bindingBefore, 
    bindingAfter 
  }: BindingOnChangeOptions<MyBinding>) {
    // Validate change
    if (bindingAfter.props.value < 0) {
      return bindingBefore // Reject change
    }
    
    return bindingAfter
  }
  
  // React to shape changes
  onAfterChangeFromShape({ 
    binding, 
    shapeBefore, 
    shapeAfter,
    reason 
  }: BindingOnShapeChangeOptions<MyBinding>) {
    // reason is 'self' or 'ancestry'
    if (reason === 'self') {
      // The shape itself changed
      this.updateBinding(binding, shapeAfter)
    }
  }
  
  onAfterChangeToShape({ 
    binding, 
    shapeBefore, 
    shapeAfter 
  }: BindingOnShapeChangeOptions<MyBinding>) {
    // Handle target shape changes
    this.updateSourceShape(binding, shapeAfter)
  }
}

Deletion hooks

class MyBindingUtil extends BindingUtil<MyBinding> {
  // Called when binding is being deleted
  onBeforeDelete({ binding }: BindingOnDeleteOptions<MyBinding>) {
    // Clean up related shapes
    const fromShape = this.editor.getShape(binding.fromId)
    if (fromShape) {
      this.editor.updateShape({
        id: fromShape.id,
        type: fromShape.type,
        meta: { ...fromShape.meta, hasBinding: false }
      })
    }
  }
  
  // Called when source shape is deleted
  onBeforeDeleteFromShape({ 
    binding, 
    shape 
  }: BindingOnShapeDeleteOptions<MyBinding>) {
    // Delete the binding
    this.editor.deleteBinding(binding.id)
  }
  
  // Called when target shape is deleted
  onBeforeDeleteToShape({ 
    binding, 
    shape 
  }: BindingOnShapeDeleteOptions<MyBinding>) {
    // Update source shape to no longer reference deleted shape
    this.editor.deleteBinding(binding.id)
  }
}

Isolation hooks

Called when shapes are separated (copy, duplicate, etc.):
class MyBindingUtil extends BindingUtil<MyBinding> {
  // Called when source shape is isolated
  onBeforeIsolateFromShape({ 
    binding, 
    removedShape 
  }: BindingOnShapeIsolateOptions<MyBinding>) {
    const fromShape = this.editor.getShape(binding.fromId)
    
    if (fromShape) {
      // Convert to standalone state
      this.editor.updateShape({
        id: fromShape.id,
        type: fromShape.type,
        props: {
          ...fromShape.props,
          standalone: true
        }
      })
    }
    
    // Delete the binding
    this.editor.deleteBinding(binding.id)
  }
  
  // Called when target shape is isolated
  onBeforeIsolateToShape({ 
    binding, 
    removedShape 
  }: BindingOnShapeIsolateOptions<MyBinding>) {
    // Similar handling for target shape
    this.editor.deleteBinding(binding.id)
  }
}

Working with bindings

Creating bindings

import { createBindingId } from '@tldraw/editor'

// Create a binding
const bindingId = createBindingId()

editor.createBinding({
  id: bindingId,
  type: 'arrow',
  fromId: arrowId,
  toId: targetShapeId,
  props: {
    terminal: 'end',
    normalizedAnchor: { x: 0.5, y: 0.5 },
    isExact: false,
    isPrecise: false
  }
})

Reading bindings

// Get binding by id
const binding = editor.getBinding(bindingId)

// Get all bindings from a shape
const bindingsFrom = editor.getBindingsFromShape(shapeId, 'arrow')

// Get all bindings to a shape
const bindingsTo = editor.getBindingsToShape(shapeId, 'arrow')

// Get all bindings involving a shape
const allBindings = editor.getBindingsInvolvingShape(shapeId)

Updating bindings

editor.updateBinding({
  id: bindingId,
  type: 'arrow',
  props: {
    normalizedAnchor: { x: 0.8, y: 0.2 }
  }
})

Deleting bindings

// Delete specific binding
editor.deleteBinding(bindingId)

// Delete all bindings involving a shape
const bindings = editor.getBindingsInvolvingShape(shapeId)
editor.deleteBindings(bindings.map(b => b.id))

ShapeUtil binding methods

Shapes can control whether they accept bindings:
export class MyShapeUtil extends ShapeUtil<MyShape> {
  // Control which bindings this shape accepts
  canBind({ 
    fromShape, 
    toShape, 
    bindingType 
  }: TLShapeUtilCanBindOpts<MyShape>) {
    // Only allow arrow bindings
    if (bindingType !== 'arrow') return false
    
    // Only allow as target (toShape), not source
    if ('id' in toShape && toShape.id === this.editor.getShape(toShape)?.id) {
      return true
    }
    
    return false
  }
  
  // Provide snap points for bindings
  getBindingSnapGeometry(shape: MyShape) {
    const bounds = this.editor.getShapeGeometry(shape).bounds
    
    return {
      // Snap points around the perimeter
      points: [
        { x: bounds.minX, y: bounds.minY },
        { x: bounds.maxX, y: bounds.minY },
        { x: bounds.maxX, y: bounds.maxY },
        { x: bounds.minX, y: bounds.maxY },
      ]
    }
  }
}

Real-world example: Arrow bindings

The built-in arrow binding demonstrates advanced features:
export class ArrowBindingUtil extends BindingUtil<TLArrowBinding> {
  static override type = 'arrow' as const
  static override props = arrowBindingProps
  
  getDefaultProps(): Partial<TLArrowBinding['props']> {
    return {
      terminal: 'start',
      normalizedAnchor: { x: 0.5, y: 0.5 },
      isExact: false,
      isPrecise: false
    }
  }
  
  onAfterChangeToShape({ 
    binding, 
    shapeAfter 
  }: BindingOnShapeChangeOptions<TLArrowBinding>) {
    const arrow = this.editor.getShape<TLArrowShape>(binding.fromId)
    if (!arrow) return
    
    // Calculate new arrow terminal position
    const targetBounds = this.editor.getShapePageBounds(shapeAfter)
    if (!targetBounds) return
    
    const { normalizedAnchor } = binding.props
    const terminal = binding.props.terminal
    
    // Get point on target shape
    const point = {
      x: targetBounds.x + targetBounds.width * normalizedAnchor.x,
      y: targetBounds.y + targetBounds.height * normalizedAnchor.y
    }
    
    // Update arrow endpoint
    this.editor.updateShape({
      id: arrow.id,
      type: 'arrow',
      props: {
        [terminal]: {
          x: point.x - arrow.x,
          y: point.y - arrow.y
        }
      }
    })
  }
  
  onBeforeDeleteToShape({ binding }: BindingOnShapeDeleteOptions) {
    // When target is deleted, remove binding
    this.editor.deleteBinding(binding.id)
    
    // Update arrow to use fixed endpoint
    const arrow = this.editor.getShape<TLArrowShape>(binding.fromId)
    if (arrow) {
      // Arrow keeps its current position but is no longer bound
      this.editor.updateShape({
        id: arrow.id,
        type: 'arrow',
        props: {
          // Arrow terminal stays at current position
        }
      })
    }
  }
  
  onBeforeIsolateFromShape({ binding }: BindingOnShapeIsolateOptions) {
    // When arrow is copied without target, convert to fixed endpoint
    this.editor.deleteBinding(binding.id)
  }
}

Best practices

Handle all lifecycle hooks: Implement all relevant hooks to ensure bindings behave correctly during copy, paste, delete, and other operations.
Validate in onBeforeCreate: Check that both shapes exist and are compatible before creating a binding.
Use isolation hooks: Implement isolation hooks to handle copying and duplicating shapes without breaking bindings.
Update in transactions: Wrap binding updates and related shape changes in editor.run() for proper undo/redo.
  • Shapes - Learn about shapes that bindings connect
  • Editor - Working with the Editor API
  • Bindings API - Complete bindings API reference