apps/docs/content/docs/handles.mdx
In tldraw, handles are interactive control points on shapes that let users manipulate shapes. Arrows have handles at their endpoints, lines have handles at each vertex, and notes have clone handles for quick duplication.
Handles appear when a shape is selected. Each handle has a position, type, and optional snapping behavior. You define handles by implementing getHandles on your ShapeUtil:
import { ShapeUtil, TLHandle, ZERO_INDEX_KEY } from 'tldraw'
class MyShapeUtil extends ShapeUtil<MyShape> {
// ...
override getHandles(shape: MyShape): TLHandle[] {
return [
{
id: 'point',
type: 'vertex',
index: ZERO_INDEX_KEY,
x: shape.props.pointX,
y: shape.props.pointY,
},
]
}
}
Handle coordinates are in the shape's local coordinate system, where (0, 0) is the shape's top-left corner.
There are four handle types:
| Type | Description |
|---|---|
vertex | A primary control point that defines part of the shape's geometry |
virtual | A secondary handle that isn't a vertex, like the arrow's midpoint bend handle |
create | A handle for adding new geometry, like inserting a point into a line segment |
clone | A handle for duplicating the shape, used by notes for quick adjacent copies |
Most custom shapes use vertex handles. The arrow shape uses a virtual handle for its midpoint, and the line shape uses create handles to let users add points between vertices.
When a user drags a handle, tldraw calls onHandleDrag with the updated handle position. Return the updated shape:
import { ShapeUtil, TLHandleDragInfo } from 'tldraw'
class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
// ...
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo<SpeechBubbleShape>) {
return {
...shape,
props: {
...shape.props,
tailX: handle.x,
tailY: handle.y,
},
}
}
}
The handle object in TLHandleDragInfo contains the updated x and y coordinates. Use these to update your shape's props.
For more control over handle interactions, implement these additional methods:
| Method | When it's called |
|---|---|
onHandleDragStart | When the user starts dragging |
onHandleDragEnd | When the user releases the handle |
onHandleDragCancel | When the drag is cancelled (escape) |
Handles can snap to other shapes' geometry. Set snapType on the handle:
{
id: 'end',
type: 'vertex',
index: ZERO_INDEX_KEY,
x: shape.props.endX,
y: shape.props.endY,
snapType: 'point', // Snap to points on other shapes
}
The snapType options are:
| Value | Behavior |
|---|---|
'point' | Snaps to key points on other shapes (corners, centers) |
'align' | Snaps to alignment guides from other shapes |
When the user holds Shift while dragging, handles snap to 15-degree angles. By default, the angle is measured relative to an adjacent vertex handle on the shape. You can use a different reference handle by setting snapReferenceHandleId:
{
id: 'controlPoint',
type: 'vertex',
index: indices[1],
x: shape.props.cpX,
y: shape.props.cpY,
snapType: 'align',
snapReferenceHandleId: 'start', // Angle snaps relative to 'start' handle
}
This is useful for bezier curves where control points should snap to angles relative to their associated endpoint.
By default, handles snap to a shape's outline and key points. Override getHandleSnapGeometry to customize what handles snap to:
import { HandleSnapGeometry, ShapeUtil } from 'tldraw'
class BezierCurveUtil extends ShapeUtil<BezierCurveShape> {
// ...
override getHandleSnapGeometry(shape: BezierCurveShape): HandleSnapGeometry {
return {
// Points other shapes' handles can snap to
points: [shape.props.start, shape.props.end],
// Points this shape's own handles can snap to (for self-snapping)
getSelfSnapPoints: (handle) => {
if (handle.id === 'controlPoint') {
return [shape.props.start, shape.props.end]
}
return []
},
}
}
}
The HandleSnapGeometry object has these properties:
| Property | Description |
|---|---|
outline | Custom outline geometry for snapping (default: shape geometry) |
points | Key points to snap to (corners, centers, etc.) |
getSelfSnapOutline | Returns outline for self-snapping given a handle |
getSelfSnapPoints | Returns points for self-snapping given a handle |
Here's a speech bubble shape with a draggable tail handle:
import {
Polygon2d,
ShapeUtil,
TLHandle,
TLHandleDragInfo,
TLShape,
Vec,
ZERO_INDEX_KEY,
} from 'tldraw'
const SPEECH_BUBBLE_TYPE = 'speech-bubble'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[SPEECH_BUBBLE_TYPE]: { w: number; h: number; tailX: number; tailY: number }
}
}
type SpeechBubbleShape = TLShape<typeof SPEECH_BUBBLE_TYPE>
class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
static override type = SPEECH_BUBBLE_TYPE
getDefaultProps(): SpeechBubbleShape['props'] {
return { w: 200, h: 100, tailX: 100, tailY: 150 }
}
getGeometry(shape: SpeechBubbleShape) {
const { w, h, tailX, tailY } = shape.props
return new Polygon2d({
points: [
new Vec(0, 0),
new Vec(w, 0),
new Vec(w, h),
new Vec(w * 0.7, h),
new Vec(tailX, tailY),
new Vec(w * 0.3, h),
new Vec(0, h),
],
isFilled: true,
})
}
override getHandles(shape: SpeechBubbleShape): TLHandle[] {
return [
{
id: 'tail',
type: 'vertex',
label: 'Move tail',
index: ZERO_INDEX_KEY,
x: shape.props.tailX,
y: shape.props.tailY,
},
]
}
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo<SpeechBubbleShape>) {
return {
...shape,
props: {
...shape.props,
tailX: handle.x,
tailY: handle.y,
},
}
}
component(shape: SpeechBubbleShape) {
const geometry = this.getGeometry(shape)
return (
<svg className="tl-svg-container">
<path d={geometry.getSvgPathData()} fill="white" stroke="black" />
</svg>
)
}
getIndicatorPath(shape: SpeechBubbleShape) {
const geometry = this.getGeometry(shape)
return new Path2D(geometry.getSvgPathData())
}
}
Use Editor#getShapeHandles to get the handles for any shape:
const handles = editor.getShapeHandles(shape)
if (handles) {
for (const handle of handles) {
console.log(handle.id, handle.x, handle.y)
}
}
Returns undefined if the shape doesn't have handles.