apps/docs/content/sdk-features/click-detection.mdx
In tldraw, the click detection system turns a pair of nearby clicks into a double_click event. The ClickManager tracks consecutive pointer down events using a state machine, dispatching a double-click event when timing and distance thresholds are met.
After a double-click, extra nearby clicks are treated as overflow. Overflow clicks do not dispatch additional click events; they suppress the sequence long enough to prevent a rapid double-double-click from becoming two double-clicks.
When a pointer down event occurs, the manager either starts a new sequence, detects a double-click, or moves the sequence into overflow. Each state has a timeout that determines how long to wait before returning to idle.
Two timeout durations control the detection speed. The first click uses doubleClickDurationMs (450ms by default), giving users time to initiate a double-click sequence. After a double-click, multiClickDurationMs (200ms by default) controls both the settle delay and the overflow suppression window. The option name is historical; it now controls only the post-double-click window.
The click state machine progresses through these states:
| State | Description |
|---|---|
idle | No active click sequence |
pendingDouble | First click registered, waiting for second |
pendingOverflow | Double-click registered, waiting for overflow |
overflow | Extra clicks detected after the double-click |
The second pointer down dispatches a double_click event immediately and starts waiting for overflow. If the timeout expires before another click, the manager dispatches a double-click settle event and returns to idle. If another pointer down arrives first, the sequence moves to overflow and no further click events are dispatched until the overflow timeout expires.
Consecutive clicks must occur within a maximum distance of 40 pixels (screen space). If pointer down events are farther apart, the new pointer down starts a fresh click sequence instead.
Click events are dispatched with a phase property indicating when in the sequence the event fired:
| Phase | When it fires |
|---|---|
down | On the second pointer down, when the double-click is detected |
up | On the matching pointer up while the double-click is still pending |
settle-down | When the timeout expires without overflow while the pointer is down |
settle-up | When the timeout expires without overflow after the pointer is up |
The phase system lets tools respond at different points in the click sequence. Most default selection and cropping behavior responds on the down phase, so it starts on the second pointer down. The hand tool's double-click zoom responds on settle-up, so an overflow click can still cancel the pending zoom.
If the pointer moves too far during a pending click sequence, the system cancels the sequence and returns to idle. This prevents double-click detection during click-drag operations. The movement threshold differs for coarse pointers (touchscreens) and fine pointers (mouse, stylus).
Tools receive click events through handler methods defined in the TLEventHandlers interface. Here's a complete example of a custom tool that zooms in as soon as a double-click is detected:
import { StateNode, TLClickEventInfo, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
class ZoomTool extends StateNode {
static override id = 'zoom'
override onDoubleClick(info: TLClickEventInfo) {
if (info.phase !== 'down') return
this.editor.zoomIn(info.point, { animation: { duration: 200 } })
}
}
const customTools = [ZoomTool]
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw
tools={customTools}
onMount={(editor) => {
editor.setCurrentTool('zoom')
}}
/>
</div>
)
}
Tools can handle StateNode#onDoubleClick events. The handler receives a TLClickEventInfo object with details about the click. Use phase: 'down' for behavior that should start on the second pointer down, or a settle phase for behavior that should wait until the overflow window has passed.
The select tool routes shape double-clicks to ShapeUtil#onDoubleClick. Return a partial shape object to apply changes:
override onDoubleClick(shape: MyShape) {
return {
id: shape.id,
type: shape.type,
props: { expanded: !shape.props.expanded },
}
}
The TLClickEventInfo type includes these properties:
| Property | Type | Description |
|---|---|---|
type | 'click' | Event type identifier |
name | 'double_click' | Which click event this is |
point | VecLike | Pointer position in client space |
pointerId | number | Unique identifier for the pointer |
button | number | Mouse button (0 = left, 1 = middle, 2 = right) |
phase | 'down' | 'up' | 'settle-down' | 'settle-up' | When in the click sequence this fired |
target | 'canvas' | 'selection' | 'shape' | 'handle' | What was clicked |
shape | TLShape | undefined | The shape, when target is 'shape' or 'handle' |
handle | TLHandle | undefined | The handle, when target is 'handle' |
shiftKey | boolean | Whether Shift was held |
altKey | boolean | Whether Alt/Option was held |
ctrlKey | boolean | Whether Control was held |
metaKey | boolean | Whether Meta/Command was held |
accelKey | boolean | Platform accelerator key (Cmd on Mac, Ctrl on Windows) |
Click timing is configured through the editor's options:
| Option | Default | Description |
|---|---|---|
doubleClickDurationMs | 450ms | Time window for the first click to become a double-click |
multiClickDurationMs | 200ms | Double-click settle delay and overflow suppression window |
onDoubleClick and other click handlers in custom shapes