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.

The ShapeUtil class is the foundation of tldraw’s shape system. Every shape type has a corresponding ShapeUtil that defines how it behaves, renders, and responds to user interactions.

Class definition

export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
  constructor(public editor: Editor) {}
  
  // Static properties
  static type: string
  static props?: RecordProps<TLUnknownShape>
  static migrations?: MigrationSequence
  
  // Required abstract methods
  abstract getDefaultProps(): Shape['props']
  abstract getGeometry(shape: Shape): Geometry2d
  abstract component(shape: Shape): any
  abstract indicator(shape: Shape): any
}

Required methods

Every ShapeUtil must implement these four methods:

getDefaultProps

Returns the default properties when creating a new shape.
returns
Shape['props']
required
Default property values for the shape
getDefaultProps(): TLGeoShape['props'] {
  return {
    w: 100,
    h: 100,
    geo: 'rectangle',
    dash: 'draw',
    growY: 0,
    url: '',
    scale: 1,
    color: 'black',
    labelColor: 'black',
    fill: 'none',
    size: 'm',
    font: 'draw',
    align: 'middle',
    verticalAlign: 'middle',
    richText: toRichText(''),
  }
}

getGeometry

Defines the shape’s geometric representation for hit testing, bounds calculation, and snapping.
shape
Shape
required
The shape to calculate geometry for
opts
TLGeometryOpts
Additional options, including context information
returns
Geometry2d
required
A Geometry2d primitive (Rectangle2d, Polygon2d, etc.)
getGeometry(shape: TLGeoShape) {
  const { props } = shape
  const path = getGeoShapePath(shape)
  const pathGeometry = path.toGeometry()
  
  const labelBounds = getLabelBounds(
    props.w / props.scale,
    (props.h + props.growY) / props.scale,
    labelSize,
    props.size,
    props.align,
    props.verticalAlign,
    props.scale
  )
  
  return new Group2d({
    children: [
      pathGeometry,
      new Rectangle2d({
        ...labelBounds,
        isFilled: true,
        isLabel: true,
      }),
    ],
  })
}
The geometry is cached and only recalculated when the shape changes. This is critical for performance.

component

Returns a React component to render the shape’s visual appearance.
shape
Shape
required
The shape to render
returns
React.ReactElement
required
React component representing the shape
component(shape: TLGeoShape) {
  const { id, type, props } = shape
  const theme = useDefaultColorTheme()
  
  return (
    <>
      <SVGContainer>
        <GeoShapeBody shape={shape} shouldScale={true} />
      </SVGContainer>
      <HTMLContainer>
        <RichTextLabel
          shapeId={id}
          font={props.font}
          fontSize={LABEL_FONT_SIZES[props.size] * props.scale}
          richText={props.richText}
          // ... other props
        />
      </HTMLContainer>
    </>
  )
}
Use SVGContainer for SVG content and HTMLContainer for HTML content. Both are positioned and transformed automatically.

indicator

Returns an SVG element shown when the shape is selected or hovered.
shape
Shape
required
The shape to render an indicator for
returns
React.ReactElement
required
SVG element (usually a path or rect)
indicator(shape: TLGeoShape) {
  const { size, dash, scale } = shape.props
  const strokeWidth = STROKE_SIZES[size]
  const path = getGeoShapePath(shape)
  
  return path.toSvg({
    style: dash === 'draw' ? 'draw' : 'solid',
    strokeWidth: 1,
    randomSeed: shape.id,
    roundness: strokeWidth * 2 * scale,
  })
}

Canvas-based indicators

For better performance, implement canvas-based indicators:
override useLegacyIndicator() {
  return false  // Use canvas rendering instead of SVG
}

override getIndicatorPath(shape: MyShape): Path2D | undefined {
  const bounds = this.editor.getShapeGeometry(shape).bounds
  const path = new Path2D()
  path.rect(0, 0, bounds.width, bounds.height)
  return path
}
For complex indicators with clipping:
override getIndicatorPath(shape: TLArrowShape): TLIndicatorPath {
  const bodyPath = new Path2D(/* arrow body */)
  const clipPath = new Path2D()
  
  // Create clip region
  clipPath.rect(bounds.x, bounds.y, bounds.width, bounds.height)
  clipPath.roundRect(labelBounds.x, labelBounds.y, labelBounds.w, labelBounds.h, 3.5)
  
  return {
    path: bodyPath,
    clipPath: clipPath,
    additionalPaths: [arrowheadPath],
  }
}

Lifecycle hooks

Creation and updates

onBeforeCreate(next: Shape): Shape | void {
  // Validate or modify shape before creation
  if (isEmptyRichText(next.props.richText)) {
    return { ...next, props: { ...next.props, growY: 0 } }
  }
  
  const labelHeight = getUnscaledLabelSize(this.editor, next).h
  const growY = Math.max(0, labelHeight - next.props.h)
  
  return { ...next, props: { ...next.props, growY } }
}

Resize events

