apps/docs/content/sdk-features/drag-and-drop.mdx
The drag and drop system lets shapes respond when other shapes are dragged over them. Frames use this to reparent shapes when you drag them inside. You can use the same callbacks in your ShapeUtil to build custom container shapes, slot-based layouts, or any shape that should react to shapes being dragged over it.
import { ShapeUtil, TLShape, TLDragShapesOutInfo } from 'tldraw'
class MyContainerShapeUtil extends ShapeUtil<MyContainerShape> {
static override type = 'my-container' as const
// Called when shapes are first dragged into this shape
override onDragShapesIn(shape: MyContainerShape, draggingShapes: TLShape[]) {
// Reparent the shapes to become children of this container
this.editor.reparentShapes(draggingShapes, shape.id)
}
// Called when shapes are dragged out of this shape
override onDragShapesOut(
shape: MyContainerShape,
draggingShapes: TLShape[],
info: TLDragShapesOutInfo
) {
// If not dragging into another shape, move back to the page
if (!info.nextDraggingOverShapeId) {
this.editor.reparentShapes(draggingShapes, this.editor.getCurrentPageId())
}
}
// ... other required methods
}
When a user drags shapes across the canvas, the editor tracks which shape (if any) is under the cursor. Your shape util can implement these callbacks to respond:
| Callback | When it fires |
|---|---|
onDragShapesIn | Shapes are first dragged over this shape |
onDragShapesOver | Shapes continue being dragged over this shape (on an interval, when cursor moves) |
onDragShapesOut | Shapes are dragged away from this shape |
onDropShapesOver | Shapes are dropped onto this shape |
The callbacks receive the target shape (the one being dragged over), an array of the shapes being dragged, and an info object with context about the drag operation.
Called once when shapes first enter this shape's bounds. Use it to reparent shapes into a container:
override onDragShapesIn(shape: MyContainerShape, draggingShapes: TLShape[]) {
// Only reparent if not already a child
const newShapes = draggingShapes.filter((s) => s.parentId !== shape.id)
if (newShapes.length > 0) {
this.editor.reparentShapes(newShapes, shape.id)
}
}
Called on an interval while shapes are being dragged over this shape, but only when the cursor moves. Use it for visual feedback or grid snapping:
override onDragShapesOver(shape: MyGridShape, draggingShapes: TLShape[]) {
// Snap shapes to grid cells while dragging
for (const dragging of draggingShapes) {
const snappedX = Math.round(dragging.x / CELL_SIZE) * CELL_SIZE
const snappedY = Math.round(dragging.y / CELL_SIZE) * CELL_SIZE
if (dragging.x !== snappedX || dragging.y !== snappedY) {
this.editor.updateShape({
id: dragging.id,
type: dragging.type,
x: snappedX,
y: snappedY,
})
}
}
}
Called when shapes are dragged away from this shape. Use it to reparent shapes back to the page:
override onDragShapesOut(
shape: MyContainerShape,
draggingShapes: TLShape[],
info: TLDragShapesOutInfo
) {
// Check if we're dragging into another container
if (info.nextDraggingOverShapeId) {
// Let the next container handle it
return
}
// Reparent back to the page
const children = draggingShapes.filter((s) => s.parentId === shape.id)
if (children.length > 0) {
this.editor.reparentShapes(children, this.editor.getCurrentPageId())
}
}
Called when shapes are dropped (mouse up) while over this shape. Use it for finalization logic:
override onDropShapesOver(shape: MySlotShape, draggingShapes: TLShape[]) {
// Lock shapes in place after dropping
for (const dragging of draggingShapes) {
this.editor.updateShape({
id: dragging.id,
type: dragging.type,
isLocked: true,
})
}
}
All drag callbacks receive an info object with context about the drag operation. The exact type varies by callback:
| Callback | Info type |
|---|---|
onDragShapesIn | TLDragShapesInInfo |
onDragShapesOver | TLDragShapesOverInfo |
onDragShapesOut | TLDragShapesOutInfo |
onDropShapesOver | TLDropShapesOverInfo |
All info types share these common properties:
| Property | Description |
|---|---|
initialDraggingOverShapeId | The shape that was under the cursor when drag started |
initialParentIds | Map of each shape's parent ID at drag start |
initialIndices | Map of each shape's z-index at drag start |
Some callbacks have additional properties:
| Property | Available in | Description |
|---|---|---|
prevDraggingOverShapeId | onDragShapesIn | The previous shape that was dragged over |
nextDraggingOverShapeId | onDragShapesOut | The next shape being dragged into |
The initialParentIds and initialIndices maps let you restore shapes to their original positions. Frames use this to preserve z-ordering when shapes are dragged back to their original parent.
The editor automatically determines which shape is being dragged over by checking which shape's geometry contains the cursor point. It tests shapes from front to back and returns the first hit.
Only shapes that implement drag callbacks are considered as drop targets. If your shape util doesn't override any drag callbacks, shapes will pass through it when being dragged.
Use the ShapeUtil#canReceiveNewChildrenOfType method to control which shape types your container accepts. The editor uses it to decide whether onDragShapesIn and onDropShapesOver fire for a dragged shape:
override canReceiveNewChildrenOfType(shape: MyContainerShape, type: TLShape['type']) {
// Only accept specific shape types
return type === 'my-item' || type === 'geo'
}
The default is false, so any shape that should accept dropped children must override this method. onDragShapesOver is not gated by this method, which lets you provide visual feedback even when the target won't accept the drop.
Use the ShapeUtil#canRemoveChildrenOfType method to control which child shape types can be dragged out of your container. The editor uses it to decide whether onDragShapesOut fires for a child shape, and to decide whether the editor should automatically reparent a child that has moved outside its parent's geometry:
override canRemoveChildrenOfType(shape: MyContainerShape, type: TLShape['type']) {
// Pin certain children in place; allow others to be removed
return type !== 'pinned-item'
}
The default is true, so children can be dragged out of any container unless this method is overridden.
Here's a complete example of a slot-based container that accepts dropped shapes:
import {
HTMLContainer,
Rectangle2d,
ShapeUtil,
TLBaseShape,
TLDragShapesOutInfo,
TLShape,
T,
} from 'tldraw'
type SlotContainerShape = TLBaseShape<'slot-container', { slots: number }>
class SlotContainerShapeUtil extends ShapeUtil<SlotContainerShape> {
static override type = 'slot-container' as const
static override props = { slots: T.number }
getDefaultProps() {
return { slots: 4 }
}
getGeometry(shape: SlotContainerShape) {
return new Rectangle2d({
width: shape.props.slots * 100,
height: 100,
isFilled: true,
})
}
override canReceiveNewChildrenOfType(_shape: SlotContainerShape, type: string) {
return type === 'geo' || type === 'text'
}
override onDragShapesIn(shape: SlotContainerShape, draggingShapes: TLShape[]) {
const newShapes = draggingShapes.filter((s) => s.parentId !== shape.id)
if (newShapes.length > 0) {
this.editor.reparentShapes(newShapes, shape.id)
}
}
override onDragShapesOut(
shape: SlotContainerShape,
draggingShapes: TLShape[],
info: TLDragShapesOutInfo
) {
if (!info.nextDraggingOverShapeId) {
const children = draggingShapes.filter((s) => s.parentId === shape.id)
this.editor.reparentShapes(children, this.editor.getCurrentPageId())
}
}
component(shape: SlotContainerShape) {
return (
<HTMLContainer
style={{
backgroundColor: '#f0f0f0',
border: '2px dashed #ccc',
display: 'grid',
gridTemplateColumns: `repeat(${shape.props.slots}, 100px)`,
}}
>
{Array.from({ length: shape.props.slots }).map((_, i) => (
<div
key={i}
style={{
width: 100,
height: 100,
borderRight: i < shape.props.slots - 1 ? '1px dashed #ccc' : undefined,
}}
/>
))}
</HTMLContainer>
)
}
getIndicatorPath(shape: SlotContainerShape) {
const path = new Path2D()
path.rect(0, 0, shape.props.slots * 100, 100)
return path
}
}
For handling content dragged from outside the browser (files, URLs, images), see External content handling. The callbacks on this page are for shape-to-shape drag and drop within the canvas.