apps/docs/content/sdk-features/snapping.mdx
When you move or resize shapes, tldraw snaps them to key geometry on nearby shapes.
There are two snap systems. Bounds snapping aligns edges, centers, and corners during movement and resizing, and also keeps gaps between shapes consistent. Handle snapping connects handles to outlines and key points, like arrow tips to shape edges.
Snap lines appear when shapes come within the snap threshold.
Snapping is off by default. Users hold Ctrl (Cmd on Mac) while translating, resizing, or dragging a handle to snap. Turning on snap mode inverts this: snapping is always on and holding Ctrl disables it. Snap mode is a user preference, toggled from the Preferences submenu in the main menu:
editor.user.updateUserPreferences({ isSnapMode: !editor.user.getIsSnapMode() })
Grid snapping is a separate system, controlled by the isGridMode flag on TLInstance.
The SnapManager coordinates all snapping behavior. Access it at editor.snaps:
// The two snap systems
editor.snaps.shapeBounds // BoundsSnaps - edge, center, and gap alignment
editor.snaps.handles // HandleSnaps - precise point connections
// Shared utilities
editor.snaps.getSnapThreshold() // Distance threshold (options.snapThreshold / zoom)
editor.snaps.getSnappableShapes() // Which shapes can be snapped to
editor.snaps.getIndicators() // Current snap indicators
editor.snaps.setIndicators(indicators) // Update visual snap lines
editor.snaps.clearIndicators() // Remove all snap indicators
The snap threshold is editor.options.snapThreshold screen pixels (default 8), scaled by the current zoom level. At 100% zoom, shapes snap when within 8 pixels. At 200% zoom, the threshold becomes 4 canvas units (still 8 screen pixels).
SnapManager#getSnappableShapes determines which shapes can be snapped to. Starting from the selection's common ancestor, it walks down the shape tree and skips selected shapes (you don't snap to what you're dragging), shapes outside the viewport, and shapes whose util's ShapeUtil#canSnap returns false. Frames are included as snap targets. For groups, it recurses into children and snaps to them, but not to the group itself.
To keep other shapes from snapping to your shape at all, override canSnap():
class MyShapeUtil extends ShapeUtil<MyShape> {
override canSnap() {
return false
}
}
The method returns a computed set that updates reactively as shapes move, selection changes, or the viewport pans.
Bounds snapping aligns bounding box edges and centers. When you move or resize shapes, the BoundsSnaps system compares snap points on the selection against snap points on nearby shapes.
Each shape defines snap points through ShapeUtil#getBoundsSnapGeometry. By default, shapes snap to their bounding box corners and center. Override this to provide custom snap points:
class MyShapeUtil extends ShapeUtil<MyShape> {
getBoundsSnapGeometry(shape: MyShape): BoundsSnapGeometry {
return {
points: [
{ x: 0, y: 0 },
{ x: shape.props.w, y: 0 },
{ x: shape.props.w / 2, y: shape.props.h / 2 },
],
}
}
}
Return { points: [] } to drop point snapping for a shape while keeping it as a gap snapping target. To opt out of all snapping, use canSnap() instead.
When moving shapes, BoundsSnaps#snapTranslateShapes finds the nearest snap alignment in each axis:
const snapData = editor.snaps.shapeBounds.snapTranslateShapes({
lockedAxis: null, // or 'x' | 'y' to constrain to one axis
initialSelectionPageBounds: selectionBounds,
initialSelectionSnapPoints: selectionSnapPoints,
dragDelta: delta,
})
// Apply the nudge to achieve snapping
const snappedDelta = Vec.Add(delta, snapData.nudge)
The returned nudge vector indicates how much to adjust the drag delta to achieve alignment. When multiple shapes align at the same distance, the system displays all of them.
When resizing, BoundsSnaps#snapResizeShapes snaps the corners and edges being moved. Which snap points are used depends on the resize handle:
const snapData = editor.snaps.shapeBounds.snapResizeShapes({
initialSelectionPageBounds: selectionBounds,
dragDelta: delta,
handle: 'bottom_right',
isAspectRatioLocked: false,
isResizingFromCenter: false,
})
Gap snapping is part of bounds snapping and keeps spacing between shapes consistent. It detects gaps between adjacent snappable shapes and snaps in two ways.
Gap center snapping centers the selection within a gap larger than itself, with equal spacing on both sides. Gap duplication snapping repeats an existing gap on the opposite side of a shape: if two shapes have a 100px gap between them, dragging a third shape snaps to create another 100px gap. When several gaps have matching lengths, the indicators show all of them together.
Gaps are calculated separately for horizontal and vertical directions. A gap exists when two shapes don't overlap in one axis but have overlapping ranges in the perpendicular axis.
Handle snapping connects handles to other shapes. When dragging a handle (like an arrow endpoint), the HandleSnaps system snaps to nearby geometry. See Handles for how to define handles.
Shapes define what handles can snap to through ShapeUtil#getHandleSnapGeometry. The method returns an object with these properties:
| Property | Description |
|---|---|
outline | A Geometry2d describing the shape's outline. Defaults to the shape's geometry. Set to null to disable outline snapping. |
points | Key points on the shape to snap to. These have higher priority than outlines. |
getSelfSnapOutline() | Returns a stable outline for snapping to the shape's own geometry. |
getSelfSnapPoints() | Returns stable points for self-snapping. |
class MyShapeUtil extends ShapeUtil<MyShape> {
getHandleSnapGeometry(shape: MyShape): HandleSnapGeometry {
return {
outline: this.getGeometry(shape),
points: [
{ x: 0, y: 0 },
{ x: shape.props.w, y: shape.props.h },
],
}
}
}
By default, handles cannot snap to their own shape. Moving the handle would change the snap target and create a feedback loop. The getSelfSnapOutline() and getSelfSnapPoints() methods enable opt-in self-snapping when the snap geometry remains stable regardless of handle position.
Handles support two snap types controlled by the snapType property on TLHandle.
Point snapping (snapType: 'point') snaps to the single nearest location. The system checks snap points first, then falls back to the nearest point on any outline.
Align snapping (snapType: 'align') aligns the handle with nearby snap points on the x and y axes independently, with a snap line in each direction.
The older
canSnapproperty on handles is deprecated. UsesnapType: 'point'orsnapType: 'align'instead. If both are set,canSnapwins and the handle uses point snapping.
Tools call HandleSnaps#snapHandle to snap a handle position:
// Get the handle from the shape (TLHandle type)
const handle = editor.getShapeHandles(shape)?.find((h) => h.id === handleId)
if (handle) {
const snapData = editor.snaps.handles.snapHandle({
currentShapeId: shape.id,
handle, // TLHandle with x, y, snapType, etc.
})
if (snapData) {
// Apply nudge to achieve snapping
const snappedPosition = Vec.Add(handle, snapData.nudge)
}
}
The method returns null if no snap is found within the threshold, or a SnapData object with the nudge vector to achieve snapping. Snap indicators are automatically set on the manager for visual feedback.
Snap indicators provide visual feedback when snapping occurs. The SnapManager holds the current SnapIndicator list, which the UI renders as SVG overlays. Read it with getIndicators().
There are two kinds. Points indicators (type: 'points') display as lines connecting aligned points; when several snap points align on the same axis, they appear as one continuous line. Gaps indicators (type: 'gaps') display spacing between shapes with measurement lines at each gap, and show every matching gap when several have equal size.
The manager drops redundant gap indicators: if every gap in one indicator already appears in a larger indicator for the same direction, only the larger one is kept.
Indicators are cleared automatically when dragging stops or when you call clearIndicators().
For working examples of custom snapping, see:
snapReferenceHandleId to control which handle Shift-angle snapping measures from.