onResize(shape: Shape, info: TLResizeInfo<Shape>) {
  const { scaleX, scaleY, newPoint } = info
  
  return {
    x: newPoint.x,
    y: newPoint.y,
    props: {
      w: shape.props.w * scaleX,
      h: shape.props.h * scaleY,
    },
  }
}

Translation events

onTranslateStart(shape: Shape): TLShapePartial<Shape> | void {
  // Called when dragging begins
}

onTranslate(initial: Shape, current: Shape): TLShapePartial<Shape> | void {
  // Called during dragging
}

onTranslateEnd(initial: Shape, current: Shape): TLShapePartial<Shape> | void {
  // Called when dragging ends
}

Rotation events

onRotateStart(shape: Shape): TLShapePartial<Shape> | void
onRotate(initial: Shape, current: Shape): TLShapePartial<Shape> | void
onRotateEnd(initial: Shape, current: Shape): TLShapePartial<Shape> | void

Handle events

For shapes with custom handles (like arrows):
getHandles(shape: TLArrowShape): TLHandle[] {
  const info = getArrowInfo(this.editor, shape)
  
  return [
    {
      id: 'start',
      type: 'vertex',
      index: 'a1' as IndexKey,
      x: info.start.handle.x,
      y: info.start.handle.y,
    },
    {
      id: 'end',
      type: 'vertex',
      index: 'a3' as IndexKey,
      x: info.end.handle.x,
      y: info.end.handle.y,
    },
  ]
}

onHandleDrag(shape: Shape, info: TLHandleDragInfo<Shape>) {
  const { handle, isPrecise } = info
  
  return {
    props: {
      [handle.id]: { x: handle.x, y: handle.y },
    },
  }
}

Interaction events

onClick(shape: Shape): TLShapePartial<Shape> | void
onDoubleClick(shape: Shape): TLShapePartial<Shape> | void
onDoubleClickEdge(shape: Shape): TLShapePartial<Shape> | void
onDoubleClickHandle(shape: Shape, handle: TLHandle): TLShapePartial<Shape> | void

Edit mode

canEdit(shape: Shape): boolean {
  return true  // Shape supports double-click to edit
}

onEditStart(shape: Shape): void {
  // Called when editing begins
}

onEditEnd(shape: Shape): void {
  // Called when editing ends
  const text = this.getText(shape)
  if (text.length === 0) {
    this.editor.deleteShapes([shape.id])
  }
}

Capability methods

Control what operations are allowed on a shape:
canResize(shape: Shape): boolean {
  return true
}

hideResizeHandles(shape: Shape): boolean {
  return false  // Show resize handles
}

isAspectRatioLocked(shape: Shape): boolean {
  return false  // Allow non-uniform scaling
}

SVG export

toSvg(shape: Shape, ctx: SvgExportContext): ReactElement | null {
  ctx.addExportDef(getFillDefForExport(shape.props.fill))
  const theme = getDefaultColorTheme(ctx)
  
  return (
    <g>
      <GeoShapeBody shape={shape} forceSolid={false} />
      <RichTextSVG
        fontSize={LABEL_FONT_SIZES[shape.props.size]}
        font={shape.props.font}
        richText={shape.props.richText}
        labelColor={getColorValue(theme, shape.props.labelColor, 'solid')}
        bounds={bounds}
      />
    </g>
  )
}

getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] {
  return [getFillDefForCanvas()]
}

Snapping geometry

getBoundsSnapGeometry(shape: Shape): BoundsSnapGeometry {
  // Define snap points for translate/resize
  return {
    points: this.getGeometry(shape).vertices,
  }
}

getHandleSnapGeometry(shape: TLGeoShape): HandleSnapGeometry {
  const geometry = this.getGeometry(shape)
  const outline = geometry.children[0]
  
  switch (shape.props.geo) {
    case 'rectangle':
    case 'triangle':
      return {
        outline: outline,
        points: [...outline.vertices, geometry.bounds.center],
      }
    case 'ellipse':
    case 'cloud':
      return {
        outline: outline,
        points: [geometry.bounds.center],
      }
  }
}

Text methods

getText(shape: Shape): string | undefined {
  return renderPlaintextFromRichText(this.editor, shape.props.richText)
}

getFontFaces(shape: Shape): TLFontFace[] {
  if (isEmptyRichText(shape.props.richText)) {
    return EMPTY_ARRAY
  }
  
  return getFontsFromRichText(this.editor, shape.props.richText, {
    family: `tldraw_${shape.props.font}`,
    weight: 'normal',
    style: 'normal',
  })
}

Configuration

Customize ShapeUtil behavior with the options property:
class MyShapeUtil extends ShapeUtil<MyShape> {
  override options = {
    showTextOutline: true,
    minElbowLegLength: { s: 12, m: 18, l: 24, xl: 36 },
  }
}

// Override options when registering
const CustomShapeUtil = MyShapeUtil.configure({
  showTextOutline: false,
})
Use ShapeUtil.configure() to create a customized version without subclassing.

Next steps

Geometry system

Learn about Geometry2d primitives

Shape interactions

Handle drag, drop, and other events