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 or any shape that should react to shapes being dragged over it.
If you want frame-like behavior (accept children, reparent them on drag in and out, clip them), extend BaseFrameLikeShapeUtil, which implements all of the callbacks below. Implement them yourself when you need something different.
While the select tool translates shapes, it tracks which shape (if any) is under the cursor using Editor#getDraggingOverShape. Your shape util can implement these callbacks to respond:
| Callback | When it fires |
|---|---|
| ShapeUtil#onDragShapesIn | Shapes are first dragged over this shape |
| ShapeUtil#onDragShapesOver | Shapes continue being dragged over this shape (on an interval, when cursor moves) |
| ShapeUtil#onDragShapesOut | Shapes are dragged away from this shape |
| ShapeUtil#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. Locked shapes are never dragged, and if every child of a group is being dragged, the group is dragged instead of its children.
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 custom 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, including when the drag moves into another 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 them to preserve z-ordering when shapes are dragged back to their original parent.
Editor#getDraggingOverShape determines which shape is being dragged over by checking which shape's geometry contains the cursor point. It tests shapes from front to back, skipping locked shapes, hidden shapes, and the shapes being dragged, 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. While shapes are dragged over a target that accepts them, the editor hints the target with Editor#setHintingShapes.
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 to 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
}
}