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.

Custom shapes let you extend tldraw’s functionality by defining your own shape types with custom rendering, geometry, and behavior.

Overview

Creating a custom shape involves:
  1. Defining the shape’s type and props
  2. Creating a ShapeUtil class
  3. Registering the shape with the editor

Basic custom shape

1

Extend the type system

First, declare your shape’s props in the global type map:
const MY_CUSTOM_SHAPE_TYPE = 'my-custom-shape'

declare module 'tldraw' {
  export interface TLGlobalShapePropsMap {
    [MY_CUSTOM_SHAPE_TYPE]: { w: number; h: number; text: string }
  }
}
This tells TypeScript about your shape’s properties.
2

Define the shape type

Create a type for your shape:
type ICustomShape = TLShape<typeof MY_CUSTOM_SHAPE_TYPE>
3

Create the ShapeUtil

Extend ShapeUtil or BaseBoxShapeUtil to define your shape’s behavior:
import { ShapeUtil, RecordProps, T, Geometry2d, Rectangle2d, HTMLContainer } from 'tldraw'

export class MyShapeUtil extends ShapeUtil<ICustomShape> {
  static override type = MY_CUSTOM_SHAPE_TYPE
  
  static override props: RecordProps<ICustomShape> = {
    w: T.number,
    h: T.number,
    text: T.string,
  }

  getDefaultProps(): ICustomShape['props'] {
    return {
      w: 200,
      h: 200,
      text: "I'm a shape!",
    }
  }

  getGeometry(shape: ICustomShape): Geometry2d {
    return new Rectangle2d({
      width: shape.props.w,
      height: shape.props.h,
      isFilled: true,
    })
  }

  component(shape: ICustomShape) {
    return <HTMLContainer style={{ backgroundColor: '#efefef' }}>
      {shape.props.text}
    </HTMLContainer>
  }

  indicator(shape: ICustomShape) {
    return <rect width={shape.props.w} height={shape.props.h} />
  }
}
4

Register with the editor

Pass your shape util to the Tldraw component:
const customShapeUtils = [MyShapeUtil]

export default function MyApp() {
  return (
    <Tldraw
      shapeUtils={customShapeUtils}
      onMount={(editor) => {
        editor.createShape({ type: MY_CUSTOM_SHAPE_TYPE, x: 100, y: 100 })
      }}
    />
  )
}

ShapeUtil vs BaseBoxShapeUtil

Use ShapeUtil when you need full control over your shape’s behavior:
export class MyShapeUtil extends ShapeUtil<ICustomShape> {
  // You must implement:
  getGeometry(shape: ICustomShape): Geometry2d { ... }
  onResize(shape: any, info: TLResizeInfo<any>) { ... }
}
You’ll need to define geometry and resize behavior manually.

Advanced features

Making shapes editable

Shapes can enter an “editing” state when double-clicked:
class EditableShapeUtil extends BaseBoxShapeUtil<IMyEditableShape> {
  override canEdit() {
    return true
  }

  override canEditWhileLocked() {
    return true // Allow editing even when locked
  }

  component(shape: IMyEditableShape) {
    const isEditing = this.editor.getEditingShapeId() === shape.id

    return (
      <HTMLContainer
        onPointerDown={isEditing ? this.editor.markEventAsHandled : undefined}
        style={{ pointerEvents: isEditing ? 'all' : 'none' }}
      >
        {isEditing ? (
          <input value={shape.props.text} onChange={...} />
        ) : (
          <p>{shape.props.text}</p>
        )}
      </HTMLContainer>
    )
  }

  override onEditEnd(shape: IMyEditableShape) {
    // Called when editing ends
  }
}

Interactive shapes

For shapes with clickable elements:
class CounterShapeUtil extends BaseBoxShapeUtil<CounterShape> {
  override component(shape: CounterShape) {
    const onClick = (event: MouseEvent, change: number) => {
      event.stopPropagation()
      this.editor.updateShape({
        id: shape.id,
        type: 'counter',
        props: { count: shape.props.count + change },
      })
    }

    return (
      <HTMLContainer style={{ pointerEvents: 'all' }}>
        <button onClick={(e) => onClick(e, -1)} onPointerDown={this.editor.markEventAsHandled}>-</button>
        <span>{shape.props.count}</span>
        <button onClick={(e) => onClick(e, 1)} onPointerDown={this.editor.markEventAsHandled}>+</button>
      </HTMLContainer>
    )
  }
}
When using interactive elements, always call this.editor.markEventAsHandled on onPointerDown to prevent the editor from treating clicks as shape selection.

Custom resize behavior

Control how shapes resize:
import { resizeBox, TLResizeInfo } from 'tldraw'

class MyShapeUtil extends ShapeUtil<ICustomShape> {
  override canResize() {
    return true
  }

  override isAspectRatioLocked() {
    return false // Allow independent width/height changes
  }

  override onResize(shape: any, info: TLResizeInfo<any>) {
    return resizeBox(shape, info)
  }
}

Shape props validation

tldraw uses validators to ensure shape data integrity:
import { T } from 'tldraw'

static override props: RecordProps<ICustomShape> = {
  w: T.positiveNumber,           // Must be positive
  h: T.positiveNumber,
  color: T.string,
  isActive: T.boolean,
  items: T.arrayOf(T.string),
  count: T.number.default(0),    // With default value
}
Common validators:
  • T.number / T.positiveNumber
  • T.string
  • T.boolean
  • T.arrayOf(validator)
  • T.object(shape)

Geometry

The getGeometry method defines hit-testing and bounds:
import { Rectangle2d } from 'tldraw'

getGeometry(shape: ICustomShape): Geometry2d {
  return new Rectangle2d({
    width: shape.props.w,
    height: shape.props.h,
    isFilled: true,
  })
}

Rendering

Component method

The component method renders your shape:
component(shape: ICustomShape) {
  return (
    <HTMLContainer id={shape.id} style={{ backgroundColor: '#efefef' }}>
      <div>{shape.props.text}</div>
    </HTMLContainer>
  )
}

Indicator method

The indicator shows the selection outline:
indicator(shape: ICustomShape) {
  return <rect width={shape.props.w} height={shape.props.h} />
}

Shape with a tool

Create a tool for easy shape creation:
import { BaseBoxShapeTool } from 'tldraw'

export class CounterShapeTool extends BaseBoxShapeTool {
  static override id = 'counter'
  override shapeType = 'counter' as const
}

// Register both:
const customShapeUtils = [CounterShapeUtil]
const customTools = [CounterShapeTool]

<Tldraw shapeUtils={customShapeUtils} tools={customTools} />
Use BaseBoxShapeTool for box-shaped tools. It handles the drag-to-create interaction automatically.

Best practices

Prefix shape types with your app name to avoid collisions:
const MY_SHAPE_TYPE = 'myapp:custom-shape'
Shape props must be JSON-serializable. Avoid functions, class instances, or circular references.
The component method can re-render frequently. Keep it lightweight and use React hooks appropriately.
Always validate props and handle cases like zero width/height gracefully.

Next steps

Custom tools

Learn to create tools for your custom shapes

Events and side effects

React to shape changes with side effects

Multiplayer

Sync custom shapes across users