# tldraw Documentation

Version: `5.3.0`

This file is generated during package publishing from the tldraw docs content for this exact package version.

## Introduction

Introduction articles for tldraw.

### Quick start

Have five minutes? Run this command in your terminal to explore tldraw [starter kits](https://tldraw.dev/starter-kits):

```bash
npm create tldraw@latest
```

Have a little more time? Let's try out the tldraw SDK in a React project.

If you're new to React, we recommend using a [Vite template](https://vitejs.dev/guide/#scaffolding-your-first-vite-project) as a starter. We'll assume your project is already running locally.

#### Getting started

First, install the `tldraw` package from npm:

```bash
npm install tldraw
```

Next, in your React project, import the `Tldraw` component and tldraw's CSS styles. Then render the `Tldraw` component inside a full screen container:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw />
		</div>
	)
}
```

That's pretty much it! At this point, you should have a complete working single-user canvas. You can draw, write, add images and video, zoom, pan, copy and paste, undo and redo—everything you'd expect from a canvas.

You'll be starting from our default [shapes](https://tldraw.dev/docs/shapes), [tools](https://tldraw.dev/docs/tools), and [user interface](https://tldraw.dev/docs/user-interface), but you can customize all of these things for your project if you wish. For now, let's show off a few more features.

#### Local persistence

Let's add local persistence by passing a `persistenceKey` prop to the `Tldraw` component:

```tsx
export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw persistenceKey="example" />
		</div>
	)
}
```

The `persistenceKey` prop saves your project to the browser's storage so that it survives a refresh. It also synchronizes the project with other instances that share the same key—even in other browser tabs! Give it a try by opening your app in a second window.

#### Real-time collaboration

To add support for multiple users collaborating in real time, you can use the [**tldraw sync**](https://tldraw.dev/docs/sync) library. Its demo hook connects your app to tldraw's demo server, which hosts temporary rooms.

First, install the `@tldraw/sync` package:

```bash
npm install @tldraw/sync
```

Next, import the `useSyncDemo` hook from the `@tldraw/sync` package. Call it in your component with a unique ID and pass the store that it returns to the `Tldraw` component:

```tsx
import { Tldraw } from 'tldraw'
import { useSyncDemo } from '@tldraw/sync'
import 'tldraw/tldraw.css'

export default function App() {
	const store = useSyncDemo({ roomId: 'insert-any-string-here' })

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw store={store} />
		</div>
	)
}
```

Try it out by opening your project in a second incognito window, or else access it from another device. You should see all of tldraw's multiplayer features: live cursors, user names, viewport following, cursor chat, and more.

If you want to go further with real-time collaboration, be sure to check out our guide to the [**tldraw sync**](https://tldraw.dev/docs/sync) library.

#### Controlling the canvas

The tldraw [editor](https://tldraw.dev/docs/editor) has a runtime JavaScript API. Everything that can happen in tldraw can be done programmatically through the `Editor` instance.

For simplicity's sake, let's roll back our persistence and sync code. We can then use the `Tldraw` component's `onMount` callback to get access to the Editor instance. We'll use the editor to create a new shape on the canvas, select it, then slowly zoom to it.

```tsx
import { Editor, Tldraw, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	const handleMount = (editor: Editor) => {
		editor.createShape({
			type: 'text',
			x: 200,
			y: 200,
			props: {
				richText: toRichText('Hello world!'),
			},
		})

		editor.selectAll()

		editor.zoomToSelection({
			animation: { duration: 5000 },
		})
	}

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw onMount={handleMount} />
		</div>
	)
}
```

The Editor is tldraw's main interface for controlling the canvas. Be sure to check out the `Editor` API documentation for more information on what you can do with it, as well as our [guide on using the editor](https://tldraw.dev/docs/editor).

#### Next steps

Now that you've seen how the tldraw canvas works, you can:

- Create your own [shapes](https://tldraw.dev/docs/shapes) and [tools](https://tldraw.dev/docs/tools)
- Customize the [user interface](https://tldraw.dev/docs/user-interface)
- Learn more about the [editor](https://tldraw.dev/docs/editor)
- Explore our [examples](https://tldraw.dev/examples)
- Build with our [starter kits](https://tldraw.dev/starter-kits)

The SDK lets you customize shapes, tools, UI, and more. In addition to our long-form docs, we have dozens of examples in our [examples section](https://tldraw.dev/examples) that cover more of its functionality. You can run these locally with the tldraw [GitHub repository](https://github.com/tldraw/tldraw).

#### Join our community

If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. Good luck!

### Installation

To use the tldraw SDK, first install the `tldraw` package:

```bash
npm install tldraw
```

Now import and use the `Tldraw` component inside of any React component. You will need React 18 or 19.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function () {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw />
		</div>
	)
}
```

##### Wrapper

The `Tldraw` component must be wrapped in a parent container with an explicit size. Its height and width are set to `100%`, so it will fill its parent container.

##### Accessing the editor

Use the `onMount` prop to access the `Editor` instance when it's ready:

```tsx
function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					// Do something with the editor
					editor.selectAll()
				}}
			/>
		</div>
	)
}
```

The callback can return a cleanup function that runs when the editor unmounts.

##### CSS

In addition to the `Tldraw` component itself, you should also import the `tldraw.css` file from the `tldraw` package.

```tsx
import 'tldraw/tldraw.css'
```

You can alternatively import this file inside of another CSS file using the `@import` syntax.

```css
@import url('tldraw/tldraw.css');
```

If you'd like to deeply change the way that tldraw looks, you can copy the `tldraw.css` file into a new CSS file, make your changes, and import that instead.

##### Fonts

The tldraw SDK bundles its own fonts for shapes: IBM Plex (Sans, Serif, Mono) and Shantell Sans (for draw-style text). These are loaded automatically from the CDN or self-hosted [static assets](#Static-assets).

The tldraw UI will inherit its font family. You can set this to any font you like. For example, to use Inter:

```css
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');

.tl-container {
	font-family: 'Inter', sans-serif;
}
```

##### HTML

If you're using the `Tldraw` component in a full-screen app, update your `index.html`'s meta viewport element as shown below.

```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
```

Without these viewport options, some features like safe area positioning won't work correctly.

#### License

If you have a [license key](https://tldraw.dev/pricing), you can pass your key to the `licenseKey` prop.

```tsx
<Tldraw licenseKey={YOUR_LICENSE_KEY} />
```

To learn more about the license and license key, visit [our pricing page](https://tldraw.dev/pricing).

#### Static assets

To use the `Tldraw` component, the app must be able to find certain assets. These are contained in the `embed-icons`, `fonts`, `icons`, and `translations` folders. We offer a few different ways of making these assets available to your app.

##### Using a bundler

If you're using a bundler like webpack or rollup, you can import the assets directly from the `@tldraw/assets` package. Your bundler will copy the assets with your bundle and insert the correct URLs in their place.

There are three options:

- `import {getAssetUrlsByImport} from '@tldraw/assets/imports'`: import asset files using import statements. You'll need to configure your bundler to treat imports of .svg, .png, .json, and .woff2 files as external assets.
- `import {getAssetUrlsByImport} from '@tldraw/assets/imports.vite'`: import asset files, appending `?url` to the asset path. This works correctly with [vite](https://vitejs.dev/guide/assets#explicit-url-imports) without any extra configuration needed.
- `import {getAssetUrlsByMetaUrl} from '@tldraw/assets/urls'`: get asset urls using `new URL(path, import.meta.url)`. This is a standards-based approach that works natively in most modern browsers & bundlers.

Call the function, and pass the resulting `assetUrls` into the `Tldraw` component:

```tsx
import { getAssetUrlsByMetaUrl } from '@tldraw/assets/urls'

const assetUrls = getAssetUrlsByMetaUrl()

<Tldraw assetUrls={assetUrls} />
```

##### Using a public CDN

By default, we serve these assets from a public CDN. This works without configuration and is a good starting point.

If you would like to customize some of the assets you can pass the customizations to our `Tldraw` component. For example, to use a custom icon for the `hand` tool you can do the following:

```tsx
const assetUrls = {
    icons: {
        'tool-hand': './custom-tool-hand.svg',
    },
}

<Tldraw assetUrls={assetUrls} />
```

This will use the custom icon for the `hand` tool and the default assets for everything else.

##### Self-hosting assets

You can also host these assets yourself:

1. Download the `embed-icons`, `fonts`, `icons`, and `translations` folders from the [assets folder](https://github.com/tldraw/tldraw/tree/main/assets) of the tldraw repository.
2. Place the folders in your project's public path.
3. Pass `assetUrls` prop to our `<Tldraw/>` component to let the component know where the assets live.

You can use our `getAssetUrls` helper function from the `@tldraw/assets` package to generate these urls for you.

```tsx
import { getAssetUrls } from '@tldraw/assets/selfHosted'

const assetUrls = getAssetUrls()

<Tldraw assetUrls={assetUrls} />
```

While these files must be available, you can overwrite the individual files: for example, by placing different icons under the same name or by modifying or adding translations.

If you use a CDN for hosting these files you can specify the base url of your assets. To recreate the above option of serving the assets from our CDN you would do the following:

```ts
import { getDefaultCdnBaseUrl } from 'tldraw'

const assetUrls = getAssetUrls({
	baseUrl: getDefaultCdnBaseUrl(),
})
```

#### Subcomponents

The `Tldraw` component combines two lower-level components: `TldrawEditor` and `TldrawUi`. If you want more granular control, you can use those lower-level components directly. See the [exploded example](https://tldraw.dev/examples/configuration/exploded) for reference.

##### Customizing components

You can customize the appearance of the tldraw editor and UI using the `Tldraw` (or `TldrawEditor`) component's `components` prop. This prop accepts a `TLComponents` object, which combines `TLEditorComponents` (canvas-level components like the background and grid) with `TLUiComponents` (UI elements like toolbar, menus, and panels).

```tsx
import { TLComponents } from 'tldraw'

const components: TLComponents = {
	// Editor components
	Background: YourCustomBackground,
	Grid: YourCustomGrid,

	// UI components
	Toolbar: YourCustomToolbar,
	MainMenu: YourCustomMainMenu,
	StylePanel: YourCustomStylePanel,
	ContextMenu: YourCustomContextMenu,
}

<Tldraw components={components} />
```

Canvas overlay elements like the selection foreground, brush, snap indicators, and scribble are rendered via the `OverlayUtil` system rather than React components. You can customize them by extending the built-in overlay utils and passing them via the `overlayUtils` prop.

See the `TLEditorComponents` and `TLUiComponents` type definitions for the complete list of customizable components.

#### Versioning

The tldraw SDK does not follow semantic versioning. To learn more, read about how we do [release versioning](https://tldraw.dev/releases).

### Releases

Release notes for all tldraw SDK versions. Each minor release has its own page documenting new features, breaking changes, API additions, improvements, and bug fixes. Patch releases are listed within their parent minor release.

For the original releases, see the [GitHub releases page](https://github.com/tldraw/tldraw/releases).

#### How tldraw is versioned

Unlike many JavaScript packages distributed on [NPM](https://www.npmjs.com/), the tldraw SDK does not follow [semantic versioning](https://semver.org/) in its release versions. Instead:

- Major version bumps are rare. We reserve them for fundamental changes to how the SDK works.
- Minor version bumps are released on a regular cadence, approximately monthly. **They may contain breaking changes**. We aim to make breaking changes as minimally disruptive as possible, but tldraw is actively evolving as we add new features. We recommend updating at a similar pace and checking the release notes.
- Patch version bumps are for bugfixes and hotfixes that can't wait for the next cadence release.

#### Latest tldraw versions

- Run `npm install tldraw` to get the latest minor version of tldraw
- Use `npm install tldraw@next` to get the latest version of tldraw that is used on [tldraw.com](https://www.tldraw.com)
- The latest changes to the `main` branch of tldraw are available to test through our [pre-release canary builds](https://www.npmjs.com/package/tldraw?activeTab=versions).

#### Migration skill

We ship an experimental `tldraw-migrate` agent skill that helps a coding agent update your project to the latest version of the SDK. It detects your current version, fetches the relevant release notes, upgrades packages, and works through any type errors using the migration guides in our release notes. See our [migration skill guide](https://tldraw.dev/releases/migration-skill) for details on how to install and use the skill.

#### Next

- [next](https://tldraw.dev/releases/next) - Changes for the upcoming release.

#### v5.x

- [v5.2](https://tldraw.dev/releases/v5.2.0) - Faster freehand ink, a frame selection action, SDK-wide performance improvements, and breaking changes to the collaborator user-id types and Node support
- [v5.1](https://tldraw.dev/releases/v5.1.0) - Page menu redesign, copy-styles shortcut, selectable locked shapes, public translation APIs, and performance improvements
- [v5.0](https://tldraw.dev/releases/v5.0.0) - Custom themes, overlays, performance, extensiblity, and attribution

#### v4.x

- [v4.5](https://tldraw.dev/releases/v4.5.0) - Click-through on transparent pixels, SVG sanitization, configurable embed definitions
- [v4.4](https://tldraw.dev/releases/v4.4.0) - Image pipeline starter kit, performance improvements, quick zoom, canvas indicators
- [v4.3](https://tldraw.dev/releases/v4.3.0) - SQLite sync storage, improved custom shape types, reactive inputs, draw shape encoding
- [v4.2](https://tldraw.dev/releases/v4.2.0) - TipTap v3, dynamic tools, custom socket implementations
- [v4.1](https://tldraw.dev/releases/v4.1.0) - Shader starter kit, localStorage atoms, minimap filtering
- [v4.0](https://tldraw.dev/releases/v4.0.0) - Starter kits, WCAG 2.2 AA compliance, licensing updates

#### v3.x

- [v3.15](https://tldraw.dev/releases/v3.15.0) - `npm create tldraw` CLI, accessibility improvements
- [v3.14](https://tldraw.dev/releases/v3.14.0) - Contextual toolbars, PathBuilder API
- [v3.13](https://tldraw.dev/releases/v3.13.0) - Elbow arrows for technical diagrams
- [v3.12](https://tldraw.dev/releases/v3.12.0) - Accessibility focus, keyboard navigation
- [v3.11](https://tldraw.dev/releases/v3.11.0) - Rich text fixes, zoom improvements
- [v3.10](https://tldraw.dev/releases/v3.10.0) - Rich text as first-class primitive
- [v3.9](https://tldraw.dev/releases/v3.9.0) - Layout improvements, AtomMap class
- [v3.8](https://tldraw.dev/releases/v3.8.0) - React 19 compatibility, i18n expansion
- [v3.7](https://tldraw.dev/releases/v3.7.0) - Presence sync customization
- [v3.6](https://tldraw.dev/releases/v3.6.0) - Expanded action helpers
- [v3.5](https://tldraw.dev/releases/v3.5.0) - Grid snapping, layer improvements
- [v3.4](https://tldraw.dev/releases/v3.4.0) - Excalidraw compatibility
- [v3.3](https://tldraw.dev/releases/v3.3.0) - Readonly mode, sync improvements
- [v3.2](https://tldraw.dev/releases/v3.2.0) - Version alignment release
- [v3.1](https://tldraw.dev/releases/v3.1.0) - Shape visibility, server-side updates
- [v3.0](https://tldraw.dev/releases/v3.0.0) - New licensing, deep links, custom embeds

#### v2.x

- [v2.4](https://tldraw.dev/releases/v2.4.0)
- [v2.3](https://tldraw.dev/releases/v2.3.0)
- [v2.2](https://tldraw.dev/releases/v2.2.0)
- [v2.1](https://tldraw.dev/releases/v2.1.0)
- [v2.0](https://tldraw.dev/releases/v2.0.0) - Initial public release

## Learn tldraw

Learn to use the tldraw SDK.

### Editor

The `Editor` class is the main way of controlling tldraw's editor. You can use it to manage the editor's internal state, make changes to the document, or respond to changes that have occurred.

By design, the `Editor`'s surface area is very large. Almost everything is available through it. Need to create some shapes? Use `Editor#createShapes`. Need to delete them? Use `Editor#deleteShapes`. Need a sorted array of every shape on the current page? Use `Editor#getCurrentPageShapesSorted`.

#### Accessing the editor

You can access the editor in two ways:

##### The onMount callback

The `Tldraw` component's `onMount` callback provides the editor as the first argument.

```tsx
function App() {
	return (
		<Tldraw
			onMount={(editor) => {
				// your editor code here
			}}
		/>
	)
}
```

##### The useEditor hook

The `useEditor` hook returns the editor instance. It must be called from within the JSX of the `Tldraw` component.

```tsx
function InsideOfContext() {
	const editor = useEditor()
	// your editor code here
	return null
}

function App() {
	return (
		<Tldraw>
			<InsideOfContext />
		</Tldraw>
	)
}
```

> If you're using the subcomponents as shown in [this example](https://tldraw.dev/examples/configuration/exploded), the editor instance is provided by the `TldrawEditor` component.

#### Reactive state

The editor's state is reactive. Methods like `Editor#getSelectedShapeIds` or `Editor#getCurrentPageShapes` return values that automatically update when the underlying data changes. You can use these values directly in React components with the `track` wrapper or `useValue` hook.

```tsx
import { track, useEditor } from 'tldraw'

export const SelectedShapeIdsCount = track(() => {
	const editor = useEditor()
	return <div>{editor.getSelectedShapeIds().length}</div>
})
```

See the [Signals](https://tldraw.dev/sdk-features/signals) article for more on tldraw's reactive state system.

#### Batching changes

Each change to the editor happens within a transaction. You can batch multiple changes into a single transaction using the `Editor#run` method. Batching improves performance and reduces overhead for persisting or distributing changes.

```ts
editor.run(() => {
	editor.createShapes(myShapes)
	editor.sendToBack(myShapes)
	editor.selectNone()
})
```

The `run` method also accepts options to control history and locked shape behavior:

```ts
// Make changes without affecting undo/redo history
editor.run(
	() => {
		editor.createShapes(myShapes)
	},
	{ history: 'ignore' }
)

// Make changes to locked shapes
editor.run(
	() => {
		editor.updateShapes(myLockedShapes)
	},
	{ ignoreShapeLock: true }
)
```

#### Capabilities

The editor provides methods and properties organized around these areas:

##### Data

- **[Signals](https://tldraw.dev/sdk-features/signals)** — Reactive state primitives
- **[Store](https://tldraw.dev/sdk-features/store)** — The reactive database holding all records
- **[Shapes](https://tldraw.dev/sdk-features/shapes)** — Create, read, update, and delete shapes
- **[Bindings](https://tldraw.dev/sdk-features/bindings)** — Relationships between shapes
- **[Pages](https://tldraw.dev/sdk-features/pages)** — Manage document pages
- **[Assets](https://tldraw.dev/sdk-features/assets)** — Images, videos, and other media

##### Interaction

- **[Tools](https://tldraw.dev/sdk-features/tools)** — The state machine that handles user input
- **[Selection](https://tldraw.dev/sdk-features/selection)** — Manage which shapes are selected
- **[Input handling](https://tldraw.dev/sdk-features/input-handling)** — Pointer and keyboard state
- **[Events](https://tldraw.dev/sdk-features/events)** — Subscribe to user interactions and state changes

##### View

- **[Camera](https://tldraw.dev/sdk-features/camera)** — Control viewport position and zoom
- **[Coordinates](https://tldraw.dev/sdk-features/coordinates)** — Convert between screen and page space

##### State management

- **[Instance state](https://tldraw.dev/sdk-features/instance-state)** — Per-editor settings like current tool and focus
- **[Visibility](https://tldraw.dev/sdk-features/visibility)** — Control which shapes are shown
- **[History](https://tldraw.dev/sdk-features/history)** — Undo, redo, and history management
- **[Side effects](https://tldraw.dev/sdk-features/side-effects)** — React to record lifecycle changes

##### Configuration

- **[User preferences](https://tldraw.dev/sdk-features/user-preferences)** — Cross-instance settings like dark mode
- **[Readonly mode](https://tldraw.dev/sdk-features/readonly)** — Disable editing
- **[Locked shapes](https://tldraw.dev/sdk-features/locked-shapes)** — Prevent changes to specific shapes

##### Output

- **[Image export](https://tldraw.dev/sdk-features/image-export)** — Export to SVG, PNG, and other formats

---

See the `Editor` API reference for the complete list of methods and properties.

### Shapes

In tldraw, a shape is something that can exist on the page, like an arrow, an image, or some text. This article provides an overview of shapes and how to create custom ones.

#### Shape basics

Shapes are JSON records stored in the editor's [store](https://tldraw.dev/sdk-features/store). Each shape has base properties (position, rotation, opacity) plus a `props` object for shape-specific data. See [Shapes](https://tldraw.dev/sdk-features/shapes) for the full shape system architecture.

The `Tldraw` component includes [default shapes](https://tldraw.dev/sdk-features/default-shapes) like geo, text, arrow, and draw. The only core shape (always present) is the [group](https://tldraw.dev/sdk-features/groups).

#### ShapeUtil

Each shape type has a `ShapeUtil` class that defines its behavior: how it renders, its geometry for hit testing, and how it responds to interactions. See [Shapes](https://tldraw.dev/sdk-features/shapes#shapeutil) for details on ShapeUtil methods, lifecycle hooks, and configuration.

#### Custom shapes

You can create your own shapes by defining a shape type and a ShapeUtil class.

> For a working example, see our [custom shapes example](https://tldraw.dev/examples/shapes/tools/custom-shape).

##### Defining the shape type

Register your shape's props using TypeScript module augmentation:

```ts
import { TLShape } from 'tldraw'

const CARD_TYPE = 'card'

declare module 'tldraw' {
	export interface TLGlobalShapePropsMap {
		[CARD_TYPE]: { w: number; h: number }
	}
}

type CardShape = TLShape<typeof CARD_TYPE>
```

##### Creating a ShapeUtil

Implement the required methods: `getDefaultProps`, `getGeometry`, `component`, and `getIndicatorPath`:

```tsx
import { HTMLContainer, Rectangle2d, ShapeUtil } from 'tldraw'

class CardShapeUtil extends ShapeUtil<CardShape> {
	static override type = CARD_TYPE

	getDefaultProps(): CardShape['props'] {
		return { w: 100, h: 100 }
	}

	getGeometry(shape: CardShape) {
		return new Rectangle2d({
			width: shape.props.w,
			height: shape.props.h,
			isFilled: true,
		})
	}

	component(shape: CardShape) {
		return <HTMLContainer>Hello</HTMLContainer>
	}

	getIndicatorPath(shape: CardShape) {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}
}
```

See [Geometry](https://tldraw.dev/sdk-features/geometry) for available geometry classes.

##### Registering your shape

Pass your ShapeUtil to the `Tldraw` component:

```tsx
export default function () {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				shapeUtils={[CardShapeUtil]}
				onMount={(editor) => {
					editor.createShape({ type: 'card' })
				}}
			/>
		</div>
	)
}
```

#### Meta

Every shape has a `meta` property for your own data. Tldraw stores and syncs this data but doesn't use it itself. It's an escape hatch for attaching extra information to shapes, like the name of the user who created a shape or the date it was last changed.

Like `props`, the data in `meta` must be JSON-serializable. Shapes aren't the only records with a `meta` property: pages, bindings, assets, and the document record have one too.

By default, a shape's `meta` is an empty object typed as `JsonObject`. To type your meta data, use an intersection:

```ts
type ShapeWithMyMeta = TLGeoShape & { meta: { createdBy: string } }

const shape = editor.getShape<ShapeWithMyMeta>(myGeoShapeId)
```

You can update a shape's `meta` with `Editor#updateShapes`, the same way you update its props:

```ts
editor.updateShapes<ShapeWithMyMeta>([
	{
		id: myGeoShapeId,
		type: 'geo',
		meta: { createdBy: 'Steve' },
	},
])
```

##### Initial meta

When `Editor#createShapes` creates a shape, it sets the shape's meta using `Editor#getInitialMetaForShape`. By default this method returns an empty object. Replace it to provide your own initial meta:

```tsx
editor.getInitialMetaForShape = (shape) => {
	if (shape.type === 'text') {
		return { createdBy: currentUser.id, lastModified: Date.now() }
	}
	return { createdBy: currentUser.id }
}
```

> For a working example, see our [shape meta on create example](https://tldraw.dev/examples/events/meta-on-create).

##### Updating meta with side effects

To keep meta up to date as shapes change, register a side effect that runs before each shape update:

```tsx
editor.sideEffects.registerBeforeChangeHandler('shape', (_prev, next, source) => {
	if (source !== 'user') return next
	return {
		...next,
		meta: { updatedBy: editor.user.getExternalId(), updatedAt: Date.now() },
	}
})
```

Side effects can run on create, update, and delete for shapes and other records. See [Side effects](https://tldraw.dev/sdk-features/side-effects) for the full API.

> For a working example, see our [shape meta on change example](https://tldraw.dev/examples/events/meta-on-change).

##### Validating meta

By default, the store accepts any JSON value in `meta`. To validate meta data at runtime, build your own schema with `createTLSchema` and pass validators for each shape type's meta:

```tsx
import { useState } from 'react'
import { createTLSchema, createTLStore, defaultShapeSchemas, T, Tldraw } from 'tldraw'

const schema = createTLSchema({
	shapes: {
		...defaultShapeSchemas,
		geo: {
			...defaultShapeSchemas.geo,
			meta: { createdBy: T.string },
		},
	},
})

export default function App() {
	const [store] = useState(() => createTLStore({ schema }))
	return <Tldraw store={store} />
}
```

Bindings, assets, and user records accept meta validators the same way. For user records, see our [custom user metadata example](https://tldraw.dev/examples/users/custom-user).

#### Extending shapes

- **`BaseBoxShapeUtil`** - Extend this for standard rectangular shape behavior
- **`ShapeUtil#configure`** - Customize built-in shapes without subclassing

#### Related topics

| Topic                                          | Description                                                        |
| ---------------------------------------------- | ------------------------------------------------------------------ |
| [Shapes](https://tldraw.dev/sdk-features/shapes)                 | Full shape system architecture, ShapeUtil methods, lifecycle hooks |
| [Default shapes](https://tldraw.dev/sdk-features/default-shapes) | Built-in shape types and their properties                          |
| [Geometry](https://tldraw.dev/sdk-features/geometry)             | Geometry classes for hit testing and bounds                        |
| [Bindings](https://tldraw.dev/sdk-features/bindings)             | Connecting shapes together (like arrows)                           |
| [Rich text](https://tldraw.dev/sdk-features/rich-text)           | Adding text labels to shapes                                       |
| [Shape clipping](https://tldraw.dev/sdk-features/shape-clipping) | Clipping children within shape boundaries                          |
| [Snapping](https://tldraw.dev/sdk-features/snapping)             | Shape snapping behavior                                            |
| [Persistence](https://tldraw.dev/sdk-features/persistence)       | Shape migrations and data persistence                              |
| [Groups](https://tldraw.dev/sdk-features/groups)                 | Grouping shapes together                                           |

### Tools

In tldraw, a **tool** is a top-level state in our state chart. The select tool, draw tool, and arrow tool are all examples of tools—each defines how the editor responds to user input while that tool is active.

#### Default and custom tools

The `<Tldraw>` component includes default tools like [select](https://tldraw.dev/reference/tldraw/SelectTool), [hand](https://tldraw.dev/reference/tldraw/HandTool), [draw](https://tldraw.dev/reference/tldraw/DrawShapeTool), and [arrow](https://tldraw.dev/reference/tldraw/ArrowShapeTool). The core `@tldraw/editor` package has no built-in tools—if you use `<TldrawEditor>` directly, you provide your own.

You can create custom tools by extending [StateNode](https://tldraw.dev/reference/editor/StateNode) and passing them to the `tools` prop:

```tsx
import { StateNode, TLPointerEventInfo, Tldraw } from 'tldraw'

class StampTool extends StateNode {
	static override id = 'stamp'

	override onPointerDown(info: TLPointerEventInfo) {
		// Create a shape at the click position
	}
}

export default function App() {
	return <Tldraw tools={[StampTool]} />
}
```

#### Changing tools

Change the active tool with [editor.setCurrentTool](https://tldraw.dev/reference/editor/Editor#setCurrentTool):

```ts
editor.setCurrentTool('select')
editor.setCurrentTool('hand')
editor.setCurrentTool('draw')
```

#### Learn more

These guides cover the tool system in depth:

- **[Tools](https://tldraw.dev/sdk-features/tools)** — Full guide to the tool system: state hierarchy, event handling, child states, tool lock, creating custom tools, and overriding defaults
- **[Events](https://tldraw.dev/sdk-features/events)** — How the editor dispatches events and how to subscribe to them
- **[Input handling](https://tldraw.dev/sdk-features/input-handling)** — Pointer tracking, keyboard state, and the `editor.inputs` API
- **[Ticks](https://tldraw.dev/sdk-features/ticks)** — Frame-synchronized updates for animations and continuous interactions

#### Examples

- [Custom tool](https://tldraw.dev/examples/shapes/tools/custom-tool) — A simple tool that adds stickers to the canvas
- [Tool with child states](https://tldraw.dev/examples/shapes/tools/tool-with-child-states) — A tool with multiple states for complex interactions
- [Screenshot tool](https://tldraw.dev/examples/shapes/tools/screenshot-tool) — A tool for capturing canvas regions

### User interface

The `tldraw` package includes a complete user interface with menus, toolbars, keyboard shortcuts, and style panels. You can hide it entirely, listen to events, or customize individual components.

#### Learn more

- [UI components](https://tldraw.dev/sdk-features/ui-components) - Component architecture, overrides, and customization
- [UI primitives](https://tldraw.dev/sdk-features/ui-primitives) - Buttons, menus, dialogs for building custom interfaces
- [Internationalization](https://tldraw.dev/sdk-features/internationalization) - Translations and language support

#### Examples

- [Hide UI](https://tldraw.dev/examples/hide-ui) - Hide the entire default interface
- [Custom UI](https://tldraw.dev/examples/custom-ui) - Build a completely custom interface
- [Hide UI components](https://tldraw.dev/examples/ui-components-hidden) - Selectively hide specific components
- [Action overrides](https://tldraw.dev/examples/action-overrides) - Customize actions and shortcuts
- [UI events](https://tldraw.dev/examples/ui-events) - Track user interactions

### Handles

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.

#### Handle basics

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`:

```tsx
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.

#### Handle types

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.

#### Responding to handle drags

When a user drags a handle, tldraw calls `onHandleDrag` with the updated handle position. Return the updated shape:

```tsx
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.

##### Lifecycle callbacks

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) |

#### Handle snapping

Handles can snap to other shapes' geometry. Set `snapType` on the handle:

```tsx
{
	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            |

##### Angle snapping

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`:

```tsx
{
	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.

##### Custom snap geometry

By default, handles snap to a shape's outline and key points. Override `getHandleSnapGeometry` to customize what handles snap to:

```tsx
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                |

#### Complete example

Here's a speech bubble shape with a draggable tail handle:

```tsx
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())
	}
}
```

#### Reading handles

Use `Editor#getShapeHandles` to get the handles for any shape:

```ts
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.

#### Examples

- [Custom shape with handles](https://tldraw.dev/examples/shapes/tools/speech-bubble) — A speech bubble shape with a draggable tail handle
- [Cubic bezier curve shape](https://tldraw.dev/examples/shapes/tools/cubic-bezier-shape) — Multiple handles with custom snapping and control point behavior

### Persistence

Persistence means storing the editor's state to a database and restoring it later. The tldraw SDK provides several approaches: automatic local persistence with a single prop, manual snapshots for custom storage backends, and a migration system for handling schema changes.

#### Local persistence

The simplest approach is the `persistenceKey` prop. This automatically saves to IndexedDB and syncs across browser tabs:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw persistenceKey="my-document" />
		</div>
	)
}
```

Each unique key represents a separate document. Two editors with the same key share the same document and stay synchronized.

#### Snapshots

For custom storage backends, use `getSnapshot` and `loadSnapshot` to save and restore editor state as JSON:

```tsx
import { getSnapshot, loadSnapshot } from 'tldraw'

// Save
const { document, session } = getSnapshot(editor.store)
await saveToDatabase(document)

// Load
const document = await loadFromDatabase()
loadSnapshot(editor.store, { document })
```

The snapshot has two parts: `document` (shapes, pages, bindings) which you typically save to a server, and `session` (camera, selection, UI state) which you keep per-user locally.

See [Persistence](https://tldraw.dev/sdk-features/persistence) for complete coverage of snapshots, async loading with `TLStoreWithStatus`, and auto-save patterns.

#### The store

The editor's [store](https://tldraw.dev/sdk-features/store) is a reactive database that holds all records. You can create a standalone store, load data into it, and pass it to the editor:

```tsx
import { useState } from 'react'
import { createTLStore, loadSnapshot, Tldraw } from 'tldraw'

export default function App() {
	const [store] = useState(() => {
		const store = createTLStore()
		const saved = localStorage.getItem('my-drawing')
		if (saved) {
			loadSnapshot(store, JSON.parse(saved))
		}
		return store
	})

	return <Tldraw store={store} />
}
```

See [Store](https://tldraw.dev/sdk-features/store) for details on store operations, listening to changes, queries, and transactions.

#### Multiplayer sync

For real-time collaboration, use the `@tldraw/sync` package. Multiple users can edit the same document simultaneously, see each other's cursors, and follow each other's viewports:

```tsx
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'

export default function App() {
	const store = useSyncDemo({ roomId: 'my-room-id' })
	return <Tldraw store={store} />
}
```

See [Collaboration](https://tldraw.dev/sdk-features/collaboration) for production setup, custom presence, authentication, and building custom sync solutions.

#### Migrations

When you load a snapshot from an older schema version, the store migrates it automatically. For custom shapes, you can define migrations to handle changes to their props over time.

See [Persistence](https://tldraw.dev/sdk-features/persistence#migrations) for details on shape props migrations and general migrations.

#### Examples

- [Persistence key](https://tldraw.dev/examples/data/persistence-key) — Automatic local persistence with a single prop
- [Snapshots](https://tldraw.dev/examples/data/snapshots) — Saving and loading editor state
- [Local storage](https://tldraw.dev/examples/data/local-storage) — Custom persistence with throttled auto-save
- [Store events](https://tldraw.dev/examples/data/store-events) — Listening to store changes
- [Shape with migrations](https://tldraw.dev/examples/data/shape-with-migrations) — Migrations for custom shape props

### Assets

Assets are records that store data about shared resources like images and videos. Shapes reference assets by ID rather than embedding files directly, so you can reuse the same image across multiple shapes without duplicating data.

For the complete guide to working with assets, see the [Assets](https://tldraw.dev/sdk-features/assets) reference.

#### Default storage behavior

`TLAssetStore` controls how assets are stored and retrieved. The default behavior depends on your store setup:

- **In-memory only** (default): `inlineBase64AssetStore` converts images to data URLs
- **With [`persistenceKey`](https://tldraw.dev/sdk-features/persistence)**: Assets are stored in the browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
- **With a [sync server](https://tldraw.dev/docs/sync)**: Implement `TLAssetStore` to upload to a storage service like S3

#### Examples

- [Using hosted images](https://tldraw.dev/examples/data/assets/hosted-images)
- [Customizing default asset options](https://tldraw.dev/examples/configuration/asset-props)
- [Handling pasted/dropped external content](https://tldraw.dev/examples/data/assets/external-content-sources)
- [Simple asset store with server upload](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx)
- [Asset store with image optimization](https://github.com/tldraw/tldraw/blob/main/packages/sync/src/useSyncDemo.ts#L178-L256)

### Indicators

Indicators are the outlines that appear around shapes when they're selected or hovered. They show which shapes are active and where each shape's bounds are.

#### How indicators work

When you select a shape in tldraw, an indicator appears as a stroke around the shape's geometry. Indicators are separate from the shape's visual appearance so they can be styled consistently across all shape types.

Each `ShapeUtil` defines how its indicator should be drawn by implementing the required `getIndicatorPath` method:

```tsx
import { HTMLContainer, Rectangle2d, ShapeUtil } from 'tldraw'

class CardShapeUtil extends ShapeUtil<CardShape> {
	static override type = 'card'

	getDefaultProps(): CardShape['props'] {
		return { w: 100, h: 100 }
	}

	getGeometry(shape: CardShape) {
		return new Rectangle2d({
			width: shape.props.w,
			height: shape.props.h,
			isFilled: true,
		})
	}

	component(shape: CardShape) {
		return <HTMLContainer>Hello</HTMLContainer>
	}

	getIndicatorPath(shape: CardShape) {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}
}
```

The `getIndicatorPath` method returns paths in the shape's local coordinate space. The editor automatically positions and styles these paths based on selection state.

#### When indicators appear

By default, indicators appear in these situations:

| State    | Description                                             |
| -------- | ------------------------------------------------------- |
| Selected | The shape is in the current selection                   |
| Hovered  | The pointer is over the shape (desktop only, not touch) |
| Hinting  | The shape is being referenced during an operation       |

Indicators are hidden during certain interactions, like when changing styles or during tool operations that would make indicators distracting.

#### Defining paths

For most shapes, return a `Path2D`:

```tsx
class MyShapeUtil extends ShapeUtil<MyShape> {
	override getIndicatorPath(shape: MyShape): Path2D | undefined {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}
}
```

Indicators are drawn on a single canvas layer, which is efficient when many shapes are selected.

##### Complex canvas indicators

For indicators that need clipping or multiple paths (like arrows with labels), return an object instead of a plain `Path2D`:

```tsx
override getIndicatorPath(shape: MyShape): TLIndicatorPath | undefined {
	const bodyPath = new Path2D()
	bodyPath.moveTo(0, 0)
	bodyPath.lineTo(100, 100)

	const arrowheadPath = new Path2D()
	arrowheadPath.moveTo(90, 95)
	arrowheadPath.lineTo(100, 100)
	arrowheadPath.lineTo(95, 90)

	const labelClipPath = new Path2D()
	labelClipPath.rect(40, 40, 20, 20)

	return {
		path: bodyPath,
		clipPath: labelClipPath, // Areas to exclude from the main path
		additionalPaths: [arrowheadPath], // Extra paths to stroke
	}
}
```

#### Collaborator indicators

In multiplayer sessions, indicators show other users' selections. These appear with the collaborator's assigned color and slightly reduced opacity. The canvas indicator system handles collaborator indicators automatically.

#### Related topics

| Topic                                                      | Description                                |
| ---------------------------------------------------------- | ------------------------------------------ |
| [Shapes](https://tldraw.dev/docs/shapes)                                     | Creating custom shapes with ShapeUtil      |
| [User interface](https://tldraw.dev/docs/user-interface)                     | Customizing tldraw's UI components         |
| [Custom indicators example](https://tldraw.dev/examples/ui/indicators-logic) | Working example of indicator customization |

### Collaboration

The tldraw SDK supports real-time collaboration. Add collaboration to your project with our [tldraw sync](https://tldraw.dev/docs/sync) library, or use our low-level data APIs to integrate other backends.

#### Quick start

The fastest way to add multiplayer is with the `useSyncDemo` hook from [`@tldraw/sync`](https://tldraw.dev/docs/sync). This connects to a hosted demo server that handles synchronization.

```bash
npm install @tldraw/sync
```

```tsx
import { Tldraw } from 'tldraw'
import { useSyncDemo } from '@tldraw/sync'

function MyApp() {
	const store = useSyncDemo({ roomId: 'my-unique-room-id' })
	return <Tldraw store={store} />
}
```

Any apps connecting to the same room ID will enter a shared collaboration session. Open your project in an incognito window or different browser to test it out.

The demo server is great for prototyping, but data only lasts 24 hours and rooms are publicly accessible. For production, you'll need to self-host the tldraw sync server—see our full guide on [tldraw sync](https://tldraw.dev/docs/sync).

#### Comments

Collaborators can also leave anchored comments on the canvas. The `@tldraw/commenting` package provides a comment tool, pins that stick to points and shapes, thread popovers with replies, mentions, reactions, and resolve, and a sidebar list of threads.

```tsx
import {
	CanvasComments,
	CommentAuthor,
	commentToolOverrides,
	commentTools,
} from '@tldraw/commenting'
import { useSync } from '@tldraw/sync'
import { commentSchemaRecords, TLComponents, Tldraw } from 'tldraw'
import '@tldraw/commenting/commenting.css'

// Your app's user directory, however you already hold it.
const AUTHORS: Record<string, CommentAuthor> = {
	'user-123': { name: 'You', color: '#EC5E41' },
}

const components: TLComponents = {
	InFrontOfTheCanvas: () => (
		<CanvasComments currentUserId="user-123" resolveAuthor={(id) => AUTHORS[id]} />
	),
}

function MyApp() {
	const store = useSync({
		uri: 'wss://your-server.com/sync/my-room',
		assets: myAssetStore,
		records: commentSchemaRecords,
	})

	return (
		<Tldraw
			store={store}
			tools={commentTools}
			overrides={[commentToolOverrides]}
			components={components}
		/>
	)
}
```

Comment records are opt-in, so register `commentSchemaRecords` on your server's schema too. Commenting is a licensed feature. See [Commenting](https://tldraw.dev/docs/commenting) for an introduction, or the [full guide](https://tldraw.dev/sdk-features/commenting) for anchors, options, and server setup.

#### Using other backends

While [tldraw sync](https://tldraw.dev/docs/sync) is our recommended solution, the tldraw SDK works with any real-time data backend. For example, [Liveblocks offers a tldraw integration](https://liveblocks.io/examples/tldraw-whiteboard), and many teams have connected tldraw to their own infrastructure.

Collaboration involves three concerns:

- **Synchronizing data** — Getting document changes in and out of the [store](https://tldraw.dev/sdk-features/store). See [Persistence](https://tldraw.dev/docs/persistence) for the basics, or [Collaboration](https://tldraw.dev/sdk-features/collaboration) for building custom sync.
- **User presence** — Sharing cursor positions, selections, and viewports with other users. See [Collaboration](https://tldraw.dev/sdk-features/collaboration) for presence APIs and [Cursors](https://tldraw.dev/sdk-features/cursors) for customizing how collaborator cursors appear.
- **Collaboration UI** — The visual elements that show other users on the canvas. See [UI components](https://tldraw.dev/sdk-features/ui-components) for overriding components like `SharePanel`. The `OverlayUtil` system renders collaborator cursors, brushes, scribbles, and selection indicators—see [Overlay utils](https://tldraw.dev/sdk-features/overlay-utils).

#### Related

- [tldraw sync](https://tldraw.dev/docs/sync) — Our recommended multiplayer solution
- [Collaboration](https://tldraw.dev/sdk-features/collaboration) — Deep dive on presence, sync hooks, and building custom sync
- [Commenting](https://tldraw.dev/docs/commenting) — Anchored comment threads on the canvas
- [Cursors](https://tldraw.dev/sdk-features/cursors) — Cursor types and collaborator cursor customization
- [User following](https://tldraw.dev/sdk-features/user-following) — Track collaborator viewports in real-time
- [Store](https://tldraw.dev/sdk-features/store) — Understanding the reactive store
- [UI components](https://tldraw.dev/sdk-features/ui-components) — Override collaboration UI components

### AI integrations

The tldraw SDK provides a foundation for AI-powered canvas applications. You can use language models to read, interpret, and generate content on the canvas—either by driving the editor APIs directly, or by building reactive systems where AI participates in visual workflows.

#### Approaches

There are three main patterns for integrating AI with tldraw:

1. **Canvas as output** — Use the canvas to display AI-generated content like images, diagrams, or interactive websites
2. **Visual workflows** — Use shapes and bindings to create node-based systems where AI models participate in data flows
3. **AI agents** — Give language models direct access to read and manipulate the canvas through the editor APIs

#### Canvas as output

The simplest AI integration uses the canvas as a surface for displaying generated content. When an AI model generates an image, website preview, or other visual output, you can place it on the canvas as a shape.

##### Embedding generated content

Use the `EmbedShapeUtil` to display websites, or create custom shapes to render generated images and HTML content:

```tsx
// Create a shape to display AI-generated content
editor.createShape({
	type: 'embed',
	x: 100,
	y: 100,
	props: {
		url: 'https://generated-preview.example.com/abc123',
		w: 800,
		h: 600,
	},
})
```

For generated images, use the `AssetRecordType` to create an asset from a blob or URL, then create an image shape:

```tsx
const asset = AssetRecordType.create({
	id: AssetRecordType.createId(),
	type: 'image',
	props: {
		src: generatedImageUrl,
		w: 512,
		h: 512,
		mimeType: 'image/png',
		name: 'generated-image.png',
		isAnimated: false,
	},
})

editor.createAssets([asset])
editor.createShape({
	type: 'image',
	x: 100,
	y: 100,
	props: {
		assetId: asset.id,
		w: 512,
		h: 512,
	},
})
```

##### Custom preview shapes

For richer AI output, create a custom shape that renders generated content. This approach works well for live HTML previews, interactive prototypes, or any content that needs special rendering:

```tsx
// Abbreviated example—a full ShapeUtil also requires getDefaultProps, getGeometry, and getIndicatorPath
class PreviewShapeUtil extends ShapeUtil<PreviewShape> {
	static override type = 'preview' as const

	component(shape: PreviewShape) {
		return (
			<HTMLContainer>
				<iframe
					srcDoc={shape.props.html}
					sandbox="allow-scripts"
					style={{ width: '100%', height: '100%', border: 'none' }}
				/>
			</HTMLContainer>
		)
	}
}
```

This pattern is useful for "make real" style applications where users sketch a UI and an AI model generates working code to preview alongside the original drawing.

#### Visual workflows

The tldraw binding system enables node-based visual programming where AI models can be part of a larger workflow. Shapes represent operations or data sources, and bindings connect them to form processing pipelines.

##### Workflow architecture

In a visual workflow, each node is a custom shape with input and output ports. Connections between nodes are bindings that track relationships as shapes move. When data flows through the system, each node processes its inputs and produces outputs for downstream nodes.

See the [Workflow starter kit](https://tldraw.dev/starter-kits/workflow) for a complete implementation of this pattern. The starter kit includes:

- Custom node shapes with configurable ports
- A binding system for smart connections that update as nodes move
- An execution engine that resolves dependencies and runs nodes in order
- Tools for creating and managing connections

##### Adding AI to workflows

To add AI capabilities to a workflow, create node types that call AI models:

```tsx
// Illustrative pseudocode—see the workflow starter kit for the full NodeDefinition interface
class LLMNode extends NodeDefinition<LLMNodeData> {
	static type = 'llm'

	async execute(shape, node, inputs) {
		const response = await fetch('/api/generate', {
			method: 'POST',
			body: JSON.stringify({ prompt: inputs.prompt }),
		})
		const result = await response.json()
		return { output: result.text }
	}
}
```

Workflow systems let users compose AI operations visually. They connect prompt sources to models, route outputs to other processing steps, and build complex pipelines without writing code. This is similar to tools like ComfyUI for image generation workflows but uses the tldraw canvas as a foundation.

#### AI agents

For full canvas control, you can give AI models direct access to read and manipulate shapes. An agent can observe what's on the canvas, understand spatial relationships, and create or modify shapes to accomplish tasks.

##### Agent architecture

The [Agent starter kit](https://tldraw.dev/starter-kits/agent) provides a complete implementation. The agent gathers visual context through screenshots and structured shape data. This gives the model an understanding of the current canvas state. The agent operates through a modular action system with utilities for creating shapes, drawing, and arranging elements. Responses stream in real-time so users see the agent's work as it happens. A memory system maintains conversation history and context across interactions.

##### Using the agent programmatically

The agent exposes a simple API for triggering canvas operations:

```tsx
// Inside a component wrapped by TldrawAgentAppProvider
const agent = useAgent()

// Simple prompt
agent.prompt('Draw a flowchart showing user authentication')

// With additional context
agent.prompt({
	message: 'Add labels to these shapes',
	bounds: { x: 0, y: 0, w: 500, h: 400 },
})
```

##### How agents see the canvas

The agent builds context from multiple sources:

- A screenshot of the current viewport
- Simplified representations of shapes within view
- Information about shape clusters outside the viewport
- The user's current selection and recent actions
- Conversation history from the session

This dual approach—visual screenshots plus structured data—gives the model both spatial understanding and precise information about shape properties.

##### How agents manipulate the canvas

Agents perform operations through typed action schemas. Each action has a defined structure, and the agent system validates, sanitizes, and applies actions to the editor:

```tsx
// Simplified illustration—the actual implementation converts through an intermediate
// shape format. See CreateActionUtil in the agent starter kit for the full code.
class CreateActionUtil extends AgentActionUtil<CreateAction> {
	override applyAction(action: Streaming<CreateAction>, helpers: AgentHelpers) {
		if (!action.complete) return

		const position = helpers.removeOffsetFromVec({ x: action.x, y: action.y })

		this.editor.createShape({
			type: action.shapeType,
			x: position.x,
			y: position.y,
			props: action.props,
		})
	}
}
```

The sanitization layer handles common LLM mistakes. It corrects shape IDs that don't exist, ensures new IDs are unique, and normalizes coordinates.

#### Reading canvas content

For applications where AI reads but doesn't modify the canvas, you can export canvas content for analysis.

##### Screenshots

Use `Editor#getSvgString` or `Editor#toImage` to export the current view or specific regions:

```tsx
const svg = await editor.getSvgString(editor.getCurrentPageShapes())
const { blob } = await editor.toImage(editor.getCurrentPageShapes(), { format: 'png' })
```

##### Structured data

Access shape data directly from the store for text extraction or structured analysis. Use `ShapeUtil#getText` to extract a shape's text content as a plain string:

```tsx
const shapes = editor.getCurrentPageShapes()

const textContent = shapes.map((shape) => ({
	id: shape.id,
	type: shape.type,
	text: editor.getShapeUtil(shape).getText(shape),
	bounds: editor.getShapePageBounds(shape),
}))
```

##### Combining approaches

For best results, send both visual and structured data to the model. The visual representation shows spatial relationships and styling, while the structured data provides exact values for text and positions.

#### Starter kits

We provide several starter kits for AI integrations:

| Kit                                            | Description                                                      |
| ---------------------------------------------- | ---------------------------------------------------------------- |
| [Agent](https://tldraw.dev/starter-kits/agent)                   | Full agent system with visual context and canvas manipulation    |
| [Chat](https://tldraw.dev/starter-kits/chat)                     | Canvas for sketching and annotation as context for chat          |
| [Branching chat](https://tldraw.dev/starter-kits/branching-chat) | Visual conversation trees with AI responses                      |
| [Workflow](https://tldraw.dev/starter-kits/workflow)             | Node-based visual programming that can incorporate AI operations |

#### Related

- [Agent starter kit](https://tldraw.dev/starter-kits/agent) — Complete AI agent implementation
- [Workflow starter kit](https://tldraw.dev/starter-kits/workflow) — Visual programming with node graphs
- [Shapes](https://tldraw.dev/docs/shapes) — Creating custom shapes for AI-generated content
- [Bindings](https://tldraw.dev/sdk-features/bindings) — Connecting shapes for workflow systems
- [Image export](https://tldraw.dev/sdk-features/image-export) — Exporting canvas content for AI analysis

### Commenting

In tldraw, a comment is a message pinned to a place on the canvas. Comments group into threads, one conversation per pin. Every comment records who wrote it, as an id your app resolves to a name.

The `@tldraw/commenting` package works at two levels. `CanvasComments` is a comments layer you render in front of the canvas, and it handles the whole flow. Everything it's built from is exported too, so you can replace any part of it.

Commenting is a licensed feature. It runs in development without a key. In production it needs a tldraw license that includes commenting.

#### Quick start

Three pieces: register the comment record types, register the comment tool, and render the layer.

```tsx
import {
	CanvasComments,
	CommentAuthor,
	commentToolOverrides,
	commentTools,
} from '@tldraw/commenting'
import { useMemo } from 'react'
import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
import '@tldraw/commenting/commenting.css'
import 'tldraw/tldraw.css'

const AUTHORS: Record<string, CommentAuthor> = { me: { name: 'You', color: '#EC5E41' } }
const resolveAuthor = (id: string) => AUTHORS[id]

const components: TLComponents = {
	InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
}

export default function App() {
	const store = useMemo(
		() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
		[]
	)

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				licenseKey={YOUR_LICENSE_KEY}
				store={store}
				tools={commentTools}
				overrides={[commentToolOverrides]}
				components={components}
			/>
		</div>
	)
}
```

Pick the comment tool from Quick Actions, or press `C`, then click the canvas to start a thread.

The layer's two required inputs are both about identity: `currentUserId` is the id stamped on whatever the user posts, and `resolveAuthor` turns an author id into a name, a color, and an optional avatar image. Commenting reads no user directory of its own, so these are where you connect it to whatever your app already knows about its users. With the read-status and mention callbacks they make up the `CommentingContext`, which `CanvasCommentsSidebar` takes too — build it once and spread it into both.

#### Comments are records

Comment threads and comments are records in the editor's store, exactly like shapes. That's the fact most worth holding onto, because almost everything else follows from it.

They aren't in the default schema, so you opt in by registering `commentSchemaRecords`. Once you have, comments persist and sync however your document already does. Adding a `persistenceKey` or a sync backend carries them along with no extra work.

It also means you can read and write them yourself. Query the store for threads, seed a document with review notes, or build a panel that does something the built-in sidebar doesn't.

```tsx
import { useCommentThreads } from '@tldraw/commenting'
import { useEditor } from 'tldraw'

function OpenThreadCount() {
	const editor = useEditor()
	const threads = useCommentThreads(editor)
	return <div>{threads.filter((thread) => !thread.resolved).length} open threads</div>
}
```

#### Anchors

What pins a thread to the canvas is its anchor. A thread can anchor to a point on the page, to a shape it then follows as that shape moves and resizes, to a rectangular region, or to the page as a whole.

Clicking empty canvas gives you a point anchor and clicking a shape gives you a shape anchor. Region anchors are off by default; turn them on and dragging the tool out covers an area.

Shape-anchored threads outlive their shape. Delete the shape and the thread converts to a point anchor where its pin last sat, so the conversation doesn't disappear along with the thing it was about.

#### What you get

The comments layer covers the whole flow out of the box:

| Feature      | Description                                                        |
| ------------ | ------------------------------------------------------------------ |
| Threads      | Pins on the canvas, opening to replies, edit, resolve, and delete. |
| Mentions     | `@`-mentions in composers, resolved against a roster you supply.   |
| Reactions    | Emoji reactions on comments, with a pluggable palette.             |
| A sidebar    | A filterable list of threads beside the canvas.                    |
| Clustering   | Nearby pins fold into count badges as you zoom out.                |
| Unread state | Pin badges and an unread filter, driven by your app's read data.   |

Each of these has options, and each visible piece is a component slot you can replace. See [Commenting](https://tldraw.dev/sdk-features/commenting) for the full guide.

#### Configuring

Commenting options live on the tool. `CommentTool.configure()` returns a configured subclass to register, mirroring `ShapeUtil.configure`:

```tsx
const tools = [CommentTool.configure({ enableRegions: true })]
```

The option worth knowing about first is `history`. Comment writes default to `'ignore'`, which keeps them off the undo stack, and that default matters in a shared document: an undoable delete would resurrect a thread a collaborator already removed. See [Comments and undo](https://tldraw.dev/sdk-features/commenting#comments-and-undo).

#### Permissions

`canComment` decides whether the viewer may participate, and the UI follows it: composers give way to a fallback slot and the action affordances hide. `canModifyComment` decides the writes that belong to someone in particular — editing a comment, deleting a comment, deleting a thread. Unset, each is its record's owner's to make; a callback widens that (a workspace admin who may remove anyone's comment) or narrows it.

They're UI-level controls and nothing more. Comment records carry a client-supplied author id, so rules about who may post, edit, or delete belong in your sync server's record authorization, where each incoming record is checked against the session's identity.

#### Syncing

Comments sync like the rest of your document, with one registration on each side. Pass `records: commentSchemaRecords` to your sync hook, and the same map to `createTLSchema` on the server.

On the server you can go a step further and serve comments through the room's object-store lane. Lane records are gated by their own per-session permission rather than by `isReadonly`, which is how a viewer who can't edit the document can still comment. See [Syncing comments](https://tldraw.dev/sdk-features/commenting#syncing-comments).

#### Related

- [Commenting](https://tldraw.dev/sdk-features/commenting) — The full guide: anchors, options, components, and sync
- [Collaboration](https://tldraw.dev/docs/collaboration) — Adding multiplayer to your project
- [tldraw sync](https://tldraw.dev/docs/sync) — Running a sync server
- [Commenting example](https://tldraw.dev/examples/collaboration/commenting) — The flow end to end

### tldraw sync

You can add realtime multi-user collaboration to your tldraw app by using **tldraw sync**. It's our library for fast, fault-tolerant shared document syncing. We use it in production on our flagship app [tldraw.com](https://tldraw.com).

We offer a [hosted demo](https://tldraw.dev/docs/collaboration) of tldraw sync which is suitable for prototyping. To use tldraw sync in production, you will need to host it yourself.

#### Deploying tldraw sync

There are two main ways to go about hosting tldraw sync:

1. Deploy a full backend to Cloudflare using our template (recommended).
2. Integrate tldraw sync into your own JavaScript backend using our examples and docs as a guide.

##### Use our Cloudflare template

The best way to get started hosting your own backend is to clone and deploy [our Cloudflare template](https://github.com/tldraw/tldraw-sync-cloudflare). The template provides the same setup that runs on tldraw.com.

It uses:

- [Durable Objects](https://developers.cloudflare.com/durable-objects/) to provide a unique WebSocket server per room. Room state is persisted automatically to the durable object's built-in SQLite storage.
- [R2](https://developers.cloudflare.com/r2/) to store large binary assets like images and videos.

There are some features that we have not provided and you might want to add yourself, such as authentication and authorization, rate limiting and size limiting for asset uploads, storing snapshots of documents over time for long-term history, and listing and searching for rooms.

Make sure you also read the section below about [deployment concerns](#deployment-concerns).

[Get started with the Cloudflare template](https://github.com/tldraw/tldraw-sync-cloudflare).

##### Integrate tldraw sync into your own backend

We recommend Cloudflare. The `@tldraw/sync-core` library also integrates tldraw sync into any JavaScript server environment that supports WebSockets.

We have a [simple server example](https://github.com/tldraw/tldraw/tree/main/templates/simple-server-example), supporting both NodeJS and Bun, to use as a reference for how things should be stitched together.

#### What does a tldraw sync backend do?

A backend for tldraw sync consists of two or three parts:

- A **WebSocket server** that provides rooms for each shared document, and is responsible for synchronizing and persisting document state.
- An **asset storage** provider for large binary files like images and videos.
- (If using the built-in bookmark shape) An **unfurling service** to extract metadata about bookmark URLs.

On the frontend, there is just one part: the **sync client**, created using the `useSync` hook from the `@tldraw/sync` package.

Here's a simple client implementation:

```tsx
import { Tldraw, TLAssetStore, Editor } from 'tldraw'
import { useSync } from '@tldraw/sync'
import { uploadFileAndReturnUrl } from './assets'
import { convertUrlToBookmarkAsset } from './unfurl'

function MyEditorComponent({ myRoomId }) {
	// This hook creates a sync client that manages the websocket connection to the server
	// and coordinates updates to the document state.
	const store = useSync({
		// This is how you tell the sync client which server and room to connect to.
		uri: `wss://my-custom-backend.com/connect/${myRoomId}`,
		// This is how you tell the sync client how to store and retrieve blobs.
		assets: myAssetStore,
	})
	// When the tldraw Editor mounts, you can register an asset handler for the bookmark URLs.
	return <Tldraw store={store} onMount={registerUrlHandler} />
}

const myAssetStore: TLAssetStore = {
	upload(asset, file) {
		return uploadFileAndReturnUrl(file)
	},
	resolve(asset) {
		return asset.props.src
	},
}

function registerUrlHandler(editor: Editor) {
	editor.registerExternalAssetHandler('url', async ({ url }) => {
		return await convertUrlToBookmarkAsset(url)
	})
}
```

And [here's a full working example](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx) of the client-side code.

##### WebSocket server

The `@tldraw/sync-core` package exports a class called `TLSocketRoom` that should be created server-side on a per-document basis.

`TLSocketRoom` is used to

- Store an authoritative copy of the document state.
- Transparently set up communication between multiple sync clients via WebSockets.
- Provide hooks for persisting the document state when it changes.

	You should make sure that there's only ever one `TLSocketRoom` globally for each room in your app.
	If there's more than one, users won't see each other and will overwrite others' changes. We use
	[Durable Objects](https://developers.cloudflare.com/durable-objects/) to achieve this on
	tldraw.com.

Read the reference docs for `TLSocketRoom`, and see an example of how to use it in the [simple server example](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/server/rooms.ts).

##### Storage backends

`TLSocketRoom` requires a storage backend to persist document state. The `@tldraw/sync-core` package provides two options:

###### InMemorySyncStorage (default)

`InMemorySyncStorage` keeps all document state in memory. It's simple to use, but data is lost when the process restarts. To persist data, pass an `onChange` callback to the constructor and save snapshots to your database.

```tsx
import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'

const storage = new InMemorySyncStorage({
	snapshot: existingData, // optional: load from your database
	onChange() {
		// Save to your database when changes occur
		saveToDatabase(storage.getSnapshot())
	},
})

const room = new TLSocketRoom({ storage })
```

###### SQLiteSyncStorage (recommended for persistence)

`SQLiteSyncStorage` stores document state in SQLite. Data survives process restarts automatically. This is the recommended approach for production deployments.

**For Cloudflare Durable Objects:**

```tsx
import { DurableObject } from 'cloudflare:workers'
import { SQLiteSyncStorage, DurableObjectSqliteSyncWrapper, TLSocketRoom } from '@tldraw/sync-core'

export class TLSyncDurableObject extends DurableObject {
	private room: TLSocketRoom

	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env)
		const sql = new DurableObjectSqliteSyncWrapper(ctx.storage)
		const storage = new SQLiteSyncStorage({ sql })
		this.room = new TLSocketRoom({ storage })
	}
}
```

**For Node.js with better-sqlite3 or node:sqlite:**

```tsx
import Database from 'better-sqlite3' // replace with 'node:sqlite' if using
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom } from '@tldraw/sync-core'

const db = new Database('rooms.db')
const sql = new NodeSqliteWrapper(db)
const storage = new SQLiteSyncStorage({ sql })
const room = new TLSocketRoom({ storage })
```

	`SQLiteSyncStorage` automatically creates and manages its database tables. You can pass a
	`tablePrefix` option to the SQL wrapper to avoid conflicts if you're sharing a database with other
	data.

##### WebSocket hibernation

Some serverless platforms let WebSocket connections survive while the surrounding object hibernates. [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/best-practices/websockets/) is the most common example. The platform keeps the sockets open, but your in-memory state (including the `TLSocketRoom`) is gone when the object wakes back up. Every wake would force every client to reconnect from scratch.

`TLSocketRoom` exposes three APIs for hibernation. The `onSessionSnapshot` callback fires when a session has had no message activity for about 5 seconds, so you can persist its state. `TLSocketRoom#getSessionSnapshot` returns that snapshot on demand. `TLSocketRoom#handleSocketResume` restores a session straight into `Connected` state when the object wakes back up.

Here's the pattern in a Cloudflare Durable Object that uses the WebSocket Hibernation API:

```tsx
import {
	DurableObjectSqliteSyncWrapper,
	type SessionStateSnapshot,
	SQLiteSyncStorage,
	TLSocketRoom,
} from '@tldraw/sync-core'
import { TLRecord } from '@tldraw/tlschema'
import { DurableObject } from 'cloudflare:workers'

interface SocketAttachment {
	sessionId: string
	snapshot?: SessionStateSnapshot
}

export class TldrawDurableObject extends DurableObject {
	private room: TLSocketRoom<TLRecord, void> | null = null
	// Maps sessionId → ws. Populate this in your webSocketMessage handler.
	private readonly sessionIdToWs = new Map<string, WebSocket>()

	private getOrCreateRoom() {
		if (this.room) return this.room

		const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
		const storage = new SQLiteSyncStorage<TLRecord>({ sql })

		this.room = new TLSocketRoom<TLRecord, void>({
			storage,
			// Cloudflare keeps WebSockets alive across hibernation, so let it manage timeouts.
			clientTimeout: Infinity,
			// Persist each session's snapshot to its WebSocket attachment when it goes idle.
			onSessionSnapshot: (sessionId, snapshot) => {
				const ws = this.sessionIdToWs.get(sessionId)
				if (ws) ws.serializeAttachment({ sessionId, snapshot })
			},
		})

		// Resume any sessions whose sockets survived hibernation.
		for (const ws of this.ctx.getWebSockets()) {
			const attachment = ws.deserializeAttachment() as SocketAttachment | null
			if (attachment?.snapshot) {
				this.room.handleSocketResume({
					sessionId: attachment.sessionId,
					socket: ws,
					snapshot: attachment.snapshot,
				})
			}
		}

		return this.room
	}
}
```

Three pieces make this work. The `onSessionSnapshot` callback persists each session's state to its WebSocket's attachment so the snapshot is still around after hibernation. When the object wakes up with sockets still open, `handleSocketResume` replays each saved snapshot and the session lands straight in `Connected` state without the client noticing. Setting `clientTimeout: Infinity` disables the room's idle timer; hibernating platforms handle keep-alive themselves and would otherwise see the room disconnect perfectly fine clients.

For a complete implementation, including the hibernation event handlers that populate `sessionIdToWs`, see the [sync-cloudflare template](https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare).

For non-hibernating environments (Node servers, long-lived processes), you don't need any of this. The in-memory `TLSocketRoom` outlives individual sockets, and the default `clientTimeout` keeps idle sessions tidy.

##### Asset storage

Tldraw also needs a way to store and retrieve large binary assets like images and videos.

You'll need to make sure your backend can handle asset uploads & downloads, then implement
`TLAssetStore` to connect it to tldraw.

- Read about [how assets work in tldraw](https://tldraw.dev/docs/assets).
- Read the `TLAssetStore` reference docs.
- See a complete example of an asset store in the
  [`tldraw-sync-cloudflare`](https://github.com/tldraw/tldraw/blob/main/templates/sync-cloudflare/client/multiplayerAssetStore.tsx)
  template.

##### Unfurling service

If you want to use the built-in bookmark shape, you'll need to use or implement an unfurling service that returns metadata about URLs.

This should be registered with the `Editor` when it loads.

```tsx
<Tldraw
	store={store}
	onMount={(editor) => {
		editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
	}}
/>
```

Refer to the simple server example for example [client](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/client/App.tsx) and [server](https://github.com/tldraw/tldraw/blob/main/templates/simple-server-example/src/server/unfurl.ts) code.

#### Using tldraw sync in your app

##### Custom shapes & bindings

`@tldraw/sync` validates the contents of your document and runs migrations to make sure clients of
different versions can collaborate without issue. To support this, you need to make sure that both
the sync client and server know about any custom shapes or bindings you've added.

###### On the client

You can pass `shapeUtils` and `bindingUtils` props to `useSync`. Unlike `<Tldraw />`,
these don't automatically include tldraw's default shapes like arrows and rectangles. You should
pass those in explicitly if you're using them:

```tsx
import { useMemo } from 'react'
import { Tldraw, defaultShapeUtils, defaultBindingUtils } from 'tldraw'
import { useSync } from '@tldraw/sync'

function MyApp() {
	const store = useSync({
		uri: '...',
		assets: myAssetStore,
		shapeUtils: useMemo(() => [...customShapeUtils, ...defaultShapeUtils], []),
		bindingUtils: useMemo(() => [...customBindingUtils, ...defaultBindingUtils], []),
	})

	return <Tldraw store={store} shapeUtils={customShapeUtils} bindingUtils={customBindingUtils} />
}
```

###### On the server

Use `createTLSchema` to create a store schema, and pass that into `TLSocketRoom`. You can
use shape/binding utils here, but the schema will only look at two properties:
[`props`](https://tldraw.dev/reference/editor/ShapeUtil#props) and
[`migrations`](https://tldraw.dev/sdk-features/persistence#migrations). You need to provide the default shape
schemas if you're using them.

```tsx
import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema'
import { TLSocketRoom } from '@tldraw/sync-core'

const schema = createTLSchema({
	shapes: {
		...defaultShapeSchemas,

		myCustomShape: {
			// Validations for this shape's `props`.
			props: myCustomShapeProps,
			// Migrations between versions of this shape.
			migrations: myCustomShapeMigrations,
		},

		// The schema knows about this shape, but it has no migrations or validation.
		mySimpleShape: {},
	},
	bindings: defaultBindingSchemas,
})

// Later, in your app server:
const room = new TLSocketRoom({
	schema: schema,
	// ...
})
```

Both `props` and `migrations` are optional. If you omit `props`, you won't have any server-side
validation for your shape, which could result in bad data being stored. If you omit `migrations`,
clients on different versions won't be able to collaborate without errors.

##### Comments and the object-store lane

Comment threads, comments, and reactions are record types that aren't in the default schema. Register
them with the `records` option on both ends—`useSync` on the client, `createTLSchema` on the
server. Both sides must register the same types, or the connection will fail schema validation.

```ts
import { TLSocketRoom } from '@tldraw/sync-core'
import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'

const schema = createTLSchema({ records: commentSchemaRecords })

const room = new TLSocketRoom({
	schema,
	objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
})
```

`objectTypes` serves those record types through the room's object-store lane instead of the document
lane. Lane records are persisted separately from the document, left out of document snapshots, and
gated by a per-session `objectAccess` permission rather than by `isReadonly`. That's how a session
can be allowed to comment without being allowed to edit:

```ts
room.handleSocketConnect({
	sessionId,
	socket,
	isReadonly: true,
	objectAccess: 'write',
})
```

`objectAccess` is `'read'` or `'write'` and defaults to `'write'`. Read the lane's contents with
`TLSocketRoom`'s `getCurrentObjectsSnapshot()`, and use the room's `onCommittedChanges` callback to
mirror committed records into your own database. See [Commenting](https://tldraw.dev/sdk-features/commenting) for the
client side.

##### Deployment concerns

	You must make sure that the tldraw version in your client matches the version on the server. We
	don't guarantee server backwards compatibility forever, and very occasionally we might release a
	version where the backend cannot meaningfully support older clients, in which case tldraw will
	display a "please refresh the page" message. So you should make sure that the backend is updated
	at the same time as the client, and that the new backend is up and running just before the new
	client rolls out.

##### Migrating data from a legacy system

If you have been using some other solution for data sync, you can migrate your existing data to the tldraw sync format.

Both `InMemorySyncStorage` and `SQLiteSyncStorage` support loading `TLStoreSnapshot` snapshots, so you can add a backwards-compatibility layer that lazily imports data from your old system and converts it to a `TLStoreSnapshot`.

**Example with SQLiteSyncStorage (recommended):**

```tsx
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom } from '@tldraw/sync-core'
import Database from 'better-sqlite3'

function loadOrMakeRoom(roomId: string, db: Database.Database) {
	const sql = new NodeSqliteWrapper(db, { tablePrefix: `room_${roomId}_` })

	// Check if we already have data in SQLite
	if (SQLiteSyncStorage.hasBeenInitialized(sql)) {
		const storage = new SQLiteSyncStorage({ sql })
		return new TLSocketRoom({ storage })
	}

	// Try to load from legacy system
	const legacyData = loadRoomDataFromLegacyStore(roomId)
	if (legacyData) {
		const snapshot = convertOldDataToSnapshot(legacyData)
		const storage = new SQLiteSyncStorage({ sql, snapshot })
		deleteLegacyRoomData(roomId)
		return new TLSocketRoom({ storage })
	}

	// No data - create a new empty room
	return new TLSocketRoom({ storage: new SQLiteSyncStorage({ sql }) })
}
```

**Example with InMemorySyncStorage:**

```tsx
import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'

async function loadOrMakeRoom(roomId: string) {
	const data = await loadRoomDataFromCurrentStore(roomId)
	if (data) {
		const storage = new InMemorySyncStorage({ snapshot: data })
		return new TLSocketRoom({ storage })
	}
	const legacyData = await loadRoomDataFromLegacyStore(roomId)
	if (legacyData) {
		// Convert your old data to a TLStoreSnapshot.
		const snapshot = convertOldDataToSnapshot(legacyData)
		// Load it into the room.
		const storage = new InMemorySyncStorage({ snapshot })
		const room = new TLSocketRoom({ storage })
		// Save an updated copy of the snapshot in the new place
		// so that next time we can load it directly.
		await saveRoomData(roomId, storage.getSnapshot())
		// Optionally delete the old data.
		await deleteLegacyRoomData(roomId)
		// And finally return the room.
		return room
	}
	// If there's no data at all, just make a new blank room.
	return new TLSocketRoom({ storage: new InMemorySyncStorage() })
}
```

##### Migrating from R2 to SQLite on Cloudflare

If you were previously using R2 to store room snapshots (as shown in earlier versions of the sync-cloudflare template), you can migrate to SQLite storage while preserving existing data.

**Step 1: Update wrangler.toml if needed**

If your Durable Object class was originally created _without_ SQLite support, you need to add a new migration with a new Durable Object class that uses SQLite storage.
You need to keep the old class for at least one release because it can't be deleted while it's still being used, but you can convert it to an empty class.

```toml
# Keep existing migration for old class
[[migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]

# Add new migration for SQLite-backed class
[[migrations]]
tag = "v2"
new_sqlite_classes = ["TldrawDurableObjectSqlite"]

[durable_objects]
bindings = [
    # Point to the new SQLite-backed class
    { name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObjectSqlite" },
]
```

**Step 2: Rename your Durable Object and add fallback loading**

Rename your existing class (e.g. `TldrawDurableObject` → `TldrawDurableObjectSqlite`) and update the loading logic to check SQLite first, then fall back to R2:

```tsx
import {
	DurableObjectSqliteSyncWrapper,
	RoomSnapshot,
	SQLiteSyncStorage,
	TLSocketRoom,
} from '@tldraw/sync-core'
import { TLRecord, createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'
import { AutoRouter, error, IRequest } from 'itty-router'

// Empty stub for the old class name - required until migration is complete
export class TldrawDurableObject {}

const schema = createTLSchema({ shapes: defaultShapeSchemas })

// Renamed from TldrawDurableObject
export class TldrawDurableObjectSqlite {
	private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
	private roomId: string | null = null

	constructor(
		private ctx: DurableObjectState,
		private env: Env
	) {
		// Load roomId from DO storage (needed for R2 fallback)
		ctx.blockConcurrencyWhile(async () => {
			this.roomId = (await ctx.storage.get('roomId')) as string | null
		})
	}

	// ... handleConnect, router, etc. stay the same ...

	private async loadRoom(): Promise<TLSocketRoom<TLRecord, void>> {
		const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)

		// If SQLite already has data, use it directly
		if (SQLiteSyncStorage.hasBeenInitialized(sql)) {
			const storage = new SQLiteSyncStorage<TLRecord>({ sql })
			return new TLSocketRoom<TLRecord, void>({ schema, storage })
		}

		// Try to load from R2 (legacy storage, only happens once per room)
		if (this.roomId) {
			const r2Object = await this.env.TLDRAW_BUCKET.get(`rooms/${this.roomId}`)
			if (r2Object) {
				const snapshot = (await r2Object.json()) as RoomSnapshot
				const storage = new SQLiteSyncStorage<TLRecord>({ sql, snapshot })

				// Optionally delete the R2 object after successful migration
				// await this.env.TLDRAW_BUCKET.delete(`rooms/${this.roomId}`)

				return new TLSocketRoom<TLRecord, void>({ schema, storage })
			}
		}

		// No existing data - create fresh room
		const storage = new SQLiteSyncStorage<TLRecord>({ sql })
		return new TLSocketRoom<TLRecord, void>({ schema, storage })
	}
}
```

**Step 3: Update your worker exports**

Make sure both classes are exported from your worker entry point:

```tsx
export { TldrawDurableObject, TldrawDurableObjectSqlite } from './TldrawDurableObject'
```

The `loadRoom` function checks SQLite first (fast path for already-migrated rooms), falls back to R2 if SQLite is empty (one-time migration), and creates fresh storage for new rooms. You can optionally delete R2 data after migration to save storage costs.

Once all your rooms have been accessed at least once, the migration is complete. You can then remove the R2 fallback code and the stub `TldrawDurableObject` class.

### Driving the editor

You can drive the tldraw editor programmatically with the **`@tldraw/driver`** package. It wraps an `Editor` with an imperative, fluent API for simulating user input. You can use it for scripting, automation, REPL sessions, and writing tests.

#### Quick start

Install the package alongside `tldraw`:

```bash
npm install @tldraw/driver
```

Wrap an `Editor` instance with a `Driver` and dispatch input:

```ts
import { Driver } from '@tldraw/driver'

const driver = new Driver(editor)

driver.click(100, 200).pointerDown(300, 400).pointerMove(500, 600).pointerUp()

driver.keyPress('a')

driver.dispose()
```

Every input method returns `this`, so calls can be chained. Call `dispose` when you're done so the driver can clean up its side-effect handlers.

#### Simulating input

Pointer, keyboard, wheel, and pinch events all flow through `editor.dispatch`, so they go through the editor's normal tool state machines. A `pointerDown` → `pointerMove` → `pointerUp` sequence with the draw tool active creates real draw shapes:

```ts
editor.setCurrentTool('draw')
driver.pointerDown(100, 100)
driver.pointerMove(150, 120)
driver.pointerMove(200, 140)
driver.pointerUp()
```

Pointer coordinates are in screen space. For page-space positions, use `editor.pageToScreen` to convert before dispatching.

Keyboard helpers track modifier state automatically:

```ts
driver.keyDown('Shift')
driver.click(100, 100, { target: 'canvas' })
driver.keyUp('Shift')
```

#### Manipulating the selection

Selection helpers work in page coordinates and convert to screen space internally, so you can move, rotate, and resize the current selection without computing pointer paths by hand:

```ts
driver.translateSelection(50, 0)
driver.rotateSelection(Math.PI / 4)
driver.resizeSelection({ scaleX: 2 }, 'bottom_right')
```

#### Clipboard

The driver keeps its own in-memory clipboard, independent of the system clipboard. Useful for scripted copy/paste flows and testing:

```ts
driver.copy() // copies the current selection
driver.paste({ x: 400, y: 400 })
```

#### Queries

The driver uses `editor.sideEffects` to track the shapes it creates, so you can grab the most recent result of a scripted action:

```ts
const shape = driver.getLastCreatedShape()
const lastFive = driver.getLastCreatedShapes(5)
```

See `Driver` for the full list of query helpers (shape/selection page centers, rotations, arrows bound to a shape, etc.).

#### Related

- `Driver` — Full reference for every input, selection, clipboard, and query method
- `Editor` — The editor class the driver wraps
- [Shapes](https://tldraw.dev/docs/shapes) — Defining shape types that driver-produced input will interact with
- [Tools](https://tldraw.dev/docs/tools) — Understanding the tool state machines that process simulated events

### Mermaid diagrams

You can turn [Mermaid](https://mermaid.js.org/) diagram syntax into native, editable tldraw shapes with the **`@tldraw/mermaid`** package. Instead of rendering a static SVG, it parses Mermaid text and creates real geo shapes, arrows, frames, and groups on the canvas—so users can move, resize, restyle, and connect them like any other shape.

#### Quick start

Install the package alongside `tldraw`:

```bash
npm install @tldraw/mermaid
```

Then call `createMermaidDiagram` with an `Editor` and a Mermaid source string:

```tsx
import { createMermaidDiagram } from '@tldraw/mermaid'

await createMermaidDiagram(
	editor,
	`
  flowchart TD
    A[Start] --> B{Decision}
    B -->|Yes| C[Do something]
    B -->|No| D[Do something else]
`
)
```

By default the diagram is centered on the current viewport. Pass a `blueprintRender.position` to place it at a specific page point, and set `centerOnPosition: false` to anchor its top-left corner instead of its center.

#### Supported diagram types

| Diagram type     | Mermaid keyword      | What you get                                                        |
| ---------------- | -------------------- | ------------------------------------------------------------------- |
| Flowchart        | `flowchart`, `graph` | Geo shapes, arrows, subgraph frames                                 |
| Sequence diagram | `sequenceDiagram`    | Actor shapes, lifelines, signal arrows, fragment frames             |
| State diagram    | `stateDiagram-v2`    | State shapes, transitions, compound state frames, fork/join, choice |
| Mindmap          | `mindmap`            | Colored geo shapes, parent-child edges, tree hierarchy              |

For unsupported types (pie, gantt, class, ER, etc.) pass an `onUnsupportedDiagram` callback—for example, to fall back to importing Mermaid's rendered SVG:

```tsx
await createMermaidDiagram(editor, text, {
	async onUnsupportedDiagram(svgString) {
		await editor.putExternalContent({ type: 'svg-text', text: svgString })
	},
})
```

Without a callback, `createMermaidDiagram` throws a `MermaidDiagramError` for unsupported types and parse failures.

#### Handling pasted Mermaid text

The most common integration is converting Mermaid text when users paste it onto the canvas. Register an external content handler that sniffs for a Mermaid keyword and dynamically imports the package:

```tsx
import { useEffect } from 'react'
import { defaultHandleExternalTextContent, useEditor } from 'tldraw'

const MERMAID_KEYWORD =
	/^\s*(flowchart|graph|sequenceDiagram|stateDiagram|classDiagram|erDiagram|gantt|pie|gitGraph|mindmap)/

export function MermaidPasteHandler() {
	const editor = useEditor()

	useEffect(() => {
		editor.registerExternalContentHandler('text', async (content) => {
			if (!MERMAID_KEYWORD.test(content.text)) {
				await defaultHandleExternalTextContent(editor, content)
				return
			}

			try {
				const { createMermaidDiagram } = await import('@tldraw/mermaid')
				await createMermaidDiagram(editor, content.text, {
					async onUnsupportedDiagram(svgString) {
						await editor.putExternalContent({ type: 'svg-text', text: svgString })
					},
				})
			} catch {
				await defaultHandleExternalTextContent(editor, content)
			}
		})
	}, [editor])

	return null
}
```

Drop `<MermaidPasteHandler />` inside your `Tldraw` component and pasting Mermaid text will render it as shapes.

	The `mermaid` dependency is ~2 MB. The pattern above pre-screens with a lightweight regex and
	lazy-loads `@tldraw/mermaid` only when the text matches, so users who never paste Mermaid don't
	pay the cost.

#### Customizing node shapes

By default, blueprint nodes are materialized as tldraw geo shapes. If you want to render them as your own custom shape type instead—for example, sticky notes for mindmap leaves, or a bespoke "actor" shape for sequence diagrams—pass `mapNodeToRenderSpec` on `blueprintRender`:

```tsx
await createMermaidDiagram(editor, text, {
	blueprintRender: {
		mapNodeToRenderSpec(input) {
			if (input.diagramKind === 'mindmap') {
				return { variant: 'shape', type: 'note', props: {} }
			}
			// Return undefined to keep the package default for this node.
			return undefined
		},
	},
})
```

The callback receives `diagramKind`, `nodeId`, `kind`, and the full `MermaidBlueprintNode`, and returns a `MermaidBlueprintNodeRenderSpec`—either a `geo` variant or a custom `shape` type that's registered on the editor. See `createMermaidDiagram` and `renderBlueprint` in the reference for the full API.

#### Examples

- [Hundreds of mermaid diagrams](https://tldraw.dev/examples/use-cases/hundred-mermaids) — A runnable demo rendering many diagram types at once
- [Customizing mermaid diagrams](https://tldraw.dev/examples/use-cases/custom-shape-mermaids) — Converting mermaid diagram nodes into custom shapes

#### Related

- `createMermaidDiagram`, `renderBlueprint`, `MermaidDiagramError` — Entry points and errors
- `DiagramMermaidBlueprint`, `MermaidBlueprintNode`, `MermaidBlueprintNodeRenderSpec` — The data model you can customize
- [Shapes](https://tldraw.dev/docs/shapes) — Define a custom shape you can map Mermaid nodes to
- [External content handlers](https://tldraw.dev/sdk-features/external-content) — More on `registerExternalContentHandler` for the paste integration

### LLM documentation

The tldraw docs are optimized for use with large language models (LLMs). Whether you're using an AI coding assistant, building an agent that works with tldraw, or prompting a chat model, you can access our documentation in formats designed for LLM consumption.

#### llms.txt

We publish our documentation at [tldraw.dev/llms.txt](https://tldraw.dev/llms.txt), following the [llms.txt standard](https://llmstxt.org/) for providing LLM-friendly content. This file serves as an index to all SDK documentation, examples, and release notes.

The index includes links to several focused exports:

| File                                                      | Contents                             |
| --------------------------------------------------------- | ------------------------------------ |
| [llms.txt](https://tldraw.dev/llms.txt)                   | Index with links to all resources    |
| [llms-full.txt](https://tldraw.dev/llms-full.txt)         | All SDK docs, releases, and examples |
| [llms-docs.txt](https://tldraw.dev/llms-docs.txt)         | SDK feature documentation only       |
| [llms-releases.txt](https://tldraw.dev/llms-releases.txt) | Release notes only                   |
| [llms-examples.txt](https://tldraw.dev/llms-examples.txt) | Examples with full source code       |

##### Using llms.txt with AI assistants

When working with an AI coding assistant, you can provide context by including the relevant llms.txt file in your prompt or conversation. For example:

- Use `llms-docs.txt` when asking about SDK features, APIs, or implementation patterns
- Use `llms-examples.txt` when looking for code examples or implementation references
- Use `llms-releases.txt` when asking about recent changes or migration between versions
- Use `llms-full.txt` when you need comprehensive context across all documentation

Many AI tools support fetching URLs directly, so you can reference these files by URL in your prompts.

#### Copy markdown button

Every documentation page on tldraw.dev includes a "Copy markdown" button in the header. Click it to copy the page content as clean markdown, ready to paste into any LLM conversation.

This is useful when you want to:

- Ask an AI assistant about a specific feature
- Include documentation context in a prompt
- Reference our docs in your own documentation or notes

The copied markdown preserves headings, code blocks, links, and other formatting while removing site-specific elements that aren't relevant in a plain text context.

#### Building AI-powered apps

If you're building AI-powered applications with tldraw, see our [AI integrations](https://tldraw.dev/docs/ai) guide. It covers patterns for using the canvas with AI models, including:

- Using the canvas to display AI-generated content
- Building visual workflows with AI nodes
- Creating AI agents that can read and manipulate the canvas

## Starter Kits

Ready-to-use templates to jumpstart your tldraw projects.

### Starter kits

Starter kits provide example implementations of common canvas-based use cases. Use them to prototype ideas or as the foundation for new projects. The source code is yours to hack on.

---

#### Create with tldraw

You can access starter kits using the `npm create tldraw@latest` command:

```bash
npm create tldraw@latest
```

Or by using a template repository on [GitHub](https://github.com/orgs/tldraw/repositories).

---

#### Available kits

##### [Workflow](https://tldraw.dev/starter-kits/workflow)

Build visual tools where users drag, connect, and execute nodes on the canvas. Includes node and connection shapes, automatic connection routing, and a replaceable execution engine.

##### [Chat](https://tldraw.dev/starter-kits/chat)

A canvas for sketching, annotating, and marking up images before sending them to an AI chat.

##### [Agent](https://tldraw.dev/starter-kits/agent)

An AI agent that interprets and manipulates drawings and other elements on the canvas.

##### [Image pipeline](https://tldraw.dev/starter-kits/image-pipeline)

Visual node-based builder for AI image generation pipelines. Chain prompts, models, and processing steps to create complex image workflows with real-time preview.

##### [Branching chat](https://tldraw.dev/starter-kits/branching-chat)

A visual branching conversation interface for creating interactive chat trees with AI integration.

##### [Multiplayer](https://tldraw.dev/starter-kits/multiplayer)

Self-hosted tldraw with real-time multiplayer collaboration powered by tldraw sync and Cloudflare Durable Objects.

##### [Shader](https://tldraw.dev/starter-kits/shader)

WebGL shader backgrounds that respond to shapes and interactions on the canvas.

---

#### Why build with starter kits

- Complete starter code built around the tldraw SDK
- Ready for real-time collaboration
- Custom shape systems and tool implementations

**Ship features in weeks, not months**
    Skip the months of research and implementation. Each kit provides tested solutions for complex canvas interactions.

**Production-ready from day one**
    Start further, prototype faster. Get extra features, patterns, and components relevant to your use-case.

**Built for customization**
    Use as a reference or the foundation for something new. The starter kit code is MIT licensed and free to hack with.

**Validated by engineering teams**
    Built around common use cases. Every component uses the SDK's extensible APIs. Add custom shapes, tools, and behaviors on top.

---

Starter kits use the same [license](https://tldraw.dev/community/license) as the tldraw SDK.

### Workflow starter kit

To build with a workflow starter kit, run this command in your terminal:

```bash
npm create tldraw@latest -- --template workflow
```

---

#### Use cases

Use the workflow starter kit to build:

---

#### How it works

![Diagram showing how nodes, ports, and connections form a flow of operations. Nodes like Multiply, Divide, and Subtract take inputs and produce outputs through ports. Results are passed via connections (arrows) between ports: Multiply outputs 40, Divide outputs 2.5, and Subtract combines them to get 37.5. At the bottom, abstract Node A and Node B illustrate that connections link specific ports, defined as start or end points.](https://tldraw.dev/images/starter-kits/chart-workflow.png)

##### 1. Shape system: Nodes and connections

Nodes are custom tldraw shapes that represent workflow steps. Each node has input and output ports defined in its shape utility, and new node types can be created or customized. Nodes accept inputs and produce outputs, which can be joined together with connections. Connections are also shapes, but they use tldraw's binding system to stay attached to specific ports on nodes.

##### 2. Binding system: Smart connections

When you create a connection between nodes, tldraw's binding system tracks the relationship. To create a connection, drag from a port. If you move a node, its connections update automatically. You can also drag an existing connection to reconnect it elsewhere or disconnect it entirely. The binding utilities handle connection lifecycle as nodes change.

##### 3. Interaction layer: Port tools

The starter kit extends tldraw's select tool with custom port interactions. When you click or drag near a port, the `PointingPort` tool activates. You can create connections by dragging from any port, or click an output port to open a node picker and insert a new connected node. These behaviors are implemented using tldraw's state machine system, where tools are organized as states that can have child states.

##### 4. Execution engine

The execution system is designed to be replaced by your own business logic. It reads your node graph, resolves dependencies (which nodes need to run before others), and executes them in the correct order. Nodes expose their computation through a simple interface, and the engine handles the orchestration. You can run a workflow graph (for example, using a "Play" action) to see how information flows between nodes.

##### 5. Data flow and processing

You control the data that flows through your workflow and what happens to it. The starter kit provides the infrastructure: nodes update instantly to show their results as data flows through the connections. The framework moves data between nodes and triggers updates when values change. Your data can come from user inputs, external APIs, databases, or file uploads.

---

#### Customization

##### Adding custom nodes

To add custom node types, create a new file in `src/nodes/types`. The easiest way is to duplicate an existing node, like `MultiplyNode.tsx`.

Start by defining the type of your node:

```tsx
import { T } from 'tldraw'

// First, we create a validator for our node type
export const CustomNode = T.object({
	type: T.literal('custom'), // each node needs a unique "type"
	someData: T.number,
	// ...
})

// Then, we can derive a typescript type from the validator
export type CustomNode = T.TypeOf<typeof CustomNode>
```

Once you have your node’s type definition, create a node definition for it:

```tsx
export class CustomNodeDefinition extends NodeDefinition<CustomNode> {
	static type = 'custom' // This must match "type" from above
	static validator = CustomNode

	// How to label your node in the UI
	title = 'My custom node'
	heading = 'Custom' // Shown in the node header
	icon = <span>🐝</span>

	// Return a default version of your node
	getDefault() { ... }

	// Return the height of your node, in pixels
	getBodyHeightPx(shape, node) { ... }

	// Return all ports for your node. Each port has a terminal:
	// 'end' = input port (receives data), 'start' = output port (sends data)
	getPorts(shape, node) { ... }

	// Run this node! Work through the input port values and
	// produce values for the output ports.
	async execute(shape, node, inputs) { ... }

	// Get values to use as outputs when we're NOT running
	// this node. Often, you might return a previously computed
	// value from `execute`.
	getOutputInfo(shape, node, inputs) { ... }

	// A react component for rendering your node on the canvas.
	Component = CustomNodeComponent
}

function CustomNodeComponent({ shape, node }) {
  return <div>...</div>
}
```

Next, add your node definition to the system. In `src/nodes/nodeTypes.tsx`, include it in `NodeDefinitions`. Finally, add your node to the UI: open `src/components/WorkflowToolbar.tsx` and insert a `<ToolbarItem tool="node-custom" />` wherever you’d like your node to appear in the toolbar. If your node has any input ports (terminal = end), you can also add an `OnCanvasComponentPickerItem` for it in `src/components/OnCanvasComponentPicker`.

##### Data fetching and integrations

Adding data fetching or integrations to a node is straightforward. You can make `fetch` requests from your custom node’s `execute` method. See `src/nodes/types/EarthquakeNode.tsx` for an example of a node that, when run, fetches recent earthquakes from the [USGS API](https://earthquake.usgs.gov/fdsnws/event/1/). Once fetched, it picks a random earthquake, displays some data, and outputs the magnitude to be used in downstream nodes.

You may also want to store data from the last execution to display in the UI. In the earthquake example, the data is stashed in an `earthquakeData` prop in the node definition. In `execute`, the shape is updated to store this data. Then, both the UI and `getOutputInfo` can reference it.

##### Extending execution

Because it’s just a demo, this starter kit has a very minimal execution model: values are either numbers or a special `STOP_EXECUTION` flag used to implement conditionals.

If you want to change the type of data flowing through the system, edit `WorkflowValue` in `src/nodes/types/shared.tsx`. You’ll need to resolve type errors elsewhere, since several places assume the system only works with numbers.

The execution system is defined in `src/execution/ExecutionGraph.tsx`. It’s designed to be easily replaced by your own custom engine. For example, you might want executions to run entirely server-side, or you might want to “test” nodes individually from the canvas and then “deploy” a running workflow as an automation on a server.

##### UI customization

Customizing tldraw’s UI mostly works by replacing specific components. Take a look at `src/App.tsx` to see how we diverge from tldraw’s default UI:

- We add the on-canvas component picker and workflow outline/play buttons in `InFrontOfTheCanvas`.
- We add a custom vertical toolbar to the left of the screen and some extra actions in the bottom of the screen in `Toolbar`.
- We remove the `MenuPanel` entirely.
- We selectively hide the `StylePanel` depending on what’s selected.

To further customize the UI of this starter kit, read up on [customizing tldraw’s UI as a whole](https://tldraw.dev/docs/user-interface).

---

#### Further reading

- **[Shape utilities](https://tldraw.dev/docs/shapes)**: Learn how to create custom shapes and extend tldraw's shape system with advanced geometry, rendering, and interaction patterns.
- **[Binding system](https://tldraw.dev/sdk-features/bindings)**: Learn more about tldraw's binding system for creating relationships between shapes, automatic updates, and connection management.
- **[Editor state management](https://tldraw.dev/docs/editor)**: Learn how to work with tldraw's reactive state system, editor lifecycle, and event handling for complex canvas applications.
- **[Customize the user interface](https://tldraw.dev/docs/user-interface)**: Learn how to customize the user interface of your tldraw application.

---

#### Building with this starter kit?

If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. We want to see what you've built!

### Chat starter kit

To build with a chat starter kit, run this command in your terminal:

```bash
npm create tldraw@latest -- --template chat
```

Once you've followed the instructions, you'll need to create a `.env.local` file with an API key from [Google AI Studio](https://aistudio.google.com/apikey):

```
GOOGLE_GENERATIVE_AI_API_KEY=<your key>
```

---

#### Use cases

This starter kit shows how to add visual capabilities to:

---

#### How it works

This starter kit demonstrates how to add tldraw's visual capabilities to an existing chat application. The integration uses a modal to bring annotation tools directly into your chat UI.

![A screenshot of a chat interface with labels pointing to different features. The interface includes a text box with two previously added sketches/images, and icons to upload and annotate an image, create a whiteboard sketch, and send a message. Red arrows with labels point to features: “Start a new chat,” “Message history,” “Previously added images & sketches,” “Upload & annotate an image,” “Create a whiteboard sketch,” and “Send message.”](https://tldraw.dev/images/starter-kits/chat-screenshot.png)

##### 1. Whiteboard modal

The whiteboard opens a modal tldraw editor whenever users want to create or edit visual content. The modal supports uploaded images, freehand drawing, and editing of images from chat history.

##### 2. Automatic image annotation

When an image is uploaded for the first time, the editor places it on the canvas, centers the camera on it, and switches into cropping mode using `editor.setCurrentTool('select.crop')`. From there, users can quickly crop their image to the desired section or add annotations on top.

##### 3. Snapshot saving

Once the user has finished sketching or annotating, the editor exports the work as a PNG ready to send to the LLM. At the same time, it saves a tldraw snapshot of the session. This allows users to reopen the image as a whiteboard and continue working from where they left off, without flattening the individual shapes into pixels.

---

#### Customization

This starter kit provides a minimal integration between tldraw and a chat app. You can tailor the modal's appearance, available tools, and overall behavior.

##### Modifying the upload workflow

You can customize the automatic crop behavior, add image processing steps, or extend support for additional file types to match your app's needs.

See `src/components/WhiteboardModal.tsx:195` for the automatic crop activation and `src/components/Chat.tsx:148` for drag-and-drop handling.

##### Adapting to your design system

Since tldraw is built with React, you can adapt both the modal behavior and its appearance to integrate with your chat interface. This includes controlling when the modal opens, how it looks, and which tools are available.

For example, see `src/components/WhiteboardModal.tsx:92` for UI component overrides, `src/components/Chat.tsx:113` for the history image click handler, and `src/app/styles.css` for styling.

---

#### Further reading

- **[Custom shape utilities](https://tldraw.dev/docs/shapes#custom-shapes)**: Learn how to create custom shapes with their own interactions.

- **[Editor API](https://tldraw.dev/docs/editor)**: Learn about the tldraw Editor.

- **[Customize the user interface](https://tldraw.dev/docs/user-interface)**: Learn how to customize the user interface of your tldraw application.

---

#### Building with this starter kit?

If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. We want to see what you've built!

### Agent starter kit

To build with an agent starter kit, run this command in your terminal:

```bash
npm create tldraw@latest -- --template agent
```

Then create a `.dev.vars` file in the project root with API keys for the model providers you want to use:

```
ANTHROPIC_API_KEY=<your key>
GOOGLE_API_KEY=<your key>
OPENAI_API_KEY=<your key>
```

We recommend Anthropic for best results. Get an API key from the [Anthropic dashboard](https://console.anthropic.com/settings/keys).

---

#### Use cases

Use the agent starter kit to build visual AI assistants that read and modify drawings, diagram generators that turn text descriptions into flowcharts or architecture diagrams, and tools that convert hand-drawn sketches into structured digital content.

#### How it works

##### What the agent can do

With its default configuration, the agent can perform the following actions:

- Create, update and delete shapes.
- Draw freehand pen strokes.
- Use higher-level operations on multiple shapes at once: rotate, resize, align, distribute, stack and reorder shapes.
- Write out its thinking and send messages to the user.
- Keep track of its task by writing and updating a todo list.
- Move its viewport to look at different parts of the canvas.
- Count shapes matching a given expression.
- Schedule further work and reviews to be carried out in follow-up requests.
- Call external APIs.

##### Architecture

##### 1. User input

To make decisions on what to do, the agent gathers information from various sources:

- The user's message.
- The user's current selection of shapes.
- What the user can currently see on their screen.
- Any additional context the user has provided, such as specific shapes or a particular position or area on the canvas.
- Actions the user has recently taken.
- A screenshot of the agent's current view of the canvas.
- A simplified format of all shapes within the agent's viewport.
- Information about clusters of shapes outside the agent's viewport.
- The history of the current session, including the user's messages and all of the agent's actions.
- Lints identifying potential issues with shapes on the canvas.

##### 2. Visual context system: Canvas understanding

The agent captures both visual screenshots and structured shape data from the canvas. This dual approach means the model understands both the visual layout and the underlying data structure, including spatial relationships between shapes.

The agent's "eyes" are defined by `PromptPartUtil` classes that gather different types of context, from user messages and canvas screenshots to shape data and interaction history.

##### 3. Action system: Canvas manipulation

The agent performs canvas operations through a modular action system. Each action type handles a specific kind of canvas modification, from creating shapes to multi-step operations like aligning or distributing groups of shapes. Every action includes its own validation, execution, and chat-panel presentation logic.

The agent's "hands" are defined by `AgentActionUtil` classes that specify what operations the agent can perform on the canvas.

##### 4. Mode system: Contextual capabilities

The agent operates in one of several modes, each with its own set of capabilities tailored to a specific kind of task. The default `working` mode has full access to the canvas. A narrower mode might strip away drawing actions and add critique-specific ones for reviewing work, or swap in different tools for a planning phase. Modes let the same agent take on different roles at different points in a task.

##### 5. Streaming system: Real-time AI responses

AI responses stream in real time. Users see the agent's thinking and its canvas modifications as they happen. Shapes get created, updated, and deleted incrementally as each action finishes streaming, so the canvas stays responsive throughout long, multi-step tasks.

##### 6. Manager architecture: State decomposition

The agent's state is organized into focused managers, each responsible for a single concern: chat history, model selection, contextual shapes, the todo list, mode transitions, and more. This decomposition keeps the agent's behavior easy to reason about as the template grows, and adding a new capability means adding a new manager rather than expanding a monolith.

##### 7. Integration system: Model flexibility

This starter kit supports multiple AI providers (Anthropic, OpenAI, Google) with consistent interfaces. The system abstracts provider differences, so you can switch models or use different providers for different operations. Adding support for a new provider doesn't require changes to the rest of the system.

#### Use the agent programmatically

Aside from using the chat panel UI, you can also prompt the agent programmatically.

Call the `prompt()` method to start an agentic loop. The agent will continue until it has finished the task you've given it.

```tsx
// Inside a component wrapped by TldrawAgentAppProvider
const agent = useAgent()
agent.prompt('Draw a cat')
```

You can optionally specify further details about the request in the form of an `AgentInput` object:

```tsx
agent.prompt({
	message: 'Draw a cat in this area',
	bounds: {
		x: 0,
		y: 0,
		w: 300,
		h: 400,
	},
})
```

There are more methods on the `TldrawAgent` class that can help when building an agentic app:

- `agent.cancel()` - Cancel the agent's current task.
- `agent.reset()` - Reset the agent's chat and memory.
- `agent.request(input)` - Send a single request to the agent and handle its response _without_ entering into an agentic loop.

---

#### Customize the agent

The agent's behavior is defined in `client/modes/AgentModeDefinitions.ts`. The `AGENT_MODE_DEFINITIONS` array contains mode definitions. Each mode has two arrays:

- `parts` determine what the agent can **see**.
- `actions` determine what the agent can **do**.

Add, edit or remove an entry in either array to change what the agent can see or do in a given mode.

##### The mode system

Modes control which parts and actions the agent has access to at any given time. They're defined in `client/modes/AgentModeDefinitions.ts`.

The default `working` mode includes all standard capabilities. You can create additional modes with different subsets of parts and actions.

Call `agent.mode.setMode(modeType)` to change modes during a prompt. To control each mode's lifecycle, implement any of the lifecycle hooks in `client/modes/AgentModeChart.ts`:

- `onEnter(agent, fromMode)` - runs when the agent enters a mode.
- `onExit(agent, toMode)` - runs when the agent exits a mode.
- `onPromptStart(agent, request)` - runs when a prompt commences.
- `onPromptEnd(agent, request)` - runs when a prompt ends.
- `onPromptCancel(agent, request)` - runs when a prompt is canceled.

##### Change what the agent can see

**Change what the agent can see by adding, editing or removing a prompt part.**

Prompt parts assemble the prompt that the model receives, with each part adding a different piece of information. This includes the user's message, the model name, the system prompt, chat history and more.

A prompt part has two pieces: a `PromptPartUtil` class on the client that gathers data, and a `PromptPartDefinition` on the worker that turns that data into messages.

This example shows how to let the model see what the current time is.

First, define a prompt part type:

```tsx
interface TimePart extends BasePromptPart<'time'> {
	time: string
}
```

Then, create a prompt part util in `client/parts/`:

```tsx
export const TimePartUtil = registerPromptPartUtil(
	class TimePartUtil extends PromptPartUtil<TimePart> {
		static override type = 'time' as const

		override getPart(): TimePart {
			return {
				type: 'time',
				time: new Date().toLocaleTimeString(),
			}
		}
	}
)
```

Next, create the prompt part definition in `shared/schema/PromptPartDefinitions.ts`:

```tsx
export const TimePartDefinition: PromptPartDefinition<TimePart> = {
	type: 'time',
	priority: -100,
	buildContent({ time }: TimePart) {
		return [`The user's current time is: ${time}`]
	},
}
```

To enable the prompt part, import its util in `client/modes/AgentModeDefinitions.ts` and add `TimePartUtil.type` to a mode's `parts` array. Its methods will be used to assemble its data and send it to the model.

- `getPart()` - Gather any data needed to construct the prompt.
- `buildContent()` - Turn the data into messages to send to the model.

There are other fields available on the `PromptPartDefinition` interface that you can override for more granular control.

- `priority` - Control where this prompt part will appear in the list of messages sent to the model. Higher priority appears later in the prompt.
- `getModelName()` - Determine which AI model to use.
- `buildMessages()` - Manually override how prompt messages are constructed from the prompt part.

##### Change what the agent can do

**Change what the agent can do by adding, editing or removing an `AgentActionUtil`.**

Agent action utils define which actions the agent can perform. Each `AgentActionUtil` adds a different capability.

This example shows how to allow the agent to clear the screen.

First, define an agent action by creating a schema for it in `shared/schema/AgentActionSchemas.ts`:

```tsx
export const ClearAction = z
	// All agent actions must have a _type field
	// The underscore encourages the model to put this field first
	.object({
		_type: z.literal('clear'),
	})
	// A title and description tell the model what the action does
	.meta({
		title: 'Clear',
		description: 'The agent deletes all shapes on the canvas.',
	})

// Infer the action's type
export type ClearAction = z.infer<typeof ClearAction>
```

Then, create an agent action util in `client/actions/`:

```tsx
export const ClearActionUtil = registerActionUtil(
	class ClearActionUtil extends AgentActionUtil<ClearAction> {
		static override type = 'clear' as const

		override applyAction(action: Streaming<ClearAction>) {
			// Don't do anything until the action has finished streaming
			if (!action.complete) return

			// Get the editor
			const { editor } = this

			// Delete all shapes on the page
			const shapes = editor.getCurrentPageShapes()
			editor.deleteShapes(shapes)
		}
	}
)
```

To enable the agent action, import its util in `client/modes/AgentModeDefinitions.ts` and add `ClearActionUtil.type` to a mode's `actions` array. Its method will be used to execute the action.

- `applyAction()` - Execute the action.

There are other methods available on the `AgentActionUtil` class that you can override for more granular control.

- `getInfo()` - Determine how the action gets displayed in the chat panel UI.
- `savesToHistory()` - Control whether actions get saved to chat history or not.
- `sanitizeAction()` - Apply transformations to the action before saving it to history and applying it. More details on transformations below.

##### Change how actions appear in chat history

**Configure the icon and description of an action in the chat panel UI using the `getInfo()` method.**

```tsx
override getInfo() {
	return {
		icon: 'trash' as const,
		description: 'Cleared the canvas',
	}
}
```

You can make an action collapsible by adding a `summary` property.

```tsx
override getInfo() {
	return {
		summary: 'Cleared the canvas',
		description: 'After much consideration, the agent decided to clear the canvas',
	}
}
```

To customize an action's appearance via CSS, define styles for the `agent-action-type-{TYPE}` class where `{TYPE}` is the type of the action.

```css
.agent-action-type-clear {
	color: red;
}
```

##### Managers

Managers are classes that extend `TldrawAgent` or `TldrawAgentApp` with a focused responsibility, such as chat history, model selection, or context management. They're available as properties on the agent instance, for example `agent.chat`, `agent.modelName`, and `agent.context`.

To add a custom manager, extend `BaseAgentManager` or `BaseAgentAppManager` and attach it to the agent in `client/agent/TldrawAgent.ts`.

##### Registering utils

Utils use a self-registration pattern. When you create a new `PromptPartUtil` or `AgentActionUtil`, wrap it with a registration function:

```tsx
export const MyPartUtil = registerPromptPartUtil(
	class MyPartUtil extends PromptPartUtil<MyPart> {
		// ...
	}
)
```

This ensures the util is discovered automatically when its module is imported in `AgentModeDefinitions.ts`.

##### Mode-scoped actions

Different modes can implement actions with the same `_type`. For example, a `team-member` mode and a `solo` mode might both have a `mark-task-done` action, but with different implementations.

To register a mode-specific action util, pass the `forModes` option:

```tsx
export const MarkTeamMemberTaskDoneActionUtil = registerActionUtil(
	class MarkTeamMemberTaskDoneActionUtil extends AgentActionUtil<MarkTeamMemberTaskDoneAction> {
		static override type = 'mark-task-done' as const
		override applyAction(action: Streaming<MarkTeamMemberTaskDoneAction>) {
			// Team member-specific implementation
		}
	},
	{ forModes: ['team-member'] }
)
```

Default schemas are auto-registered when exported from `AgentActionSchemas.ts`. For mode-specific schemas, call `registerActionSchema` explicitly with the `forModes` option.

See the [template README](https://github.com/tldraw/tldraw/blob/main/templates/agent/README.md#mode-scoped-actions) for a full worked example.

##### Schedule further work

You can let the agent work over multiple turns by scheduling further work using the `schedule` method as part of an action.

This example shows how to schedule an extra step for adding detail to the canvas.

```tsx
override applyAction(action: Streaming<AddDetailAction>) {
	if (!action.complete) return
	if (!this.agent) return
	this.agent.schedule('Add more detail to the canvas.')
}
```

As with the `prompt` method, you can specify further details about the request.

```tsx
agent.schedule({
	message: 'Add more detail in this area.',
	bounds: { x: 0, y: 0, w: 100, h: 100 },
})
```

You can schedule multiple things by calling the `schedule()` method more than once.

```tsx
agent.schedule('Add more detail to the canvas.')
agent.schedule('Check for spelling mistakes.')
```

To interrupt the agent with a new prompt instead of waiting for the current prompt to end, use the `interrupt` method. `interrupt` also lets you specify a mode to transition into.

```tsx
override applyAction(action: Streaming<EnterReviewingModeAction>) {
	if (!action.complete) return
	this.agent.interrupt({
		mode: 'reviewing',
		input: {
			message: 'Review the new area thoroughly for any mistakes',
			bounds: action.bounds,
		},
	})
}
```

##### Retrieve data from an external API

To let the agent retrieve information from an external API, fetch the data within `applyAction` and schedule a follow-up request with any data you want the agent to have access to.

```tsx
override async applyAction(action: Streaming<CountryInfoAction>) {
	if (!action.complete) return
	if (!this.agent) return

	// Fetch from the external API
	const data = await fetchCountryInfo(action.code)

	// Schedule a follow-up request with the data
	this.agent.schedule({ data: [data] })
}
```

##### Sanitize data received from the model

The model can make mistakes. Sometimes this is due to hallucinations, and sometimes this is due to the canvas changing since the last time the model saw it. Either way, an incoming action might contain invalid data by the time you receive it.

To correct incoming mistakes, apply fixes in the `sanitizeAction()` method of an action util. They'll get carried out before the action is applied to the editor or saved to chat history.

For example, ensure that a shape ID received from the model refers to an existing shape by using the `ensureShapeIdExists()` method.

```tsx
override sanitizeAction(action: Streaming<DeleteAction>, helpers: AgentHelpers) {
	if (!action.complete) return action

	// Ensure the shape ID refers to an existing shape
	action.shapeId = helpers.ensureShapeIdExists(action.shapeId)

	// If the shape ID doesn't refer to an existing shape, cancel the action
	if (!action.shapeId) return null

	return action
}
```

The `AgentHelpers` class contains more helpers for sanitizing data received from the model.

- `ensureShapeIdExists()` - Ensure that a shape ID refers to a real shape. Useful for interacting with existing shapes.
- `ensureShapeIdIsUnique()` - Ensure that a shape ID is unique. Useful for creating new shapes.
- `ensureValueIsVec()`, `ensureValueIsNumber()` - Ensure that a value is a certain type. Useful for more complex actions where the model is more likely to make mistakes.

##### Send positions to and from the model

By default, every position sent to the model is offset by the starting position of the current chat.

**To apply this offset to a position sent to the model, use the `applyOffsetToVec` method.**

```tsx
override getPart(request: AgentRequest, helpers: AgentHelpers): ViewportCenterPart {
	if (!this.editor) return { part: 'user-viewport-center', center: null }

	// Get the center of the user's viewport
	const viewportCenter = this.editor.getViewportBounds().center

	// Apply the chat's offset to the vector
	const offsetViewportCenter = helpers.applyOffsetToVec(viewportCenter)

	// Return the prompt part
	return {
		part: 'user-viewport-center',
		center: offsetViewportCenter,
	}
}
```

**To remove the offset from a position received from the model, use the `removeOffsetFromVec` method.**

```tsx
override applyAction(action: Streaming<MoveAction>, helpers: AgentHelpers) {
	if (!action.complete) return

	// Remove the offset from the position
	const position = helpers.removeOffsetFromVec({ x: action.x, y: action.y })

	// Do something with the position...
}
```

It's a good idea to round numbers before sending them to the model. If you want to be able to restore the original number later, use the `roundAndSaveNumber` and `unroundAndRestoreNumber` methods.

```tsx
// In `getPart`...
const roundedX = helpers.roundAndSaveNumber(x, 'my_key_x')
const roundedY = helpers.roundAndSaveNumber(y, 'my_key_y')

// In `applyAction`...
const unroundedX = helpers.unroundAndRestoreNumber(x, 'my_key_x')
const unroundedY = helpers.unroundAndRestoreNumber(y, 'my_key_y')
```

To round all the numbers on a shape, use the `roundShape` and `unroundShape` methods. See the [Send shapes to the model](#send-shapes-to-the-model) section below for more details.

##### Send shapes to the model

By default, the agent converts tldraw shapes to various simplified formats to improve the model's understanding and performance.

There are three main formats used in this starter:

- `BlurryShape` - The format for shapes within the agent's viewport. It contains a shape's bounds, its ID, its type, and any text it contains. The "blurry" name refers to the fact that the agent can't make out the details of shapes from this format. Instead, it gives the model an overview of what it's looking at.
- `FocusedShape` - The format for shapes that the agent is focusing on, such as shapes you've manually added to its context. The format contains most of a shape's properties, including color, fill, alignment, and any other shape-specific information. This is also the format the model outputs when creating shapes.
- `PeripheralShapeCluster` - The format for shapes outside the agent's viewport. Nearby shapes are grouped together into clusters, each with the group's bounds and a count of how many shapes are inside it. This is the least detailed format. Its role is to give the model an awareness of shapes elsewhere on the page.

To send the model some shapes in one of these formats, use one of the conversion functions found within the `shared/format` folder, such as `convertTldrawShapeToFocusedShape()`.

This example picks one random shape on the canvas and sends it to the model in the Focused format.

```tsx
override getPart(request: AgentRequest, helpers: AgentHelpers): RandomShapePart {
	const { editor } = this
	if (!this.editor) return { type: 'random-shape', shape: null}

	// Get a random shape
	const shapes = editor.getCurrentPageShapes()
	const randomShape = shapes[Math.floor(Math.random() * shapes.length)]

	// Convert the shape to the Focused format
	const focusedShape = convertTldrawShapeToFocusedShape(editor, randomShape)

	// Normalize the shape's position
	const offsetShape = helpers.applyOffsetToShape(focusedShape)
	const roundedShape = helpers.roundShape(offsetShape)

	return {
		type: 'random-shape',
		shape: roundedShape,
	}
}
```

##### Change the system prompt

To change the default system prompt, edit the sections in `worker/prompt/sections/`. These sections are assembled by `worker/prompt/buildSystemPrompt.ts`.

The system prompt is rebuilt for each step in the agentic loop depending on which actions and parts are available in the agent's current mode. To give the model more detailed instructions for how to use any custom actions or parts you add, edit `worker/prompt/sections/rules-section.ts`.

##### Change to a different model

You can set an agent's model by calling `setModelName` on the `modelName` manager.

```tsx
agent.modelName.setModelName('gemini-3-flash-preview')
```

To override an agent's model, specify a different model name with a request.

```tsx
agent.prompt({
	modelName: 'gemini-3-flash-preview',
	message: 'Draw a diagram of a volcano.',
})
```

You can conditionally override the model name by overriding the `getModelName()` method on any `PromptPartDefinition`.

```tsx
override getModelName(part: MyCustomPromptPart) {
	return part.fastMode ? 'gemini-3-flash-preview' : 'claude-sonnet-4-5'
}
```

##### Support a different model

To add support for a different model, add the model's definition to `AGENT_MODEL_DEFINITIONS` in the `shared/models.ts` file.

```tsx
'claude-sonnet-4-5': {
	name: 'claude-sonnet-4-5',
	id: 'claude-sonnet-4-5',
	provider: 'anthropic',
}
```

If you need to add any extra setup or configuration for your provider, you can add it to the `worker/do/AgentService.ts` file.

##### Support custom shapes

If your app includes [custom shapes](https://tldraw.dev/docs/shapes#custom-shapes), the agent will be able to see, move, delete, resize, rotate and arrange them with no extra setup. However, you might want to also let the agent create and edit them, and read their custom properties.

To support custom shapes, you have two main options:

1. Add an action that lets the agent create your custom shape.

   See the [Let the agent create custom shapes with an action](#let-the-agent-create-a-custom-shape-with-an-action) section below.

2. Add your custom shape to the schema so that the agent read, edit and create it like any other shape.

   See the [Add your custom shape to the schema](#add-a-custom-shape-to-the-schema) section below.

###### Let the agent create a custom shape with an action

To add partial support for a custom shape, let the agent create it with an [agent action](#change-what-the-agent-can-do). For example, this action lets the agent create a custom "sticker" shape:

```tsx
// In shared/schema/AgentActionSchemas.ts
export const StickerAction = z
	.object({
		_type: z.literal('sticker'),
		stickerType: z.enum(['❤️', '⭐']),
		x: z.number(),
		y: z.number(),
	})
	.meta({
		title: 'Sticker',
		description: 'Add a sticker to the canvas.',
	})

export type StickerAction = z.infer<typeof StickerAction>
```

Define how the action gets applied to the canvas by creating an action util:

```tsx
// In client/actions/StickerActionUtil.ts
export const StickerActionUtil = registerActionUtil(
	class StickerActionUtil extends AgentActionUtil<StickerAction> {
		static override type = 'sticker' as const

		// Tell the model how to display the action in chat history
		override getInfo(action: Streaming<StickerAction>) {
			return {
				icon: 'pencil' as const,
				description: 'Added a sticker',
			}
		}

		// Execute the action
		override applyAction(action: Streaming<StickerAction>, helpers: AgentHelpers) {
			if (!action.complete) return
			if (!this.editor) return

			// Normalize the position
			const position = helpers.removeOffsetFromVec({ x: action.x, y: action.y })

			// Create the custom shape
			this.editor.createShape({
				type: 'sticker',
				id: createShapeId(),
				x: position.x,
				y: position.y,
				props: { stickerType: action.stickerType },
			})
		}
	}
)
```

###### Add a custom shape to the schema

To let the agent see the custom properties of your custom shape, add it to the schema in `shared/format/FocusedShape.ts`.

For example, here's a schema for a custom sticker shape.

```tsx
const FocusedStickerShape = z
	.object({
		// Required properties
		_type: z.literal('sticker'),
		note: z.string(),
		shapeId: z.string(),

		// Custom properties
		stickerType: z.enum(['❤️', '⭐']),
		x: z.number(),
		y: z.number(),
	})
	.meta({
		// Information about the shape to give to the agent
		title: 'Sticker Shape',
		description:
			'A sticker shape is a small symbol stamped onto the canvas. There are two types of stickers: heart and star.',
	})
```

The `_type` and `shapeId` properties are required so that the app can identify your shape. The `note` property is also required. The agent uses it to leave notes for itself.

For optional properties, it's worth considering how the agent should see your custom shape. You might want to leave out some properties and focus on showing the most important ones. It's also best to keep them in alphabetical order for better performance with Gemini models.

Enable your custom shape schema by adding it to the list of `FOCUSED_SHAPES` in the same file.

```tsx
const FOCUSED_SHAPES = [
	FocusedDrawShape,
	FocusedGeoShape,
	FocusedLineShape,
	FocusedTextShape,
	FocusedArrowShape,
	FocusedNoteShape,
	FocusedUnknownShape,

	// Our custom shape
	FocusedStickerShape,
] as const
```

Tell the app how to convert your custom shape into the `FocusedShape` format by adding a case in `shared/format/convertTldrawShapeToFocusedShape.ts`:

```tsx
export function convertTldrawShapeToFocusedShape(editor: Editor, shape: TLShape): FocusedShape {
	switch (shape.type) {
		// ...
		case 'sticker':
			const bounds = getShapeBounds(shape)
			return {
				_type: 'sticker',
				note: (shape.meta.note as string) ?? '',
				shapeId: convertTldrawIdToSimpleId(shape.id),
				stickerType: shape.props.stickerType,
				x: bounds.x,
				y: bounds.y,
			}
		// ...
	}
}
```

To allow the agent to edit your custom shape's properties, tell the app how to convert your shape from the `FocusedShape` format that the model outputs to the actual format of your shape:

```tsx
export function convertFocusedShapeToTldrawShape(
	editor: Editor,
	focusedShape: FocusedShape,
	{ defaultShape }: { defaultShape: Partial<TLShape> }
) {
	switch (focusedShape._type) {
		// ...
		case 'sticker':
			const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
			return {
				shape: {
					id: shapeId,
					x: focusedShape.x,
					y: focusedShape.y,
					// ...
					props: {
						// ...
						stickerType: focusedShape.stickerType,
					},
					meta: {
						note: focusedShape.note ?? '',
					},
				},
			}
		// ...
	}
}
```

---

#### Further reading

- **[Multiplayer starter kit](https://tldraw.dev/starter-kits/multiplayer)**: Use the tldraw multiplayer sync starter kit to build multi-user agent environments.

- **[Shape utilities](https://tldraw.dev/docs/shapes#custom-shapes)**: Learn how to create custom shapes and extend tldraw's shape system with advanced geometry, rendering, and interaction patterns.

- **[Editor state management](https://tldraw.dev/docs/editor)**: Learn how to work with tldraw's reactive state system, editor lifecycle, and event handling for complex canvas applications.

---

#### Building with this starter kit?

If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. We want to see what you've built!

### Image pipeline starter kit

To build with an image pipeline starter kit, run this command in your terminal:

```bash
npm create tldraw@latest -- --template image-pipeline
```

The template works out of the box with placeholder images. To connect real AI providers, create a `.dev.vars` file:

```
REPLICATE_API_TOKEN=<your key>
```

---

#### Use cases

Use the image pipeline starter kit to build:

---

#### How it works

##### 1. Shape system: Nodes and connections

Nodes are custom tldraw shapes that represent pipeline steps. Each node has typed input and output ports — there are six port data types (image, text, model, number, latent, and any) that enforce connection compatibility. Connections are also shapes, rendered as color-coded bezier curves that use tldraw's binding system to stay attached to specific ports.

##### 2. Type-safe port system

Ports are color-coded by data type and enforce compatibility during connection. When you drag a connection, only compatible ports highlight as valid targets. The system also prevents cycles, replaces existing connections on single-input ports, and supports multi-input ports that accept multiple connections.

##### 3. Pipeline execution

The execution engine builds a DAG (directed acyclic graph) from your node connections and resolves dependencies automatically. Independent branches execute in parallel. Each node receives its input values, runs its operation, and passes results downstream. Nodes show loading states during execution and cache their results.

##### 4. AI image generation

The starter kit integrates with AI providers through a Cloudflare Worker backend. The Generate node sends prompts, model selection, and parameters (steps, CFG scale, seed) to the worker, which dispatches to providers like Replicate. When no backend is configured, the system returns placeholder SVGs so you can explore the full UI without API credentials.

##### 5. Image processing pipeline

Beyond generation, the template includes processing nodes for upscaling, style transfer, ControlNet-guided generation, IP-Adapter reference images, blending, and image adjustments. These can be chained together to build complex multi-step image processing workflows.

---

#### Customization

##### Adding custom nodes

To add a custom node type, create a new file in `src/nodes/types/`. The easiest approach is to duplicate an existing node like `AdjustNode.tsx`.

Define your node's data shape:

```tsx
import { T } from 'tldraw'

export const CustomNode = T.object({
	type: T.literal('custom'),
	// your node's parameters...
})

export type CustomNode = T.TypeOf<typeof CustomNode>
```

Then create a node definition:

```tsx
export class CustomNodeDefinition extends NodeDefinition<CustomNode> {
	static type = 'custom'
	static validator = CustomNode

	title = 'My custom node'
	icon = <span>🔧</span>
	category = 'process'

	getDefault() { ... }
	getBodyHeightPx(shape, node) { ... }
	getPorts(shape, node) { ... }
	async execute(shape, node, inputs) { ... }
	getOutputInfo(shape, node, inputs) { ... }
	Component = CustomNodeComponent
}
```

Register your node in `src/nodes/nodeTypes.tsx` by adding it to `NodeDefinitions`, and add it to the sidebar in `src/components/ImagePipelineSidebar.tsx`.

##### Adding AI providers

The worker uses a provider abstraction. To add a new provider, create a class implementing the `ImageProvider` interface in `worker/providers/`. The provider receives generation parameters and returns image URLs. Register it in the provider resolution logic so it can be selected from the Model node.

##### UI customization

The template overrides several tldraw UI components:

- A sidebar node palette replaces the default toolbar, organized by category (input, process, output, utility)
- Region overlays detect connected node groups and provide per-region play/stop controls
- An on-canvas node picker appears when dragging a connection to empty space, filtered to show only type-compatible nodes
- A template system lets users save and restore workflow configurations

To further customize the UI, read up on [customizing tldraw's UI as a whole](https://tldraw.dev/docs/user-interface).

---

#### Further reading

- **[Shape utilities](https://tldraw.dev/docs/shapes)**: Learn how to create custom shapes and extend tldraw's shape system with advanced geometry, rendering, and interaction patterns.
- **[Binding system](https://tldraw.dev/sdk-features/bindings)**: Learn more about tldraw's binding system for creating relationships between shapes, automatic updates, and connection management.
- **[Editor state management](https://tldraw.dev/docs/editor)**: Learn how to work with tldraw's reactive state system, editor lifecycle, and event handling for complex canvas applications.
- **[Customize the user interface](https://tldraw.dev/docs/user-interface)**: Learn how to customize the user interface of your tldraw application.

---

#### Building with this starter kit?

If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. We want to see what you've built!

### Branching chat starter kit

To build with a branching chat starter kit, run this command in your terminal:

```bash
npm create tldraw@latest -- --template branching-chat
```

Then create a `.env` file in the project root with an API key from [Google AI Studio](https://aistudio.google.com/apikey):

```
GOOGLE_GENERATIVE_AI_API_KEY=<your key>
```

---

#### Use cases

Use the branching chat starter kit to build:

---

#### How it works

![Diagram showing how nodes, ports, and connections form a flow of operations. Two shapes, Node A and Node B illustrate that connections link specific ports, defined as start or end points.](https://tldraw.dev/images/starter-kits/chart-branching-chat.png)

##### 1. Visual node system: Interactive message containers

Each conversation message appears as a draggable node on the infinite canvas. The `NodeShapeUtil` class extends tldraw's shape system to create custom chat message containers. These containers dynamically resize based on content length and provide input fields for user messages, areas for AI responses, and connection ports for linking conversations. You can also create nodes directly from the toolbar.

##### 2. Connection architecture: Conversation flow management

The `ConnectionBindingUtil` manages relationships between message nodes and creates visual lines that represent conversation flow. You create connections by dragging between node ports to branch conversations and link context. When users send a message, the system traces backwards through connected nodes to build complete conversation history. This gives AI responses the full dialogue context.

##### 3. AI streaming integration: Real-time response handling

The backend uses Cloudflare Workers with the Vercel AI SDK to stream responses from Google's Gemini API. As AI text generates, the streaming fetch implementation decodes response chunks and updates the tldraw document state in real time. Users see responses appear progressively within the connected node.

##### 4. Port-based connection system: Visual conversation linking

Nodes feature input and output ports that let users create branching dialogue structures by dragging connections between messages. You can build complex conversation trees where multiple messages feed into a single AI response or diverge into parallel branches. Connected nodes automatically establish context relationships across different paths in the dialogue graph.

---

#### Customization

This starter kit is built on top of tldraw's extensible architecture. You can customize everything. The canvas renders using React DOM, so you can use familiar React patterns, components, and state management across your conversation interface. Let's have a look at some ways to change this starter kit.

##### Adding custom node types

To create new types of conversation nodes beyond basic messages, you can extend the node system with custom node definitions. The system uses a pluggable architecture where each node type defines its own behavior, rendering, and port configuration.

See `client/nodes/types/MessageNode.tsx` as an example. This file shows how to define a complete node type with TypeScript validation, React component rendering, AI streaming integration, and dynamic sizing based on content length.

##### Customizing AI integration

To integrate with different AI providers or modify response behavior, you can customize the streaming implementation and API endpoints. The system uses the Vercel AI SDK which supports multiple providers including OpenAI, Anthropic, and Google.

See `worker/worker.ts` as an example. This file demonstrates how to configure AI providers, handle streaming responses, and process conversation context from connected nodes.

##### Customizing node appearance

To modify how conversation nodes look and behave, you can override the node rendering and styling system. Each node type has complete control over its visual presentation while maintaining integration with tldraw's interaction system.

See `client/nodes/NodeShapeUtil.tsx` as an example. This file shows how the shape utility defines node geometry, interaction behavior, and visual indicators including port positioning and selection bounds.

##### Customizing connection behavior

To modify how conversation flows connect and interact, you can customize the connection and binding system. This controls how nodes link together, how context flows between connected messages, and how the visual connections appear.

See `client/connection/ConnectionBindingUtil.tsx` as an example. This file demonstrates how to define binding behavior between shapes, including automatic cleanup when nodes are deleted and visual feedback during connection creation.

---

#### Further reading

- [Workflow starter kit](https://tldraw.dev/starter-kits/workflow): Learn how to build visual programming interfaces with node-based workflows on infinite canvas.
- [Shape utilities](https://tldraw.dev/docs/shapes#custom-shapes): Learn how to create custom shapes and extend tldraw's shape system with advanced geometry, rendering, and interaction patterns.
- [Binding system](https://tldraw.dev/sdk-features/bindings): Learn more about tldraw's binding system for creating relationships between shapes, automatic updates, and connection management.
- [Editor state management](https://tldraw.dev/docs/editor): Learn how to work with tldraw's reactive state system, editor lifecycle, and event handling for complex canvas applications.

---

#### Building with this starter kit?

If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. We want to see what you've built!

### Multiplayer starter kit

The multiplayer starter kit provides a production-ready foundation for building collaborative tldraw applications with real-time synchronization. It uses Cloudflare Durable Objects for room management, WebSocket connections for instant updates, and SQLite storage for automatic persistence.

To build with this starter kit, run this command in your terminal:

```bash
npm create tldraw@latest -- --template multiplayer
```

---

#### Use cases

This starter kit is perfect for building:

---

#### How it works

##### 1. Durable Objects: Room management

Each collaborative room runs in its own Cloudflare Durable Object instance. There's only ever one authoritative copy of each room's data, and all users connect to that same instance. This ensures strong consistency. Presence indicators (avatars and names) show who is currently in the room, and real-time cursors show where other users are pointing or selecting.

##### 2. WebSocket synchronization: Real-time updates

Changes are synchronized instantly via WebSocket connections. When a user draws or modifies content, the change is sent to the Durable Object, applied to the in-memory document, and broadcast to all connected clients. The sync protocol keeps state consistent across all clients. If a connection drops, the client automatically reconnects and replays missed changes to restore consistency.

##### 3. Persistent storage: SQLite

Room data is automatically persisted to Durable Object SQLite storage. Every change is saved immediately, so data survives restarts without manual save logic.

##### 4. Asset management: Scalable file handling

Images, videos, and other files are uploaded directly to R2 storage and served through Cloudflare's global edge network. Shared assets are synchronized across all connected users. Pasted URLs automatically unfurl into link previews.

---

#### Customization

This starter kit is built on top of tldraw's extensible architecture, which means that everything can be customized. The canvas renders using React DOM, so you can use familiar React patterns, components, and state management. Here are some ways to customize this starter kit.

##### Custom shapes integration

To add custom shapes that work with multiplayer sync, you need to configure both the client-side shape utilities and the server-side schema. Both sides must know about your custom shapes so that validation, synchronization, and version compatibility work correctly.

See `worker/TldrawDurableObject.ts` and `client/pages/Room.tsx` as examples. The server file shows schema configuration while the client shows how to connect custom shapes to the sync client.

In the code example below, we add a custom sticky note shape on both client and server:

```tsx
// 1. Server-side: Add custom shape schema in worker/TldrawDurableObject.ts
import { createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'

const schema = createTLSchema({
	shapes: {
		...defaultShapeSchemas,
		'sticky-note': {
			props: {
				text: { type: 'string', default: '' },
				color: { type: 'string', default: 'yellow' },
			},
			migrations: {
				currentVersion: 1,
				migrators: {},
			},
		},
	},
})

// 2. Client-side: Create ShapeUtil class in client/StickyNoteShapeUtil.tsx
import { ShapeUtil, Rectangle2d, HTMLContainer } from 'tldraw'

class StickyNoteShapeUtil extends ShapeUtil<StickyNoteShape> {
	static override type = 'sticky-note' as const

	getDefaultProps() {
		return { text: '', color: 'yellow' }
	}

	getGeometry(shape: StickyNoteShape) {
		return new Rectangle2d({ width: 200, height: 200, isFilled: true })
	}

	component(shape: StickyNoteShape) {
		return <HTMLContainer>{shape.props.text}</HTMLContainer>
	}

	getIndicatorPath(shape: StickyNoteShape) {
		const path = new Path2D()
		path.rect(0, 0, 200, 200)
		return path
	}
}

// 3. Client-side: Pass ShapeUtil to Tldraw in client/pages/Room.tsx
;<Tldraw store={store} shapeUtils={[StickyNoteShapeUtil]} />
```

##### Asset upload customization

To customize how assets are uploaded and served, modify the asset store configuration. You can add authentication, preprocessing, or serve different asset variants based on user permissions.

See `client/multiplayerAssetStore.tsx` as an example. This file shows how to upload assets to your Cloudflare Worker and retrieve them for display.

In the code example below, we add authentication to asset uploads:

```tsx
// Custom asset store with authentication
export const authenticatedAssetStore: TLAssetStore = {
	async upload(asset, file) {
		const id = uniqueId()
		const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-')

		const response = await fetch(`/api/uploads/${objectName}`, {
			method: 'POST',
			body: file,
			headers: {
				Authorization: `Bearer ${getAuthToken()}`,
				'X-User-ID': getCurrentUserId(),
			},
		})

		if (!response.ok) {
			throw new Error(`Upload failed: ${response.statusText}`)
		}

		return { src: `/api/uploads/${objectName}` }
	},

	resolve(asset) {
		return `${asset.props.src}?token=${getAuthToken()}`
	},
}
```

##### Deployment configuration

Modify the Cloudflare Worker configuration to customize your deployment. You can adjust resource limits, add custom domains, and set environment-specific variables.

See `wrangler.toml` as an example. This configuration file controls how your worker is deployed, including bucket names, environment variables, and routing rules.

In the code example below, we configure production deployment settings:

```toml
# Production deployment configuration
name = "my-multiplayer-app"
main = "worker/worker.ts"
compatibility_date = "2024-08-01"

[env.production]
vars = { ENVIRONMENT = "production" }

[[env.production.r2_buckets]]
binding = "TLDRAW_BUCKET"
bucket_name = "my-app-production-bucket"

[[env.production.durable_objects.bindings]]
name = "TLDRAW_DURABLE_OBJECT"
class_name = "TldrawDurableObject"

[[env.production.routes]]
pattern = "myapp.com/api/*"
zone_name = "myapp.com"
```

##### Room persistence

Room data is automatically persisted via SQLite storage in the Durable Object. The `SQLiteSyncStorage` class handles all persistence logic. You don't need to implement custom save logic.

See `worker/TldrawDurableObject.ts` as an example. The room is created lazily and supports WebSocket hibernation:

```tsx
import {
	DurableObjectSqliteSyncWrapper,
	type SessionStateSnapshot,
	SQLiteSyncStorage,
	TLSocketRoom,
} from '@tldraw/sync-core'
import { DurableObject } from 'cloudflare:workers'

export class TldrawDurableObject extends DurableObject {
	// Room is created lazily on first connection
	private room: TLSocketRoom<TLRecord, void> | null = null

	private getOrCreateRoom(): TLSocketRoom<TLRecord, void> {
		if (!this.room) {
			const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
			const storage = new SQLiteSyncStorage<TLRecord>({ sql })

			this.room = new TLSocketRoom<TLRecord, void>({
				schema,
				storage,
				// Disable idle timeout — Cloudflare handles keep-alive via auto-response
				clientTimeout: Infinity,
				// Persist session state to WebSocket attachments for hibernation recovery
				onSessionSnapshot: (sessionId, snapshot) => {
					const ws = this.sessionIdToWs.get(sessionId)
					if (ws) ws.serializeAttachment({ sessionId, snapshot })
				},
			})

			// Resume any sessions that survived hibernation
			for (const ws of this.ctx.getWebSockets()) {
				const attachment = ws.deserializeAttachment() as SocketAttachment | null
				if (!attachment?.sessionId) continue
				if (attachment.snapshot) {
					this.room.handleSocketResume({
						sessionId: attachment.sessionId,
						socket: ws,
						snapshot: attachment.snapshot,
					})
				}
			}
		}
		return this.room
	}
}
```

---

#### Further reading

- **[Sync documentation](https://tldraw.dev/docs/sync)**: Learn how to integrate tldraw sync into existing applications and customize the synchronization behavior.
- **[Editor state management](https://tldraw.dev/docs/editor)**: Learn how to work with tldraw's reactive state system, editor lifecycle, and event handling for complex canvas applications.

---

#### Building with this starter kit?

If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. We want to see what you've built!

### Shader starter kit

To build with a shader starter kit, run this command in your terminal:

```bash
npm create tldraw@latest -- --template shader
```

---

#### Use cases

Use the shader starter kit to build:

---

#### How it works

##### WebGLManager lifecycle

The reusable `WebGLManager` class ([`src/WebGLManager.ts`](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/WebGLManager.ts)) creates and manages a WebGL2 context that is synchronized with the tldraw canvas. It owns the render loop and exposes lifecycle hooks—`onInitialize()`, `onUpdate()`, `onRender()`, and `onDispose()`. Each shader manager can focus on its effect-specific logic while sharing viewport coordination, resolution control, and animation timing. The Minimal, Rainbow, and Shadows examples extend this base class; the Fluid example uses a different architecture with its own simulation system.

##### Config panel system

The config panel components ([`src/config-panel/`](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/config-panel/)) provide ready-made UI controls for editing shader uniforms. Each panel stores settings in reactive atoms and persists them to `localStorage`, so your shader parameters survive reloads without extra wiring.

##### Example gallery

The template ships with four complete demos that follow the same pattern of manager, renderer, config panel, and GLSL files:

- **Fluid simulation** ([`src/fluid/`](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/fluid/)) — Navier-Stokes-based flow, built on Pavel Dobryakov's WebGL fluid implementation, that turns shape and pointer movement into velocity splats. Includes an in-depth [guide](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/fluid/fluid.md).
- **Rainbow** ([`src/rainbow/`](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/rainbow/)) — Gradient animation that demonstrates time-based uniforms and color cycling.
- **Shadows** ([`src/shadow/`](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/shadow/)) — Raymarched shadow effect using signed distance fields derived from canvas geometry.
- **Minimal** ([`src/minimal/`](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/minimal/)) — Dark-mode-aware solid color shader that makes it easy to start your own effect, with a [step-by-step walkthrough](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/minimal/minimal-example.md).

Switch between demos from the example menu next to the style panel to see how different managers plug into the same infrastructure.

---

#### Customization

##### Start from the minimal template

Copy the minimal shader to create a new effect:

```bash
cp -r src/minimal src/my-shader
```

Then update:

- `config.ts` — Define uniforms and UI controls for your shader.
- `fragment.glsl` / `vertex.glsl` — Implement rendering logic.
- `MyShaderManager.ts` — Extend `WebGLManager` and coordinate buffers, uniforms, and lifecycle hooks.
- `MyRenderer.tsx` — Mount the manager from React and handle cleanup.
- `MyConfigPanel.tsx` — Customize the controls exposed to users.

Finally, register the new manager in [`src/App.tsx`](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/App.tsx).

##### Extend WebGLManager hooks

Each manager can override lifecycle hooks to add render targets, resize behavior, or post-processing passes. Use `onInitialize()` to set up buffers, textures, and framebuffers; `onUpdate()` for per-frame uniform updates (such as time, resolution, or editor-driven state); and `onRender()` to issue draw calls. The base class also exposes helpers for handling pixel density and canvas resizes.

##### Integrate with tldraw data

Every manager receives the live tldraw editor instance, which lets you:

- Access shapes with `editor.getCurrentPageShapes()`.
- Subscribe to document changes through `editor.store.listen()`.
- Read the current camera via `editor.getCamera()`.
- Convert coordinates with `editor.pageToViewport()`.
- Track pointer data from `editor.inputs.getCurrentScreenPoint()`.

See [`src/fluid/FluidManager.ts`](https://github.com/tldraw/tldraw/tree/main/templates/shader/src/fluid/FluidManager.ts) for a full example of mixing editor state with GPU simulation.

#### Resources

- [WebGL2 Fundamentals](https://webgl2fundamentals.org/) - WebGL tutorials
- [The Book of Shaders](https://thebookofshaders.com/) - GLSL shader programming
- [Shadertoy](https://www.shadertoy.com/) - Shader examples

---

#### Further reading

For multi-user shader environments, see the [Multiplayer starter kit](https://tldraw.dev/starter-kits/multiplayer). To create custom shapes with advanced geometry and rendering, see [Shape utilities](https://tldraw.dev/docs/shapes#custom-shapes). For working with tldraw's reactive state system and event handling, see [Editor state management](https://tldraw.dev/docs/editor).

---

#### Building with this starter kit?

If you build something great, please share it with us in our [#show-and-tell](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. We want to see what you've built!

## SDK features

Detailed guides to tldraw's features and capabilities.

### Accessibility

Tldraw includes accessibility features for keyboards and assistive technologies. The SDK announces shape selections to screen readers, supports keyboard navigation between shapes, respects reduced motion preferences, and provides hooks for custom shapes to supply descriptive text.

#### Screen reader announcements

When users select shapes, tldraw announces the selection to screen readers through a live region. The announcement includes the shape type, any descriptive text, and the shape's position in reading order.

For a single shape selection, the announcement follows this pattern: "[description], [shape type]. [position] of [total]". For example, selecting an image with alt text might announce "A team photo, image. 3 of 7". Multiple selections announce the count: "4 shapes selected".

The announcement system uses the `DefaultA11yAnnouncer` component, which renders a visually hidden live region that screen readers monitor for changes. The `useA11y` hook provides programmatic access to announce custom messages:

```tsx
import { Tldraw, useA11y } from 'tldraw'
import 'tldraw/tldraw.css'

function CustomAnnouncement() {
	const a11y = useA11y()

	const handleCustomAction = () => {
		a11y.announce({ msg: 'Custom action completed', priority: 'polite' })
	}

	return <button onClick={handleCustomAction}>Do action</button>
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw>
				<CustomAnnouncement />
			</Tldraw>
		</div>
	)
}
```

The `priority` option accepts `'polite'` or `'assertive'`. Polite announcements wait for a pause in speech, while assertive announcements interrupt immediately.

> See the [screen reader accessibility example](https://tldraw.dev/examples/ui/screen-reader-accessibility) for custom shape descriptions and announcements.

#### Keyboard navigation

Users can navigate between shapes using the keyboard. Tab moves focus to the next shape in reading order, and Shift+Tab moves to the previous shape. Arrow keys with Ctrl/Cmd move selection to the nearest shape in that direction.

Tldraw determines reading order by analyzing shape positions on the canvas. Shapes are grouped into rows based on their vertical position, then sorted left-to-right within each row. This creates a natural top-to-bottom, left-to-right reading order similar to text. You can access this order programmatically with `Editor#getCurrentPageShapesInReadingOrder`.

A "Skip to main content" link appears when users Tab into the editor, allowing keyboard users to jump directly to the first shape on the canvas.

##### Excluding shapes from keyboard navigation

Exclude custom shapes from keyboard navigation by overriding `ShapeUtil#canTabTo`:

```tsx
class DecorativeShapeUtil extends ShapeUtil<DecorativeShape> {
	// Decorative shapes don't receive keyboard focus
	canTabTo() {
		return false
	}

	// ...
}
```

Shapes that return `false` from `canTabTo()` are skipped during Tab navigation and excluded from reading order calculations.

#### Shape descriptions for screen readers

When a shape is selected, the announcement includes descriptive text from two sources: the shape's text content and its ARIA descriptor. `ShapeUtil#getText` returns the shape's primary text content, while `ShapeUtil#getAriaDescriptor` provides alternative text specifically for accessibility purposes.

For most shapes, `getText()` is sufficient. The default implementation returns `undefined`, which produces announcements with just the shape type and position.

##### Providing alt text for media shapes

Image and video shapes support an `altText` property that `getAriaDescriptor()` returns. Users can set alt text through the media toolbar when an image or video is selected:

```tsx
// Setting alt text programmatically
editor.updateShapes([
	{
		id: imageShape.id,
		type: 'image',
		props: { altText: 'A diagram showing the system architecture' },
	},
])
```

##### Custom shape descriptions

Give custom shapes screen reader descriptions by overriding `ShapeUtil#getAriaDescriptor`:

```tsx
class CardShapeUtil extends ShapeUtil<CardShape> {
	getAriaDescriptor(shape: CardShape) {
		// Return a description that makes sense when read aloud
		return `${shape.props.title}: ${shape.props.summary}`
	}

	// ...
}
```

If your shape has visible text, override `ShapeUtil#getText` instead. The announcement system checks `getAriaDescriptor()` first, then falls back to `getText()`:

```tsx
class NoteShapeUtil extends ShapeUtil<NoteShape> {
	getText(shape: NoteShape) {
		return shape.props.content
	}

	// ...
}
```

#### Reduced motion

The SDK respects user motion preferences through the `animationSpeed` user preference. When set to 0, animations are disabled. By default, this value matches the operating system's `prefers-reduced-motion` setting.

Use `usePrefersReducedMotion` in custom shape components to check whether to show animations:

```tsx
import { usePrefersReducedMotion } from 'tldraw'

function AnimatedIndicator() {
	const prefersReducedMotion = usePrefersReducedMotion()

	if (prefersReducedMotion) {
		return <StaticIndicator />
	}

	return <PulsingIndicator />
}
```

The hook returns `true` when:

- The `animationSpeed` preference is 0 (the default when the OS prefers reduced motion)
- When used outside an editor context, the operating system's reduced motion preference is enabled

Users can toggle reduced motion through the accessibility menu, found under Preferences in the main menu.

> See the [reduced motion example](https://tldraw.dev/examples/configuration/reduced-motion) for a custom shape that respects motion preferences.

#### Enhanced accessibility mode

The `enhancedA11yMode` user preference adds visible labels to UI elements that normally rely on icons alone. When enabled, the style panel shows text labels for each section like "Color", "Opacity", and "Align", making the interface more navigable for users who need additional context.

Toggle this setting programmatically:

```tsx
editor.user.updateUserPreferences({
	enhancedA11yMode: true,
})
```

#### Disabling keyboard shortcuts

Assistive technologies often have their own keyboard commands that conflict with tldraw's shortcuts. The `areKeyboardShortcutsEnabled` preference lets users turn tldraw's shortcuts off:

```tsx
editor.user.updateUserPreferences({
	areKeyboardShortcutsEnabled: false,
})
```

When disabled, tldraw's keyboard shortcuts don't interfere with assistive technology shortcuts. Basic navigation with Tab and arrow keys still works for shape selection.

#### Accessibility menu

The default UI includes an accessibility submenu under Preferences in the main menu, with toggles for:

| Setting                     | Effect                                        |
| --------------------------- | --------------------------------------------- |
| Reduce motion               | Disables animations                           |
| Keyboard shortcuts          | Enables or disables tldraw keyboard shortcuts |
| Enhanced accessibility mode | Shows visible labels on UI elements           |

You can use these components individually to build custom accessibility controls:

```tsx
import {
	ToggleReduceMotionItem,
	ToggleKeyboardShortcutsItem,
	ToggleEnhancedA11yModeItem,
} from 'tldraw'
```

See `AccessibilityMenu` for the default implementation.

#### Best practices for custom shapes

When building custom shapes, consider these accessibility guidelines:

**Provide meaningful descriptions.** Override `getAriaDescriptor()` or `getText()` to give screen reader users context about what the shape contains. A shape labeled "card" is less useful than "Meeting notes: Q4 planning session".

**Use semantic HTML where possible.** The shape's `component()` renders inside an HTML container. Use appropriate heading levels, lists, and other semantic elements rather than styled divs.

**Support keyboard interaction.** If your shape has interactive elements, ensure they're focusable and operable with the keyboard. Use standard focus indicators and ARIA attributes where needed.

**Respect motion preferences.** Check `usePrefersReducedMotion()` before showing animations. Provide static alternatives for animated content.

**Consider contrast.** Shape colors must meet WCAG contrast requirements against typical backgrounds. The default color styles are designed with accessibility in mind.

For more on creating custom shapes, see [Custom shapes](https://tldraw.dev/docs/shapes).

#### Debugging accessibility

Enable the `a11y` debug flag to log accessibility announcements to the console:

```tsx
import { debugFlags } from 'tldraw'

debugFlags.a11y.set(true)
```

With this flag enabled, the console shows each announcement and logs the accessible name of elements as they receive keyboard focus. This is useful for verifying that your custom shapes provide appropriate descriptions.

### Actions

Actions are named operations that users trigger from menus, keyboard shortcuts, or custom UI. Each action bundles an identifier, display metadata (label, icon, keyboard shortcut), and a handler function. Actions let you define operations like "undo", "group", or "export as PNG" once and invoke them from multiple places with consistent behavior.

```tsx
import { Tldraw, TLUiOverrides } from 'tldraw'
import 'tldraw/tldraw.css'

const overrides: TLUiOverrides = {
	actions(editor, actions, helpers) {
		// Add a custom action
		actions['show-selection-count'] = {
			id: 'show-selection-count',
			label: 'action.show-selection-count',
			kbd: 'shift+c',
			onSelect(source) {
				const count = editor.getSelectedShapeIds().length
				helpers.addToast({ title: `${count} shapes selected` })
			},
		}
		return actions
	},
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw overrides={overrides} />
		</div>
	)
}
```

The `tldraw` package includes nearly 100 default actions covering editing, arrangement, export, zoom, and preferences. You can override any of these or add your own through the `overrides` prop.

#### How actions work

Actions live in a React context provided by `ActionsProvider`. When the UI mounts, it registers all default actions, applies any overrides you've provided, and makes them available through the `useActions` hook. Menus and toolbars look up actions by ID and render them with their labels, icons, and keyboard shortcuts.

Each action has an `onSelect` handler that receives a source parameter indicating where it was triggered:

```tsx
const actions = useActions()
const duplicateAction = actions['duplicate']

// Trigger programmatically
duplicateAction.onSelect('toolbar')
```

Keyboard shortcuts are bound automatically. The `useKeyboardShortcuts` hook parses each action's `kbd` property and registers hotkey handlers. When a shortcut fires, it calls the action's `onSelect` with `'kbd'` as the source.

#### Action structure

The `TLUiActionItem` interface defines what an action contains:

```typescript
interface TLUiActionItem {
	id: string
	label?: string | { [key: string]: string }
	icon?: string | React.ReactElement
	kbd?: string
	readonlyOk?: boolean
	checkbox?: boolean
	isRequiredA11yAction?: boolean
	onSelect(source: TLUiEventSource): Promise<void> | void
}
```

| Property               | Description                                                                                                                                                   |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                   | Unique identifier for the action (e.g., `'duplicate'`, `'zoom-in'`)                                                                                           |
| `label`                | Translation key for display text. Can be a string or an object mapping contexts to different keys (see [Context-sensitive labels](#context-sensitive-labels)) |
| `icon`                 | Icon name from tldraw's icon set, or a custom React element                                                                                                   |
| `kbd`                  | Keyboard shortcut string. Use commas to separate platform alternatives: `'cmd+g,ctrl+g'` binds Cmd+G on Mac and Ctrl+G elsewhere                              |
| `readonlyOk`           | When `true`, the action works in readonly mode. Defaults to `false`                                                                                           |
| `checkbox`             | When `true`, renders as a toggle with a checkmark indicator in menus                                                                                          |
| `isRequiredA11yAction` | When `true`, the keyboard shortcut works even when shortcuts are normally disabled (e.g., while editing a shape). Used for accessibility actions              |
| `onSelect`             | Handler called when the action is triggered. Receives a `source` parameter indicating the trigger origin (`'kbd'`, `'menu'`, `'toolbar'`, etc.)               |

#### Accessing actions

Use the `useActions` hook to get all registered actions:

```typescript
import { useActions } from 'tldraw'

function MyComponent() {
	const actions = useActions()

	return (
		<button onClick={() => actions['undo'].onSelect('toolbar')}>
			Undo
		</button>
	)
}
```

The hook returns a record mapping action IDs to action objects. You can iterate over it to build custom menus or filter actions by property.

#### Default actions

The default actions cover most editing operations you'd expect in a canvas application. Here are some common categories:

**Editing**: `undo`, `redo`, `duplicate`, `delete`, `copy`, `cut`, `paste`

**Grouping**: `group`, `ungroup`

**Arrangement**: `bring-to-front`, `bring-forward`, `send-backward`, `send-to-back`, `align-left`, `align-center-horizontal`, `align-right`, `distribute-horizontal`, `distribute-vertical`

**Export**: `export-as-svg`, `export-as-png`, `copy-as-svg`, `copy-as-png`

**Zoom**: `zoom-in`, `zoom-out`, `zoom-to-100`, `zoom-to-fit`, `zoom-to-selection`, `select-zoom-tool`

**Preferences**: `toggle-dark-mode`, `toggle-snap-mode`, `toggle-grid`, `toggle-focus-mode`

Most actions guard themselves inside their handlers. For example, `group` and the arrangement actions do nothing unless shapes are selected and the select tool is active.

#### Overriding actions

Pass an `overrides` prop to customize actions. The override function receives the editor, the default actions, and helper utilities:

```typescript
import { Tldraw, TLUiOverrides } from 'tldraw'

const overrides: TLUiOverrides = {
	actions(editor, actions, helpers) {
		// Modify existing action
		actions['duplicate'].kbd = 'cmd+shift+d,ctrl+shift+d'

		// Disable an action by removing it
		delete actions['print']

		return actions
	},
}

function App() {
	return <Tldraw overrides={overrides} />
}
```

##### Modifying behavior

To change what an action does, replace its `onSelect` handler:

```typescript
const overrides: TLUiOverrides = {
	actions(editor, actions, helpers) {
		const originalDuplicate = actions['duplicate'].onSelect

		actions['duplicate'].onSelect = async (source) => {
			console.log('Duplicating shapes...')
			await originalDuplicate(source)
			console.log('Done!')
		}

		return actions
	},
}
```

You can call the original handler before or after your custom logic, or replace it entirely.

##### Adding custom actions

Add new actions by inserting them into the actions record:

```typescript
const overrides: TLUiOverrides = {
	actions(editor, actions, helpers) {
		actions['my-custom-action'] = {
			id: 'my-custom-action',
			label: 'action.my-custom-action',
			kbd: 'cmd+shift+k,ctrl+shift+k',
			icon: 'external-link',
			onSelect(source) {
				const shapes = editor.getSelectedShapes()
				console.log('Custom action on', shapes.length, 'shapes')
			},
		}

		return actions
	},
}
```

Custom actions integrate with the keyboard shortcut system automatically. To add them to menus, you'll also need to override the menu components.

##### Using helper utilities

The override function receives a `helpers` object with useful utilities:

```typescript
const overrides: TLUiOverrides = {
	actions(editor, actions, helpers) {
		actions['show-toast'] = {
			id: 'show-toast',
			label: 'action.show-toast',
			onSelect(source) {
				helpers.addToast({
					title: 'Hello!',
					description: 'This is a custom action.',
				})
			},
		}

		return actions
	},
}
```

Available helpers:

| Helper                  | Description                          |
| ----------------------- | ------------------------------------ |
| `addToast`              | Show a toast notification            |
| `removeToast`           | Remove a specific toast              |
| `clearToasts`           | Remove all toasts                    |
| `addDialog`             | Open a dialog                        |
| `removeDialog`          | Close a specific dialog              |
| `clearDialogs`          | Close all dialogs                    |
| `msg`                   | Get a translated string by key       |
| `isMobile`              | Boolean indicating mobile breakpoint |
| `insertMedia`           | Open file picker and insert media    |
| `replaceImage`          | Replace selected image with new file |
| `replaceVideo`          | Replace selected video with new file |
| `printSelectionOrPages` | Print selection or all pages         |
| `cut`                   | Cut selected shapes to clipboard     |
| `copy`                  | Copy selected shapes to clipboard    |
| `paste`                 | Paste from clipboard                 |
| `copyAs`                | Copy shapes as SVG or PNG            |
| `exportAs`              | Export shapes as SVG, PNG, or JSON   |
| `getEmbedDefinition`    | Get embed info for a URL             |

#### Keyboard shortcuts

Shortcuts use a simple string format with modifier keys separated by `+`. Use commas to specify alternatives for different platforms:

```typescript
kbd: 'cmd+g,ctrl+g' // Cmd+G on Mac, Ctrl+G elsewhere
kbd: 'shift+1' // Shift+1 on all platforms
kbd: 'cmd+shift+s,ctrl+shift+s' // Cmd+Shift+S on Mac, Ctrl+Shift+S elsewhere
```

Available modifiers are `cmd`, `ctrl`, `shift`, and `alt`. Special keys include `del`, `backspace`, `enter`, `escape`, and arrow keys.

Shortcuts are disabled when a menu is open, a shape is being edited, the editor has a crashing error, or the user has disabled keyboard shortcuts in preferences. Actions marked with `isRequiredA11yAction: true` bypass this check for accessibility purposes.

#### Actions in menus

The default UI uses `TldrawUiMenuActionItem` to render actions in menus:

```typescript
import { TldrawUiMenuActionItem, TldrawUiMenuGroup } from 'tldraw'

function CustomMenu() {
	return (
		<TldrawUiMenuGroup id="edit">
			<TldrawUiMenuActionItem actionId="undo" />
			<TldrawUiMenuActionItem actionId="redo" />
			<TldrawUiMenuActionItem actionId="duplicate" />
		</TldrawUiMenuGroup>
	)
}
```

This component looks up the action by ID and renders it with the correct label, icon, shortcut hint, and disabled state. For toggle actions, use `TldrawUiMenuActionCheckboxItem` which displays a checkmark when active.

#### Context-sensitive labels

Some actions show different labels depending on where they appear. The `label` property can be an object mapping context names to translation keys:

```typescript
actions['export-as-svg'] = {
	id: 'export-as-svg',
	label: {
		default: 'action.export-as-svg',
		menu: 'action.export-as-svg.short',
		'context-menu': 'action.export-as-svg.short',
	},
	// ...
}
```

The menu component uses the appropriate label based on its context. If no specific label exists for a context, it falls back to `default`.

#### Tracking action usage

The `source` parameter tells you where the action was triggered. Use this for analytics:

```tsx
actions['custom-action'] = {
	id: 'custom-action',
	label: 'action.custom',
	kbd: 'cmd+k,ctrl+k',
	onSelect(source) {
		trackEvent('custom-action', { source })
		// source: 'kbd', 'menu', 'context-menu', 'toolbar', 'quick-actions', 'zoom-menu', etc.
	},
}
```

#### Related examples

- [Action overrides](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/action-overrides) — Add custom actions and modify existing action shortcuts using the overrides prop.
- [Keyboard shortcuts](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/keyboard-shortcuts) — Change keyboard shortcuts for tools and actions.
- [Custom menus](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/custom-menus) — Build custom menus that use actions with proper labels and shortcuts.

### Animation

The animation system controls smooth transitions for shapes and camera movements. Shape animations handle properties like position, rotation, and opacity. Camera animations manage viewport transitions for panning and zooming.

Shape animations run independently and can be interrupted or replaced. Camera animations integrate with the camera system and respect user animation speed preferences for accessibility.

#### How it works

Animations in tldraw use the tick system to drive frame-by-frame updates. On each tick, active animations calculate elapsed time, apply easing functions to determine progress, and interpolate between start and end values.

The editor emits `tick` events at the browser's animation frame rate. The animation methods handle all of this internally: when you call `animateShape()` or `setCamera()` with animation options, the editor subscribes to tick events, calculates progress using the easing function, and updates the shape or camera state until the animation completes.

Camera animations respect the user's animation speed preference, which can be set to zero to disable animations entirely. Shape animations do not check this preference. If you need reduced motion support for shape animations, check `editor.user.getAnimationSpeed()` yourself and skip the animation when it returns zero.

#### Shape animations

Shape animations transition individual shape properties smoothly. The editor tracks each animating shape independently, so multiple animations can run at once.

Use `animateShape()` to animate a single shape or `animateShapes()` to animate multiple shapes simultaneously:

```typescript
import { createShapeId, EASINGS } from 'tldraw'

const shapeId = createShapeId('myshape')

editor.animateShape(
	{ id: shapeId, type: 'geo', x: 200, y: 100 },
	{ animation: { duration: 500, easing: EASINGS.easeOutCubic } }
)
```

##### Animated properties

The animation system handles interpolation for core shape properties that are common across all shape types. These built-in properties use linear interpolation (lerp) to calculate intermediate values between the start and end states:

- `x` - Horizontal position
- `y` - Vertical position
- `opacity` - Shape transparency (0 to 1)
- `rotation` - Shape rotation angle in radians

For shape-specific properties like width, height, or custom values, shape utilities define their own interpolation logic by implementing the `getInterpolatedProps()` method. For example, box shapes interpolate their dimensions:

```typescript
getInterpolatedProps(startShape: Shape, endShape: Shape, t: number) {
	return {
		...endShape.props,
		w: lerp(startShape.props.w, endShape.props.w, t),
		h: lerp(startShape.props.h, endShape.props.h, t),
	}
}
```

##### Animation lifecycle

Each animation receives a unique ID when started. The editor maintains a map of shape IDs to animation IDs to track which shapes are currently animating.

You can interrupt animations in two ways. Calling `updateShapes()` on an animating shape cancels its animation and immediately applies the new values. Starting a new animation for a shape automatically cancels any existing animation for that shape.

User interactions always take precedence over ongoing animations. If you drag a shape that's currently animating, the animation stops and the shape responds to your input immediately.

#### Camera animations

Camera animations move the viewport smoothly using easing functions. These animations integrate with the camera system and can be interrupted by user input like panning or zooming.

##### Viewport animations

The `setCamera()` method accepts an animation option to smoothly transition to a new camera position:

```typescript
editor.setCamera(
	{ x: 0, y: 0, z: 1 },
	{ animation: { duration: 320, easing: EASINGS.easeInOutCubic } }
)
```

Camera animations automatically stop when the user interacts with the viewport through mouse wheel, pinch gestures, or keyboard navigation.

##### Zooming to bounds

Use `zoomToBounds()` to animate the camera so a specific area fills the viewport. Use it to focus on shapes or build slideshow-style transitions:

```typescript
const bounds = { x: 0, y: 0, w: 800, h: 600 }
editor.zoomToBounds(bounds, { animation: { duration: 500 } })
```

You can also specify a target zoom level and inset padding:

```typescript
editor.zoomToBounds(bounds, {
	animation: { duration: 500 },
	targetZoom: 1, // zoom to 100%
	inset: 50, // padding around the bounds in pixels
})
```

The `zoomToFit()` method is a convenience wrapper that zooms to fit all shapes on the current page:

```typescript
editor.zoomToFit({ animation: { duration: 200 } })
```

##### Camera slide

The `slideCamera()` method creates momentum-based camera movement that gradually decelerates:

```typescript
editor.slideCamera({
	speed: 2,
	direction: { x: 1, y: 0 },
	friction: 0.1,
})
```

This method respects the user's animation speed preference. If animation speed is set to zero, the slide animation is disabled.

#### Easing functions

Easing functions control the rate of change during an animation. The right curve makes movement feel natural rather than mechanical. Use `easeOut` variants when responding to user actions (the animation starts fast and settles), `easeIn` for exits or dismissals (gradual start, quick finish), and `easeInOut` for autonomous movements like camera transitions (smooth start and end).

The editor provides these easing functions in `EASINGS`:

- `linear` - Constant rate of change, useful for progress indicators
- `easeInQuad`, `easeOutQuad`, `easeInOutQuad` - Subtle, gentle curves
- `easeInCubic`, `easeOutCubic`, `easeInOutCubic` - Balanced, natural-feeling motion
- `easeInQuart`, `easeOutQuart`, `easeInOutQuart` - More pronounced acceleration
- `easeInQuint`, `easeOutQuint`, `easeInOutQuint` - Strong acceleration curves
- `easeInSine`, `easeOutSine`, `easeInOutSine` - Very gentle, sinusoidal curves
- `easeInExpo`, `easeOutExpo`, `easeInOutExpo` - Dramatic, exponential curves

Shape animations default to `linear` easing. Camera animations default to `easeInOutCubic`. For shape animations responding to user actions, `easeOutCubic` or `easeOutQuad` often feel more responsive since the animation starts fast and settles gradually.

#### User preferences

Camera animations check `editor.user.getAnimationSpeed()` before running. This value is a speed multiplier: the editor divides animation durations by it, so users can speed up, slow down, or disable animations entirely.

When animation speed is zero, camera methods like `setCamera()`, `slideCamera()`, `zoomToBounds()`, and `zoomToFit()` skip the animation and jump immediately to the final position. Shape animations via `animateShape()` and `animateShapes()` do not check this preference automatically. If you need reduced motion support for shape animations, check the animation speed yourself:

```typescript
if (editor.user.getAnimationSpeed() > 0) {
	editor.animateShape(
		{ id: shapeId, type: 'geo', x: 200, y: 100 },
		{ animation: { duration: 500 } }
	)
} else {
	editor.updateShape({ id: shapeId, type: 'geo', x: 200, y: 100 })
}
```

#### Related examples

The [slideshow example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/use-cases/slideshow) uses camera animations to smoothly transition between slides using `zoomToBounds` with animation options. The [camera options example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/camera-options) demonstrates camera constraints and animations for bounded canvas experiences.

### Assets

Assets are external resources like images, videos, and bookmarks that shapes display on the canvas. They're stored as separate records in the store and referenced by ID from shapes. This lets you reuse the same image across multiple shapes without duplicating data, and swap out storage backends without touching your shapes.

The SDK includes three asset types: image, video, and bookmark. Each asset record holds metadata (dimensions, MIME type, source URL) while the actual file lives wherever you want to put it. You provide upload and resolve handlers that tell tldraw how to store files and fetch them for rendering.

#### How it works

##### Asset records and the store

Assets live in the store alongside shapes and pages. Each asset record contains metadata (dimensions, MIME type, name) but not the actual file bytes—those live in your storage backend.

When someone drops an image onto the canvas, tldraw creates two records: an asset record with dimensions and metadata, and a shape record with position and size. The shape references the asset through its `assetId` property. Multiple shapes can reference the same asset, so deleting one image shape won't remove the asset if other shapes still use it.

Asset records have a `props` object for type-specific properties and a `meta` object for your custom data. The `src` property in props holds the URL returned by your upload handler—this can be an HTTP URL, a data URL, or any string your resolve handler understands.

##### Asset types

The SDK defines three built-in asset types.

**Image assets** store raster images like PNG, JPEG, or GIF. They track width, height, MIME type, animation status, and file size. The `isAnimated` flag is true for animated GIFs.

```typescript
const imageAsset: TLImageAsset = {
	id: 'asset:image123' as TLAssetId,
	typeName: 'asset',
	type: 'image',
	props: {
		w: 1920,
		h: 1080,
		name: 'photo.jpg',
		isAnimated: false,
		mimeType: 'image/jpeg', // can be null if unknown
		src: 'https://storage.example.com/uploads/photo.jpg', // can be null before upload
		fileSize: 245000, // optional
	},
	meta: {},
}
```

**Video assets** store video files like MP4 or WebM. They have the same structure as image assets: dimensions, MIME type, source URL, and `isAnimated` (which is typically true for videos).

```typescript
const videoAsset: TLVideoAsset = {
	id: 'asset:video456' as TLAssetId,
	typeName: 'asset',
	type: 'video',
	props: {
		w: 1920,
		h: 1080,
		name: 'clip.mp4',
		isAnimated: true,
		mimeType: 'video/mp4',
		src: 'https://storage.example.com/uploads/clip.mp4',
		fileSize: 5242880,
	},
	meta: {},
}
```

**Bookmark assets** store web page previews. When someone pastes a URL, tldraw fetches metadata from the page and creates a bookmark that renders as a preview card.

```typescript
const bookmarkAsset: TLBookmarkAsset = {
	id: 'asset:bookmark1' as TLAssetId,
	typeName: 'asset',
	type: 'bookmark',
	props: {
		title: 'Example Website',
		description: 'A great example of web design',
		image: 'https://example.com/preview.jpg',
		favicon: 'https://example.com/favicon.ico',
		src: 'https://example.com',
	},
	meta: {},
}
```

##### The TLAssetStore interface

`TLAssetStore` defines how tldraw talks to your storage backend. You provide an implementation when creating the editor, and tldraw calls your handlers whenever someone adds or accesses assets.

The default behavior depends on your store setup:

- **In-memory only** (default): `inlineBase64AssetStore` converts images to data URLs—quick for prototyping but doesn't persist across sessions
- **With [`persistenceKey`](https://tldraw.dev/sdk-features/persistence#The-persistenceKey-prop)**: Assets are stored in the browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) alongside other document data
- **With a [sync server](https://tldraw.dev/docs/sync)**: Implement `TLAssetStore` to upload files to a storage service like S3, Google Cloud Storage, or your own API

The interface has three methods:

| Method    | Purpose                                                                           |
| --------- | --------------------------------------------------------------------------------- |
| `upload`  | Store a file and return its URL                                                   |
| `resolve` | Return the URL to use when rendering an asset (optional, defaults to `props.src`) |
| `remove`  | Clean up files when assets are deleted (optional)                                 |

The **upload** method receives an asset record (with metadata already populated) and the File to store. Return an object with `src` (the URL) and optionally `meta` (custom metadata to merge into the asset record). You also get an AbortSignal for cancellation.

```typescript
async upload(asset: TLAsset, file: File, abortSignal?: AbortSignal): Promise<{ src: string; meta?: JsonObject }>
```

The **resolve** method receives an asset and a `TLAssetContext` describing how the asset is being displayed. Return the URL to use for rendering, or null if unavailable. This is where you can get clever—return optimized thumbnails when zoomed out, high-resolution images for export, or add authentication tokens.

```typescript
resolve(asset: TLAsset, ctx: TLAssetContext): Promise<string | null> | string | null
```

The **remove** method receives asset IDs that are no longer needed—clean up the stored files to free space. This method is optional.

```typescript
async remove(assetIds: TLAssetId[]): Promise<void>
```

Here's a minimal implementation that converts files to data URLs (good for prototyping, not so great for production):

```typescript
import { Tldraw, TLAssetStore } from 'tldraw'
import 'tldraw/tldraw.css'

const assetStore: TLAssetStore = {
	async upload(asset, file) {
		const dataUrl = await new Promise<string>((resolve) => {
			const reader = new FileReader()
			reader.onload = () => resolve(reader.result as string)
			reader.readAsDataURL(file)
		})
		return { src: dataUrl }
	},

	resolve(asset, ctx) {
		return asset.props.src
	},
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw assets={assetStore} />
		</div>
	)
}
```

##### The TLAssetContext

When resolving assets, tldraw gives you a `TLAssetContext` with information about the current render environment. Use this to optimize asset delivery.

| Property                  | Type             | Description                                                                                                       |
| ------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------- |
| `screenScale`             | `number`         | How much the asset is scaled relative to native dimensions. A 1000px image rendered at 500px has screenScale 0.5. |
| `steppedScreenScale`      | `number`         | screenScale rounded to the nearest power of 2, useful for tiered caching.                                         |
| `dpr`                     | `number`         | Device pixel ratio. Retina displays are 2 or 3.                                                                   |
| `networkEffectiveType`    | `string \| null` | Browser's connection type: 'slow-2g', '2g', '3g', or '4g'.                                                        |
| `shouldResolveToOriginal` | `boolean`        | True when exporting or copy-pasting. Return full quality.                                                         |

Here's a resolve handler that serves optimized images based on context—notice how you can tailor the response to network conditions and zoom level:

```typescript
resolve(asset, ctx) {
	const baseUrl = asset.props.src
	if (!baseUrl) return null

	// For exports, always return original
	if (ctx.shouldResolveToOriginal) {
		return baseUrl
	}

	// On slow connections, serve lower quality
	if (ctx.networkEffectiveType === 'slow-2g' || ctx.networkEffectiveType === '2g') {
		return `${baseUrl}?quality=low`
	}

	// Serve resolution appropriate for current zoom
	const targetWidth = Math.ceil(asset.props.w * ctx.steppedScreenScale * ctx.dpr)
	return `${baseUrl}?w=${targetWidth}`
}
```

#### Key components

##### Editor asset methods

The `Editor` class provides methods for managing assets:

| Method                      | Description                               |
| --------------------------- | ----------------------------------------- |
| `Editor#createAssets`    | Add asset records to the store            |
| `Editor#updateAssets`    | Update existing assets                    |
| `Editor#deleteAssets`    | Remove assets and call the remove handler |
| `Editor#getAsset`        | Get an asset by ID                        |
| `Editor#getAssets`       | Get all assets in the store               |
| `Editor#resolveAssetUrl` | Resolve an asset ID to a renderable URL   |

Asset operations happen outside the undo/redo history since they're typically part of larger operations like pasting images—you don't want "undo" to magically un-upload a file.

```typescript
// Create an asset
editor.createAssets([imageAsset])

// Update an asset. Spread the existing props: the update replaces them wholesale
editor.updateAssets([
	{
		...imageAsset,
		props: { ...imageAsset.props, name: 'new-name.jpg' },
	},
])

// Get an asset with type safety
const asset = editor.getAsset<TLImageAsset>(imageAsset.id)

// Resolve to a URL for rendering
const url = await editor.resolveAssetUrl(imageAsset.id, { screenScale: 0.5 })

// Delete assets
editor.deleteAssets([imageAsset.id])
```

##### Shape and asset relationships

Shapes reference assets through an `assetId` property in their props. Image shapes, video shapes, and bookmark shapes all follow this pattern. The shape stores position, size, rotation, and crop settings while the asset stores the media metadata and source URL.

This separation pays off:

- Update an asset's `src` and every shape referencing it updates immediately
- Duplicate a shape without duplicating storage
- Implement lazy loading where assets only load when shapes become visible

When you delete an asset, shapes referencing it fall back to displaying the URL directly or show a placeholder.

#### Extension points

##### Custom storage backends

Implement `TLAssetStore` to integrate with any storage backend. For local development, convert files to data URLs. For production, upload to S3, Google Cloud Storage, or your own API—whatever works for you.

Here's an example that uploads to a custom API:

```typescript
const assetStore: TLAssetStore = {
	async upload(asset, file, abortSignal) {
		const formData = new FormData()
		formData.append('file', file)
		formData.append('assetId', asset.id)

		const response = await fetch('/api/upload', {
			method: 'POST',
			body: formData,
			signal: abortSignal,
		})

		const { url, uploadedAt } = await response.json()
		return {
			src: url,
			meta: { uploadedAt }, // Custom metadata gets merged into the asset
		}
	},

	resolve(asset, ctx) {
		// Add auth token for private content
		const token = getAuthToken()
		return `${asset.props.src}?token=${token}`
	},

	async remove(assetIds) {
		await fetch('/api/assets', {
			method: 'DELETE',
			body: JSON.stringify({ ids: assetIds }),
		})
	},
}
```

##### Custom asset types

Custom asset types let you store domain-specific media alongside images, videos, and bookmarks. Each asset type has a corresponding `AssetUtil` that defines type-specific behavior: which MIME types it accepts, how to derive an asset record from a dropped file, and what default props new instances start with. The built-in `ImageAssetUtil`, `VideoAssetUtil`, and `BookmarkAssetUtil` follow this pattern and live in `defaultAssetUtils`.

`AssetUtil` is the asset-side counterpart to `ShapeUtil`. You register one util per type on the editor at startup, and the editor calls its methods whenever a file enters the system.

Register your asset's props on `TLGlobalAssetPropsMap` via TypeScript module augmentation, then implement an `AssetUtil` for it:

```typescript
import { AssetUtil, TLAsset, TLAssetId } from 'tldraw'

const AUDIO_TYPE = 'audio'

declare module 'tldraw' {
	export interface TLGlobalAssetPropsMap {
		[AUDIO_TYPE]: {
			src: string | null
			mimeType: string | null
			name: string
		}
	}
}

type TLAudioAsset = TLAsset<typeof AUDIO_TYPE>

class AudioAssetUtil extends AssetUtil<TLAudioAsset> {
	static override type = AUDIO_TYPE

	override getDefaultProps(): TLAudioAsset['props'] {
		return { src: null, mimeType: null, name: '' }
	}

	override getSupportedMimeTypes() {
		return ['audio/mpeg', 'audio/wav', 'audio/ogg']
	}

	override async getAssetFromFile(file: File, assetId: TLAssetId): Promise<TLAudioAsset | null> {
		return {
			id: assetId,
			typeName: 'asset',
			type: AUDIO_TYPE,
			props: {
				src: null, // populated by the asset store after upload
				mimeType: file.type,
				name: file.name,
			},
			meta: {},
		}
	}
}
```

Pass the util to the `<Tldraw>` component alongside whichever defaults you still want:

```tsx
import { Tldraw, defaultAssetUtils } from 'tldraw'

const assetUtils = [...defaultAssetUtils, AudioAssetUtil]

export default function App() {
	return <Tldraw assetUtils={assetUtils} />
}
```

When a file is dropped or pasted, the editor finds the first registered util whose `getSupportedMimeTypes()` includes the file's MIME type and calls its `getAssetFromFile()`. The returned asset record then flows through your `TLAssetStore.upload` handler, which assigns the final `src`. Custom shape utils that render audio (or whatever else you registered) read the resolved URL through `editor.resolveAssetUrl()` like the built-in shapes do.

##### Configuring built-in asset utils

Use `AssetUtil#configure` to tweak options on a built-in util without subclassing it. For example, lock image uploads down to PNG:

```tsx
import { ImageAssetUtil, defaultAssetUtils, Tldraw } from 'tldraw'

const PngOnlyImageAssetUtil = ImageAssetUtil.configure({
	supportedMimeTypes: ['image/png'],
})

const assetUtils = defaultAssetUtils.map((util) =>
	util === ImageAssetUtil ? PngOnlyImageAssetUtil : util
)

<Tldraw assetUtils={assetUtils} />
```

`ImageAssetUtil` exposes `maxDimension` and `supportedMimeTypes` options. `VideoAssetUtil` exposes `supportedMimeTypes`.

##### Asset validation and migrations

Asset records use the migration system to evolve their schema. Each asset type has its own migration sequence that handles adding properties, renaming fields, and validating data. When you load a document with old asset records, migrations transform them to the current schema automatically.

Validators ensure asset data matches the expected structure at runtime. Use `createAssetValidator` to build a validator for your custom asset type—it produces a discriminated union on the `type` field. Add migration sequences to handle schema changes over time.

#### Security

##### SVG sanitization

SVG files can contain scripts, event handlers, and external resource references that execute during rendering. tldraw automatically sanitizes all SVGs on paste and file drop using an allowlist-based sanitizer that:

- Strips `<script>`, `<iframe>`, `<object>`, `<embed>`, and other dangerous elements
- Removes all `on*` event handler attributes (`onerror`, `onload`, etc.)
- Blocks `javascript:` and `data:` URIs in links
- Restricts `<image>` and `<feImage>` hrefs to `data:` URIs only
- Restricts `<use>` hrefs to fragment references (`#id`) only
- Sanitizes CSS to remove `@import`, `expression()`, and external `url()` references
- Preserves `<foreignObject>` content (needed for text rendering) with a separate HTML allowlist
- Preserves `<style>` elements with `data:` font URLs (needed for embedded fonts)

For stronger guarantees, we recommend using [DOMPurify](https://github.com/cure53/DOMPurify) — a battle-tested, widely-audited sanitizer. We don't bundle it to avoid imposing a ~17KB dependency, but if your app already uses it or you want the extra assurance, you can wire it into your external content handlers. Note that DOMPurify's default SVG profile strips `<foreignObject>` and `<style>` elements, which tldraw uses for text rendering and embedded fonts — you'll need to configure it to preserve those for tldraw's SVG output to round-trip correctly.

If you're implementing custom external content handlers, you can also import our built-in sanitizer directly:

```typescript
import { sanitizeSvg } from 'tldraw'

const sanitized = sanitizeSvg(svgText)
if (!sanitized) {
	// SVG contained no safe content
}
```

##### Recommended CSP policy

For defense in depth, we recommend deploying a Content Security Policy. This protects against attack vectors that sanitization alone cannot cover:

```
Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob: https://your-asset-domain.com;
  font-src 'self' data:;
  connect-src 'self' https://your-api.com;
  object-src 'none';
  base-uri 'self';
```

- **`script-src 'self'`** prevents inline scripts in SVGs from executing
- **`style-src 'self' 'unsafe-inline'`** allows tldraw's inline styles while blocking external stylesheet loading (`'unsafe-inline'` is needed for tldraw's runtime styles)
- **`img-src 'self' data: blob:`** allows data URLs for embedded images and blob URLs for asset previews, while blocking loads to arbitrary external origins
- **`object-src 'none'`** blocks `<object>` and `<embed>` elements entirely
- **`base-uri 'self'`** prevents `<base>` tag injection that could redirect relative URLs

##### Hosting assets on a separate domain

We recommend serving user-uploaded assets from a completely separate domain (e.g. `example-assets.com` rather than a subdomain like `assets.example.com`). This provides an extra layer of protection: if a malicious file somehow bypasses sanitization, browser same-origin policies prevent it from accessing cookies, storage, or APIs on your main domain.

#### Related examples

- [Hosted images](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/data/assets/hosted-images) - Implement a TLAssetStore that uploads images to a server
- [Local images](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/local-images) - Create image shapes from local asset records
- [Local videos](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/local-videos) - Create video shapes from local asset records
- [Asset options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/asset-props) - Control allowed asset types, max size, and dimensions
- [Static assets](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/data/assets/static-assets) - Pre-load custom fonts and icons

### Attribution

The attribution system lets you track which users create or edit shapes. Connect tldraw to your auth system through a `TLUserStore` to resolve display names, render attribution labels, and persist user records alongside your document data. The built-in note shape uses attribution to show who first edited a note's text, and you can add similar tracking to custom shapes.

#### User store

A `TLUserStore` provides a reactive `currentUser` signal for the active user and an optional `resolve` method for looking up other users by ID. Pass it as the `users` prop on the `Tldraw` component or sync hooks:

```tsx
import { computed, createUserId, Tldraw, TLUserStore, UserRecordType } from 'tldraw'
import 'tldraw/tldraw.css'

const currentUser = computed('currentUser', () =>
	UserRecordType.create({
		id: createUserId('user-123'),
		name: 'Alice',
		color: '#e03131',
	})
)

const users: TLUserStore = {
	currentUser,
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw users={users} />
		</div>
	)
}
```

When no `users` prop is provided, the editor falls back to user preferences and collaborator presence for display names.

##### Resolving other users

The optional `resolve` method looks up users by their raw ID string. This is called when rendering attribution labels for shapes edited by other users:

```tsx
import {
	computed,
	createCachedUserResolve,
	createUserId,
	TLUserStore,
	UserRecordType,
} from 'tldraw'

const users: TLUserStore = {
	currentUser: computed('currentUser', () =>
		UserRecordType.create({
			id: createUserId('user-123'),
			name: 'Alice',
			color: '#e03131',
		})
	),
	resolve: createCachedUserResolve((userId) => {
		// Look up the user from your auth system
		return myUserCache.get(userId) ?? null
	}),
}
```

The `createCachedUserResolve` helper wraps a lookup function so that each user ID gets a single stable reactive signal, avoiding redundant re-evaluations.

#### How attribution works

Attribution is opt-in per shape type. Each shape util decides what to track and when to stamp a user ID. When a shape stores a user ID in its props, the editor persists a corresponding `user:` record in the store so that display names survive across sessions, clipboard paste, and `.tldr` file exports.

##### Note shape attribution

The built-in note shape tracks who first added text. When a user types in an empty note, `NoteShapeUtil` sets the `textFirstEditedBy` prop to the current user's ID via `editor.getAttributionUserId()`. Subsequent edits by other users don't change this value. Clearing the text resets it to `null`. The note renders the first editor's name as a small label in the corner.

#### Reading attribution

The `Editor` provides three methods for working with attribution. These methods check the `TLUserStore` first, then fall back to `user:` records in the store:

```tsx
import { useEditor, useValue } from 'tldraw'

function AttributionLabel({ userId }: { userId: string }) {
	const editor = useEditor()

	const name = useValue('attribution-name', () => editor.getAttributionDisplayName(userId), [
		editor,
		userId,
	])

	if (!name) return null
	return <span>{name}</span>
}
```

#### Custom shape attribution

Add attribution tracking to your own shapes by storing a user ID in your shape's props and overriding `getReferencedUserIds` on your `ShapeUtil`:

```tsx
import { ShapeUtil, T, TLBaseShape } from 'tldraw'

type MyShape = TLBaseShape<
	'my-shape',
	{
		createdBy: string | null
	}
>

class MyShapeUtil extends ShapeUtil<MyShape> {
	static override type = 'my-shape' as const
	static override props = {
		createdBy: T.string.nullable(),
	}

	override getReferencedUserIds(shape: MyShape) {
		return shape.props.createdBy ? [shape.props.createdBy] : []
	}

	// ... other ShapeUtil methods
}
```

Overriding `getReferencedUserIds` ensures that when shapes are copied to the clipboard or exported, the referenced `user:` records are included so that display names remain available on the other side.

To stamp the current user when creating shapes:

```tsx
const userId = editor.getAttributionUserId()

editor.createShape({
	type: 'my-shape',
	props: {
		createdBy: userId,
	},
})
```

#### Extensible user records

Extend user records with custom metadata by passing validators to `createTLSchema`:

```tsx
import { createTLSchema, T } from 'tldraw'

const schema = createTLSchema({
	user: {
		meta: {
			department: T.string,
			isAdmin: T.boolean,
		},
	},
})
```

Custom metadata is validated and persisted alongside the standard user fields. Access it through the `meta` property on `TLUser` records.

#### API reference

| Method                                     | Description                                                             |
| ------------------------------------------ | ----------------------------------------------------------------------- |
| `editor.getAttributionUserId()`            | Get the current user's ID for stamping shapes. Returns `string \| null` |
| `editor.getAttributionDisplayName(userId)` | Resolve a display name from a user ID. Returns `string \| null`         |
| `editor.getAttributionUser(userId)`        | Resolve a full `TLUser` record from a user ID                        |

| Type                      | Description                                          |
| ------------------------- | ---------------------------------------------------- |
| `TLUserStore`             | Interface for connecting to your auth/user system    |
| `TLUser`               | A user record in the store                           |
| `UserRecordType`          | The default user record type                         |
| `createUserId`            | Create a typed user ID                               |
| `createCachedUserResolve` | Create a cached resolve function for `TLUserStore`   |
| `createUserRecordType`    | Build a user record type with custom meta validators |

For setting up user identity in multiplayer, see the [Collaboration](https://tldraw.dev/sdk-features/collaboration#user-identity) page.

#### Related examples

- [Attribution](https://tldraw.dev/examples/users/attribution) — Basic attribution with a user switcher and attribution inspector
- [Attribution timeline](https://tldraw.dev/examples/users/attribution-timeline) — Timeline scrubber with per-user filtering

### Bindings

Bindings create persistent relationships between shapes. When you draw an arrow to a rectangle, a binding stores that connection so the arrow stays attached when you move the rectangle. Bindings power features like arrows that follow shapes, stickers that stick to other shapes, and layout constraints that keep shapes aligned.

The SDK handles bookkeeping for you: when you delete a shape, its bindings are cleaned up automatically, and lifecycle hooks let bound shapes react to changes.

#### How it works

When you create a binding, the editor stores it as a record with `fromId` and `toId` fields pointing to shape IDs. The editor maintains an index of all bindings touching each shape. When either shape changes position, transforms, or gets deleted, the binding's `BindingUtil` receives callbacks that can update the bound shapes accordingly.

The system handles several scenarios automatically:

- When a shape is deleted, all its bindings are removed and the remaining shapes receive isolation callbacks
- When shapes are copied, only bindings between copied shapes are duplicated
- When shapes are moved to different pages, cross-page bindings are automatically removed
- When bound shapes are transformed together, the binding maintains their relationship

The bindings index is a computed value that updates incrementally as bindings change. Lookups are fast and never scan all records.

#### Key concepts

##### Directional relationships

Every binding has direction. The `fromId` points to the source shape, and the `toId` points to the target shape. For arrows, the arrow is always the "from" shape and the shape it points to is the "to" shape. This directionality determines which lifecycle hooks fire and lets the system know which shape "owns" the relationship.

The distinction matters when shapes change. If you move a rectangle that an arrow points to, the arrow's `onAfterChangeToShape` hook fires. If you move the arrow itself, its `onAfterChangeFromShape` hook fires. Both hooks can update the arrow's position, but they're called in different contexts.

##### Binding records

A binding record contains just enough information to identify the relationship and store relationship-specific data:

```typescript
interface TLBaseBinding<Type, Props> {
	id: TLBindingId
	typeName: 'binding'
	type: Type
	fromId: TLShapeId
	toId: TLShapeId
	props: Props
	meta: JsonObject
}
```

The `props` field holds binding-specific data. Arrow bindings store the normalized anchor point on the target shape and whether the attachment is "precise" or should snap to the shape's edge. Custom bindings can store any data appropriate to the relationship type.

##### BindingUtil lifecycle

Each binding type implements a `BindingUtil` class that responds to events throughout the binding's lifetime. The lifecycle hooks fall into several categories:

**Creation and changes** - `onBeforeCreate`, `onAfterCreate`, `onBeforeChange`, and `onAfterChange` fire when the binding record itself is modified. These hooks can return new binding records to override the changes.

**Shape changes** - `onAfterChangeFromShape` and `onAfterChangeToShape` fire when the bound shapes change. These are the most commonly used hooks for keeping shapes synchronized. Arrow bindings use these to update the arrow's position and parent when the target shape moves.

**Deletion and isolation** - `onBeforeDelete` and `onAfterDelete` fire when the binding is removed. More importantly, `onBeforeIsolateFromShape` and `onBeforeIsolateToShape` fire before a binding is removed due to separation (deletion, copy, or duplication). Isolation hooks let shapes "bake in" the binding's current state before it disappears.

**Batch completion** - `onOperationComplete` fires after all binding operations in a transaction finish. This is useful for computing aggregate updates across multiple related bindings.

##### Isolation vs deletion

Isolation callbacks handle a specific problem: when an arrow's target shape is deleted, the arrow shouldn't suddenly point to empty space. The `onBeforeIsolateFromShape` hook lets the arrow update its terminal position to match the current attachment point before the binding is removed. The arrow then appears to "let go" of the shape naturally.

Isolation also occurs during copy and duplicate operations. If you copy an arrow but not its target, the copied arrow needs to convert its binding into a fixed position. The isolation callback handles this transformation.

Use isolation callbacks for consistency updates that should happen whenever shapes separate. Use deletion callbacks for actions specific to deletion, like removing a sticker when its parent shape is deleted.

#### API patterns

##### Creating bindings

Create bindings using `Editor#createBinding` or `Editor#createBindings`. You must provide the binding type, fromId, and toId. The BindingUtil supplies default props.

```typescript
editor.createBinding({
	type: 'arrow',
	fromId: arrowShape.id,
	toId: targetShape.id,
	props: {
		terminal: 'end',
		normalizedAnchor: { x: 0.5, y: 0.5 },
		isPrecise: false,
		isExact: false,
		snap: 'none',
	},
})
```

##### Querying bindings

The editor provides several methods for finding bindings:

```typescript
// Get a specific binding by ID
const binding = editor.getBinding(bindingId)

// Get all bindings where this shape is the source
const outgoing = editor.getBindingsFromShape(shape.id, 'arrow')

// Get all bindings where this shape is the target
const incoming = editor.getBindingsToShape(shape.id, 'arrow')

// Get all bindings involving this shape (either direction)
const all = editor.getBindingsInvolvingShape(shape.id, 'arrow')
```

##### Updating and deleting bindings

Update bindings with partials, just like shapes:

```typescript
editor.updateBinding({
	id: binding.id,
	props: { normalizedAnchor: { x: 0.8, y: 0.2 } },
})
```

Delete bindings directly or let the system remove them automatically when shapes are deleted. Pass `isolateShapes: true` to trigger isolation callbacks:

```typescript
editor.deleteBinding(binding.id, { isolateShapes: true })
```

##### Controlling which shapes can bind

Shapes control whether they accept bindings by implementing `ShapeUtil#canBind`:

```typescript
class MyShapeUtil extends ShapeUtil<MyShape> {
	canBind({ fromShape, toShape, bindingType }: TLShapeUtilCanBindOpts) {
		// Only allow arrow bindings where this shape is the target
		return bindingType === 'arrow' && toShape.type === this.type
	}
}
```

The editor calls both shapes' `canBind()` methods before creating a binding. If either returns false, the binding is not created.

#### Extension points

Custom binding types let you create new kinds of relationships between shapes. The process involves defining the binding's data structure, implementing its behavior, and registering it with the editor.

##### Defining the binding type

First, extend the type system to include your binding's props. Use TypeScript's module augmentation to add your binding type to the global binding props map:

```typescript
declare module 'tldraw' {
	export interface TLGlobalBindingPropsMap {
		myBinding: {
			anchor: VecModel
			strength: number
		}
	}
}
```

##### Implementing BindingUtil

Create a class extending `BindingUtil` with your binding type. At minimum, implement `getDefaultProps()`. Add lifecycle hooks based on what behavior you need:

```typescript
class MyBindingUtil extends BindingUtil<MyBinding> {
	static override type = 'myBinding'

	override getDefaultProps() {
		return { anchor: { x: 0.5, y: 0.5 }, strength: 1 }
	}

	override onAfterChangeToShape({ binding, shapeAfter }) {
		// Update the "from" shape when the "to" shape moves
	}

	override onBeforeIsolateFromShape({ binding }) {
		// Bake in current state before the binding is removed
	}
}
```

##### Registering the binding

Pass your BindingUtil to the editor via the `bindingUtils` prop:

```tsx
<Tldraw bindingUtils={[MyBindingUtil]} />
```

#### Related examples

The examples app includes several binding implementations that demonstrate different use cases:

- **[Sticker bindings](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/sticker-bindings)** - Shapes that stick to other shapes and follow them when moved. Demonstrates `onAfterChangeToShape` for position updates and `onBeforeDeleteToShape` for cascading deletion.
- **[Pin bindings](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/pin-bindings)** - Pins that connect networks of shapes together, moving them as a group. Demonstrates `onOperationComplete` for computing aggregate updates across multiple related bindings.
- **[Layout bindings](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/layout-bindings)** - Constraining shapes to layout positions. Demonstrates using bindings to enforce spatial relationships between shapes.

### Camera system

The camera system controls how users view and navigate the infinite canvas. It manages viewport position and zoom level, and transforms coordinates between screen space and page space. The editor uses these transformations to map mouse positions to canvas locations and render shapes at any zoom level.

The camera handles user input for panning and zooming, supports constraints for bounded experiences, and provides methods for programmatic movement with smooth animations. It also integrates with collaboration features for real-time viewport following.

#### How it works

The camera represents the viewport's position and zoom using three values: `x` and `y` for position in page space, and `z` for zoom level. A zoom of `1` means 100%, `0.5` is 50%, and `2` is 200%. The camera's `x` and `y` are the page-space offset of the viewport: the page point at the viewport's top-left corner is `(-x, -y)`.

The camera transforms between two coordinate spaces:

- **Screen space** - Browser pixels from the document origin
- **Page space** - The infinite canvas coordinate system

The `screenToPage()` method converts mouse positions to canvas coordinates, while `pageToScreen()` converts canvas coordinates to screen positions:

```typescript
const pagePoint = editor.screenToPage({ x: event.clientX, y: event.clientY })
const screenPoint = editor.pageToScreen({ x: shape.x, y: shape.y })
```

The camera responds to user input through mouse wheel, trackpad gestures, keyboard shortcuts, and touch events. The `wheelBehavior` option determines whether scrolling pans or zooms the viewport.

#### Camera options

Camera behavior is configured through `TLCameraOptions`:

```typescript
editor.setCameraOptions({
	isLocked: false,
	wheelBehavior: 'pan',
	panSpeed: 1,
	zoomSpeed: 1,
	zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
})
```

The `isLocked` option prevents all camera movement, useful for fixed-viewport experiences.

The `wheelBehavior` option determines how mouse wheel or trackpad scroll affects the viewport: `'pan'` for navigating large diagrams, `'zoom'` for detail work, or `'none'` to disable wheel interaction.

The `panSpeed` and `zoomSpeed` multipliers adjust input sensitivity. Values below 1 slow down movement, values above 1 speed it up.

The `zoomSteps` array defines discrete zoom levels. The first value sets minimum zoom, the last sets maximum zoom, and intermediate values determine snap points for zoom controls.

#### Camera constraints

Camera constraints limit where users can navigate. Use them for presentations, guided experiences, or applications with fixed content areas:

```typescript
editor.setCameraOptions({
	constraints: {
		bounds: { x: 0, y: 0, w: 1920, h: 1080 },
		padding: { x: 50, y: 50 },
		origin: { x: 0.5, y: 0.5 },
		initialZoom: 'fit-min',
		baseZoom: 'default',
		behavior: 'inside',
	},
})
```

The `bounds` define the constrained area in page space. The camera restricts panning outside this rectangle based on the `behavior` setting.

The `padding` adds screen space margin inside the viewport so content doesn't touch the screen edges.

The `origin` determines how bounds are positioned within the viewport when using `'fixed'` behavior. Values of `{ x: 0.5, y: 0.5 }` center the bounds, while `{ x: 0, y: 0 }` aligns to the top-left.

##### Zoom fitting

The `initialZoom` and `baseZoom` options control how content fits the viewport:

- `'default'`: 100% zoom, showing content at actual size
- `'fit-min'`: Fit the smaller axis so the full bounds stay visible
- `'fit-max'`: Fit the larger axis; the other axis may extend past the viewport
- `'fit-x'`: Fit horizontally, filling the viewport width
- `'fit-y'`: Fit vertically, filling the viewport height
- `'fit-x-100'`: Fit horizontally or use 100%, whichever is smaller
- `'fit-y-100'`: Fit vertically or use 100%, whichever is smaller
- `'fit-min-100'`: Fit the smaller axis or use 100%, whichever is smaller
- `'fit-max-100'`: Fit the larger axis or use 100%, whichever is smaller

The `initialZoom` sets the starting zoom when the camera resets. The `baseZoom` defines the reference point for zoom steps, affecting how zoom in/out operations scale relative to the viewport.

##### Constraint behaviors

The `behavior` option controls how bounds constrain camera movement:

- `'free'`: Bounds are ignored, allowing unlimited panning
- `'fixed'`: Bounds are positioned at the origin regardless of pan attempts
- `'inside'`: Bounds must stay completely within the viewport
- `'outside'`: Bounds must stay touching the viewport edges
- `'contain'`: Uses `'fixed'` when zoomed out and `'inside'` when zoomed in

Set behavior per axis for asymmetric constraints:

```typescript
behavior: {
  x: 'free',    // Horizontal panning unrestricted
  y: 'inside',  // Vertical panning keeps bounds visible
}
```

#### Camera methods

The editor provides methods for programmatic camera control. All methods accept an optional options object with:

- `animation` - add smooth transitions with `duration` and `easing`
- `immediate` - move the camera immediately rather than on the next tick
- `force` - move the camera even when `isLocked` is true

##### Basic navigation

Move the camera to a specific position and zoom:

```typescript
editor.setCamera({ x: -500, y: -300, z: 1.5 })
```

Center the viewport on a point:

```typescript
editor.centerOnPoint({ x: 1000, y: 500 })
```

Zoom in or out. Both methods accept an optional screen point to zoom toward:

```typescript
editor.zoomIn()
editor.zoomOut()
editor.zoomIn(editor.inputs.getCurrentScreenPoint(), { animation: { duration: 200 } })
```

##### Zoom to content

Focus the camera on shapes or bounds:

```typescript
// Fit all shapes on the current page
editor.zoomToFit()

// Fit the current selection
editor.zoomToSelection()

// Reset zoom to 100% (or initial zoom if constraints are set)
editor.resetZoom()

// Fit specific bounds with padding
const bounds = { x: 0, y: 0, w: 1000, h: 800 }
editor.zoomToBounds(bounds, { inset: 100 })
```

The `zoomToBounds` method accepts `inset` to add padding around the bounds and `targetZoom` to limit the maximum zoom level.

##### Quick zoom navigation

The default tldraw UI includes a quick zoom tool activated by pressing `z`. This zooms out to show the entire canvas with a viewport brush that marks where you'll zoom to. Move the cursor to reposition the target viewport, then release to zoom to that location. Press Escape to cancel and return to the original view.

##### Animated movement

Add smooth transitions using the `animation` option. The `EASINGS` object provides common easing functions:

```typescript
import { EASINGS } from 'tldraw'

editor.setCamera(
	{ x: 0, y: 0, z: 1 },
	{
		animation: {
			duration: 500,
			easing: EASINGS.easeInOutCubic,
		},
	}
)
```

Camera animations stop automatically when users pan or zoom: user input takes precedence over programmatic movement. You can also stop camera animations at any time with the editor's `stopCameraAnimation()` method.

##### Momentum scrolling

Create momentum-based camera movement:

```typescript
editor.slideCamera({
	speed: 2,
	direction: { x: 1, y: 0 },
	friction: 0.1,
	speedThreshold: 0.01,
})
```

The `speed` and `direction` control initial velocity. The `friction` value determines how fast the camera decelerates (higher friction stops movement faster). The `speedThreshold` sets the minimum speed before the animation stops completely. Use this for kinetic scrolling or to continue movement after gesture completion.

#### Collaboration features

The camera system integrates with collaboration through user following. When following another user, the camera tracks their viewport position and zoom.

User following respects the follower's viewport size. If aspect ratios differ, the system adjusts zoom to keep the followed user's content visible while maintaining the follower's viewport dimensions.

See [User following](https://tldraw.dev/sdk-features/user-following) for implementation details.

#### Related examples

- **[Camera options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/camera-options)** - Configure the camera's options and constraints including zoom behavior, pan speed, and camera bounds.
- **[Image annotator](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/use-cases/image-annotator)** - An image annotator that demonstrates how to configure camera options for fixed-viewport annotation apps.
- **[Slideshow (fixed camera)](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/use-cases/slideshow)** - A simple slideshow app with a fixed camera using camera constraints.
- **[Lock camera zoom](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/lock-camera-zoom)** - Lock the camera at a specific zoom level using the camera controls API.
- **[Zoom to bounds](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/zoom-to-bounds)** - Programmatically zoom the camera to specific bounds using the editor's `zoomToBounds` method.
- **[Scrollable container](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/layout/scroll)** - Use the editor inside a scrollable container with proper mousewheel event handling.

### Click detection

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.

#### How it works

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.

##### State transitions

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.

##### Distance validation

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 event phases

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.

##### Movement cancellation

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).

#### Handling click events

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:

```tsx
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:

```tsx
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) |

#### Timing configuration

Click timing is configured through the editor's [options](https://tldraw.dev/sdk-features/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 |

#### Related examples

- [Canvas events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/canvas-events) — logs pointer events including click sequences to understand the event flow
- [Custom double-click behavior](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/custom-double-click-behavior) — overrides the default double-click handler in the SelectTool
- [Custom shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/custom-shape) — implements `onDoubleClick` and other click handlers in custom shapes

### Clipboard

The clipboard lets you copy, cut, and paste shapes within a single editor or between different editor instances. When you copy shapes, the editor serializes them along with their bindings and assets into a `TLContent` object. This format preserves document structure and relationships so shapes paste correctly elsewhere.

#### How clipboard operations work

Clipboard operations have two flows: extracting content (copy/cut) and placing content (paste).

##### Extracting content

When you copy or cut shapes, the editor calls `Editor#getContentFromCurrentPage` to serialize them into a `TLContent` object:

```ts
const content = editor.getContentFromCurrentPage(editor.getSelectedShapeIds())
// content contains shapes, bindings, assets, and schema
```

This method collects the selected shapes and their descendants, gathers bindings between them, and includes any referenced assets. Root shapes (those whose parents aren't in the selection) get transformed to page coordinates so they paste at the correct position.

The method keeps only bindings where both the `fromId` and `toId` shapes are in the copied set. This prevents dangling references to shapes that won't exist in the pasted content.

##### Placing content

`Editor#putContentOntoCurrentPage` handles paste operations. It takes `TLContent` and reconstructs shapes on the current page:

```ts
// Paste at a specific point
editor.putContentOntoCurrentPage(content, {
	point: { x: 100, y: 100 },
	select: true,
})

// Paste and preserve original positions
editor.putContentOntoCurrentPage(content, {
	preservePosition: true,
})
```

The method migrates the content through the store's schema system to handle version differences, remaps shape and binding IDs to prevent collisions, and finds an appropriate parent for the pasted shapes.

The parent selection logic works like this: if shapes are selected when pasting, the editor finds the selected shape with the fewest ancestors and uses its parent. This creates intuitive behavior where pasting with a frame selected places shapes inside the frame, while pasting with shapes on the page pastes beside them. When pasting at a specific point (like the cursor position), the editor looks for an appropriate parent at that location.

##### Browser clipboard integration

The editor writes clipboard data in multiple formats. For HTML-aware applications, it embeds serialized `TLContent` in a `<div data-tldraw>` element. For plain text, it extracts text from text shapes. This multi-format approach preserves tldraw-specific data while staying compatible with other applications.

The clipboard uses a versioned format with compression. Version 3 (the current format) stores assets as plain JSON and compresses other data using LZ compression. This reduces payload size while keeping asset information quickly accessible.

When pasting, the editor tries the browser's Clipboard API first because it preserves metadata that the clipboard event API strips out. If that fails, it falls back to reading from the paste event's clipboard data. The editor handles images, files, URLs, HTML, and plain text, routing each through the appropriate handler.

##### Asset resolution

Before writing to the clipboard, the editor calls `Editor#resolveAssetsInContent` to convert asset references into data URLs:

```ts
const content = editor.getContentFromCurrentPage(editor.getSelectedShapeIds())
const resolved = await editor.resolveAssetsInContent(content)
// resolved.assets now contain data URLs instead of asset references
```

This embeds images and videos directly in the clipboard data rather than relying on URLs that might not be accessible when pasting elsewhere. The resolved content becomes fully portable across editor instances.

##### Cut operations

Cut combines copy and delete. The editor first copies the selected shapes to the clipboard, then deletes the originals. This order ensures the clipboard has the data before shapes disappear, preventing data loss if the copy fails.

##### Plain text paste

`Cmd+Shift+V` (or `Ctrl+Shift+V` on Windows and Linux) pastes the clipboard as plain text. HTML and rich formatting are stripped. This is the standard "paste without formatting" shortcut. It's handy when styled text from a browser or word processor would otherwise bring its fonts and colors onto the canvas with it.

To extend or override this behavior, use the `onClipboardPasteRaw` hook on `TldrawOptions`. It fires before tldraw parses the clipboard, so you can read the raw `ClipboardEvent` data yourself. Return `false` to short-circuit the default pipeline, or `void` to let it continue.

#### Content structure

The `TLContent` type defines the clipboard payload:

```ts
interface TLContent {
	shapes: TLShape[]
	bindings: TLBinding[] | undefined
	rootShapeIds: TLShapeId[]
	assets: TLAsset[]
	schema: SerializedSchema
	users?: TLUser[]
}
```

- `shapes` contains all copied shapes in serialized form
- `rootShapeIds` identifies which shapes have no parent in the copied set, distinguishing top-level shapes from nested children
- `bindings` holds relationships between shapes, like arrows connected to boxes
- `assets` includes images, videos, and other external resources
- `schema` preserves the store schema version, so content from a different editor version can be migrated on paste
- `users` carries any user records referenced by the copied shapes, so [attribution](https://tldraw.dev/sdk-features/attribution) display names survive the paste

#### Position handling

`Editor#putContentOntoCurrentPage` offers flexible positioning:

- By default, shapes paste at a slight offset from their original position so it's clear that new shapes were created
- When pasting with the shift key pressed (or with paste-at-cursor mode enabled), shapes paste at the cursor position
- The `preservePosition` option places shapes at their exact stored coordinates, skipping offset calculation entirely

The editor uses `preservePosition` internally when moving shapes between pages, where position preservation matters.

#### ID remapping

Shape and binding IDs get remapped during paste to prevent collisions with existing shapes. The editor creates a mapping from old IDs to new IDs, then updates all references throughout the pasted content: parent-child relationships, binding endpoints, and asset references all get the new IDs.

The `preserveIds` option disables remapping. The editor uses it when moving shapes between pages, where the shapes should keep their existing IDs.

#### External content handling

For non-tldraw content (images, URLs, plain text), use `Editor#putExternalContent` to route it through registered handlers:

```ts
// Paste files at a specific point
await editor.putExternalContent({
	type: 'files',
	files: droppedFiles,
	point: { x: 100, y: 200 },
})

// Paste a URL
await editor.putExternalContent({
	type: 'url',
	url: 'https://example.com/image.png',
	point: editor.inputs.getCurrentPagePoint(),
})

// Paste text
await editor.putExternalContent({
	type: 'text',
	text: 'Hello world',
	point: { x: 100, y: 100 },
})
```

Register custom handlers with `Editor#registerExternalContentHandler` to customize how different content types are processed. See [External content](https://tldraw.dev/sdk-features/external-content) for details on the handler system.

#### Related examples

- [Custom paste behavior](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/data/assets/custom-paste) shows how to customize paste by registering an external content handler that changes where pasted shapes are positioned.
- [External content sources](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/data/assets/external-content-sources) shows how to handle different content types when pasting into tldraw, including custom handling for HTML content.

### Collaboration

The `@tldraw/sync` package provides real-time multiplayer collaboration for tldraw. Multiple users can edit the same document simultaneously, see each other's cursors, and follow each other's viewports. The sync system handles connection management, conflict resolution, and presence synchronization automatically.

Collaboration requires a server component to coordinate changes between clients. Use tldraw's demo server for prototyping, or run your own server for production.

#### Quick start with the demo server

The fastest way to add multiplayer is with `useSyncDemo`. This hook connects to a hosted demo server that handles synchronization:

```tsx
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	const store = useSyncDemo({ roomId: 'my-room-id' })

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw store={store} />
		</div>
	)
}
```

Anyone who opens the app with the same `roomId` will see the same document and each other's cursors. The demo server is great for prototyping, but data is deleted after a day and rooms are publicly accessible by ID. Don't use it in production.

#### Production setup with useSync

For production, use the `useSync` hook with your own server:

```tsx
import { useSync } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function Room({ roomId }: { roomId: string }) {
	const store = useSync({
		uri: `wss://your-server.com/sync/${roomId}`,
		assets: myAssetStore,
	})

	if (store.status === 'loading') {
		return <div>Connecting...</div>
	}

	if (store.status === 'error') {
		return <div>Connection error: {store.error.message}</div>
	}

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw store={store.store} />
		</div>
	)
}
```

The `useSync` hook returns a `RemoteTLStoreWithStatus` object with three possible states:

| Status          | Description                                                    |
| --------------- | -------------------------------------------------------------- |
| `loading`       | Establishing connection and performing initial sync            |
| `synced-remote` | Connected and syncing. Includes `store` and `connectionStatus` |
| `error`         | Connection failed. Includes `error` with details               |

##### Asset storage

Production setups require an asset store for handling images, videos, and other files:

```tsx
const myAssetStore: TLAssetStore = {
	upload: async (asset, file) => {
		const response = await fetch('/api/upload', {
			method: 'POST',
			body: file,
		})
		const { url } = await response.json()
		return { src: url }
	},
	resolve: (asset, context) => {
		// context includes dpr, networkEffectiveType, and shouldResolveToOriginal
		return asset.props.src
	},
}

const store = useSync({
	uri: `wss://your-server.com/sync/${roomId}`,
	assets: myAssetStore,
})
```

See the [Assets](https://tldraw.dev/docs/assets) documentation for more on implementing asset stores.

#### User identity

By default, users get a default name and a random color, stored in localStorage. To customize this, pass a `users` store whose methods return reactive `Signal` values:

```tsx
import { computed, UserRecordType, createUserId } from 'tldraw'

const currentUser = computed('currentUser', () =>
	UserRecordType.create({
		id: createUserId('user-123'),
		name: 'Alice',
		color: '#ff0000',
	})
)

const store = useSyncDemo({
	roomId: 'my-room',
	users: {
		currentUser,
	},
})
```

The `users` store is also used for shape attribution — the same `currentUser` signal stamps shapes with user IDs. See the [Attribution](https://tldraw.dev/sdk-features/attribution) page for details on tracking which users create or edit shapes. Provide a `resolve` method to look up other users by ID:

```tsx
const users: TLUserStore = {
	currentUser: myAuth.currentUser$,
	resolve: (userId) => myUserCache.getSignal(userId),
}
```

##### Integrating with useTldrawCurrentUser

If you need to let users edit their preferences through tldraw's UI, use `useTldrawCurrentUser`:

```tsx
import { useSyncDemo } from '@tldraw/sync'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
	atom,
	computed,
	createUserId,
	TLUserPreferences,
	TLUserStore,
	Tldraw,
	UserRecordType,
	useTldrawCurrentUser,
} from 'tldraw'

export default function App({ roomId }: { roomId: string }) {
	const [userPreferences, setUserPreferences] = useState<TLUserPreferences>({
		id: 'user-123',
		name: 'Alice',
		color: 'coral',
		colorScheme: 'dark',
	})

	const userPrefsAtom = useRef(atom('userPrefs', userPreferences)).current
	useEffect(() => {
		userPrefsAtom.set(userPreferences)
	}, [userPreferences, userPrefsAtom])

	const users: TLUserStore = useMemo(() => {
		const currentUser = computed('currentUser', () => {
			const p = userPrefsAtom.get()
			return UserRecordType.create({
				id: createUserId(p.id),
				name: p.name ?? '',
				color: p.color ?? undefined,
			})
		})
		return { currentUser }
	}, [userPrefsAtom])

	const store = useSyncDemo({ roomId, users })
	const user = useTldrawCurrentUser({ userPreferences, setUserPreferences })

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw store={store} user={user} />
		</div>
	)
}
```

#### Connection status

When using `useSync`, the store object includes connection status information:

```tsx
const store = useSync({ uri, assets })

if (store.status === 'synced-remote') {
	// store.connectionStatus is 'online' or 'offline'
	console.log('Connection:', store.connectionStatus)
}
```

The connection status reflects the WebSocket connection state. When offline, changes are queued locally and sync when the connection resumes.

#### Custom presence

The presence system controls what information is shared with other users. By default, it includes cursor position, selected shapes, and viewport bounds. You can customize this with `getUserPresence`:

```tsx
import { getDefaultUserPresence } from 'tldraw'

const store = useSyncDemo({
	roomId: 'my-room',
	getUserPresence(store, user) {
		const defaults = getDefaultUserPresence(store, user)
		if (!defaults) return null

		return {
			...defaults,
			// Remove camera/viewport to disable follow functionality
			camera: undefined,
		}
	},
})
```

Return `null` from `getUserPresence` to hide this user's presence entirely. This is useful for spectator modes where you want a user to observe without appearing in the room.

#### Authentication

To add authentication, generate the WebSocket URI dynamically:

```tsx
const store = useSync({
	uri: async () => {
		const token = await getAuthToken()
		return `wss://your-server.com/sync/${roomId}?token=${token}`
	},
	assets: myAssetStore,
})
```

The `uri` option accepts a function that returns a string or Promise. This runs when establishing the connection and on reconnection, so tokens can refresh automatically.

#### Running your own server

For production, you'll need to run a sync server. The `@tldraw/sync-core` package provides `TLSocketRoom` for server-side room management.

We provide a complete Cloudflare Workers template that includes:

- WebSocket sync via Durable Objects (one per room)
- Asset storage with R2
- Bookmark unfurling for URL previews
- Production-ready architecture that scales automatically

Get started with the template:

```bash
npx create-tldraw@latest --template sync-cloudflare
```

Or copy the relevant pieces to your existing infrastructure. The template handles the complexity of room lifecycle, connection management, and state persistence.

##### Server architecture

The sync server uses a room-based model:

1. Each document has a unique room ID
2. Clients connect via WebSocket to their room
3. The server maintains one `TLSocketRoom` per active room
4. Changes broadcast to all connected clients in real-time
5. The server is authoritative for conflict resolution

```
┌─────────┐     ┌─────────────────┐     ┌─────────┐
│ Client  │────▶│   TLSocketRoom  │◀────│ Client  │
└─────────┘     │   (per room)    │     └─────────┘
                └────────┬────────┘
                         │
                    ┌────▼────┐
                    │ Storage │
                    └─────────┘
```

#### Custom shapes and bindings

If you use custom shapes or bindings, register them with the sync hooks using schema options:

```tsx
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import { MyCustomShapeUtil } from './MyCustomShape'
import { MyCustomBindingUtil } from './MyCustomBinding'

const customShapes = [MyCustomShapeUtil]
const customBindings = [MyCustomBindingUtil]

export default function App({ roomId }: { roomId: string }) {
	const store = useSyncDemo({
		roomId,
		shapeUtils: customShapes,
		bindingUtils: customBindings,
	})

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw store={store} shapeUtils={customShapes} bindingUtils={customBindings} />
		</div>
	)
}
```

Pass the shape and binding utilities to both the sync hook (for schema registration) and the Tldraw component (for rendering). If they don't match, shapes may fail to sync or render correctly.

#### Comments

Comment threads sync the same way, through record types you opt into with the `records` option:

```tsx
import { useSync } from '@tldraw/sync'
import { commentSchemaRecords } from 'tldraw'

const store = useSync({
	uri: `wss://your-server.com/sync/${roomId}`,
	assets: myAssetStore,
	records: commentSchemaRecords,
})
```

Your server's schema needs the same registration. On the server you can also serve comments through
the room's object-store lane, which gates them by their own per-session permission—so a viewer who
can't edit the document can still comment. See [Commenting](https://tldraw.dev/sdk-features/commenting) for the tool,
the canvas layer, and the server setup.

#### Building custom sync

The `@tldraw/sync` package handles many complexities: connection management, reconnection, conflict resolution, and protocol versioning. For most applications, it's the right choice. However, you might need custom sync when integrating with existing infrastructure, using a different transport (like WebRTC), or implementing specialized conflict resolution.

tldraw's store provides the primitives you need to build your own sync layer.

##### Listening to changes

The store's `listen` method notifies you when records change:

```tsx
const unsubscribe = editor.store.listen(
	(entry) => {
		// entry.changes contains all modifications
		// entry.source is 'user' (local) or 'remote'
		console.log('Changes:', entry.changes)
	},
	{ source: 'user', scope: 'document' }
)

// Later: stop listening
unsubscribe()
```

Filter options narrow what changes you receive:

| Filter   | Values                                           | Description               |
| -------- | ------------------------------------------------ | ------------------------- |
| `source` | `'user'`, `'remote'`, `'all'`                    | Who made the change       |
| `scope`  | `'document'`, `'session'`, `'presence'`, `'all'` | What type of data changed |

For sync, you typically want `source: 'user'` (only local changes) and `scope: 'document'` (only persistent data).

##### Change structure

Changes arrive as a `RecordsDiff` object with three categories:

```tsx
interface RecordsDiff<R> {
	added: Record<string, R> // New records
	updated: Record<string, [from: R, to: R]> // Changed records (before/after)
	removed: Record<string, R> // Deleted records
}
```

Each entry is keyed by record ID. For updates, you get both the previous and current state, which is useful for conflict detection or generating patches.

##### Applying remote changes

When you receive changes from other clients, wrap them in `mergeRemoteChanges`:

```tsx
function applyRemoteChanges(records: TLRecord[], deletedIds: TLRecord['id'][]) {
	editor.store.mergeRemoteChanges(() => {
		if (records.length > 0) {
			editor.store.put(records)
		}
		if (deletedIds.length > 0) {
			editor.store.remove(deletedIds)
		}
	})
}
```

This marks the changes as `'remote'` source, so your own listener won't echo them back to the server. It also batches the operations into a single history entry.

##### Snapshots and serialization

For initial sync or persistence, serialize the entire store:

```tsx
// Get all document records as a plain object
const data = editor.store.serialize('document')
// Returns: { 'shape:abc': {...}, 'page:xyz': {...}, ... }

// Get a snapshot with schema information (recommended for persistence)
const snapshot = editor.store.getStoreSnapshot('document')
// Returns: { store: {...}, schema: {...} }

// Restore from snapshot (handles migrations automatically)
editor.store.loadStoreSnapshot(snapshot)
```

The snapshot format includes schema information, so tldraw can automatically migrate old data when your schema evolves.

##### Presence records

User presence (cursors, selections, viewports) uses special `instance_presence` records:

```tsx
import { InstancePresenceRecordType } from 'tldraw'

// Create a presence record for a remote user
const presence = InstancePresenceRecordType.create({
	id: InstancePresenceRecordType.createId(
		editor.store.id // Store ID identifies this client
	),
	userId: 'user-123',
	userName: 'Alice',
	color: '#ff6b6b',
	currentPageId: editor.getCurrentPageId(),
	cursor: { x: 100, y: 200, type: 'default', rotation: 0 },
	selectedShapeIds: [],
	camera: { x: 0, y: 0, z: 1 },
	screenBounds: { x: 0, y: 0, w: 1920, h: 1080 },
	lastActivityTimestamp: Date.now(),
	chatMessage: '',
	brush: null,
	scribbles: [],
	followingUserId: null,
	meta: {},
})

// Add to store
editor.store.put([presence])

// Update cursor position
editor.store.update(presence.id, (record) => ({
	...record,
	cursor: { ...record.cursor, x: 150, y: 250 },
}))

// Remove when user disconnects
editor.store.remove([presence.id])
```

Listen for presence changes separately from document changes:

```tsx
editor.store.listen(
	(entry) => {
		// Broadcast presence to other clients
		sendPresence(entry.changes)
	},
	{ source: 'user', scope: 'presence' }
)
```

##### Example: simple broadcast sync

Here's a minimal example using a WebSocket for broadcast sync (no conflict resolution):

```tsx
import { useEffect, useRef, useState } from 'react'
import { Tldraw, createTLStore, defaultShapeUtils } from 'tldraw'

function App() {
	const [store] = useState(() => createTLStore({ shapeUtils: defaultShapeUtils }))
	const wsRef = useRef<WebSocket | null>(null)

	useEffect(() => {
		const ws = new WebSocket('wss://your-server.com/room/123')
		wsRef.current = ws

		// Send local changes to server
		const unsubscribe = store.listen(
			(entry) => {
				ws.send(
					JSON.stringify({
						type: 'changes',
						added: Object.values(entry.changes.added),
						updated: Object.values(entry.changes.updated).map(([, to]) => to),
						removed: Object.keys(entry.changes.removed),
					})
				)
			},
			{ source: 'user', scope: 'document' }
		)

		// Apply remote changes
		ws.onmessage = (event) => {
			const msg = JSON.parse(event.data)
			if (msg.type === 'changes') {
				store.mergeRemoteChanges(() => {
					if (msg.added.length || msg.updated.length) {
						store.put([...msg.added, ...msg.updated])
					}
					if (msg.removed.length) {
						store.remove(msg.removed)
					}
				})
			}
		}

		return () => {
			unsubscribe()
			ws.close()
		}
	}, [store])

	return <Tldraw store={store} />
}
```

This example omits important concerns like initial state sync, reconnection handling, and conflict resolution. For production use, consider starting with `@tldraw/sync` and customizing it, or studying its implementation for guidance on handling these edge cases.

#### Related examples

- [Multiplayer sync](https://tldraw.dev/examples/collaboration/sync-demo) — Basic multiplayer setup with the demo server
- [Custom user](https://tldraw.dev/examples/collaboration/sync-custom-user) — Setting custom user identity for multiplayer
- [Custom presence](https://tldraw.dev/examples/collaboration/sync-custom-presence) — Customizing presence data sent to collaborators
- [Custom shapes](https://tldraw.dev/examples/collaboration/sync-custom-shape) — Syncing custom shapes with multiplayer

### Commenting

In tldraw, a comment is a message pinned to a place on the canvas. Comments group into threads, one conversation per pin. Every comment records who wrote it and comments can mention other people.

The `@tldraw/commenting` package works at two levels. `CanvasComments` is a comments layer you render in front of the canvas: it draws the pins, opens the threads, and reads and writes the records itself. Everything it's built from is exported too, so you can replace any part of it or assemble your own layer from the pieces.

Commenting is a licensed feature. It runs in development without a key. In production it needs a tldraw license that includes commenting.

#### Setup

There are three pieces: register the comment record types with your store, register the comment tool, and render the comments layer.

```tsx
import {
	CanvasComments,
	CommentAuthor,
	commentTools,
	commentToolOverrides,
} from '@tldraw/commenting'
import { useMemo } from 'react'
import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
import '@tldraw/commenting/commenting.css'
import 'tldraw/tldraw.css'

// Your app's user directory. Any id you can't resolve falls back to a generic byline.
const AUTHORS: Record<string, CommentAuthor> = {
	me: { name: 'You', color: '#EC5E41' },
	ada: { name: 'Ada Lovelace', color: '#0E9F6E' },
}
const resolveAuthor = (id: string) => AUTHORS[id]

const components: TLComponents = {
	InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
}

export default function App() {
	const store = useMemo(
		() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
		[]
	)

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				licenseKey={YOUR_LICENSE_KEY}
				store={store}
				tools={commentTools}
				overrides={[commentToolOverrides]}
				components={components}
			/>
		</div>
	)
}
```

Comments are records in the editor's store, exactly like shapes. `commentSchemaRecords` registers the `comment-thread` and `comment` types, which aren't in the default schema. Once they're registered, comments persist and sync however your document already does: add a `persistenceKey` or a sync backend and they come along with it.

`CanvasComments` needs two things, and both are about identity. `currentUserId` is the id of the person commenting, or `null` for a viewer who can read comments but not post them. `resolveAuthor` maps an author id to a `CommentAuthor`: a `name`, an optional `color` for their avatar and pin, and an optional `image`. Return `undefined` for an id you can't resolve and the layer falls back to an anonymous user. Together with the read-status and mention callbacks below they make up the `CommentingContext`, which the sidebar takes too — so a host mounting both surfaces builds one object and spreads it into each.

Mount the layer through the `InFrontOfTheCanvas` component slot so it sits above the canvas but below the UI, and import `commenting.css` alongside `tldraw.css`.

	Without a license key that includes commenting, every commenting component renders nothing in
	production. The feature is fully enabled in development, so a missing key shows up as a blank
	canvas after you deploy. See [License key](https://tldraw.dev/sdk-features/license-key).

#### Placing comments

`commentToolOverrides` puts the comment tool in Quick Actions with the `C` shortcut. With the tool active, clicking empty canvas starts a thread anchored to that point, and clicking a shape anchors a comment to the shape. You can also turn on region comments, where dragging the tool out covers an area. Either way the click only opens a composer: nothing is written to the store until the comment is posted.

Once a comment thread exists, its pin is the handle for everything else.

| Input       | Result                                                               |
| ----------- | -------------------------------------------------------------------- |
| Click a pin | Opens the thread, with its replies and a reply composer.             |
| Drag a pin  | Re-anchors the thread. Drop it on a shape to attach it to the shape. |
| `Escape`    | Closes the open thread.                                              |
| `Shift+C`   | Hides and shows the pins on the canvas.                              |

In the built-in layer, resolve and delete-thread sit in the thread's header, and the edit and delete controls for a single comment appear only on the author's own comments by default. [`canModifyComment`](#who-can-edit-and-delete) changes who gets those controls, and [Syncing comments](#syncing-comments) covers enforcing the same rules on the server.

To put the show and hide toggle in a menu of your own, use `CommentsMenuItem`. It's a checkbox item wired to the same state as the `Shift+C` shortcut.

```tsx
import { CommentsMenuItem } from '@tldraw/commenting'
import { DefaultMainMenu, TldrawUiMenuGroup } from 'tldraw'

function MainMenu() {
	return (
		<DefaultMainMenu>
			<TldrawUiMenuGroup id="comments">
				<CommentsMenuItem />
			</TldrawUiMenuGroup>
		</DefaultMainMenu>
	)
}
```

#### Configuring the tool

Commenting options live on the comment tool. Set them with `CommentTool.configure()`, which mirrors `ShapeUtil.configure` and returns a configured subclass to register:

```tsx
import { CommentTool, commentToolOverrides } from '@tldraw/commenting'
import { Tldraw } from 'tldraw'

const tools = [CommentTool.configure({ enableRegions: true })]

function App() {
	return <Tldraw tools={tools} overrides={[commentToolOverrides]} components={components} />
}
```

Options are fixed once the tool is registered, so this is static configuration only. Live values, like the current user and the author resolver, are the `CommentingContext` instead, passed as props to each surface. Calls to `configure` can be chained, and each one layers over the last.

The sections below introduce the relevant comment options, and [All options](#all-options) lists every one together.

#### Anchors

A thread's `anchor` says where it lives. It's a discriminated union, so new anchor kinds can arrive without breaking existing threads.

| Anchor   | Description                                                                                                              |
| -------- | ------------------------------------------------------------------------------------------------------------------------ |
| `point`  | A fixed page point. What a click on empty canvas produces.                                                               |
| `shape`  | A shape, with `x`/`y` as a normalized (0–1) offset in its bounds. The pin keeps its spot as the shape moves and resizes. |
| `region` | A rectangular area of the page, with the pin on one corner.                                                              |
| `page`   | The page itself, with no spatial anchor. These threads have no pin and surface in a list instead.                        |

Shape-anchored threads outlive their shape. Delete the shape and the thread converts to a `point` anchor where its pin last sat, so the conversation doesn't vanish with the thing it was about. Bring the shape back, whether by undoing the delete or by a page move that re-creates it, and the shape anchor is restored. The exception is a pin someone moved by hand in the meantime: a manual placement wins over the restore.

Threads follow their shape across pages too. Move the shape to another page and the thread's `pageId`, along with the denormalized `pageId` on each of its comments, updates to match. Comments do not move when you cut or copy a shape.

##### Shape anchor precision

A comment that lands on a shape anchors in one of two ways. A **precise** anchor pins to the exact clicked spot. An **imprecise** one addresses the shape as a whole and renders its pin at a spot you choose, the `impreciseShapeAnchor`, which is the shape's top-right corner by default. Both track the shape as it moves and resizes, and both store the clicked `x`/`y`, so precision governs rendering rather than data.

Shape comments are precise by default. `shouldBePrecise` makes the call, and it receives the gesture: the target shape, the release point, and whether Alt was held.

```tsx
// Precise on notes, shape-level everywhere else
const tools = [
	CommentTool.configure({
		shouldBePrecise: (editor, { shapeId }) => editor.getShape(shapeId)?.type === 'note',
	}),
]
```

Return `() => false` for shape-level anchoring throughout, or `(editor, { altKey }) => altKey` to make precision a per-placement choice the user holds Alt for. The predicate governs new placements only. Anchors already stored render the way they were made.

#### Comments and undo

Comment writes are not undoable by default. The `history` option governs all of them, including posting, replying, editing and resolving, and it defaults to `'ignore'`. It covers your own writes too: a record you write with `putCommentRecords` lands on the undo stack, or doesn't, exactly like one the built-in UI writes.

In a shared document an undoable delete resurrects a thread a collaborator already removed, and an undoable resolve reverts their newer state. `'record'` is safe single-player, or against a comment store that isn't synced.

Deleting is the one write `history` doesn't reach. It's never undoable, for a reason particular to how deletes work — see [Deleting is a soft delete](#deleting-is-a-soft-delete).

Pin drags are the exception worth configuring separately. Re-anchoring a comment is a spatial edit that may reasonably undo alongside the shape move that prompted it, so `dragHistory` overrides `history` for drags alone:

```tsx
CommentTool.configure({ dragHistory: 'record' })
```

The [comments and undo example](https://tldraw.dev/examples/collaboration/comment-history) lets you feel the difference: post a comment, press undo, and watch whether the thread count moves.

#### Who can comment

Left unset, `canComment` allows participation whenever `currentUserId` is set. Participation covers composing threads and replies, editing and deleting your own comments, resolving threads, and moving pins. Pass a callback to decide for yourself:

```tsx
CommentTool.configure({
	canComment: ({ currentUserId }) => currentUserId !== null && getRole() !== 'viewer',
	components: {
		ComposerFallback: ({ context }) => (context === 'thread' ? <SignInPrompt /> : null),
	},
})
```

When `canComment` returns false, composers give way to the `ComposerFallback` slot and the action affordances hide. That slot's `context` says which surface is asking: the bottom of an open thread (`'thread'`), or the placement popover the tool opens (`'pending'`). Leave the slot unset and those surfaces render nothing.

`canComment` is read during render through `useCanComment`, so a callback that reads signals re-evaluates when they change.

#### Who can edit and delete

`canComment` is about the viewer; `canModifyComment` is about the viewer and one particular record. It's asked for the three writes that belong to someone in particular — editing a comment, deleting a comment, deleting a thread — and where it returns false, that affordance isn't rendered.

Left unset, each is its record's owner's to make: you edit and delete your own comments, and delete threads you started. Pass a callback to widen that, composing with `defaultCanModifyComment` so the owner keeps what they already had:

```tsx
import { CommentTool, defaultCanModifyComment } from '@tldraw/commenting'

CommentTool.configure({
	canModifyComment: (ctx) =>
		// Moderators may remove anything. Editing stays the author's, whoever you are.
		(ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
		defaultCanModifyComment(ctx),
})
```

The `ctx` carries the editor, the viewer's `currentUserId`, and the write itself as a discriminated union: `{ action: 'edit-comment' | 'delete-comment', comment }` or `{ action: 'delete-thread', thread }`. Narrowing works too — return false to close edits after an hour, or on a resolved thread.

Resolving, reopening, reacting, and moving a pin aren't asked about: none of them is anyone's in particular, so `canComment` is the only gate on them. `canModifyComment` is checked after `canComment`, so a viewer who may not participate gets no action affordances whatever it returns. Like `canComment`, it's read during render (through `useCanModifyComment`), so a callback that reads signals re-evaluates when they change.

Whatever you decide here, decide it again on the server: `createCommentAuthorizers` takes a `canModifyComment` of its own, and it's the one that counts. See [enforcing the same rules on the server](#syncing-comments).

	`canComment` and `canModifyComment` hide UI. They don't enforce anything. Comment records carry a
	client-supplied `createdBy` and `authorId`, and a client can write whatever your sync server
	accepts. Enforce permissions in your server's record authorization, and verify author ids against
	the session's authenticated identity. See [Syncing comments](#syncing-comments).

#### Mentions

Composers support `@`-mentions. Supply the roster with `getMentionSuggestions`, which can be synchronous or async:

```tsx
import { CanvasComments, filterMentionMembers, MentionMember } from '@tldraw/commenting'

const MEMBERS: MentionMember[] = [
	{ id: 'me', name: 'You', color: '#EC5E41', you: true },
	{ id: 'ada', name: 'Ada Lovelace', color: '#0E9F6E' },
	{ id: 'grace', name: 'Grace Hopper', color: '#4465E9', secondary: 'grace@example.com' },
]

function Comments() {
	return (
		<CanvasComments
			currentUserId="me"
			resolveAuthor={resolveAuthor}
			getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)}
		/>
	)
}
```

A `MentionMember` is a `CommentAuthor` plus an `id`, an optional `secondary` line for the picker, and `you` to mark the current user. `filterMentionMembers` does the matching; supply your own filter to query a server instead. To change how a row looks, pass `renderMentionSuggestion`.

A mention is a node in the body rather than text in it, so you can't find one by searching the string. To detect a mention, look for `{ type: 'mention', attrs: { id } }` in the body's `content` tree.

The mention components come from `@tldraw/mentions` and are re-exported here. That package also powers mentions in shape rich text. See [Rich text](https://tldraw.dev/sdk-features/rich-text).

#### Reactions

Hover a comment and open the picker to react with an emoji. A pill appears with a live count, and hovering it names who reacted.

Each reaction is its own `comment-reaction` record, one per (comment, user, emoji), rather than a field on the comment. Two people reacting at once therefore write different records and neither can clobber the other. The record id is derived from the triple, so re-picking an emoji toggles it. Registering `commentSchemaRecords` registers the reaction type along with the rest.

Reactions are multi-select by default, where each emoji toggles independently. Set `allowMultipleReactions: false` for single-select, where a new emoji replaces your existing one.

The palette is pluggable. A reaction's emoji is treated as an opaque token: the layer stores it, syncs it, and hands it to a renderer, and never assumes it's a glyph. So you can swap in tokens of your own.

```tsx
CommentTool.configure({
	components: {
		ReactionContent: MyTokenRenderer, // how a token is drawn
		ReactionPalette: MyPalette, // what the add-reaction button opens
	},
	isAllowedReaction: (token) => isMyToken(token) || isAllowedReactionEmoji(token),
})
```

`isAllowedReaction` is enforced client-side. If arbitrary tokens would be a problem for you, validate them on your server too.

#### Region comments

A region thread covers a rectangular area rather than a point. Regions are off by default, so the tool stays click-only until you turn them on:

```tsx
CommentTool.configure({ enableRegions: true })
```

That's the whole configuration. A region reveals its dashed box and resize handles while the pointer is inside it, moves by its pin, and resizes from its corners. The pin sits on whichever corner the creating drag was released on, which the anchor remembers.

#### The sidebar

`CanvasCommentsSidebar` lists comment threads in a panel beside the canvas. Clicking a row brings that thread's pin into view and opens it.

Its open state is a signal you drive, so the toggle lives wherever your app wants it. `useCommentsSidebarOpen` reads it and `toggleCommentsSidebar` flips it; the underlying `commentsSidebarOpen` atom is exported too, for code that has an editor but no React context:

```tsx
import {
	CanvasComments,
	CanvasCommentsSidebar,
	CommentingContext,
	toggleCommentsSidebar,
	useCommentsSidebarOpen,
} from '@tldraw/commenting'
import { TldrawUiButton, TldrawUiButtonLabel, useEditor } from 'tldraw'

function SidebarToggle() {
	const editor = useEditor()
	const open = useCommentsSidebarOpen()
	return (
		<TldrawUiButton type="normal" onClick={() => toggleCommentsSidebar(editor)}>
			<TldrawUiButtonLabel>{open ? 'Hide comments' : 'Comments'}</TldrawUiButtonLabel>
		</TldrawUiButton>
	)
}

// Both surfaces read the same context, so build it once and spread it into each.
const commenting: CommentingContext = { currentUserId: 'me', resolveAuthor }

const components: TLComponents = {
	InFrontOfTheCanvas: () => (
		<>
			<CanvasComments {...commenting} />
			<CanvasCommentsSidebar {...commenting} />
		</>
	),
	SharePanel: SidebarToggle,
}
```

The sidebar's filters cover resolved threads, only comments you made, only unread comments, and only the current page. They're held per editor, so a user's choices survive the panel closing. Pass `header` and `empty` to replace the chrome around the list.

#### Unread state

The layer doesn't track who has read what. That data lives in your app. Supply it as two callbacks. `isCommentUnread` reports whether a comment is unread, and `onCommentRead` fires for each unread comment the user actually sees in an open thread.

```tsx
<CanvasComments
	currentUserId="me"
	resolveAuthor={resolveAuthor}
	isCommentUnread={(commentId) => !readReceipts.has(commentId)}
	onCommentRead={(commentId) => markCommentRead(commentId)}
	onPostComment={(comment) => notifyMentionedUsers(comment)}
/>
```

Unread state drives the pin badges and the sidebar's unread filter. Without `isCommentUnread`, both are hidden. `onPostComment` fires when this user posts a comment through one of the built-in composers — a new thread or a reply — which is where notifications and mention emails belong. It does not fire for comments arriving over sync, so the sender is the one who notifies.

#### Custom components

Every visible piece of the layer is a slot. Set them through the `components` option, and leave a slot unset to keep its default.

| Slot               | Replaces                                                               |
| ------------------ | ---------------------------------------------------------------------- |
| `CommentBody`      | A comment's body, normally the rich-text renderer.                     |
| `PinContent`       | A pin's inner content, normally the author's initial.                  |
| `ThreadPreview`    | A sidebar row's preview, normally the body as plain text.              |
| `ThreadRow`        | A whole sidebar row, normally `CommentListItem`.                       |
| `ThreadActions`    | Adds controls to an open thread's header. Additive, not a replacement. |
| `ComposerFallback` | What shows where a composer would sit when the viewer can't comment.   |
| `ReactionContent`  | How a reaction token is drawn.                                         |
| `ReactionPalette`  | What the add-reaction button opens.                                    |
| `ReactionTooltip`  | The list of who reacted, shown on a reaction pill.                     |

```tsx
import { CommentTool, richTextToPlaintext } from '@tldraw/commenting'
import { TLComment } from 'tldraw'

function PriorityBody({ comment }: { comment: TLComment }) {
	const urgent = comment.meta.priority === 'urgent'
	return (
		<div className={urgent ? 'urgent-comment' : 'comment'}>{richTextToPlaintext(comment.body)}</div>
	)
}

const tools = [CommentTool.configure({ components: { CommentBody: PriorityBody } })]
```

Both record types carry a `meta` field the layer never reads. Use it for priorities, categories, external ticket ids, or anything else your app tracks alongside a comment.

`ThreadRow` and `ThreadActions` are the two slots that add to a surface rather than replace a piece of it, so they get the records behind what's on screen.

`ThreadRow` receives the summarized row along with the thread record, and the default row is exported — so a row that only adds something can spread the props into `CommentListItem` rather than start over:

```tsx
import { CommentListItem, CommentTool } from '@tldraw/commenting'

function StatusRow({ thread, ...row }: CommentListItemRenderProps & { thread: TLCommentThread }) {
	return (
		<div className="status-row">
			<CommentListItem {...row} />
			{thread.meta.status === 'blocked' && <span className="blocked">Blocked</span>}
		</div>
	)
}

const tools = [CommentTool.configure({ components: { ThreadRow: StatusRow } })]
```

`ThreadActions` adds controls to an open thread's header, ahead of the built-in resolve and dismiss buttons. This is where host verbs go — assign a thread, link it to a ticket, mark it as a to-do. It adds alongside the built-in actions rather than replacing them, so a thread never loses the ability to be resolved.

```tsx
function AssignAction({ thread }: { thread: TLCommentThread }) {
	return (
		<button type="button" className="tlui-cmt-thread__action" onClick={() => assign(thread.id)}>
			Assign
		</button>
	)
}

const tools = [CommentTool.configure({ components: { ThreadActions: AssignAction } })]
```

A link affordance doesn't need this slot: see [Linking to a thread](#linking-to-a-thread).

#### Working with comment records

Comment records aren't part of the `TLRecord` union, so `editor.store` doesn't know their types statically. These helpers own that reinterpretation and keep your call sites typed.

| Helper                  | Description                                                                          |
| ----------------------- | ------------------------------------------------------------------------------------ |
| `getLiveCommentThreads` | The threads that render.                                                             |
| `getLiveComments`       | The comments that render.                                                            |
| `getCommentThreads`     | Every thread in the store, including deleted ones.                                   |
| `getComments`           | Every comment in the store, including deleted ones.                                  |
| `getCommentRecord`      | One record by id, or `undefined`.                                                    |
| `putCommentRecords`     | Write threads and comments.                                                          |
| `removeCommentRecords`  | Remove them by id. Rarely what you want — see [Writing comments](#writing-comments). |

Prefer the live reads. Deleting a comment doesn't remove its record — it flags it and lets the server prune it, as [Writing comments](#writing-comments) explains — so the unfiltered reads include records that nothing renders. `getLiveCommentThreads` also drops threads whose comments have all gone, which have no surface left.

Those reads are non-reactive. In React use the hooks instead, which read the live set: `useCommentThreads`, `useComments`, and `useThreadComments` for one thread's replies, oldest first.

```tsx
import { useCommentThreads } from '@tldraw/commenting'
import { useEditor } from 'tldraw'

function OpenThreadCount() {
	const editor = useEditor()
	const threads = useCommentThreads(editor)
	const open = threads.filter((thread) => !thread.resolved)
	return <div>{open.length} open threads</div>
}
```

#### Writing comments

To post a thread yourself, whether you're seeding a document with review notes or importing comments from another system, build the records and write them:

```tsx
import { putCommentRecords } from '@tldraw/commenting'
import { createComment, createCommentThread, toRichText } from 'tldraw'

const thread = createCommentThread({
	pageId: editor.getCurrentPageId(),
	anchor: { type: 'point', x: 120, y: 240 },
	createdBy: 'ada',
})

const comment = createComment({
	threadId: thread.id,
	pageId: thread.pageId,
	authorId: 'ada',
	body: toRichText('Can we make this arrow dashed?'),
})

putCommentRecords(editor, [thread, comment])
```

The other writes each have a rule attached — a timestamp to stamp, or the delete protocol below — so they come as functions. The built-in thread view calls exactly these, so a UI of your own behaves like the one in the box.

| Helper                          | Description                                            |
| ------------------------------- | ------------------------------------------------------ |
| `editComment`                   | Replace a comment's body and mark it edited.           |
| `resolveThread`, `reopenThread` | Resolve a thread, stamping who and when, or reopen it. |
| `deleteComment`                 | Delete a comment.                                      |
| `deleteThread`                  | Delete a thread and its whole conversation.            |

```tsx
import { deleteComment, resolveThread } from '@tldraw/commenting'

resolveThread(editor, thread, currentUserId)
deleteComment(editor, comment)
```

The record you pass says which comment or thread to act on; the change itself lands on the version currently in the store. So a record you took a copy of earlier is safe to pass: it won't put back a field that has moved since — a thread's anchor changes on its own as pinned shapes move, and a comment's body changes when its author edits it elsewhere — and it won't re-create a record that has already been deleted, which a plain `putCommentRecords` of a stale copy would. If the record is gone, the call does nothing.

##### Deleting is a soft delete

`deleteComment` and `deleteThread` don't remove records. They set an `isDeleted` flag and leave the pruning to the server, which then removes the thread, its comments, and their reactions.

That indirection is what makes deleting safe in a shared document. A reaction belongs to whoever left it, not to whoever is deleting the comment underneath it, so no client should be removing it — and a server enforcing per-record permissions gets a write it can check against the session's identity rather than a deletion it can only refuse. `removeCommentRecords` is a hard delete, and against such a server it will simply be rejected. Reach for it on a local, unsynced comment store.

The flag is write-once server-side, so deletes are never undoable, whatever `history` says: an undo clearing the flag would be vetoed and rebased rather than bring the comment back.

Deleting a thread's last comment leaves the thread with nothing to render. The record stays for the server to prune, since whoever deleted the comment may not be the thread's creator.

#### Revealing a thread

To open a thread from outside the canvas, whether from a notification, a shared link, or a list of your own, call `revealThread` with a thread or comment id. `CanvasComments` serves the request: it waits for the records to arrive, switches pages if it needs to, zooms in if the pin is inside a cluster, then opens the thread.

```tsx
import { revealThread } from '@tldraw/commenting'

// e.g. from a ?comment=<id> search param
revealThread(editor, commentId)
```

An unserved request is inert, so it's safe to call before the records have synced in. `useRevealThreadPending` returns the id of a request that hasn't been served yet, which is how you notice a deep link to a comment that no longer exists — give it a grace period first, since a request also sits there while its records are still arriving, and re-check with `getRevealThreadPending(editor)` when the grace period elapses so you don't act on a request that cleared inside it.

For a thread you already hold, `focusThread(editor, thread)` centers and opens it directly.

#### Linking to a thread

The other half of a deep link is producing one. Give the commenting context a `getThreadHref` and every surface that can link to a thread does:

```tsx
<CanvasComments
	currentUserId="me"
	resolveAuthor={resolveAuthor}
	getThreadHref={(threadId) => `/file/${fileId}?comment=${encodeURIComponent(threadId)}`}
/>
```

Sidebar rows become anchors, so ctrl/cmd-click and middle-click open a thread in a new tab, and an open thread's header menu offers **Copy link**. A relative href is resolved against the current document before it reaches the clipboard, so what gets pasted is a whole URL.

Without `getThreadHref` neither appears — a link the host can't construct isn't one the layer can invent.

#### Pin clustering

Zoom out far enough and pins pile on top of each other. Clustering folds nearby pins into a count badge as you zoom out, then splits them apart as you zoom back in. Splits happen at a wider spacing than merges, so pins don't flicker at the boundary. Clicking a badge zooms to just past the point where that cluster breaks up.

Clustering is on by default. Turn it off with `enableClustering: false`.

The work is precomputed once per comment add, remove, or move, so per-frame camera changes cost a single walk over a sorted event table. See the [comment clustering example](https://tldraw.dev/examples/collaboration/comment-clustering).

#### Syncing comments

Comment records don't ship in the default schema, so both ends of the connection have to register them, and they have to match. A client with the types talking to a server without them will fail schema validation.

On the client, pass `records` to the sync hook:

```tsx
const store = useSync({
	uri: `wss://your-server.com/sync/${roomId}`,
	assets: myAssetStore,
	records: commentSchemaRecords,
})
```

On the server, pass the same map to `createTLSchema`:

```ts
import { TLSocketRoom } from '@tldraw/sync-core'
import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'

const schema = createTLSchema({ records: commentSchemaRecords })

const room = new TLSocketRoom({
	schema,
	// Serve comments through the object-store lane rather than the document
	objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
})
```

`objectTypes` moves those record types onto a separate lane. Lane records are stored apart from the document and left out of document snapshots. More usefully, they're gated by their own per-session permission rather than by `isReadonly`, so a session can be allowed to comment without being allowed to edit:

```ts
room.handleSocketConnect({
	sessionId,
	socket,
	isReadonly: true, // can't touch the document
	objectAccess: 'write', // but can still comment
})
```

`objectAccess` is `'read'` or `'write'` and defaults to `'write'`. To persist the lane separately, read it with `TLSocketRoom`'s `getCurrentObjectsSnapshot()`. To mirror comments into your own database as they commit, use the room's `onCommittedChanges` callback:

```ts
const room = new TLSocketRoom({
	schema,
	objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
	onCommittedChanges({ diff }) {
		// Project comment records into Postgres for notifications and search
		projectComments(diff)
	},
})
```

`onCommittedChanges` only fires for client pushes. Server-initiated writes don't trigger it, including `updateStore`, `loadSnapshot`, and writing to storage directly, so anything mirroring room state has to handle those paths itself.

This is also where per-record permissions belong. The lane's write access is all or nothing, so rules like "only the author may edit a comment" or "only the thread's creator may delete it" are enforced as your server validates each incoming record against the session's identity. `createCommentAuthorizers` is that validation, ready-made — pass it to the room's `authorizeRecord`:

```ts
import { createCommentAuthorizers } from '@tldraw/sync-collaboration'

const room = new TLSocketRoom({
	schema,
	objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
	authorizeRecord: {
		...createCommentAuthorizers<SessionMeta>({
			getUserId: (session) => session.meta.userId,
			// Moderators may take anything down. Editing stays the author's, whoever you are.
			canModifyComment: (ctx) =>
				(ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||
				ctx.userId === ctx.ownerId,
		}),
	},
})
```

It stamps authorship from the session so nothing can be posted or resolved in someone else's name, keeps `isDeleted` write-once, and — through `canModifyComment` — decides who may edit a comment, delete a comment, or delete a thread. That last one is the same question the client's [`canModifyComment`](#who-can-edit-and-delete) answers, and this is the answer that counts: the client's only decides which affordances the UI offers. Widen the two together. A moderator offered a delete the server then rejects sees the comment disappear and come back, with nothing to explain it.

Left unset it defaults to the record's owner, matching the client. The callback is asked after the structural rules, so widening it grants those three writes and nothing else: attribution stays immutable, a soft delete stays write-once, and clients still can't hard-delete a record.

#### Building your own comments UI

`CanvasComments` is one way to assemble the parts, and every part it uses is exported. You can rebuild it, or build something quite different, from the same pieces.

| Export                                   | Description                                                 |
| ---------------------------------------- | ----------------------------------------------------------- |
| `CommentTool`, `commentToolOverrides`    | Placement: the tool state machine and its toolbar entry.    |
| `CommentPin`, `CountBadge`               | The pin marker and the clustered-count badge.               |
| `CommentThread`, `CommentCard`, `Byline` | A thread and its comments.                                  |
| `CommentComposer`, `SendButton`          | The composer and its send control.                          |
| `CommentsList`, `CommentListItem`        | A list of threads and one row of it.                        |
| `EmptyState`, `sortSidebarRows`          | The list's empty state, and the sidebar's ordering.         |
| `Avatar`, `Mention`, `MentionList`       | Author avatars and the mention picker.                      |
| `Reaction`, `Reactions`                  | A reaction pill and a comment's row of them.                |
| `anchorPagePoint`, `shapeAnchorAt`       | Anchor math: page position from an anchor, and the reverse. |
| `editComment`, `deleteComment`           | The write verbs. See [Writing comments](#writing-comments). |
| `registerCommentAnchorLifecycle`         | Keeps shape-anchored threads alive across shape deletion.   |

The presentational components take plain props and know nothing about the editor. The canvas layer, the tool, and the hooks build on them. `CanvasComments` registers the anchor lifecycle for you, so call `registerCommentAnchorLifecycle` yourself only if you're replacing the layer wholesale.

#### All options

Every option below is set with `CommentTool.configure()`. Anything you leave unset falls back to `defaultCommentingOptions`.

##### History

| Option        | Default     | Description                                                             |
| ------------- | ----------- | ----------------------------------------------------------------------- |
| `history`     | `'ignore'`  | How comment writes interact with the undo stack.                        |
| `dragHistory` | `undefined` | History mode for pin drags specifically. Unset, drags follow `history`. |

##### Anchoring

| Option                 | Default        | Description                                               |
| ---------------------- | -------------- | --------------------------------------------------------- |
| `shouldBePrecise`      | `() => true`   | Whether a shape placement anchors precisely.              |
| `impreciseShapeAnchor` | `{x: 1, y: 0}` | Where imprecise shape pins sit within the shape's bounds. |

##### Permissions

| Option             | Default     | Description                                                                         |
| ------------------ | ----------- | ----------------------------------------------------------------------------------- |
| `canComment`       | `undefined` | Whether the viewer may participate. Unset, allowed whenever `currentUserId` is set. |
| `canModifyComment` | `undefined` | Whether the viewer may edit or delete a record. Unset, each is its owner's to make. |

##### Reactions

| Option                   | Default       | Description                                                  |
| ------------------------ | ------------- | ------------------------------------------------------------ |
| `allowMultipleReactions` | `true`        | Whether a user can hold more than one reaction on a comment. |
| `isAllowedReaction`      | emoji palette | Which reaction tokens may be written.                        |

##### Regions

| Option          | Default | Description                                                |
| --------------- | ------- | ---------------------------------------------------------- |
| `enableRegions` | `false` | Whether dragging the comment tool creates a region anchor. |

##### Clustering

| Option             | Default | Description                                                 |
| ------------------ | ------- | ----------------------------------------------------------- |
| `enableClustering` | `true`  | Fold nearby pins into count badges as the camera zooms out. |

##### Components

| Option       | Default | Description                                                       |
| ------------ | ------- | ----------------------------------------------------------------- |
| `components` | `{}`    | Component overrides. See [Custom components](#custom-components). |

#### Related articles

- [Commenting](https://tldraw.dev/docs/commenting) — A shorter introduction to the feature
- [Collaboration](https://tldraw.dev/sdk-features/collaboration) — Presence, sync hooks, and custom sync
- [tldraw sync](https://tldraw.dev/docs/sync) — Running a sync server
- [Rich text](https://tldraw.dev/sdk-features/rich-text) — The `TLRichText` format comment bodies use
- [License key](https://tldraw.dev/sdk-features/license-key) — Enabling licensed features in production

#### Related examples

- [Commenting](https://tldraw.dev/examples/collaboration/commenting) — The full flow: tool, pins, threads, mentions
- [Commenting sidebar](https://tldraw.dev/examples/collaboration/commenting-sidebar) — A thread list panel beside the canvas
- [Comment clustering](https://tldraw.dev/examples/collaboration/comment-clustering) — Merging pins into count badges as you zoom out
- [Comment anchors](https://tldraw.dev/examples/collaboration/comment-anchors) — The ways a comment can attach to the canvas
- [Shape comment precision](https://tldraw.dev/examples/collaboration/comment-shape-precision) — Precise, shape-level, and Alt-gated anchoring
- [Comments and undo](https://tldraw.dev/examples/collaboration/comment-history) — How comment writes interact with the undo stack
- [Region comments](https://tldraw.dev/examples/collaboration/comment-regions) — Commenting on an area, and tuning the interaction

### Coordinates

The editor uses three coordinate systems: screen space, viewport space, and page space. When you move the mouse over the canvas, the browser gives you screen coordinates. To create a shape at that location, you need to convert those coordinates to page space.

The editor provides methods to convert between these coordinate systems. You'll use these when building custom tools, positioning DOM overlays, or responding to pointer events.

#### The three coordinate systems

##### Screen space

Screen space uses pixel coordinates from the browser window's top-left corner. These are the values you get from `MouseEvent.clientX` and `MouseEvent.clientY`. Screen coordinates include any space outside the editor container, like browser chrome or other page content.

##### Viewport space

Viewport space uses pixel coordinates from the editor container's top-left corner. This accounts for where the editor sits on the page. If the editor is embedded in a scrollable element or positioned away from the browser's origin, viewport coordinates differ from screen coordinates by that offset.

The editor tracks the container's position in `editor.getViewportScreenBounds()`.

##### Page space

Page space is the infinite canvas itself. A shape at `x: 100, y: 200` stays at those coordinates regardless of how the user pans or zooms. All shape positions are stored in page space.

The camera determines which part of page space is visible. When you zoom in, the same page-space region takes up more screen pixels. When you pan, different page-space coordinates come into view.

#### Coordinate transformations

##### Screen to page space

Use `editor.screenToPage()` to convert screen coordinates to page coordinates:

```typescript
// Convert mouse event coordinates to page space
const pagePoint = editor.screenToPage({ x: event.clientX, y: event.clientY })

// Create a shape at the clicked location
editor.createShape({
	type: 'geo',
	x: pagePoint.x,
	y: pagePoint.y,
	props: { w: 100, h: 100, geo: 'rectangle' },
})
```

This is the most common transformation. It accounts for the editor container's position, the camera position, and the zoom level.

##### Page to screen space

Use `editor.pageToScreen()` to convert page coordinates to screen coordinates:

```typescript
// Convert shape position to screen coordinates
const shape = editor.getShape(shapeId)
const screenPoint = editor.pageToScreen({ x: shape.x, y: shape.y })

// Position a DOM element at the shape's screen location
element.style.left = `${screenPoint.x}px`
element.style.top = `${screenPoint.y}px`
```

Use this when positioning DOM elements relative to shapes on the canvas.

##### Page to viewport space

Use `editor.pageToViewport()` to convert page coordinates to viewport coordinates:

```typescript
// Get viewport coordinates for a page point
const viewportPoint = editor.pageToViewport({ x: 500, y: 300 })

// Check if a point is visible in the viewport
const viewportBounds = editor.getViewportScreenBounds()
const isVisible =
	viewportPoint.x >= 0 &&
	viewportPoint.x <= viewportBounds.w &&
	viewportPoint.y >= 0 &&
	viewportPoint.y <= viewportBounds.h
```

This is like `pageToScreen()` but relative to the editor container rather than the browser window. Use this for canvas rendering or when you don't care about the editor's position on the page.

#### Viewport bounds

Use `editor.getViewportScreenBounds()` to get the editor container's position and size in screen space:

```typescript
const screenBounds = editor.getViewportScreenBounds()
// screenBounds.x, screenBounds.y - container position in screen space
// screenBounds.w, screenBounds.h - container dimensions in pixels
```

Use `editor.getViewportPageBounds()` to get the visible area in page space:

```typescript
const pageBounds = editor.getViewportPageBounds()
// pageBounds.x, pageBounds.y - top-left corner of visible area in page space
// pageBounds.w, pageBounds.h - visible area dimensions in page units
```

The page bounds change when the user pans or zooms. At higher zoom levels, the visible page-space area is smaller.

#### Using the inputs manager

The editor's inputs manager tracks the current pointer position in both coordinate systems. This is useful when you need to access the pointer position from anywhere in your code:

```typescript
// Get the current pointer position
const screenPoint = editor.inputs.getCurrentScreenPoint()
const pagePoint = editor.inputs.getCurrentPagePoint()

// Get the position where the current drag started
const originScreenPoint = editor.inputs.getOriginScreenPoint()
const originPagePoint = editor.inputs.getOriginPagePoint()
```

In custom tools, you can also convert the event's `point` property (which is in screen space) to page space:

```typescript
editor.on('event', (event) => {
	if (event.type === 'pointer' && event.name === 'pointer_down') {
		const pagePoint = editor.screenToPage(event.point)
		// Use pagePoint for shape manipulation
	}
})
```

##### Positioning custom overlays

To position a DOM element over a shape, convert the shape's page coordinates to screen coordinates. Then subtract the viewport's screen position to get coordinates relative to the editor container:

```typescript
const shape = editor.getShape(shapeId)
const screenBounds = editor.getViewportScreenBounds()
const screenPoint = editor.pageToScreen({ x: shape.x, y: shape.y })

// Position relative to the editor container
overlay.style.left = `${screenPoint.x - screenBounds.x}px`
overlay.style.top = `${screenPoint.y - screenBounds.y}px`
```

#### Related examples

- [Reactive inputs](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/reactive-inputs) - Display page and screen coordinates reactively as the pointer moves.
- [Selection UI](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/selection-ui) - Use selection bounds in screen space to position custom UI around shapes.

### Culling

The culling system optimizes rendering performance by hiding shapes that are outside the viewport.

Culled shapes remain in the DOM but have their `display` property set to `none`, so they don't incur any rendering cost. The system uses incremental derivations to track visibility changes efficiently as the camera moves or shapes change. Performance stays consistent even with thousands of shapes on the canvas.

#### Using the culling APIs

The editor provides two methods for working with culled shapes:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function CullingExample() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					// Get the IDs of shapes outside the viewport (before selection filtering)
					const notVisible = editor.getNotVisibleShapes()

					// Get the IDs of shapes that should not render (excludes selected/editing shapes)
					const culled = editor.getCulledShapes()

					console.log('Not visible:', notVisible.size)
					console.log('Actually culled:', culled.size)
				}}
			/>
		</div>
	)
}
```

Use `Editor#getNotVisibleShapes` to get the IDs of all shapes whose bounds don't intersect the viewport. Use `Editor#getCulledShapes` to get the final set of shape IDs that won't render. The difference between these is that `getCulledShapes` excludes selected shapes and the shape currently being edited, so users can always see what they're working with.

#### How it works

The culling system operates in two layers.

The first layer identifies all shapes whose page bounds don't intersect with the viewport. It queries the editor's spatial index for shapes inside the viewport bounds, then marks everything else as not visible.

The second layer refines this set by removing shapes that should remain visible despite being outside the viewport. Selected shapes and the currently editing shape are never culled. This means users can scroll a shape partially or fully out of view while still seeing and interacting with it.

#### Shape-level control

Each shape type can opt out of culling by overriding the `canCull` method on its ShapeUtil. By default, `canCull` returns `true`, so most shapes participate in culling.

```tsx
import { ShapeUtil, TLBaseShape, RecordProps, T, Rectangle2d } from 'tldraw'

type MyShape = TLBaseShape<'my-shape', { w: number; h: number; hasGlow: boolean }>

class MyShapeUtil extends ShapeUtil<MyShape> {
	static override type = 'my-shape' as const
	static override props: RecordProps<MyShape> = {
		w: T.number,
		h: T.number,
		hasGlow: T.boolean,
	}

	getDefaultProps(): MyShape['props'] {
		return { w: 100, h: 100, hasGlow: false }
	}

	getGeometry(shape: MyShape) {
		return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true })
	}

	override canCull(shape: MyShape): boolean {
		// Shapes with glow effects shouldn't be culled because
		// the glow might be visible even when the shape bounds aren't
		if (shape.props.hasGlow) {
			return false
		}
		return true
	}

	component(shape: MyShape) {
		return <div style={{ width: shape.props.w, height: shape.props.h }} />
	}

	getIndicatorPath(shape: MyShape) {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}
}
```

When `canCull` returns `false`, the culling system treats that shape as always visible regardless of its position.

Common reasons to disable culling:

- Shapes with visual effects (shadows, glows) that extend beyond their bounds
- Shapes that measure their DOM content and need to stay rendered
- Shapes with animations that should continue even when off-screen

#### Related examples

- **[Size from DOM](https://tldraw.dev/examples/shapes/size-from-dom)** - A shape that disables culling because it measures its DOM element to determine size.

### Cursor chat

Cursor chat lets users send short messages that appear as bubbles near their cursor. It's designed for quick, ephemeral communication during collaborative sessions—a fast "look here" or "nice work" that doesn't interrupt the canvas workflow.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					// Start chatting programmatically
					editor.updateInstanceState({ isChatting: true })

					// Or set a message directly
					editor.updateInstanceState({
						chatMessage: 'Hello from the canvas!',
					})
				}}
			/>
		</div>
	)
}
```

Cursor chat only appears when collaboration UI is enabled. On desktop, users press `/` to open the chat input, type their message (up to 64 characters), and press Enter to send. The message follows their cursor for a few seconds, then fades away.

#### Chat state

Chat state lives in instance state with two properties:

| Property      | Type      | Description                             |
| ------------- | --------- | --------------------------------------- |
| `isChatting`  | `boolean` | Whether the user is actively typing     |
| `chatMessage` | `string`  | The current message (max 64 characters) |

Read the current state with `Editor#getInstanceState`:

```tsx
const { isChatting, chatMessage } = editor.getInstanceState()
```

Update it with `Editor#updateInstanceState`:

```tsx
// Start chatting
editor.updateInstanceState({ isChatting: true })

// Update the message
editor.updateInstanceState({ chatMessage: 'Looking at this shape' })

// Stop chatting and clear the message
editor.updateInstanceState({ isChatting: false, chatMessage: '' })
```

Both properties are ephemeral—they don't persist to storage or survive page reloads.

#### How it works

When a user starts chatting:

1. The `CursorChatBubble` component renders an input field at the cursor position
2. The input tracks the cursor via `pointermove` events
3. As the user types, `chatMessage` updates in instance state
4. When they press Escape, press Enter with an empty input, or the input loses focus, `isChatting` becomes `false`
5. The message stays visible for 2 seconds, then clears automatically

While the input is open, chat times out after 5 seconds of inactivity.

#### Keyboard shortcuts

The default keyboard action for cursor chat is `/`. You can find it under the action ID `open-cursor-chat`:

```tsx
import { Tldraw, useActions } from 'tldraw'

function ChatButton() {
	const actions = useActions()

	return (
		<button onClick={() => actions['open-cursor-chat'].onSelect('menu')}>Open cursor chat</button>
	)
}
```

Inside the chat input:

| Key      | Action                                                            |
| -------- | ----------------------------------------------------------------- |
| `Enter`  | Clear input (if content exists) or stop chatting (if input empty) |
| `Escape` | Stop chatting                                                     |

#### Presence synchronization

In multiplayer sessions, chat messages synchronize automatically through presence records. The `chatMessage` field in `TLInstancePresence` contains the message other users see:

```tsx
import { InstancePresenceRecordType, Tldraw } from 'tldraw'

// Creating a remote user's presence with a chat message
const peerPresence = InstancePresenceRecordType.create({
	id: InstancePresenceRecordType.createId(editor.store.id),
	currentPageId: editor.getCurrentPageId(),
	userId: 'peer-1',
	userName: 'Alice',
	cursor: { x: 100, y: 200, type: 'default', rotation: 0 },
	chatMessage: 'Check out this arrow!',
})

editor.store.mergeRemoteChanges(() => {
	editor.store.put([peerPresence])
})
```

The presence derivation automatically includes the local user's `chatMessage` from instance state. Changes broadcast to other users automatically.

#### Customizing the chat bubble

You can replace the default chat bubble by providing a custom `CursorChatBubble` component:

```tsx
import { Tldraw, TLUiComponents, useEditor, track } from 'tldraw'
import 'tldraw/tldraw.css'

const CustomCursorChat = track(function CustomCursorChat() {
	const editor = useEditor()
	const { isChatting, chatMessage } = editor.getInstanceState()

	if (!isChatting && !chatMessage) return null

	return (
		<div
			style={{
				position: 'fixed',
				bottom: 20,
				left: '50%',
				transform: 'translateX(-50%)',
				padding: '8px 16px',
				background: editor.user.getColor(),
				borderRadius: 8,
				color: 'white',
			}}
		>
			{isChatting ? 'Typing...' : chatMessage}
		</div>
	)
})

const components: TLUiComponents = {
	CursorChatBubble: CustomCursorChat,
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw components={components} />
		</div>
	)
}
```

Remote users' chat messages render as part of the collaborator cursor, in the DOM cursor layer. To customize how they appear, replace the `CollaboratorCursor` component — see [Cursors](https://tldraw.dev/sdk-features/cursors#rendering-collaborator-cursors) for details.

#### Availability

Cursor chat requires:

- Collaboration enabled (`editor.store.props.collaboration !== undefined`)
- A non-touch device (disabled on mobile/tablet)

Check availability before triggering chat programmatically:

```tsx
const hasCollaboration = editor.store.props.collaboration !== undefined
const isTouchDevice = editor.getInstanceState().isCoarsePointer

if (hasCollaboration && !isTouchDevice) {
	editor.updateInstanceState({ isChatting: true })
}
```

#### Related articles

- [Cursors](https://tldraw.dev/sdk-features/cursors) — Cursor types, colors, and collaborator cursor customization
- [Collaboration](https://tldraw.dev/sdk-features/collaboration) — Presence synchronization and multiplayer setup
- [User preferences](https://tldraw.dev/sdk-features/user-preferences) — User colors and identity

#### Related examples

- [User presence](https://tldraw.dev/examples/collaboration/user-presence) — Display collaborator cursors and chat messages

### Cursors

The cursor system controls what cursor users see when interacting with the canvas. The current cursor is stored in instance state and changes automatically as users hover over different elements or use different tools. You can also set the cursor manually for custom tools.

#### Cursor state

The cursor state consists of a type and a rotation angle. Access it through `Editor#getInstanceState`:

```typescript
const { type, rotation } = editor.getInstanceState().cursor
```

The `type` determines the visual appearance—like `'default'`, `'grab'`, or `'nwse-resize'`. The `rotation` is an angle in radians that rotates the cursor icon. Rotation is mainly used for resize and rotate cursors so they align with the shape being manipulated.

#### Cursor types

tldraw supports these cursor types:

| Type            | Description                                    |
| --------------- | ---------------------------------------------- |
| `default`       | Standard pointer arrow                         |
| `pointer`       | Hand indicating clickable element              |
| `cross`         | Crosshair for precise positioning              |
| `grab`          | Open hand for draggable content                |
| `grabbing`      | Closed hand while dragging                     |
| `text`          | I-beam for text editing                        |
| `move`          | Four-way arrow for moving elements             |
| `zoom-in`       | Magnifying glass with plus                     |
| `zoom-out`      | Magnifying glass with minus                    |
| `ew-resize`     | Horizontal resize (east-west)                  |
| `ns-resize`     | Vertical resize (north-south)                  |
| `nesw-resize`   | Diagonal resize (northeast-southwest)          |
| `nwse-resize`   | Diagonal resize (northwest-southeast)          |
| `resize-edge`   | Edge resize (used for edge handles)            |
| `resize-corner` | Corner resize (used for corner handles)        |
| `nesw-rotate`   | Rotation handle (northeast-southwest position) |
| `nwse-rotate`   | Rotation handle (northwest-southeast position) |
| `senw-rotate`   | Rotation handle (southeast-northwest position) |
| `swne-rotate`   | Rotation handle (southwest-northeast position) |
| `rotate`        | General rotation cursor                        |
| `none`          | Hidden cursor                                  |

Static cursors like `default`, `pointer`, and `grab` use CSS cursor values directly. Dynamic cursors like the resize and rotate types render as custom SVGs with rotation applied.

#### Setting the cursor

Use `Editor#setCursor` to change the cursor:

```typescript
editor.setCursor({ type: 'cross', rotation: 0 })
```

You can update just the type or just the rotation—the other property keeps its current value:

```typescript
// Change only the type
editor.setCursor({ type: 'grab' })

// Change only the rotation
editor.setCursor({ type: 'nwse-resize', rotation: Math.PI / 4 })
```

##### Cursor rotation

Rotation is specified in radians. When users resize or rotate shapes that are themselves rotated, the cursor rotates to match:

```typescript
// Get the selection's rotation and apply it to a resize cursor
const selectionRotation = editor.getSelectionRotation()
editor.setCursor({
	type: 'nwse-resize',
	rotation: selectionRotation,
})
```

This keeps the cursor aligned with the shape's edges rather than the screen axes. The default tools handle cursor rotation automatically. You only need to set it manually for custom tools.

#### Cursors in custom tools

Custom tools typically set the cursor when entering a state and reset it when exiting:

```typescript
import { StateNode } from 'tldraw'

export class MyCustomTool extends StateNode {
	static override id = 'my-tool'

	override onEnter() {
		this.editor.setCursor({ type: 'cross', rotation: 0 })
	}

	override onExit() {
		this.editor.setCursor({ type: 'default', rotation: 0 })
	}
}
```

For tools with child states, each state can set its own cursor. A drawing state might use `'cross'`, while a dragging state uses `'grabbing'`.

#### Cursor colors

Dynamic cursors (resize and rotate types) adapt to the current color scheme. In light mode, the cursor style color is set to black. In dark mode, it's set to white. The SVG patterns themselves have fixed black and white fills for contrast. This happens automatically through the internal `useCursor` hook.

#### Collaborator cursors

In multiplayer sessions, each user's cursor appears on other users' canvases. These remote cursors use the user's presence color—a randomly assigned color from the user color palette.

##### User color palette

When a user first loads tldraw, they're assigned a random color from this palette:

```typescript
const USER_COLORS = [
	'#FF802B',
	'#EC5E41',
	'#F2555A',
	'#F04F88',
	'#E34BA9',
	'#BD54C6',
	'#9D5BD2',
	'#7B66DC',
	'#02B1CC',
	'#11B3A3',
	'#39B178',
	'#55B467',
]
```

You can read or change a user's color through user preferences:

```typescript
// Get the user's color
const color = editor.user.getColor()

// Set a specific color
editor.user.updateUserPreferences({ color: '#FF802B' })
```

##### Rendering collaborator cursors

Remote cursors render as DOM elements in a dedicated layer stacked above the canvas and below the UI panels. Each visible collaborator gets a cursor showing:

- The cursor arrow in the user's color
- The user's name as a label next to the cursor
- Any active chat message in a bubble

A collaborator whose cursor is outside your viewport shows as a small hint arrow clamped to the viewport edge, pointing toward them — the hints stay canvas-drawn (`CollaboratorHintOverlayUtil`), like the other drawing chrome.

To customize the cursor, pass your own component via the `components` prop's `CollaboratorCursor` slot (see `DefaultCursor` and `TLCursorProps`):

```tsx
import { TLCursorProps, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

function CustomCollaboratorCursor({ point, color, name, zoom }: TLCursorProps) {
	if (!point) return null
	return (
		<div
			style={{
				position: 'absolute',
				// The layer is scaled by the camera, so counter-scale by 1 / zoom to keep the
				// cursor a constant on-screen size (the default components do the same).
				transform: `translate(${point.x}px, ${point.y}px) scale(${1 / zoom})`,
				transformOrigin: 'top left',
				pointerEvents: 'none',
			}}
		>
			{/* A dot instead of the default arrow */}
			<div style={{ width: 16, height: 16, borderRadius: '50%', backgroundColor: color }} />
			{name && <div style={{ color }}>{name}</div>}
		</div>
	)
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw components={{ CollaboratorCursor: CustomCollaboratorCursor }} />
		</div>
	)
}
```

Cursor components are positioned in page space inside a camera-transformed layer, so `point` can be used directly as a translation; sizes in screen pixels stay constant on screen by scaling with `1 / zoom` (the default components do this via their `zoom` prop).

##### Cursor position in presence

Collaborator cursor positions are stored in presence records (`TLInstancePresence`). The cursor field includes position, type, and rotation:

```typescript
{
  cursor: {
    x: number
    y: number
    type: TLCursorType
    rotation: number
  } | null
}
```

The editor automatically broadcasts cursor position updates to other users in the same room. Cursor position is null when the user's pointer is outside the canvas.

#### Related articles

- [Cursor chat](https://tldraw.dev/sdk-features/cursor-chat) - Send ephemeral chat messages at the cursor position
- [Tools](https://tldraw.dev/sdk-features/tools) - Learn how tools handle input and set cursors
- [Collaboration](https://tldraw.dev/sdk-features/collaboration) - User presence and multiplayer features
- [User preferences](https://tldraw.dev/sdk-features/user-preferences) - Manage user colors and other preferences
- [Overlay utils](https://tldraw.dev/sdk-features/overlay-utils) - Canvas overlays, including the collaborator cursor overlay

#### Related examples

- [Custom tool](https://tldraw.dev/examples/shapes/tools/custom-tool) - Build a custom tool with cursor handling
- [User presence](https://tldraw.dev/examples/collaboration/user-presence) - Display collaborator cursors and presence

### Deep links

Deep links serialize editor state into URL-safe strings. They let users share links that open the editor at specific locations: individual shapes, viewport positions, or entire pages.

The simplest way to enable deep links is with the `deepLinks` option:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw persistenceKey="example" options={{ deepLinks: true }} />
		</div>
	)
}
```

With `deepLinks` enabled, the URL updates as users navigate. Anyone opening the URL sees the same page and viewport position.

For more control, use the editor methods directly: `createDeepLink()` generates URLs with encoded state, `navigateToDeepLink()` moves the editor to a specified location, and `registerDeepLinkListener()` updates URLs automatically as users navigate.

#### Deep link types

| Type       | Purpose                      | Encoded prefix | Example use case                  |
| ---------- | ---------------------------- | -------------- | --------------------------------- |
| `shapes`   | Links to specific shapes     | `s`            | Share selected shapes with a team |
| `viewport` | Links to a bounding box view | `v`            | Share current viewport position   |
| `page`     | Links to a specific page     | `p`            | Navigate to a particular page     |

Shape links focus the editor on specific elements. Viewport links preserve the exact camera position and zoom level. Page links navigate to particular pages in multi-page documents.

#### How it works

Deep links are encoded as compact strings with a single-character prefix identifying the type:

- Shape links (`s`) encode shape IDs separated by dots: `s<id1>.<id2>.<id3>`
- Viewport links (`v`) encode bounding box coordinates: `v<x>.<y>.<w>.<h>` with optional page ID
- Page links (`p`) encode a page ID: `p<pageId>`

All IDs are URL-encoded to handle special characters. The default query parameter is `d`, but you can customize this. When navigating to a shapes deep link, the editor switches to the page containing the most shapes and zooms to fit them. Viewport links set the camera to the exact specified bounds.

#### API methods

##### createDeepLink

Creates a URL with a deep link query parameter encoding the current viewport and page:

```typescript
// Create a link to the current viewport
const url = editor.createDeepLink()
navigator.clipboard.writeText(url.toString())
```

Specify a target to link to specific shapes:

```typescript
// Link to currently selected shapes
const url = editor.createDeepLink({
	to: { type: 'shapes', shapeIds: editor.getSelectedShapeIds() },
})
```

##### navigateToDeepLink

Navigates the editor to the location specified by a deep link URL or object:

```typescript
// Navigate using the current URL's query parameter
editor.navigateToDeepLink()

// Navigate to a specific URL
editor.navigateToDeepLink({ url: 'https://example.com?d=v100.100.200.200' })

// Navigate directly to shapes
editor.navigateToDeepLink({
	type: 'shapes',
	shapeIds: ['shape:abc' as TLShapeId, 'shape:xyz' as TLShapeId],
})
```

##### registerDeepLinkListener

Sets up automatic URL updates as the viewport changes. The listener debounces updates (500ms by default) to avoid excessive history entries:

```typescript
// Use default behavior (replaces the current URL without adding history entries)
const unlisten = editor.registerDeepLinkListener()

// Custom change handler with longer debounce
const unlisten = editor.registerDeepLinkListener({
	onChange(url) {
		window.history.replaceState({}, document.title, url.toString())
	},
	debounceMs: 1000,
})

// Clean up when done
unlisten()
```

You can also enable this via the `deepLinks` option on the Tldraw component instead of calling this method directly.

#### Related examples

- **[Deep links](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/deep-links)** - Demonstrates how to use the `deepLinks` option to enable URL-based navigation and how to create, parse, and handle deep links manually using the editor methods.

### Default shapes

The tldraw package includes thirteen shape types that cover common diagramming and whiteboarding needs. Each shape type has a corresponding `ShapeUtil` that defines its rendering, geometry, and interaction behavior. You can use these shapes as-is, configure their behavior through options, or use them as reference when building custom shapes.

#### Shape overview

The default shapes loosely fall into five categories based on their primary function:

| Category   | Shape types                           | Description                                                           |
| ---------- | ------------------------------------- | --------------------------------------------------------------------- |
| Text       | `text`, `note`                        | Text labels and sticky notes                                          |
| Drawing    | `geo`, `draw`, `line`, `highlight`    | Freehand strokes, multi-point lines, geometric shapes, and highlights |
| Media      | `image`, `video`, `bookmark`, `embed` | External content and media files                                      |
| Structural | `frame`, `group`                      | Organization and containment                                          |
| Connectors | `arrow`                               | Lines that connect shapes together                                    |

#### Basic shapes

##### Geo

The geo shape renders geometric primitives with optional text labels. It supports 20 different geometric forms—rectangles, ellipses, triangles, stars, polygons, directional arrows, and special shapes like clouds and hearts. Geo shapes can display rich text labels with configurable alignment, making them the foundation for flowcharts, diagrams, and annotated illustrations.

For complete documentation on geometric forms, label positioning, tool interactions, and configuration, see [Geo shape](https://tldraw.dev/sdk-features/geo-shape).

Quick reference:

| Property        | Type                            | Description                                 |
| --------------- | ------------------------------- | ------------------------------------------- |
| `geo`           | `TLGeoShapeGeoStyle`            | The geometric form                          |
| `w`             | `number`                        | Width in pixels                             |
| `h`             | `number`                        | Height in pixels                            |
| `richText`      | `TLRichText`                    | Text label displayed inside the shape       |
| `color`         | `TLDefaultColorStyle`           | Stroke/outline color                        |
| `labelColor`    | `TLDefaultColorStyle`           | Text label color (separate from stroke)     |
| `fill`          | `TLDefaultFillStyle`            | Fill style                                  |
| `dash`          | `TLDefaultDashStyle`            | Stroke pattern                              |
| `size`          | `TLDefaultSizeStyle`            | Size preset affecting stroke width          |
| `font`          | `TLDefaultFontStyle`            | Font family for the label                   |
| `align`         | `TLDefaultHorizontalAlignStyle` | Horizontal text alignment                   |
| `verticalAlign` | `TLDefaultVerticalAlignStyle`   | Vertical text alignment                     |
| `growY`         | `number`                        | Additional vertical space for text overflow |
| `url`           | `string`                        | Optional hyperlink URL                      |
| `scale`         | `number`                        | Scale factor applied to the shape           |

##### Text

The text shape displays formatted text content with automatic sizing. Text shapes resize to fit their content by default, or you can set them to a fixed width with text wrapping. The shape supports rich text formatting including bold, italic, and other inline styles.

For a complete guide to text shapes, including auto-size vs fixed-width modes, the text tool's interaction patterns, and configuration options, see [Text shape](https://tldraw.dev/sdk-features/text-shape).

```tsx
editor.createShape({
	type: 'text',
	x: 100,
	y: 100,
	props: {
		richText: toRichText('Hello world'),
		color: 'black',
		size: 'm',
		font: 'draw',
		textAlign: 'start',
		autoSize: true,
		w: 200,
	},
})
```

Quick reference:

| Property    | Type                      | Description                                     |
| ----------- | ------------------------- | ----------------------------------------------- |
| `richText`  | `TLRichText`              | The text content with formatting                |
| `color`     | `TLDefaultColorStyle`     | Text color                                      |
| `size`      | `TLDefaultSizeStyle`      | Font size preset (`s`, `m`, `l`, `xl`)          |
| `font`      | `TLDefaultFontStyle`      | Font family (`draw`, `sans`, `serif`, `mono`)   |
| `textAlign` | `TLDefaultTextAlignStyle` | Horizontal alignment (`start`, `middle`, `end`) |
| `autoSize`  | `boolean`                 | When true, shape resizes to fit content         |
| `w`         | `number`                  | Width when autoSize is false                    |
| `scale`     | `number`                  | Scale factor applied to the shape               |

##### Note

The note shape renders as a sticky note with a colored background and rich text content. Notes have special interaction behaviors: clone handles on the edges let you quickly create adjacent notes, and keyboard shortcuts navigate between them. Notes have a fixed base size but grow vertically to fit their content.

For a complete guide to note interactions, clone handles, keyboard navigation, and configuration, see [Note shape](https://tldraw.dev/sdk-features/note-shape).

```tsx
editor.createShape({
	type: 'note',
	x: 100,
	y: 100,
	props: {
		color: 'yellow',
		labelColor: 'black',
		richText: toRichText('Remember this'),
		size: 'm',
		font: 'draw',
		align: 'middle',
		verticalAlign: 'middle',
	},
})
```

#### Drawing shapes

##### Draw

The draw shape captures freehand strokes and straight line segments. It supports pressure-sensitive input from pens and styluses, automatic shape closing, angle snapping when holding Shift, and hybrid freehand/straight-line drawing modes. Points are stored in a delta-encoded base64 format for efficiency.

For complete documentation on the draw shape and draw tool, including drawing modes, pen support, angle snapping, and programmatic creation, see the [Draw shape](https://tldraw.dev/sdk-features/draw-shape) article.

Quick reference:

| Property     | Type                   | Description                                                |
| ------------ | ---------------------- | ---------------------------------------------------------- |
| `color`      | `TLDefaultColorStyle`  | Stroke color                                               |
| `fill`       | `TLDefaultFillStyle`   | Fill style (applies when `isClosed` is true)               |
| `dash`       | `TLDefaultDashStyle`   | Stroke pattern                                             |
| `size`       | `TLDefaultSizeStyle`   | Stroke width preset                                        |
| `segments`   | `TLDrawShapeSegment[]` | Array of segments with `type` and base64-encoded `path`    |
| `isComplete` | `boolean`              | Whether the user has finished drawing this stroke          |
| `isClosed`   | `boolean`              | Whether the path forms a closed shape                      |
| `isPen`      | `boolean`              | Whether drawn with a stylus (enables pressure-based width) |

##### Line

The line shape creates multi-point lines with draggable handles. Unlike draw shapes, line shapes have explicit control points that you can manipulate after creation. Each point has an ID and index for ordering, allowing points to be added, removed, or repositioned. Lines support both straight segments and smooth cubic spline interpolation.

```tsx
editor.createShape({
	type: 'line',
	x: 100,
	y: 100,
	props: {
		color: 'black',
		dash: 'solid',
		size: 'm',
		spline: 'line',
		points: {
			a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
			a2: { id: 'a2', index: 'a2', x: 100, y: 50 },
			a3: { id: 'a3', index: 'a3', x: 200, y: 0 },
		},
		scale: 1,
	},
})
```

Properties:

| Property | Type                               | Description                                                   |
| -------- | ---------------------------------- | ------------------------------------------------------------- |
| `color`  | `TLDefaultColorStyle`              | Stroke color                                                  |
| `dash`   | `TLDefaultDashStyle`               | Stroke pattern                                                |
| `size`   | `TLDefaultSizeStyle`               | Stroke width preset                                           |
| `spline` | `TLLineShapeSplineStyle`           | Interpolation: `line` for straight, `cubic` for curves        |
| `points` | `Record<string, TLLineShapePoint>` | Dictionary of control points with IDs, indices, and positions |
| `scale`  | `number`                           | Scale factor applied to the shape                             |

Line shapes don't have configuration options.

##### Highlight

The highlight shape works like draw but renders semi-transparently for marking up content. It simulates a highlighter pen, rendering with configurable opacity layers that create the characteristic translucent appearance. Like draw shapes, highlights support pressure-sensitive input and automatic shape splitting for long strokes.

```tsx
editor.createShape({
	type: 'highlight',
	x: 100,
	y: 100,
	props: {
		color: 'yellow',
		size: 'l',
		segments: [],
		isComplete: true,
		isPen: false,
		scale: 1,
	},
})
```

Properties:

| Property     | Type                   | Description                                  |
| ------------ | ---------------------- | -------------------------------------------- |
| `color`      | `TLDefaultColorStyle`  | Highlight color (yellow, green, blue, etc.)  |
| `size`       | `TLDefaultSizeStyle`   | Stroke width preset                          |
| `segments`   | `TLDrawShapeSegment[]` | Array of segments with base64-encoded points |
| `isComplete` | `boolean`              | Whether the user has finished this stroke    |
| `isPen`      | `boolean`              | Whether drawn with a stylus                  |
| `scale`      | `number`               | Scale factor applied to the shape            |
| `scaleX`     | `number`               | Horizontal scale factor for lazy resize      |
| `scaleY`     | `number`               | Vertical scale factor for lazy resize        |

Configuration options:

| Option              | Type     | Default | Description                                                               |
| ------------------- | -------- | ------- | ------------------------------------------------------------------------- |
| `maxPointsPerShape` | `number` | `600`   | Maximum points before starting a new shape. Same behavior as draw shapes. |

The highlight's `underlayOpacity` (default `0.82`) and `overlayOpacity` (default `0.35`) are display values that can be overridden via `getCustomDisplayValues`:

```tsx
const ConfiguredHighlightUtil = HighlightShapeUtil.configure({
	maxPointsPerShape: 800,
	getCustomDisplayValues() {
		return { underlayOpacity: 0.7, overlayOpacity: 0.4 }
	},
})
```

#### Media shapes

##### Image

The image shape displays raster images with support for cropping, flipping, and animation control. Images link to asset records that store the actual image data (either as base64 or URLs). The shape maintains aspect ratio during resize and supports circular cropping for profile photos or decorative effects.

```tsx
editor.createShape({
	type: 'image',
	x: 100,
	y: 100,
	props: {
		w: 400,
		h: 300,
		assetId: 'asset:abc123' as TLAssetId,
		url: '',
		crop: null,
		flipX: false,
		flipY: false,
		playing: true,
		altText: 'Description for accessibility',
	},
})
```

Properties:

| Property  | Type                  | Description                                                     |
| --------- | --------------------- | --------------------------------------------------------------- |
| `w`       | `number`              | Display width in pixels                                         |
| `h`       | `number`              | Display height in pixels                                        |
| `assetId` | `TLAssetId \| null`   | Reference to asset record containing image data                 |
| `url`     | `string`              | Direct URL (used when no asset)                                 |
| `crop`    | `TLShapeCrop \| null` | Crop region with `topLeft`, `bottomRight` (0-1), and `isCircle` |
| `flipX`   | `boolean`             | Mirror the image horizontally                                   |
| `flipY`   | `boolean`             | Mirror the image vertically                                     |
| `playing` | `boolean`             | Whether animated images (GIFs) should play                      |
| `altText` | `string`              | Accessibility description                                       |

Image shapes don't have configuration options. Image handling behavior is controlled through the editor's asset management system.

##### Video

The video shape displays video content with playback controls. Like images, videos link to asset records. The shape tracks playback position and state, and supports autoplay for automatic playback when the shape becomes visible.

```tsx
editor.createShape({
	type: 'video',
	x: 100,
	y: 100,
	props: {
		w: 640,
		h: 480,
		assetId: 'asset:video123' as TLAssetId,
		url: '',
		time: 0,
		playing: false,
		autoplay: true,
		altText: 'Video description',
	},
})
```

Properties:

| Property   | Type                | Description                            |
| ---------- | ------------------- | -------------------------------------- |
| `w`        | `number`            | Display width in pixels                |
| `h`        | `number`            | Display height in pixels               |
| `assetId`  | `TLAssetId \| null` | Reference to asset record              |
| `url`      | `string`            | Direct URL (used when no asset)        |
| `time`     | `number`            | Current playback position in seconds   |
| `playing`  | `boolean`           | Whether the video is currently playing |
| `autoplay` | `boolean`           | Whether to start playing automatically |
| `altText`  | `string`            | Accessibility description              |

Configuration options:

| Option     | Type      | Default | Description                                     |
| ---------- | --------- | ------- | ----------------------------------------------- |
| `autoplay` | `boolean` | `true`  | Default autoplay behavior for new video shapes. |

```tsx
const ConfiguredVideoUtil = VideoShapeUtil.configure({
	autoplay: false,
})
```

##### Bookmark

The bookmark shape displays a URL as a card with metadata including title, description, and preview image. Bookmarks are created when you paste URLs onto the canvas. The editor fetches metadata from the URL and stores it in an associated asset record. Bookmark shapes have fixed dimensions and can't be resized.

```tsx
editor.createShape({
	type: 'bookmark',
	x: 100,
	y: 100,
	props: {
		url: 'https://example.com',
		assetId: 'asset:bookmark123' as TLAssetId,
		w: 300,
		h: 320,
	},
})
```

Properties:

| Property  | Type                | Description                                             |
| --------- | ------------------- | ------------------------------------------------------- |
| `url`     | `string`            | The bookmarked URL                                      |
| `assetId` | `TLAssetId \| null` | Reference to asset containing title, description, image |
| `w`       | `number`            | Width (fixed, not user-resizable)                       |
| `h`       | `number`            | Height (fixed, not user-resizable)                      |

Bookmark shapes don't have configuration options. URL metadata fetching is handled by the editor's external content handlers.

##### Embed

The embed shape displays interactive content from external services (YouTube, Figma, CodeSandbox, and more) within an iframe. When you paste a URL from a supported service, tldraw automatically converts it to an interactive embed with appropriate dimensions.

For complete documentation on supported services, URL transformation, iframe security, custom embed definitions, and interaction modes, see [Embed shape](https://tldraw.dev/sdk-features/embed-shape).

```tsx
editor.createShape({
	type: 'embed',
	x: 100,
	y: 100,
	props: {
		url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
		w: 560,
		h: 315,
	},
})
```

Properties:

| Property | Type     | Description                                          |
| -------- | -------- | ---------------------------------------------------- |
| `url`    | `string` | The original URL (converted to embed URL internally) |
| `w`      | `number` | Width of the embed container                         |
| `h`      | `number` | Height of the embed container                        |

#### Structural shapes

##### Frame

The frame shape provides a visual container for organizing shapes. Shapes inside a frame are clipped to its bounds and move with the frame when it's repositioned. Frames display a header with the frame's name, and optionally colored borders and backgrounds. They're useful for creating sections, organizing content into logical groups, or defining artboard-like regions for export.

```tsx
editor.createShape({
	type: 'frame',
	x: 100,
	y: 100,
	props: {
		w: 800,
		h: 600,
		name: 'Header Section',
		color: 'blue',
	},
})
```

Properties:

| Property | Type                  | Description                                           |
| -------- | --------------------- | ----------------------------------------------------- |
| `w`      | `number`              | Frame width in pixels                                 |
| `h`      | `number`              | Frame height in pixels                                |
| `name`   | `string`              | Label displayed in the frame header                   |
| `color`  | `TLDefaultColorStyle` | Color for border and header (when colors are enabled) |

Configuration options:

| Option           | Type      | Default | Description                                                                                     |
| ---------------- | --------- | ------- | ----------------------------------------------------------------------------------------------- |
| `showColors`     | `boolean` | `false` | When true, frames display colored borders and header backgrounds based on the `color` property. |
| `resizeChildren` | `boolean` | `false` | When true, resizing the frame also scales the frame's children proportionally.                  |

When `showColors` is enabled, the frame's `color` property becomes a style that users can change from the style panel. When disabled, all frames appear with the same neutral styling.

```tsx
const ConfiguredFrameUtil = FrameShapeUtil.configure({
	showColors: true,
	resizeChildren: true,
})
```

To build your own container shape that behaves like a frame, extend `BaseFrameLikeShapeUtil` rather than re-implementing every behavior on `FrameShapeUtil`. The base class provides defaults for clipping children, full-brush selection, blocking erasure from inside, and drag-and-drop reparenting — see [Shapes](https://tldraw.dev/sdk-features/shapes#frames) for details and the [portal shapes example](https://tldraw.dev/examples/shapes/tools/portal-shapes) for a working implementation.

##### Group

The group shape logically combines multiple shapes without visual representation. Groups let you move and transform shapes together while preserving their relative positions. The group's geometry is computed as the union of all child shapes' geometries. Groups are created through the editor API rather than directly, and they delete themselves automatically when their last child is removed or ungrouped.

```tsx
// Create a group from selected shapes
editor.groupShapes(editor.getSelectedShapeIds())

// Ungroup a group
editor.ungroupShapes([groupId])

// Access group children
const children = editor.getSortedChildIdsForParent(groupId)
```

Groups have no visual properties; their `props` object is empty. All visual characteristics come from their child shapes. Arrows can't bind to groups. Double-click a group to enter it and edit its children directly.

Group shapes don't have configuration options.

#### Connectors

##### Arrow

The arrow shape creates lines that can bind to other shapes. Arrows automatically update when their connected shapes move, maintaining the visual connection. They support two routing modes: `arc` for smooth curves controlled by a bend parameter, and `elbow` for right-angle routing that navigates around obstacles. Arrows can display text labels positioned along their length, and offer multiple arrowhead styles for both terminals.

The arrow binding system creates connections automatically when arrow terminals are dragged near other shapes. Bindings can be "precise" (connecting to a specific point) or "imprecise" (connecting to the shape's center or edge).

```tsx
editor.createShape({
	type: 'arrow',
	x: 100,
	y: 100,
	props: {
		kind: 'arc',
		start: { x: 0, y: 0 },
		end: { x: 200, y: 100 },
		bend: 0,
		color: 'black',
		fill: 'none',
		dash: 'solid',
		size: 'm',
		arrowheadStart: 'none',
		arrowheadEnd: 'arrow',
		font: 'draw',
		richText: toRichText(''),
		labelPosition: 0.5,
		labelColor: 'black',
		scale: 1,
		elbowMidPoint: 0.5,
	},
})
```

Properties:

| Property         | Type                         | Description                                             |
| ---------------- | ---------------------------- | ------------------------------------------------------- |
| `kind`           | `TLArrowShapeKind`           | Routing mode: `arc` for curved, `elbow` for right-angle |
| `start`          | `VecModel`                   | Start terminal position (relative to shape origin)      |
| `end`            | `VecModel`                   | End terminal position                                   |
| `bend`           | `number`                     | Curvature for arc arrows (0 = straight)                 |
| `color`          | `TLDefaultColorStyle`        | Stroke color                                            |
| `fill`           | `TLDefaultFillStyle`         | Fill style (for arrowheads)                             |
| `dash`           | `TLDefaultDashStyle`         | Stroke pattern                                          |
| `size`           | `TLDefaultSizeStyle`         | Stroke width preset                                     |
| `arrowheadStart` | `TLArrowShapeArrowheadStyle` | Start terminal style                                    |
| `arrowheadEnd`   | `TLArrowShapeArrowheadStyle` | End terminal style                                      |
| `font`           | `TLDefaultFontStyle`         | Font family for label                                   |
| `richText`       | `TLRichText`                 | Optional text label                                     |
| `labelPosition`  | `number`                     | Label position along arrow (0 = start, 1 = end)         |
| `labelColor`     | `TLDefaultColorStyle`        | Label text color                                        |
| `scale`          | `number`                     | Scale factor                                            |
| `elbowMidPoint`  | `number`                     | Position of the midpoint handle for elbow arrows        |

The available arrowhead styles are: `none`, `arrow`, `triangle`, `square`, `dot`, `pipe`, `diamond`, `inverted`, `bar`

Configuration options:

Arrows have extensive configuration options that control snap behavior, timing, and rendering:

| Option                                      | Type                                 | Default                              | Description                                                                                          |
| ------------------------------------------- | ------------------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `expandElbowLegLength`                      | `Record<TLDefaultSizeStyle, number>` | `{ s: 28, m: 36, l: 44, xl: 66 }`    | How far elbow arrows extend from target shapes, per size.                                            |
| `minElbowLegLength`                         | `Record<TLDefaultSizeStyle, number>` | Based on stroke width × 3            | Minimum length of an elbow arrow's leg segment.                                                      |
| `minElbowHandleDistance`                    | `number`                             | `16`                                 | Minimum screen pixels between two elbow handles. Closer handles are hidden.                          |
| `arcArrowCenterSnapDistance`                | `number`                             | `16`                                 | Screen pixels at which arc arrows snap to target shape centers. Set to 0 to disable.                 |
| `elbowArrowCenterSnapDistance`              | `number`                             | `24`                                 | Screen pixels at which elbow arrows snap to target shape centers.                                    |
| `elbowArrowEdgeSnapDistance`                | `number`                             | `20`                                 | Screen pixels at which elbow arrows snap to target shape edges.                                      |
| `elbowArrowPointSnapDistance`               | `number`                             | `24`                                 | Screen pixels at which elbow arrows snap to directional points (top, right, bottom, left) of shapes. |
| `elbowArrowAxisSnapDistance`                | `number`                             | `16`                                 | Screen pixels at which elbow arrows snap to axes through shape centers.                              |
| `labelCenterSnapDistance`                   | `number`                             | `10`                                 | Screen pixels at which arrow labels snap to the arrow's center when dragged.                         |
| `elbowMidpointSnapDistance`                 | `number`                             | `10`                                 | Screen pixels at which elbow midpoint handles snap to the midpoint between shapes.                   |
| `elbowMinSegmentLengthToShowMidpointHandle` | `number`                             | `20`                                 | Minimum segment length before showing the midpoint drag handle.                                      |
| `hoverPreciseTimeout`                       | `number`                             | `600`                                | Milliseconds to wait while hovering before switching to precise targeting.                           |
| `pointingPreciseTimeout`                    | `number`                             | `320`                                | Milliseconds to wait while pointing/dragging before switching to precise targeting.                  |
| `shouldBeExact`                             | `(editor: Editor) => boolean`        | Returns `editor.inputs.getAltKey()`  | Function determining whether arrows stop exactly at pointer vs. at shape edges.                      |
| `shouldIgnoreTargets`                       | `(editor: Editor) => boolean`        | Returns `editor.inputs.getCtrlKey()` | Function determining whether to skip binding to target shapes.                                       |
| `showTextOutline`                           | `boolean`                            | `true`                               | Whether to show a text outline on arrow labels for readability.                                      |

```tsx
const ConfiguredArrowUtil = ArrowShapeUtil.configure({
	arcArrowCenterSnapDistance: 24,
	hoverPreciseTimeout: 400,
	showTextOutline: false,
	shouldBeExact: (editor) => editor.inputs.getAltKey() || editor.inputs.getShiftKey(),
})
```

#### Common properties

All shapes share base properties defined in `TLBaseShape`:

```typescript
interface TLBaseShape {
	id: TLShapeId
	type: string
	x: number // Position relative to parent
	y: number
	rotation: number // Rotation in radians
	index: IndexKey // Fractional index for z-ordering
	parentId: TLParentId // Page ID or parent shape ID
	isLocked: boolean
	opacity: number // 0-1
	props: object // Shape-specific properties
	meta: object // Custom metadata (for your application)
}
```

Most shapes also support common style properties through the style system:

| Style   | Values                                                   | Description       |
| ------- | -------------------------------------------------------- | ----------------- |
| `color` | `black`, `grey`, `light-violet`, etc.                    | Stroke/text color |
| `fill`  | `none`, `semi`, `solid`, `pattern`, `fill`, `lined-fill` | Fill style        |
| `dash`  | `draw`, `solid`, `dashed`, `dotted`, `none`              | Stroke pattern    |
| `size`  | `s`, `m`, `l`, `xl`                                      | Size preset       |
| `font`  | `draw`, `sans`, `serif`, `mono`                          | Font family       |

#### Using configured shapes

To use configured shape utilities, pass them to the `shapeUtils` prop when initializing tldraw. Configured utilities replace the default utilities for their shape type:

```tsx
import { Tldraw, ArrowShapeUtil, FrameShapeUtil, NoteShapeUtil } from 'tldraw'
import 'tldraw/tldraw.css'

const ConfiguredArrowUtil = ArrowShapeUtil.configure({
	showTextOutline: false,
	hoverPreciseTimeout: 400,
})

const ConfiguredFrameUtil = FrameShapeUtil.configure({
	showColors: true,
})

const ConfiguredNoteUtil = NoteShapeUtil.configure({
	resizeMode: 'scale',
})

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw shapeUtils={[ConfiguredArrowUtil, ConfiguredFrameUtil, ConfiguredNoteUtil]} />
		</div>
	)
}
```

You can also extend shape utilities to add custom behavior beyond configuration options. See the [custom shapes](https://tldraw.dev/docs/shapes) guide for details.

#### Related examples

- [Custom shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/custom-shape): Create a custom shape utility to understand how default shapes are implemented.
- [Custom styles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-custom-styles): Add custom style properties similar to those used by default shapes.
- [Shape options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/configure-shape-util): Configure shape utility behavior using the configure() method.

### Drag and drop

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.

```tsx
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
}
```

#### Drag callbacks

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.

##### onDragShapesIn

Called once when shapes first enter this shape's bounds. Use it to reparent shapes into a container:

```typescript
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)
	}
}
```

##### onDragShapesOver

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:

```typescript
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,
			})
		}
	}
}
```

##### onDragShapesOut

Called when shapes are dragged away from this shape. Use it to reparent shapes back to the page:

```typescript
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())
	}
}
```

##### onDropShapesOver

Called when shapes are dropped (mouse up) while over this shape. Use it for finalization logic:

```typescript
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,
		})
	}
}
```

#### The info object

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.

#### Determining what's under the cursor

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.

#### Controlling which shapes can be dropped

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:

```typescript
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.

#### Controlling which shapes can be dragged out

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:

```typescript
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.

#### Example: slot container

Here's a complete example of a slot-based container that accepts dropped shapes:

```tsx
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
	}
}
```

#### External content

For handling content dragged from outside the browser (files, URLs, images), see [External content handling](https://tldraw.dev/sdk-features/external-content). The callbacks on this page are for shape-to-shape drag and drop within the canvas.

#### Related examples

- [Drag and drop](https://tldraw.dev/examples/shapes/tools/drag-and-drop) - Custom shapes that can be dragged onto each other.
- [Drag and drop tray](https://tldraw.dev/examples/ui/drag-and-drop-tray) - Drag items from a custom UI into the canvas.

#### Related docs

- [External content handling](https://tldraw.dev/sdk-features/external-content) - Handle files, URLs, and other content dragged from outside the browser.
- [Parenting and ancestors](https://tldraw.dev/sdk-features/parenting) - Learn about parent-child relationships and the `Editor#reparentShapes` method.

### Draw shape

The draw shape captures freehand strokes and straight line segments. It supports pressure-sensitive input, automatic shape closing, angle snapping, and hybrid freehand/straight-line drawing modes. The draw tool detects pen and stylus input and produces variable-width strokes that respond to pressure.

#### Drawing modes

The draw tool supports two segment types that you can switch between while drawing:

| Mode     | Trigger                  | Behavior                                                         |
| -------- | ------------------------ | ---------------------------------------------------------------- |
| Freehand | Default                  | Captures natural hand motion with optional pressure sensitivity  |
| Straight | Hold Shift while drawing | Creates straight line segments that snap to 15° angle increments |

You can mix both modes in a single stroke. Start drawing freehand, then hold Shift to switch to straight lines. Release Shift to return to freehand. Each mode change creates a new segment in the shape.

##### Freehand drawing

Freehand mode captures your natural hand motion. The tool records points as you drag and interpolates them into smooth curves. When you draw with a pen or stylus, the tool captures pressure data and produces variable-width strokes.

The stroke appearance depends on the dash style:

- **Draw** style uses the freehand algorithm to create organic, hand-drawn strokes with natural width variation
- **Solid**, **dashed**, and **dotted** styles render uniform-width strokes

##### Straight line mode

Hold Shift while drawing to create straight line segments. The line snaps to 15° angle increments relative to the previous point, making it easy to draw horizontal, vertical, and diagonal lines.

Release Shift to continue with freehand drawing from the current endpoint. The transition creates a smooth connection between the straight segment and the freehand stroke.

##### Extending previous strokes

If you've already drawn a stroke and want to continue from it, hold Shift and click to connect. The draw tool creates a straight line segment from the previous stroke's endpoint to your click position. Continue holding Shift and drag to extend with more straight segments, or release Shift to switch to freehand.

This connect-the-dots behavior only activates when you Shift+click after completing a previous stroke with the same draw tool session. It won't connect across different shapes or after switching tools.

#### Pen and stylus support

The draw tool distinguishes between mouse/touch input and pen/stylus input. When it detects a pen or stylus, it enables pressure-sensitive rendering:

| Input type  | Pressure behavior                                                  |
| ----------- | ------------------------------------------------------------------ |
| Mouse/touch | Simulates pressure based on velocity—faster strokes appear thinner |
| Pen/stylus  | Uses actual pressure data for variable stroke width                |

The tool detects stylus input through the pressure value in pointer events. Values between 0 and 0.5 (exclusive) or between 0.5 and 1 (exclusive) indicate stylus input, as mice report exactly 0.5.

The shape stores pen detection in the `isPen` property. This affects how the stroke renders—pen strokes use a different stroke profile optimized for real pressure data.

#### Automatic shape closing

Draw shapes can automatically close when you bring the endpoint near the starting point. This creates filled shapes when combined with a fill style other than "none".

The shape closes when:

- The path length exceeds 4× the stroke width
- The endpoint is near the starting point, within a threshold based on stroke width and zoom level

When a shape closes, the shape sets `isClosed` to true and fills according to its fill style. Highlight shapes don't support closing.

#### Line snapping

When drawing straight lines with Shift held, you can snap to previous segments in the current stroke. This helps create precise geometric constructions:

- Enable snap mode in user preferences, or hold Ctrl (when snap mode is disabled) to temporarily enable snapping
- Hold Ctrl (when snap mode is enabled) to temporarily disable snapping
- The tool snaps to the nearest point on previous straight segments within 8 pixels (adjusted for zoom)

Visual snap indicators appear when snapping is active.

#### Angle snapping

Straight line segments snap to 15° increments (24 divisions of a full circle). This makes it easy to draw:

- Horizontal lines (0°, 180°)
- Vertical lines (90°, 270°)
- 45° diagonals
- 30° and 60° angles for isometric-style drawings

Hold Ctrl while in straight line mode to disable angle snapping temporarily.

#### Dynamic resize mode

When dynamic resize mode is enabled in user preferences, new draw shapes scale inversely with zoom level. Drawing while zoomed out creates shapes that appear the same size on screen as they would at 100% zoom. The shape's `scale` property stores this adjustment.

Access dynamic resize mode through `Editor#user`:

```typescript
// Check current mode
const isDynamic = editor.user.getIsDynamicResizeMode()

// Enable dynamic resize mode
editor.user.updateUserPreferences({ isDynamicSizeMode: true })
```

#### Shape properties

Draw shapes store their path data in an efficient delta-encoded base64 format. The first point uses full Float32 precision (12 bytes), with subsequent points stored as Float16 deltas (6 bytes each).

| Property     | Type                   | Description                                                 |
| ------------ | ---------------------- | ----------------------------------------------------------- |
| `color`      | `TLDefaultColorStyle`  | Stroke color                                                |
| `fill`       | `TLDefaultFillStyle`   | Fill style (applies when `isClosed` is true)                |
| `dash`       | `TLDefaultDashStyle`   | Stroke pattern: `draw`, `solid`, `dashed`, `dotted`, `none` |
| `size`       | `TLDefaultSizeStyle`   | Stroke width preset: `s`, `m`, `l`, `xl`                    |
| `segments`   | `TLDrawShapeSegment[]` | Array of segments with `type` and base64-encoded `path`     |
| `isComplete` | `boolean`              | Whether the user has finished drawing this stroke           |
| `isClosed`   | `boolean`              | Whether the path forms a closed shape                       |
| `isPen`      | `boolean`              | Whether drawn with a stylus (enables pressure-based width)  |
| `scale`      | `number`               | Scale factor applied to the shape                           |
| `scaleX`     | `number`               | Horizontal scale factor for lazy resize                     |
| `scaleY`     | `number`               | Vertical scale factor for lazy resize                       |

Each segment has a `type` of `'free'` or `'straight'` and a `path` containing the encoded point data with x, y, and z (pressure) values.

#### Configuration options

Configure the draw shape utility to adjust behavior:

| Option              | Type     | Default | Description                                              |
| ------------------- | -------- | ------- | -------------------------------------------------------- |
| `maxPointsPerShape` | `number` | `600`   | Maximum points before automatically starting a new shape |

```tsx
import { DrawShapeUtil } from 'tldraw'

const ConfiguredDrawUtil = DrawShapeUtil.configure({
	maxPointsPerShape: 1000,
})
```

When a stroke exceeds the maximum point count, the draw tool completes the current shape and creates a new one at the current position. This prevents performance issues with very long strokes.

#### Creating draw shapes programmatically

To create a draw shape through the editor API, you need to encode the point data:

```tsx
import { b64Vecs, createShapeId } from 'tldraw'

// Define your points with x, y, and z (pressure)
const points = [
	{ x: 0, y: 0, z: 0.5 },
	{ x: 50, y: 30, z: 0.5 },
	{ x: 100, y: 10, z: 0.5 },
]

editor.createShape({
	id: createShapeId(),
	type: 'draw',
	x: 100,
	y: 100,
	props: {
		color: 'black',
		fill: 'none',
		dash: 'draw',
		size: 'm',
		segments: [
			{
				type: 'free',
				path: b64Vecs.encodePoints(points),
			},
		],
		isComplete: true,
		isClosed: false,
		isPen: false,
		scale: 1,
		scaleX: 1,
		scaleY: 1,
	},
})
```

The `b64Vecs.encodePoints` function converts an array of point objects to the delta-encoded base64 format. Use `b64Vecs.decodePoints` to read points back from a segment's path.

#### Stroke rendering

The draw shape uses a freehand stroke algorithm to render organic-looking lines. The algorithm applies:

- **Streamline**: Smooths the path by pulling points toward the stroke's center
- **Smoothing**: Applies curve fitting for natural-looking strokes
- **Thinning**: Varies stroke width based on velocity (for mouse) or pressure (for pen)

When `dash` is set to `'draw'`, the shape renders using the full freehand algorithm. Other dash styles use simpler uniform-width strokes with the appropriate dash pattern.

At low zoom levels, the shape automatically switches to solid rendering for performance. This happens when the zoom level is below 50% and also below a threshold based on stroke width (`zoomLevel < 1.5 / strokeWidth`).

#### Geometry

The shape's geometry depends on its content:

- **Single point (dot)**: Returns a `Circle2d` centered at the point with radius equal to the stroke width
- **Closed path**: Returns a `Polygon2d` that can be filled
- **Open path**: Returns a `Polyline2d` following the stroke's center line

The geometry uses the processed stroke points (after applying streamline and smoothing), not the raw input points.

#### Related shapes

- **[Highlight](https://tldraw.dev/sdk-features/default-shapes#highlight)**: Uses the same point capture system but renders semi-transparently for marking up content
- **[Line](https://tldraw.dev/sdk-features/default-shapes#line)**: Creates editable multi-point lines with draggable handles

#### Related articles

- [Default shapes](https://tldraw.dev/sdk-features/default-shapes) — Overview of all built-in shapes
- [Tools](https://tldraw.dev/sdk-features/tools) — How tools handle user input
- [Styles](https://tldraw.dev/sdk-features/styles) — Working with shape styles like color and size

### Edge scrolling

Edge scrolling automatically pans the camera when you drag shapes toward the viewport edges. This lets you move shapes across the canvas without releasing the drag to scroll manually.

The system activates only during drag operations. It requires three conditions: you must be dragging (not panning), the camera must be unlocked, and the pointer must be within a proximity zone at the viewport edge.

#### How it works

Tools that support edge scrolling call `editor.edgeScrollManager.updateEdgeScrolling(elapsed)` on every tick during drag operations. The manager checks the pointer position against the viewport bounds and calculates a proximity factor for each axis.

When the pointer enters the edge scroll zone, the manager tracks elapsed time. After a configurable delay, scrolling starts and gradually accelerates using an easing function. The camera moves on each tick until the pointer leaves the edge zone or the drag ends.

```typescript
override onTick({ elapsed }: TLTickEventInfo) {
	this.editor.edgeScrollManager.updateEdgeScrolling(elapsed)
}
```

The built-in select tool uses edge scrolling in three states: Translating (moving shapes), Brushing (selection box), and Resizing (dragging handles).

#### Edge detection

The manager determines edge proximity by comparing the pointer position to the viewport bounds. An edge scroll zone extends inward from each screen edge by a distance defined in `editor.options.edgeScrollDistance` (default: 8 pixels).

##### Proximity calculation

When the pointer enters this zone, the manager calculates a proximity factor from 0 to 1 based on how deeply the pointer penetrates the zone. At the zone boundary, the factor is 0. At the screen edge (or beyond), it reaches 1. Each axis is calculated independently.

##### Touch input

For touch input, the system expands the effective pointer size using `editor.options.coarsePointerWidth` (default: 12 pixels). The expanded pointer area is centered on the touch point, making edge scrolling easier to trigger on mobile devices.

##### Inset handling

Edge detection respects screen insets from the editor's instance state. The insets array records whether each edge of the editor is flush with the browser window, in CSS order: `[top, right, bottom, left]`.

When an edge is flush with the window, the pointer can't move past it, so the scroll zone extends inward from that edge. When an edge is inset—the editor is embedded with other page content beside it—the zone starts at the editor's boundary instead, and scrolling begins once the pointer moves outside the editor.

#### Scrolling behavior

Once the pointer enters the edge zone, the manager waits for `editor.options.edgeScrollDelay` milliseconds (default: 200ms) before starting to scroll. This delay prevents accidental scrolling when the pointer briefly crosses the edge.

After the delay, scrolling begins with gradual acceleration controlled by `editor.options.edgeScrollEaseDuration` (default: 200ms). The manager applies `EASINGS.easeInCubic` to create smooth acceleration from zero to full speed.

##### Speed calculation

The scroll speed combines several factors. The base speed comes from `editor.options.edgeScrollSpeed` (default: 25 pixels per tick) multiplied by the user preference from `editor.user.getEdgeScrollSpeed()` (default: 1).

The proximity factor (0 to 1) scales speed based on how close the pointer is to the screen edge. Scrolling is slower near the zone boundary and faster at the edge itself.

On smaller displays, a screen size factor of 0.612 applies when that viewport dimension is below 1000 pixels. This reduces speed independently for each axis. The final scroll delta divides by the current zoom level to maintain consistent canvas-space velocity.

```typescript
const pxSpeed = editor.user.getEdgeScrollSpeed() * editor.options.edgeScrollSpeed
const screenSizeFactorX = screenBounds.w < 1000 ? 0.612 : 1
const screenSizeFactorY = screenBounds.h < 1000 ? 0.612 : 1
const scrollDeltaX = (pxSpeed * proximityFactor.x * screenSizeFactorX) / zoomLevel
const scrollDeltaY = (pxSpeed * proximityFactor.y * screenSizeFactorY) / zoomLevel
```

##### Conditions for scrolling

The camera only moves when all these conditions are met:

- The user is dragging, not panning. The built-in tool states check `editor.inputs.getIsDragging()` and `editor.inputs.getIsPanning()` before calling `updateEdgeScrolling()`.
- The camera is not locked (`editor.getCameraOptions().isLocked` is false)
- The proximity factor is non-zero for at least one axis

When the pointer leaves the edge zone, scrolling stops and the internal duration timer resets.

#### Configuration options

You can customize edge scrolling through the editor's options:

| Option                   | Default | Description                                        |
| ------------------------ | ------- | -------------------------------------------------- |
| `edgeScrollDelay`        | 200     | Milliseconds to wait before starting scroll        |
| `edgeScrollEaseDuration` | 200     | Milliseconds to accelerate from zero to full speed |
| `edgeScrollSpeed`        | 25      | Base scroll speed in pixels per tick               |
| `edgeScrollDistance`     | 8       | Width of the edge scroll zone in pixels            |
| `coarsePointerWidth`     | 12      | Expanded pointer size for touch input (pixels)     |

Set these options when creating the editor:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

const options = {
	edgeScrollSpeed: 50, // Double the default speed
	edgeScrollDelay: 100, // Start scrolling sooner
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw options={options} />
		</div>
	)
}
```

##### User preferences

Edge scroll speed is also a user preference. `editor.user.getEdgeScrollSpeed()` returns a multiplier that defaults to 1, and you can change it with `editor.user.updateUserPreferences({ edgeScrollSpeed: 2 })`. The preference persists across sessions and scales all edge scrolling without changing the base configuration.

#### Tool integration

To add edge scrolling to your custom tool, call `updateEdgeScrolling()` in the tick handler. The manager reads the pointer position from the editor's input system, so you only need to pass the elapsed time. Like the built-in tool states, skip the call unless the user is dragging:

```typescript
import { StateNode, TLTickEventInfo } from '@tldraw/editor'

export class CustomDragState extends StateNode {
	static override id = 'dragging'

	override onTick({ elapsed }: TLTickEventInfo) {
		const { editor } = this
		if (!editor.inputs.getIsDragging() || editor.inputs.getIsPanning()) return
		editor.edgeScrollManager.updateEdgeScrolling(elapsed)
	}
}
```

The manager tracks whether edge scrolling is active using `getIsEdgeScrolling()`. This returns true when the pointer is in the edge zone and scrolling has started (after the delay).

Only call `updateEdgeScrolling()` during states where edge scrolling makes sense. The built-in select tool calls it during translating, brushing, and resizing, but not during idle or pointing states.

#### Related examples

See the [custom tool](https://tldraw.dev/examples/shapes/tools/custom-tool) example for building tools that can implement edge scrolling. For complex tools with multiple states, see the [tool with child states](https://tldraw.dev/examples/shapes/tools/tool-with-child-states) example.

### Editor

The `Editor` class is the main way of controlling tldraw's editor. It provides methods for creating, reading, updating, and deleting shapes; managing selection and history; controlling the camera; and responding to user input. By design, the editor's surface area is very large—almost everything is available through it.

Need to create some shapes? Use `Editor#createShapes`. Need to delete them? Use `Editor#deleteShapes`. Want a sorted array of every shape on the current page? Use `Editor#getCurrentPageShapesSorted`. The editor is your primary interface for interacting with the canvas.

#### Accessing the editor

You can access the editor in two ways:

1. From the `Tldraw` component's `onMount` callback:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					// Your editor code here
					editor.createShape({ type: 'geo', x: 100, y: 100 })
				}}
			/>
		</div>
	)
}
```

2. Via the `useEditor` hook, which must be called from within the `Tldraw` component tree:

```tsx
function InsideOfContext() {
	const editor = useEditor()
	// Your editor code here
	return null
}

function App() {
	return (
		<Tldraw>
			<InsideOfContext />
		</Tldraw>
	)
}
```

#### Architecture overview

The editor orchestrates several interconnected systems. Understanding how they fit together helps when building on top of tldraw.

##### Store

The editor holds document data in its `Editor#store` property. The store is a reactive database containing records for shapes, pages, bindings, assets, and editor state. All records are JSON-serializable.

```ts
// Access a shape record directly from the store
const shape = editor.store.get(shapeId)

// Listen to store changes
editor.store.listen((entry) => {
	console.log('Changed:', entry.changes)
})
```

The store is reactive: when data changes, the UI updates automatically. The editor wraps the store with higher-level methods like `createShapes()` and `deleteShapes()`, so you rarely need to interact with it directly.

See [Store](https://tldraw.dev/sdk-features/store) for details on working with the store directly.

##### Signals

Tldraw uses a signals-based reactive system. The editor exposes many of its internal values as signals—methods like `editor.getSelectedShapeIds()` and `editor.getCurrentPageShapes()` return reactive values that update automatically when the underlying state changes.

```tsx
import { track, useEditor } from 'tldraw'

const SelectedCount = track(function SelectedCount() {
	const editor = useEditor()
	return <div>{editor.getSelectedShapeIds().length} shapes selected</div>
})
```

The `track` higher-order component automatically subscribes to signals accessed during render. When those signals change, the component re-renders.

See [Signals](https://tldraw.dev/sdk-features/signals) for the full reactive API.

##### State chart

The editor uses a hierarchical state machine for handling user interactions. Tools like the select tool, draw tool, and hand tool are implemented as `StateNode` instances. Each node can have child states, and the active state determines how the editor responds to events.

```ts
// Change the current tool
editor.setCurrentTool('draw')
editor.setCurrentTool('hand')

// Check the current tool
editor.getCurrentToolId() // 'select', 'draw', 'hand', etc.

// Check the full state path
editor.root.getPath() // 'root.select.idle', 'root.draw.drawing', etc.

// Check if a state is active
editor.isIn('select') // true if select tool is active
editor.isIn('select.idle') // true if in idle state of select tool

// Check if any of several states are active
editor.isInAny('select.idle', 'hand.idle') // true if in either state
```

###### State transitions

Tools transition between child states as the user interacts. For example, the select tool has states like `idle`, `pointing`, `brushing`, and `translating`. When you click and drag on the canvas, the state might flow like this:

1. `select.idle` — waiting for input
2. `select.pointing` — pointer down, waiting to see if this is a click or drag
3. `select.brushing` — dragging to create a selection box
4. `select.idle` — pointer up, back to waiting

Each state handles events differently. The `idle` state responds to `pointer_down` by transitioning to `pointing`. The `brushing` state responds to `pointer_move` by updating the brush bounds and `pointer_up` by completing the selection.

###### Event flow

Events flow from the root state down through active children. When you press a key or move the pointer, the editor dispatches an event that each active state can handle:

```ts
// Events bubble through: root → select → idle
// Each state can:
// - Handle the event and stop propagation
// - Handle the event and let it continue
// - Ignore the event entirely
```

States define handlers for events like `onPointerDown`, `onPointerMove`, `onKeyDown`, and `onEnter`/`onExit` for state transitions. The active state chain determines which handlers run.

See [Tools](https://tldraw.dev/sdk-features/tools) for building custom tools.

##### Managers

The editor delegates specialized functionality to manager classes:

| Manager                     | Responsibility                                |
| --------------------------- | --------------------------------------------- |
| `HistoryManager`         | Undo/redo stack and history marks             |
| `SnapManager`            | Shape snapping during transforms              |
| FocusManager                | Focus state and keyboard event handling       |
| `TextManager`            | Text measurement and layout                   |
| `FontManager`            | Font loading and management                   |
| TickManager                 | Animation frame scheduling                    |
| `InputsManager`          | Pointer position and modifier key tracking    |
| `ClickManager`           | Click, double-click, and long-press detection |
| `ScribbleManager`        | Brush and scribble interactions               |
| `EdgeScrollManager`      | Auto-scroll at viewport edges                 |
| `UserPreferencesManager` | User settings persistence                     |

Access managers through the editor instance:

```ts
// Mark history for undo
editor.markHistoryStoppingPoint('my-action')

// Check snap points
editor.snaps.getIndicators()
```

#### Working with shapes

Shapes are the content on your canvas. Each shape has a type, position, rotation, and type-specific props.

##### Creating shapes

```ts
// Create a shape with auto-generated ID
editor.createShape({
	type: 'geo',
	x: 100,
	y: 100,
	props: {
		geo: 'rectangle',
		w: 200,
		h: 150,
		color: 'blue',
	},
})

// Create with a specific ID
import { createShapeId } from 'tldraw'

const id = createShapeId('my-shape')
editor.createShape({
	id,
	type: 'geo',
	x: 100,
	y: 100,
})
```

##### Reading shapes

```ts
// Get a shape by ID
const shape = editor.getShape(shapeId)

// Get all shapes on the current page
const shapes = editor.getCurrentPageShapes()

// Get selected shapes
const selected = editor.getSelectedShapes()
```

##### Updating shapes

```ts
editor.updateShape({
	id: shape.id,
	type: shape.type, // Required
	x: 200,
	props: {
		color: 'red',
	},
})
```

##### Deleting shapes

```ts
// Delete by ID
editor.deleteShapes([shapeId])

// Delete by shape record
editor.deleteShapes([shape])
```

See [Shapes](https://tldraw.dev/sdk-features/shapes) for the complete shape system.

#### Selection

The editor tracks which shapes are selected. Selection drives many operations—transform handles, copy/paste, delete, and more.

```ts
// Select shapes
editor.select(shapeId)
editor.select(shapeId1, shapeId2)

// Add to selection
editor.setSelectedShapes([...editor.getSelectedShapeIds(), newId])

// Clear selection
editor.selectNone()

// Select all on current page
editor.selectAll()

// Get selection
editor.getSelectedShapeIds()
editor.getSelectedShapes()
```

See [Selection](https://tldraw.dev/sdk-features/selection) for selection details.

#### History

The editor maintains an undo/redo stack through its history manager. Changes to the store accumulate until you create a mark, which becomes an undo stopping point.

```ts
// Mark before an operation
editor.markHistoryStoppingPoint('rotate shapes')
editor.rotateShapesBy(editor.getSelectedShapeIds(), Math.PI / 4)

// Undo returns to the mark
editor.undo()

// Redo reapplies changes
editor.redo()
```

See [History](https://tldraw.dev/sdk-features/history) for the full history API.

#### Transactions

Use `Editor#run` to batch changes into a single transaction. This improves performance and reduces intermediate renders.

```ts
editor.run(() => {
	editor.createShapes(shapes)
	editor.sendToBack(shapes)
	editor.selectNone()
})
```

The `run` method also accepts options for controlling history:

```ts
// Ignore changes (don't add to undo stack)
editor.run(
	() => {
		editor.updateShape({ id, type: 'geo', x: 100 })
	},
	{ history: 'ignore' }
)

// Allow editing locked shapes
editor.run(
	() => {
		editor.updateShapes(lockedShapes)
	},
	{ ignoreShapeLock: true }
)
```

See [History](https://tldraw.dev/sdk-features/history) for more on how transactions interact with undo/redo.

#### Camera and viewport

The editor manages a camera that determines which part of the infinite canvas is visible. The camera has x, y, and z (zoom) coordinates.

```ts
// Move camera to specific coordinates
editor.setCamera({ x: 0, y: 0, z: 1 })

// Zoom controls
editor.zoomIn()
editor.zoomOut()
editor.resetZoom()

// Fit content in view
editor.zoomToFit()
editor.zoomToSelection()

// Center on a point
editor.centerOnPoint({ x: 500, y: 500 })

// Lock the camera
editor.setCameraOptions({ isLocked: true })
```

The viewport is the visible area of the canvas. Get its bounds in screen or page coordinates:

```ts
// Screen coordinates (component size)
editor.getViewportScreenBounds()

// Page coordinates (what's visible on the canvas)
editor.getViewportPageBounds()
```

See [Camera](https://tldraw.dev/sdk-features/camera) and [Coordinates](https://tldraw.dev/sdk-features/coordinates) for the full camera API.

#### Input state

The `Editor#inputs` object tracks the user's current input state: cursor position, pressed keys, drag state, and more. All values on the inputs object are reactive signals—when you access them inside a tracked component or computed, your code automatically re-runs when those values change.

```ts
// Cursor position in page coordinates (reactive)
editor.inputs.getCurrentPagePoint()

// Cursor position in screen coordinates (reactive)
editor.inputs.getCurrentScreenPoint()

// Where the current drag started (reactive)
editor.inputs.getOriginPagePoint()

// Interaction state (reactive)
editor.inputs.getIsDragging()
editor.inputs.getIsPointing()
editor.inputs.getIsPinching()

// Modifier keys (reactive)
editor.inputs.getShiftKey()
editor.inputs.getCtrlKey()
editor.inputs.getAltKey()
```

See [Input handling](https://tldraw.dev/sdk-features/input-handling) for input details.

#### Instance state

The editor maintains per-instance state in a `TLInstance` record. This includes which page is current, whether the editor is in readonly mode, the current tool, tool lock state, and UI state.

```ts
// Get instance state
const instance = editor.getInstanceState()

// Update instance state
editor.updateInstanceState({ isReadonly: true })

// Enable tool lock (keeps current tool active after creating shapes)
editor.updateInstanceState({ isToolLocked: true })
```

See [Tools](https://tldraw.dev/sdk-features/tools#tool-lock) for more on tool lock.

Each page also has instance state (`TLInstancePageState`) tracking selection, hovered shape, and editing shape for that page:

```ts
// Get current page state
const pageState = editor.getCurrentPageState()
```

#### User preferences

User preferences are shared across all editor instances. They control things like color scheme and locale.

```ts
// Turn on dark mode
editor.user.updateUserPreferences({ colorScheme: 'dark' })

// Use system color scheme
editor.user.updateUserPreferences({ colorScheme: 'system' })

// Get current preferences
editor.user.getUserPreferences()
```

See [User preferences](https://tldraw.dev/sdk-features/user-preferences) for all preference options.

#### Side effects

Register callbacks to respond to record lifecycle events. Side effects let you maintain relationships, enforce constraints, or sync external state.

```ts
// After a shape is created
editor.sideEffects.registerAfterCreateHandler('shape', (shape) => {
	if (shape.type === 'arrow') {
		console.log('Arrow created:', shape.id)
	}
})

// Before a shape is deleted
editor.sideEffects.registerBeforeDeleteHandler('shape', (shape) => {
	// Return false to prevent deletion
})
```

See [Side effects](https://tldraw.dev/sdk-features/side-effects) for the complete API.

#### Events

The editor receives events through `Editor#dispatch`. You typically don't call this directly—the canvas handles DOM events and dispatches them for you. But you can listen for events on the editor:

```ts
editor.on('event', (info) => {
	if (info.name === 'pointer_down') {
		console.log('Pointer down at', info.point)
	}
})
```

See [Events](https://tldraw.dev/sdk-features/events) for event types.

#### Related examples

- **[Controlling the canvas](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/api)** — Create and manipulate shapes, selection, and camera through the editor API.
- **[Minimal editor](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/only-editor)** — Use `TldrawEditor` for a bare-bones editor without default shapes or UI.
- **[Sublibraries](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/exploded)** — Compose tldraw from individual sublibraries for full customization.
- **[Canvas events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/canvas-events)** — Listen to editor events including pointer, keyboard, and shape changes.
- **[Editor focus](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/editor-focus)** — Control editor focus state.

### Embed shape

The embed shape displays interactive content from external services within an iframe. When you paste a URL from a supported service onto the canvas, tldraw automatically converts it to an embed with the appropriate dimensions and settings.

#### Creating embeds

Paste a supported URL onto the canvas, or create an embed shape programmatically:

```tsx
editor.createShape({
	type: 'embed',
	x: 100,
	y: 100,
	props: {
		url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
		w: 560,
		h: 315,
	},
})
```

The embed system recognizes URLs from supported services and converts them to their embeddable equivalents. A YouTube watch URL becomes an embed URL automatically.

##### Pasting iframe code

You can also paste raw `<iframe>` HTML directly onto the canvas to create an embed shape, even when the source URL doesn't match a known provider. tldraw extracts the iframe's `src` attribute and creates an embed pointing at it. If the iframe element is missing `width` and `height`, the embed uses default dimensions instead.

This covers services tldraw doesn't know about — OpenStreetMap, SoundCloud, Loom, internal tools — without a custom embed definition. Iframes pasted this way receive a stricter sandbox than the built-in providers (no `allow-same-origin`, no forms, no popups), since tldraw can't make any safety guarantees about the source.

#### Supported services

| Service         | Hostnames                             | Resizable | Aspect ratio locked |
| --------------- | ------------------------------------- | --------- | ------------------- |
| tldraw          | tldraw.com, beta.tldraw.com           | Yes       | No                  |
| Figma           | figma.com                             | Yes       | No                  |
| YouTube         | youtube.com, \*.youtube.com, youtu.be | Yes       | Yes                 |
| Google Maps     | google.\*                             | Yes       | No                  |
| Google Calendar | calendar.google.\*                    | Yes       | No                  |
| Google Slides   | docs.google.\*                        | Yes       | No                  |
| CodeSandbox     | codesandbox.io                        | Yes       | No                  |
| CodePen         | codepen.io                            | Yes       | No                  |
| Scratch         | scratch.mit.edu                       | No        | No                  |
| Val Town        | val.town                              | Yes       | No                  |
| GitHub Gist     | gist.github.com                       | Yes       | No                  |
| Replit          | replit.com                            | Yes       | No                  |
| Felt            | felt.com                              | Yes       | No                  |
| Spotify         | open.spotify.com                      | Yes       | No                  |
| Vimeo           | vimeo.com, player.vimeo.com           | Yes       | Yes                 |
| Observable      | observablehq.com                      | Yes       | No                  |
| Desmos          | desmos.com                            | Yes       | No                  |
| Canva           | canva.com                             | Yes       | No                  |

Each service has default dimensions appropriate for its content type. YouTube embeds default to 800×450 (16:9), while Spotify defaults to 720×500.

#### Interacting with embeds

Embed shapes behave differently from other shapes because they contain live interactive content.

**Locked embeds**: When an embed shape is locked, you can interact with the content inside—play videos, scroll through code, use the embedded application—without accidentally moving the shape. This is the recommended way to use embeds for viewing content.

**Unlocked embeds**: When an embed is unlocked, clicking on it selects the shape rather than interacting with the content. To interact with an unlocked embed, double-click it to enter editing mode.

**Editing mode**: In editing mode, pointer events pass through to the iframe. You can scroll, click buttons, and interact with the embedded content. Click outside the shape or press Escape to exit editing mode.

#### URL transformation

The embed system converts user-facing URLs to embed URLs automatically. When you paste `https://www.youtube.com/watch?v=dQw4w9WgXcQ`, the embed shape stores the original URL and renders `https://www.youtube.com/embed/dQw4w9WgXcQ`.

Each embed definition includes two transformation functions:

- `toEmbedUrl`: Converts a shareable URL to an embeddable URL
- `fromEmbedUrl`: Converts an embed URL back to the original URL

This bidirectional transformation means the shape can display the original URL to users while rendering the embed version internally.

#### Fallback to bookmarks

When you paste a URL that no embed definition recognizes, the default URL handler creates a [bookmark shape](https://tldraw.dev/sdk-features/default-shapes#bookmark) instead of an embed. An existing embed shape whose URL doesn't match any definition still renders the URL in an iframe, using the stricter sandbox for unknown sources.

You can check whether a URL is embeddable before creating a shape:

```tsx
import { getEmbedInfo, DEFAULT_EMBED_DEFINITIONS } from 'tldraw'

const embedInfo = getEmbedInfo(DEFAULT_EMBED_DEFINITIONS, 'https://youtube.com/watch?v=abc123')

if (embedInfo) {
	// URL is embeddable
	console.log(embedInfo.definition.title) // "YouTube"
	console.log(embedInfo.embedUrl) // "https://www.youtube.com/embed/abc123"
} else {
	// URL is not embeddable, will render as bookmark
}
```

#### Iframe security

Embeds run in sandboxed iframes with restricted permissions. The default sandbox settings are:

| Permission                                | Default | Description                                       |
| ----------------------------------------- | ------- | ------------------------------------------------- |
| `allow-scripts`                           | Yes     | Allow JavaScript execution                        |
| `allow-same-origin`                       | Yes     | Allow access to same-origin storage and APIs      |
| `allow-forms`                             | Yes     | Allow form submission                             |
| `allow-popups`                            | Yes     | Allow opening new windows (for linking to source) |
| `allow-downloads`                         | No      | Block file downloads                              |
| `allow-modals`                            | No      | Block modal dialogs like `window.prompt()`        |
| `allow-pointer-lock`                      | No      | Block pointer lock API                            |
| `allow-top-navigation`                    | No      | Block navigating away from tldraw                 |
| `allow-storage-access-by-user-activation` | No      | Block access to parent storage                    |

Individual embed definitions can override these defaults. YouTube embeds allow `allow-presentation` for fullscreen video. Tldraw embeds allow `allow-top-navigation` so users can open rooms in new tabs.

GitHub Gist embeds receive special handling: they use `srcDoc` instead of `src` to load the gist script, with an additional security check that restricts gist IDs to hexadecimal characters only. This prevents JSONP callback attacks.

#### Custom embed definitions

Replace or extend the default embed definitions using `EmbedShapeUtil.configure()`:

```tsx
import { Tldraw, EmbedShapeUtil, DEFAULT_EMBED_DEFINITIONS } from 'tldraw'
import 'tldraw/tldraw.css'

// Add a custom embed definition
const myEmbedDefinitions = [
	...DEFAULT_EMBED_DEFINITIONS,
	{
		type: 'myservice',
		title: 'My Service',
		hostnames: ['myservice.com'],
		width: 600,
		height: 400,
		doesResize: true,
		toEmbedUrl: (url) => {
			const match = url.match(/myservice\.com\/item\/(\w+)/)
			if (match) {
				return `https://myservice.com/embed/${match[1]}`
			}
			return undefined
		},
		fromEmbedUrl: (url) => {
			const match = url.match(/myservice\.com\/embed\/(\w+)/)
			if (match) {
				return `https://myservice.com/item/${match[1]}`
			}
			return undefined
		},
		embedOnPaste: true,
	},
]

const shapeUtils = [EmbedShapeUtil.configure({ embedDefinitions: myEmbedDefinitions })]

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw shapeUtils={shapeUtils} />
		</div>
	)
}
```

##### Embed definition properties

| Property                | Type                                   | Required | Description                                                               |
| ----------------------- | -------------------------------------- | -------- | ------------------------------------------------------------------------- |
| `type`                  | `string`                               | Yes      | Unique identifier for this embed type                                     |
| `title`                 | `string`                               | Yes      | Display name shown in the UI                                              |
| `hostnames`             | `string[]`                             | Yes      | URL hostnames to match (supports glob patterns like `*.youtube.com`)      |
| `width`                 | `number`                               | Yes      | Default width in pixels                                                   |
| `height`                | `number`                               | Yes      | Default height in pixels                                                  |
| `doesResize`            | `boolean`                              | Yes      | Whether the shape can be resized                                          |
| `toEmbedUrl`            | `(url: string) => string \| undefined` | Yes      | Convert shareable URL to embed URL                                        |
| `fromEmbedUrl`          | `(url: string) => string \| undefined` | Yes      | Convert embed URL to shareable URL                                        |
| `minWidth`              | `number`                               | No       | Minimum width when resizing                                               |
| `minHeight`             | `number`                               | No       | Minimum height when resizing                                              |
| `isAspectRatioLocked`   | `boolean`                              | No       | Lock aspect ratio when resizing                                           |
| `canEditWhileLocked`    | `boolean`                              | No       | Allow interaction when shape is locked (default: true)                    |
| `overridePermissions`   | `TLEmbedShapePermissions`              | No       | Custom iframe sandbox permissions                                         |
| `backgroundColor`       | `string`                               | No       | Background color for the embed container                                  |
| `overrideOutlineRadius` | `number`                               | No       | Custom border radius (Spotify uses 12px)                                  |
| `embedOnPaste`          | `boolean`                              | No       | When true, URLs are auto-converted to embeds on paste                     |
| `instructionLink`       | `string`                               | No       | Help URL for services requiring setup (like Google Calendar public links) |

#### Embed-on-paste behavior

By default, pasting a URL from a supported service creates an embed shape. Set `embedOnPaste: false` in the embed definition to create a bookmark instead.

The tldraw embed definition sets `embedOnPaste: false`, so pasting a tldraw.com URL creates a bookmark. Separately, embed shapes refuse to render a nested tldraw canvas when the page itself is running inside an iframe, which prevents infinite nesting.

#### Shape properties

| Property | Type     | Description                                          |
| -------- | -------- | ---------------------------------------------------- |
| `url`    | `string` | The original URL (converted to embed URL internally) |
| `w`      | `number` | Width of the embed container                         |
| `h`      | `number` | Height of the embed container                        |

#### SVG export

Embed shapes render as blank rectangles in SVG exports. The iframe content can't be captured directly, so exports show a placeholder with the embed's background color and border radius.

#### Related

- [Default shapes](https://tldraw.dev/sdk-features/default-shapes) — Overview of all built-in shape types
- [Bookmark shape](https://tldraw.dev/sdk-features/default-shapes#bookmark) — URL link cards (fallback for non-embeddable URLs)
- [External content handling](https://tldraw.dev/sdk-features/external-content) — Handling pasted and dropped content
- [Custom embeds example](https://tldraw.dev/examples/configuration/custom-embed) — Add a custom embed definition for a new service

### Environment detection

The tldraw SDK provides two objects for detecting the user's environment: `tlenv` for fixed browser and platform information, and `tlenvReactive` for values that change during a session (like whether the user is using touch input). We use these internally to work around browser quirks, and you can use them in custom shapes or tools.

#### Static environment: tlenv

The `tlenv` object contains values detected at page load. These don't change during a session.

```typescript
import { tlenv } from 'tldraw'

// Browser detection
tlenv.isSafari // true if Safari (excluding Chrome on iOS)
tlenv.isFirefox // true if Firefox
tlenv.isChromeForIos // true if Chrome running on iOS

// Platform detection
tlenv.isIos // true if iPad or iPhone
tlenv.isAndroid // true if Android device
tlenv.isDarwin // true if macOS

// Capability detection
tlenv.hasCanvasSupport // true if Promise and HTMLCanvasElement exist
```

##### Common patterns

**Platform-specific keyboard shortcuts:**

```typescript
// Use Cmd on Mac, Ctrl elsewhere
const accelKey = tlenv.isDarwin ? e.metaKey : e.ctrlKey
```

**Mobile detection:**

```typescript
const isMobile = tlenv.isIos || tlenv.isAndroid
if (isMobile) {
	// Adjust UI for mobile
}
```

**Browser-specific workarounds:**

```typescript
// Safari needs extra time for SVG image export
if (tlenv.isSafari) {
	await new Promise((r) => setTimeout(r, 250))
}
```

#### Reactive environment: tlenvReactive

The `tlenvReactive` atom contains values that can change during a session, like the current pointer type. Use `useValue` to subscribe to changes in React components.

```tsx
import { tlenvReactive, useValue } from 'tldraw'

function TouchFriendlyButton() {
	const { isCoarsePointer } = useValue(tlenvReactive)

	return (
		<button style={{ padding: isCoarsePointer ? 16 : 8 }}>
			{/* Larger touch target when using touch input */}
			Click me
		</button>
	)
}
```

##### Coarse pointer detection

The `isCoarsePointer` value tracks whether the user is currently using touch input. We detect this two ways: by listening to the `(any-pointer: coarse)` media query, and by checking `pointerType` on each pointer down event. This dual approach handles devices that support both mouse and touch—like laptops with touchscreens—where the user might switch input methods mid-session.

```typescript
import { tlenvReactive, react } from 'tldraw'

// Access the current value directly
const isCoarse = tlenvReactive.get().isCoarsePointer

// Subscribe to changes outside React
react('pointer type changed', () => {
	const { isCoarsePointer } = tlenvReactive.get()
	console.log('Coarse pointer:', isCoarsePointer)
})
```

Note: We force fine pointer mode on Firefox desktop regardless of the actual input device, since Firefox's coarse pointer reporting is unreliable there.

#### Cross-window contexts

When the editor is mounted inside an iframe, an Electron pop-out window, or an Obsidian plugin, the global `document` and `window` aren't necessarily the ones the editor's DOM lives in. Reading `document.activeElement` or attaching a listener to the global `window` will silently fail or target the wrong frame.

Use the editor's container helpers instead of bare globals:

```typescript
const doc = editor.getContainerDocument() // the document the editor is mounted in
const win = editor.getContainerWindow() // the window the editor is mounted in
```

When you have a DOM node and want the document or window that owns it (rather than where the editor lives), use the standalone helpers:

```typescript
import { getOwnerDocument, getOwnerWindow } from 'tldraw'

const doc = getOwnerDocument(node)
const win = getOwnerWindow(node)
```

These are what the editor uses internally for export, measurement, focus tracking, and event listeners. Custom shapes and tools that touch the DOM directly should use them too — anything that calls `document` or `window` directly will break the moment your app is embedded in a context with multiple realms.

#### Browser quirks we handle

Here's a sample of what we use environment detection for internally.

**Safari** has the most workarounds. During SVG-to-image export, Safari fires the image's load event before the fonts inside the SVG have loaded, so we wait an extra 250ms—a WebKit bug that's been open for years. Text outlines are a performance problem on Safari, so we disable them there.

**iOS** support for coalesced pointer events is unreliable—`getCoalescedEvents` is sometimes missing entirely—so we skip coalesced events on iOS and dispatch individual pointer events instead.

**Firefox** desktop's `(any-pointer: coarse)` media query reports false positives when a touchscreen is present but not in use. We force fine pointer mode on Firefox desktop to avoid jumpy UI.

**Chrome for iOS** has its own print implementation that doesn't trigger the standard `beforeprint` event, so we detect it and handle printing manually.

### Error handling

The editor uses multiple layers of React error boundaries to isolate failures. When a shape throws during render, only that shape shows a fallback. The rest of the editor keeps working. This matters because custom shapes are a common extension point, and third-party code should not crash the whole experience.

#### Error boundary layers

Error boundaries exist at three levels:

**Application level.** Wraps the entire editor. If something throws here, we show a full-screen error with options to refresh or reset local data. This is the last resort.

**Shape level.** Each shape renders inside its own boundary. A broken shape disappears, but the user can still interact with everything else. ShapeUtil code is the most likely place for bugs, especially in custom shapes.

**Indicator level.** Selection indicators have their own boundaries, separate from shape content. A shape can render correctly even if its indicator throws, and vice versa.

```tsx
// The editor automatically wraps your content
<TldrawEditor>
	<OptionalErrorBoundary fallback={ErrorFallback}>
		{/* Your shapes, each with their own boundary */}
		<Shape>
			<OptionalErrorBoundary fallback={ShapeErrorFallback}>
				{/* Shape content */}
			</OptionalErrorBoundary>
		</Shape>
	</OptionalErrorBoundary>
</TldrawEditor>
```

#### Default fallbacks

Each boundary level has a default fallback component.

`DefaultErrorFallback` shows a modal with the error message and stack trace. It tries to render the canvas behind the modal so users can see their work is probably still there. The modal offers buttons to copy the error, refresh the page, or reset local data.

`DefaultShapeErrorFallback` renders an empty div with the class `tl-shape-error-boundary`. The shape vanishes, but nothing else breaks. Style this class if you want broken shapes to be more visible.

#### Customizing error components

Replace any error fallback through the `components` prop:

```tsx
import { Tldraw, TLErrorFallbackComponent, TLShapeErrorFallbackComponent } from 'tldraw'

const MyErrorFallback: TLErrorFallbackComponent = ({ error, editor }) => {
	return (
		<div className="my-error-screen">
			<h1>Oops!</h1>
			<p>{error instanceof Error ? error.message : String(error)}</p>
			<button onClick={() => window.location.reload()}>Refresh</button>
		</div>
	)
}

const MyShapeErrorFallback: TLShapeErrorFallbackComponent = ({ error }) => {
	return <div className="broken-shape">This shape failed to render</div>
}

;<Tldraw
	components={{
		ErrorFallback: MyErrorFallback,
		ShapeErrorFallback: MyShapeErrorFallback,
	}}
/>
```

Set a fallback to `null` to disable the error boundary at that level. Errors will propagate to the parent boundary instead.

#### Crash handling

When the editor encounters a fatal error during event processing, it enters a crashed state. Listen for this with the `crash` event:

```tsx
editor.on('crash', ({ error }) => {
	console.error('Editor crashed:', error)
	// Report to error tracking service
})
```

When crashed, the editor stops processing new events to prevent further damage. The error boundary displays the fallback UI, giving users options to recover.

#### Error annotations

The SDK can attach debugging metadata to errors. Use `getErrorAnnotations` to retrieve tags and extra context, which is useful for error tracking services like Sentry:

```tsx
import { getErrorAnnotations, TLErrorFallbackComponent } from 'tldraw'

const MyErrorFallback: TLErrorFallbackComponent = ({ error }) => {
	const annotations = error instanceof Error ? getErrorAnnotations(error) : null

	// Send to error tracking
	if (annotations) {
		Sentry.setTags(annotations.tags)
		Sentry.setExtras(annotations.extras)
	}

	return (
		<div>
			<h1>Something went wrong</h1>
			<pre>{JSON.stringify(annotations, null, 2)}</pre>
		</div>
	)
}
```

Annotations include `tags` (key-value pairs for categorization) and `extras` (additional context data).

#### ErrorBoundary component

Use the exported `ErrorBoundary` component directly in your own code:

```tsx
import { ErrorBoundary, TLErrorFallbackComponent } from 'tldraw'

const MyFallback: TLErrorFallbackComponent = ({ error }) => (
	<div>Error: {error instanceof Error ? error.message : String(error)}</div>
)

function MyComponent() {
	return (
		<ErrorBoundary fallback={MyFallback} onError={(error) => console.error('Caught:', error)}>
			<RiskyComponent />
		</ErrorBoundary>
	)
}
```

The `fallback` prop accepts a component that receives `{ error: unknown; editor?: Editor }`. The `onError` callback fires when an error is caught, before the fallback renders.

#### API reference

##### Components

| Component            | Props                              | Description                       |
| -------------------- | ---------------------------------- | --------------------------------- |
| `ErrorFallback`      | `{ error, editor? }`               | Application-level error screen    |
| `ShapeErrorFallback` | `{ error }`                        | Per-shape error placeholder       |
| `ErrorBoundary`      | `{ fallback, onError?, children }` | Reusable error boundary component |

##### Types

| Type                            | Description                                          |
| ------------------------------- | ---------------------------------------------------- |
| `TLErrorFallbackComponent`      | `ComponentType<{ error: unknown; editor?: Editor }>` |
| `TLShapeErrorFallbackComponent` | `ComponentType<{ error: any }>`                      |
| `TLErrorBoundaryProps`          | Props for the ErrorBoundary component                |

##### Functions

| Function                     | Description                                          |
| ---------------------------- | ---------------------------------------------------- |
| `getErrorAnnotations(error)` | Retrieve tags and extras attached to an error object |

##### Events

| Event   | Payload              | Description                                |
| ------- | -------------------- | ------------------------------------------ |
| `crash` | `{ error: unknown }` | Fired when the editor enters crashed state |

#### Related examples

- **[Error boundary](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/error-boundary)** — Customize ShapeErrorFallback to display a custom message when shapes throw errors.
- **[Custom error capture](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/custom-error-capture)** — Override ErrorFallback to create a custom error screen with annotations for debugging.

### Events

The editor emits events for user interactions, state changes, and lifecycle moments. You subscribe using `on()` and `off()` methods inherited from EventEmitter. Events range from low-level input (pointer moves, key presses) to high-level changes (shapes created, camera moved). Use them to build analytics, sync external state, or extend editor behavior.

#### Subscribing to events

The `Editor` extends EventEmitter and provides typed event subscriptions:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					editor.on('event', (info) => {
						console.log('Event:', info.type, info.name)
					})
				}}
			/>
		</div>
	)
}
```

Unsubscribe by calling `off()` with the same handler:

```tsx
const handleEvent = (info) => {
	console.log('Event:', info.type)
}

editor.on('event', handleEvent)
editor.off('event', handleEvent)
```

Always unsubscribe when your component unmounts to prevent memory leaks. In React, return a cleanup function from your effect:

```tsx
useEffect(() => {
	const handleChange = (entry) => {
		console.log('Store changed:', entry.changes)
	}

	editor.on('change', handleChange)
	return () => editor.off('change', handleChange)
}, [editor])
```

#### Event categories

##### Input events

The `event` and `before-event` events fire for all user interactions. Each receives a `TLEventInfo` object describing the input.

```tsx
editor.on('event', (info) => {
	if (info.type === 'pointer' && info.name === 'pointer_down') {
		console.log('Clicked at', info.point)
	}
})
```

The `before-event` fires before the event reaches the tool state machine. Use it to inspect or log events before processing. The `event` fires after tool processing completes.

Input events have different types:

| Type       | Names                                                                                     | Description                         |
| ---------- | ----------------------------------------------------------------------------------------- | ----------------------------------- |
| `pointer`  | `pointer_down`, `pointer_move`, `pointer_up`, `right_click`, `middle_click`, `long_press` | Mouse, touch, and pen interactions  |
| `click`    | `double_click`                                                                            | Double-click sequences              |
| `keyboard` | `key_down`, `key_up`, `key_repeat`                                                        | Keyboard input                      |
| `wheel`    | `wheel`                                                                                   | Scroll wheel and trackpad scrolling |
| `pinch`    | `pinch_start`, `pinch`, `pinch_end`                                                       | Two-finger pinch gestures           |

Pointer events include the target—what the pointer is over:

```tsx
editor.on('event', (info) => {
	if (info.type === 'pointer' && info.name === 'pointer_down') {
		switch (info.target) {
			case 'canvas':
				console.log('Clicked empty canvas')
				break
			case 'shape':
				console.log('Clicked shape:', info.shape.id)
				break
			case 'selection':
				console.log('Clicked selection bounds')
				break
			case 'handle':
				console.log('Clicked handle on:', info.shape.id)
				break
		}
	}
})
```

##### Shape events

Shape events fire when shapes change. These are convenience wrappers around store changes—you can also use the `change` event to track all store modifications.

| Event            | Payload       | Description                      |
| ---------------- | ------------- | -------------------------------- |
| `created-shapes` | `TLRecord[]`  | Shapes were added                |
| `edited-shapes`  | `TLRecord[]`  | Shapes were modified             |
| `deleted-shapes` | `TLShapeId[]` | Shapes were removed              |
| `edit`           | None          | Fires alongside any of the above |

```tsx
editor.on('created-shapes', (shapes) => {
	console.log('Created:', shapes.map((s) => s.type).join(', '))
})

editor.on('deleted-shapes', (ids) => {
	console.log('Deleted:', ids.length, 'shapes')
})
```

##### Store changes

The `change` event fires whenever the store updates. It receives a `HistoryEntry` containing the diff and source:

```tsx
editor.on('change', (entry) => {
	const { added, updated, removed } = entry.changes

	for (const record of Object.values(added)) {
		if (record.typeName === 'shape') {
			console.log('Added shape:', record.type)
		}
	}

	for (const [from, to] of Object.values(updated)) {
		if (from.typeName === 'shape') {
			console.log('Updated shape:', from.id)
		}
	}

	for (const record of Object.values(removed)) {
		if (record.typeName === 'shape') {
			console.log('Removed shape:', record.id)
		}
	}
})
```

The `source` property indicates where the change originated:

```tsx
editor.on('change', (entry) => {
	if (entry.source === 'user') {
		// Change from local user interaction
		scheduleAutosave()
	} else if (entry.source === 'remote') {
		// Change from collaboration sync
	}
})
```

##### Frame events

Two events fire on every animation frame:

| Event   | Payload  | Description                   |
| ------- | -------- | ----------------------------- |
| `tick`  | `number` | Milliseconds since last tick  |
| `frame` | `number` | Milliseconds since last frame |

```tsx
editor.on('tick', (elapsed) => {
	// Update animations, physics, etc.
	updateParticleSystem(elapsed)
})
```

These fire frequently (60+ times per second). Keep handlers fast to avoid dropping frames.

##### Lifecycle events

| Event     | Payload              | Description                  |
| --------- | -------------------- | ---------------------------- |
| `mount`   | None                 | Editor finished initializing |
| `dispose` | None                 | Editor is being cleaned up   |
| `crash`   | `{ error: unknown }` | Editor encountered an error  |
| `update`  | None                 | Editor state updated         |

```tsx
editor.on('mount', () => {
	console.log('Editor ready')
})

editor.on('crash', ({ error }) => {
	reportError(error)
})
```

##### UI and camera events

| Event                   | Payload                   | Description                    |
| ----------------------- | ------------------------- | ------------------------------ |
| `resize`                | `BoxModel`                | Viewport dimensions changed    |
| `stop-camera-animation` | None                      | Camera animation interrupted   |
| `stop-following`        | None                      | Stopped following another user |
| `select-all-text`       | `{ shapeId: TLShapeId }`  | Triple-clicked to select text  |
| `place-caret`           | `{ shapeId, point }`      | Text caret positioned          |
| `max-shapes`            | `{ name, pageId, count }` | Page reached shape limit       |

```tsx
editor.on('resize', (bounds) => {
	console.log('Canvas size:', bounds.w, 'x', bounds.h)
})

editor.on('max-shapes', ({ pageId, count }) => {
	showWarning(`Page has reached the ${count} shape limit`)
})
```

#### UI events

The `Tldraw` component's `onUiEvent` prop captures high-level UI interactions separately from canvas events. This includes toolbar selections, menu actions, and keyboard shortcuts.

```tsx
<Tldraw
	onUiEvent={(name, data) => {
		console.log('UI event:', name, data)
	}}
/>
```

UI events track actions like selecting tools, grouping shapes, toggling dark mode, and zooming. They fire regardless of whether the action came from a click or keyboard shortcut. For the full list of events, see `TLUiEventMap`.

#### Listening to store changes directly

For fine-grained control over store subscriptions, use `editor.store.listen()` instead of editor events:

```tsx
const cleanup = editor.store.listen(
	(entry) => {
		// Handle changes
	},
	{ source: 'user', scope: 'all' }
)

// Later, unsubscribe
cleanup()
```

The `listen()` method accepts filter options:

- `source`: `'user'`, `'remote'`, or `'all'`—filter by change origin
- `scope`: `'all'`, `'document'`, `'session'`, or `'presence'`—filter by record scope

See [Side effects](https://tldraw.dev/sdk-features/side-effects) for registering handlers that can intercept and modify changes.

#### Related examples

- **[Canvas events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/canvas-events)** - Log pointer, keyboard, and wheel events as you interact with the canvas.
- **[Store events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/store-events)** - Track shape creation, updates, and deletion through store change events.
- **[UI events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/ui-events)** - Capture high-level UI interactions like tool selection and menu actions.

### External content handling

The external content system handles content from outside the editor: pasted text, dropped files, embedded URLs, and more. You register handlers for specific content types, and the editor routes incoming content to the appropriate handler.

```tsx
import { Tldraw, Editor, defaultHandleExternalTextContent } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	function handleMount(editor: Editor) {
		editor.registerExternalContentHandler('text', async (content) => {
			// Check if this is HTML content
			const htmlSource = content.sources?.find((s) => s.type === 'text' && s.subtype === 'html')
			if (htmlSource) {
				// Handle HTML specially
				const center = content.point ?? editor.getViewportPageBounds().center
				editor.createShape({
					type: 'text',
					x: center.x,
					y: center.y,
					props: { text: 'Custom HTML handling!' },
				})
			} else {
				// Fall back to default behavior
				await defaultHandleExternalTextContent(editor, content)
			}
		})
	}

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw onMount={handleMount} />
		</div>
	)
}
```

#### How it works

Two systems handle external content: content handlers and asset handlers.

**Content handlers** transform external content into shapes. When a user pastes text, drops an image, or embeds a URL, the content handler for that type processes the data and creates shapes on the canvas.

**Asset handlers** process external assets into asset records. When an image file arrives, the asset handler extracts dimensions, uploads the file, and returns an asset record with the uploaded URL. The content handler then creates a shape referencing that asset.

The flow works like this:

1. Content arrives (paste, drop, or API call)
2. The editor calls `putExternalContent` with the content object
3. The registered handler for that content type processes it
4. The handler creates shapes, assets, or both

#### Content types

The system supports these content types:

##### Text

Text content comes from clipboard paste operations. The handler receives `text` (plain text), optional `html` (HTML markup), and `point` (where to place the content). The default handler creates text shapes, detecting right-to-left languages and handling multi-line text.

```typescript
interface TLTextExternalContent {
	type: 'text'
	text: string
	html?: string
	point?: VecLike
	sources?: TLExternalContentSource[]
}
```

##### Files

File content represents one or more files dropped onto the canvas. The handler receives an array of `File` objects and validates file types and sizes before creating shapes.

```typescript
interface TLFilesExternalContent {
	type: 'files'
	files: File[]
	point?: VecLike
	ignoreParent?: boolean
}
```

The default handler creates temporary previews for images, uploads the files, and creates image or video shapes arranged horizontally from the drop point.

##### File replace

File replace content handles replacing an existing image or video shape's asset. This is used when a user drags a new file onto an existing image shape.

```typescript
interface TLFileReplaceExternalContent {
	type: 'file-replace'
	file: File
	shapeId: TLShapeId
	isImage: boolean // Deprecated: no longer used by the default handler
	point?: VecLike
}
```

The default handler validates the file, creates a new asset, and updates the target shape to reference the new asset while preserving any existing crop settings.

##### URLs

URL content represents a URL to insert. The default handler checks if the URL matches a known embed pattern (YouTube, Figma, etc.) and creates an embed shape. Otherwise, it fetches Open Graph metadata and creates a bookmark shape.

```typescript
interface TLUrlExternalContent {
	type: 'url'
	url: string
	point?: VecLike
}
```

##### SVG text

SVG text content handles raw SVG markup. The handler parses the SVG, extracts dimensions, creates an image asset, and inserts an image shape.

```typescript
interface TLSvgTextExternalContent {
	type: 'svg-text'
	text: string
	point?: VecLike
}
```

##### Embeds

Embed content creates embed shapes for embeddable URLs like YouTube videos. This content type is usually invoked by the URL handler when it detects an embeddable URL.

```typescript
interface TLEmbedExternalContent<EmbedDefinition> {
	type: 'embed'
	url: string
	embed: EmbedDefinition
	point?: VecLike
}
```

##### tldraw and excalidraw content

These handlers process serialized content from other tldraw editors or Excalidraw. The `tldraw` handler calls `putContentOntoCurrentPage` to insert shapes. The `excalidraw` handler converts Excalidraw shapes to tldraw equivalents.

```typescript
interface TLTldrawExternalContent {
	type: 'tldraw'
	content: TLContent
	point?: VecLike
}
```

#### Asset handling

Asset handlers turn external files and URLs into asset records. There are two asset handler types:

| Type   | Input         | Output                                  |
| ------ | ------------- | --------------------------------------- |
| `file` | `File` object | Image or video asset record             |
| `url`  | URL string    | Bookmark asset with Open Graph metadata |

The `file` handler extracts dimensions and file size, uploads the file via `editor.uploadAsset`, and returns an asset record. The `url` handler fetches the page's Open Graph metadata (title, description, image) and creates a bookmark asset.

```typescript
editor.registerExternalAssetHandler('file', async ({ file, assetId }) => {
	const size = await MediaHelpers.getImageSize(file)

	const asset = {
		id: assetId ?? AssetRecordType.createId(),
		type: 'image' as const,
		typeName: 'asset' as const,
		props: {
			name: file.name,
			src: '',
			w: size.w,
			h: size.h,
			mimeType: file.type,
			isAnimated: false,
			fileSize: file.size,
		},
		meta: {},
	}

	const result = await editor.uploadAsset(asset, file)
	asset.props.src = result.src

	return AssetRecordType.create(asset)
})
```

#### API methods

| Method                           | Purpose                                                 |
| -------------------------------- | ------------------------------------------------------- |
| `registerExternalContentHandler` | Register a handler for a content type                   |
| `registerExternalAssetHandler`   | Register a handler for an asset type                    |
| `putExternalContent`             | Process external content through the registered handler |
| `getAssetForExternalContent`     | Create an asset from external content                   |

Use `putExternalContent` to programmatically insert content:

```typescript
// Insert text at a specific point
editor.putExternalContent({
	type: 'text',
	text: 'Hello, world!',
	point: { x: 100, y: 100 },
})

// Insert a URL (creates embed or bookmark)
editor.putExternalContent({
	type: 'url',
	url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
	point: { x: 200, y: 200 },
})
```

Use `getAssetForExternalContent` when you need an asset without creating a shape:

```typescript
const asset = await editor.getAssetForExternalContent({
	type: 'file',
	file: myFile,
})
```

Remove a handler by passing `null`:

```typescript
editor.registerExternalContentHandler('text', null)
```

#### Customizing handlers

Register a new handler to replace the default behavior for any content type. Your handler receives the content object and can create shapes, insert assets, or do anything else.

To extend rather than replace, call the default handler from your custom handler:

```typescript
import { defaultHandleExternalTextContent } from 'tldraw'

editor.registerExternalContentHandler('text', async (content) => {
	// Custom handling for HTML
	const htmlSource = content.sources?.find((s) => s.type === 'text' && s.subtype === 'html')
	if (htmlSource) {
		const center = content.point ?? editor.getViewportPageBounds().center
		editor.createShape({
			type: 'my-html-shape',
			x: center.x,
			y: center.y,
			props: { html: htmlSource.data },
		})
		return
	}

	// Fall back to default for plain text
	await defaultHandleExternalTextContent(editor, content)
})
```

The default handlers are exported from `tldraw`:

- `defaultHandleExternalTextContent`
- `defaultHandleExternalFileContent`
- `defaultHandleExternalUrlContent`
- `defaultHandleExternalSvgTextContent`
- `defaultHandleExternalEmbedContent`
- `defaultHandleExternalTldrawContent`
- `defaultHandleExternalExcalidrawContent`
- `defaultHandleExternalFileAsset`
- `defaultHandleExternalUrlAsset`
- `defaultHandleExternalFileReplaceContent`

#### Related examples

- [External content sources](https://tldraw.dev/examples/data/assets/external-content-sources) - Handle pasted HTML by creating custom shapes.
- [Hosted images](https://tldraw.dev/examples/data/assets/hosted-images) - Upload images to your own server using a custom asset store.

### Focus

Focus determines whether the editor receives keyboard shortcuts, scroll wheel gestures, and pointer move events. When focused, these inputs go to the editor. When unfocused, they pass through to the rest of your page.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw autoFocus={true} />
		</div>
	)
}
```

#### Controlling focus

Use `Editor#focus` and `Editor#blur` to programmatically control focus:

```typescript
editor.focus()
editor.blur()
editor.getIsFocused() // true or false
```

Both methods accept options to control whether the container element should also receive or lose DOM focus:

```typescript
editor.focus({ focusContainer: false })
editor.blur({ blurContainer: false })
```

##### Focus/blur options

| Method  | Option           | Default | Description                                             |
| ------- | ---------------- | ------- | ------------------------------------------------------- |
| `focus` | `focusContainer` | `true`  | Whether to also dispatch a DOM focus event to container |
| `blur`  | `blurContainer`  | `true`  | Whether to also dispatch a DOM blur event to container  |

#### Why focus is separate from DOM focus

The editor tracks focus separately from the browser's DOM focus. The browser's focus model isn't reliable enough for an editor like tldraw. Iframes aren't considered descendants of their parent elements, many menus are portalled into different parts of the document tree, and the document's active element can be unpredictable.

The editor maintains its own `isFocused` state in the instance record. This lets you distinguish between "editor focus" (whether the editor responds to keyboard shortcuts) and "element focus" (which HTML element is active in the DOM).

When `isFocused` changes, the editor adds or removes the `tl-container__focused` CSS class on the container. Use this class for styling instead of `:focus` or `:focus-within` pseudo-selectors, which can't reliably detect editor focus.

#### Auto-focus

The `autoFocus` prop controls whether the editor focuses when it mounts. It defaults to `true`. Set it to `false` when embedding the editor in a page where you don't want it to capture keyboard input immediately.

```tsx
<Tldraw autoFocus={false} />
```

#### Focus ring visibility

The editor manages focus ring visibility for accessibility. Focus rings appear around focused elements during keyboard navigation but are hidden during mouse interactions.

When you press Tab, ArrowUp, or ArrowDown, the editor removes the `tl-container__no-focus-ring` class to show focus rings. Mouse clicks add the class back to hide them. Focus rings also stay hidden during shape editing.

#### Completing interactions on blur

When you call `editor.blur()`, it calls `editor.complete()` to finish any ongoing interaction like a drag or draw operation. This prevents the editor from being left in an incomplete state when focus is lost.

#### Multiple editors

When you have multiple editors on the same page, you'll need to manage focus yourself. The browser's DOM focus alone isn't enough to reliably switch which editor receives keyboard input.

```tsx
function MultipleEditors() {
	const [editors, setEditors] = useState<Editor[]>([])

	function handleEditorFocus(focusedEditor: Editor) {
		for (const editor of editors) {
			if (editor === focusedEditor) {
				editor.focus()
			} else {
				editor.blur()
			}
		}
	}

	return (
		<>
			<div onFocus={() => editors[0] && handleEditorFocus(editors[0])}>
				<Tldraw autoFocus={false} onMount={(e) => setEditors((prev) => [...prev, e])} />
			</div>
			<div onFocus={() => editors[1] && handleEditorFocus(editors[1])}>
				<Tldraw autoFocus={false} onMount={(e) => setEditors((prev) => [...prev, e])} />
			</div>
		</>
	)
}
```

#### Related examples

- [Editor focus](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/editor-focus) - Control editor focus with focus and blur methods.
- [Multiple editors](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/layout/multiple) - Manage focus between multiple tldraw instances.

### Frame shape

The frame shape is a container with a labeled header that holds other shapes. Children of a frame are clipped to its bounds, follow the frame when it moves, and export together as a single image. Frames are useful for laying out artboards, screen mockups, slides, and any visual region that should travel as one unit.

```tsx
editor.createShape({
	type: 'frame',
	x: 100,
	y: 100,
	props: {
		w: 800,
		h: 600,
		name: 'Login screen',
		color: 'black',
	},
})
```

#### Creating frames

You can create a frame programmatically with `editor.createShape`, or interactively with the frame tool. The frame tool ships in the default toolbar; users can also press `F` to switch to it.

When the user drags a frame around existing shapes, the frame tool reparents any sibling shapes that fall fully inside the frame's bounds. Locked shapes are skipped. This is what makes "draw a frame around the things I want to group" work as a single gesture.

Children inside a frame use coordinates relative to the frame's origin. Moving the frame moves every descendant; rotating the frame rotates them too.

#### Frame header

Every frame renders a heading above its top edge that displays the frame's `name` property. Double-click the heading to start editing the name. The heading rotates with the frame to stay above whichever edge is currently "up", so it remains readable.

Empty names render as `Frame` in exports so unnamed frames still get a label. The shape's `name` is also exposed via `ShapeUtil#getAriaDescriptor` so screen readers announce it during keyboard navigation.

#### Child clipping

Frames clip their children to the frame's rectangle during rendering. A shape that extends past the frame edge is rendered up to the boundary and then cut off. Clipping is purely visual — the shape's geometry, hit testing, and bounds are unchanged.

The `BaseFrameLikeShapeUtil` base class implements clipping via `getClipPath`. If you need clipping for a custom container shape, see the [Frames](https://tldraw.dev/sdk-features/shapes#frames) section in the Shapes guide.

#### Shape properties

| Property | Type                  | Description                                          |
| -------- | --------------------- | ---------------------------------------------------- |
| `w`      | `number`              | Frame width in pixels (default: `320`)               |
| `h`      | `number`              | Frame height in pixels (default: `180`)              |
| `name`   | `string`              | Label displayed in the frame header                  |
| `color`  | `TLDefaultColorStyle` | Color for the border and heading (when `showColors`) |

#### Configuration options

`FrameShapeUtil` exposes two configuration options:

| Option           | Type      | Default | Description                                                                                             |
| ---------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `showColors`     | `boolean` | `false` | When true, frames display a colored border and header background based on the frame's `color` property. |
| `resizeChildren` | `boolean` | `false` | When true, resizing a frame scales its children proportionally instead of leaving them in place.        |

```tsx
import { FrameShapeUtil, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

const ConfiguredFrameUtil = FrameShapeUtil.configure({
	showColors: true,
	resizeChildren: true,
})

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw shapeUtils={[ConfiguredFrameUtil]} />
		</div>
	)
}
```

When `showColors` is on, `color` becomes a real style property — users can change it from the style panel and it syncs across selections like other styles. With `showColors` off (the default), `color` still validates and persists, but the frame renders with a neutral palette so it doesn't compete visually with the shapes inside it.

#### Frame helpers

The editor exposes helpers that work with frames and any other shape that opts into frame-like behavior.

##### Detecting frames

```tsx
// Check whether a shape is a frame (or a custom frame-like shape)
const shape = editor.getShape(shapeId)
if (shape && editor.isShapeFrameLike(shape)) {
	// ...
}
```

##### Fitting a frame to its content

`fitFrameToContent` resizes a frame so it tightly wraps its children, with a configurable padding. Use it after the user has finished arranging shapes inside an artboard:

```tsx
import { fitFrameToContent } from 'tldraw'

fitFrameToContent(editor, frameId)

// Or with a custom padding
fitFrameToContent(editor, frameId, { padding: 24 })
```

##### Removing a frame

`removeFrame` deletes a frame but preserves its children — they're moved out of the frame and reselected so they survive:

```tsx
import { removeFrame } from 'tldraw'

removeFrame(editor, [frameId])
```

This is also wired up to the **Remove frame** action (`Cmd/Ctrl+Shift+F`) and the **Fit frame to content** action in the actions menu and context menu when a frame is selected.

#### Exporting frames

Frames are export bounds containers — `FrameShapeUtil.isExportBoundsContainer()` returns `true`. When a frame contains all the other shapes in an export, the export pipeline skips the usual padding around the result and uses the frame's bounds exactly. The export still includes every descendant of the frame.

This makes frames ideal for mockup-style workflows where you need pixel-perfect output at a specific aspect ratio — design the frame, drop content inside, export.

#### Frames vs. groups

Frames and groups both contain other shapes, but they solve different problems:

|                              | Frame                                   | Group                            |
| ---------------------------- | --------------------------------------- | -------------------------------- |
| Visible on the canvas        | Yes — bordered rectangle with a heading | No — no visual representation    |
| Clips children to its bounds | Yes                                     | No                               |
| Has a fixed size and shape   | Yes (`w`, `h`, position)                | No (geometry follows children)   |
| Used for export bounds       | Yes                                     | No                               |
| Auto-deletes when empty      | No                                      | Yes (when last child is removed) |
| Created by                   | Frame tool or `editor.createShape`      | `editor.groupShapes(...)`        |

Reach for a frame when you want a labeled, bounded region — a slide, a screen, an artboard. Reach for a group when you just need to move several shapes together without changing their visual layout.

#### Related articles

- [Shapes](https://tldraw.dev/sdk-features/shapes#frames) — How to build your own container shape with `BaseFrameLikeShapeUtil`
- [Default shapes](https://tldraw.dev/sdk-features/default-shapes#frame) — Overview of all built-in shapes
- [Shape clipping](https://tldraw.dev/sdk-features/shape-clipping) — How custom shapes can clip their children
- [Image export](https://tldraw.dev/sdk-features/image-export) — Exporting frames and selections as images
- [Parenting](https://tldraw.dev/sdk-features/parenting) — How shapes are nested inside containers

#### Related examples

- [Portal shapes](https://tldraw.dev/examples/shapes/tools/portal-shapes) — A custom frame-like shape that teleports children between instances

### Geo shape

The geo shape is one of the default shapes in tldraw. It renders geometric primitives—rectangles, ellipses, stars, clouds, and 16 other forms—with optional rich text labels. Geo shapes are the usual building blocks for flowcharts and diagrams.

#### Geometric forms

The geo shape supports a variety of built-in forms, grouped by type:

##### Basic shapes

| Form        | Description                                  |
| ----------- | -------------------------------------------- |
| `rectangle` | Four-sided shape with right angles (default) |
| `ellipse`   | Oval or circular shape                       |
| `triangle`  | Three-sided shape pointing upward            |
| `diamond`   | Square rotated 45 degrees                    |
| `oval`      | Stadium shape (rectangle with rounded ends)  |

##### Polygons

| Form       | Description                 |
| ---------- | --------------------------- |
| `pentagon` | Five-sided regular polygon  |
| `hexagon`  | Six-sided regular polygon   |
| `octagon`  | Eight-sided regular polygon |
| `star`     | Five-pointed star           |

##### Parallelograms

| Form        | Description                                   |
| ----------- | --------------------------------------------- |
| `rhombus`   | Parallelogram slanted to the right            |
| `rhombus-2` | Parallelogram slanted to the left             |
| `trapezoid` | Four-sided shape with parallel top and bottom |

##### Directional arrows

| Form          | Description                   |
| ------------- | ----------------------------- |
| `arrow-up`    | Block arrow pointing upward   |
| `arrow-down`  | Block arrow pointing downward |
| `arrow-left`  | Block arrow pointing left     |
| `arrow-right` | Block arrow pointing right    |

##### Special shapes

| Form        | Description                                    |
| ----------- | ---------------------------------------------- |
| `cloud`     | Organic cloud shape with randomly varied bumps |
| `heart`     | Heart shape                                    |
| `x-box`     | Rectangle with an X through it                 |
| `check-box` | Rectangle with a checkmark inside              |

#### Creating geo shapes

Create a geo shape using `Editor#createShape`:

```tsx
import { toRichText } from 'tldraw'

editor.createShape({
	type: 'geo',
	x: 100,
	y: 100,
	props: {
		geo: 'rectangle',
		w: 200,
		h: 150,
		color: 'blue',
		fill: 'solid',
		dash: 'draw',
		size: 'm',
	},
})
```

##### Adding text labels

Geo shapes support rich text labels positioned inside the shape:

```tsx
editor.createShape({
	type: 'geo',
	x: 100,
	y: 100,
	props: {
		geo: 'ellipse',
		w: 200,
		h: 150,
		richText: toRichText('Process step'),
		labelColor: 'black',
		align: 'middle',
		verticalAlign: 'middle',
		font: 'draw',
	},
})
```

The label text automatically wraps within the shape bounds. When text overflows the shape height, the shape grows vertically to accommodate it. The `growY` property tracks this additional height.

##### Changing the geometric form

Switch between forms by updating the `geo` property:

```tsx
// Change a rectangle to an ellipse
editor.updateShape({
	id: shapeId,
	type: 'geo',
	props: {
		geo: 'ellipse',
	},
})
```

You can also set the default geometric form for the geo tool by updating the style for the next shape:

```tsx
import { GeoShapeGeoStyle } from 'tldraw'

// Set the default geo style to star
editor.setStyleForNextShapes(GeoShapeGeoStyle, 'star')
```

#### Using the geo tool

The geo tool creates shapes through click or click-and-drag interactions.

##### Click to create

Click anywhere on the canvas to create a shape at the default size. The shape centers on your click position. Different geometric forms have different default sizes:

| Form    | Default size |
| ------- | ------------ |
| `star`  | 200 × 190    |
| `cloud` | 300 × 180    |
| Other   | 200 × 200    |

##### Click and drag to create

Click and drag to create a shape at a custom size. The shape's corner follows your pointer as you drag. Release to complete the shape.

##### Editing labels

Press Enter while a geo shape is selected to edit its label. The shape enters edit mode, where you can type or modify the rich text content. Press Escape to exit edit mode.

##### Tool lock

When tool lock is enabled (via the toolbar or `editor.updateInstanceState({ isToolLocked: true })`), you can create multiple shapes without returning to the select tool after each one.

#### Shape properties

| Property        | Type                            | Description                                                 |
| --------------- | ------------------------------- | ----------------------------------------------------------- |
| `geo`           | `TLGeoShapeGeoStyle`            | The geometric form                                          |
| `w`             | `number`                        | Width in pixels                                             |
| `h`             | `number`                        | Height in pixels                                            |
| `richText`      | `TLRichText`                    | Text label displayed inside the shape                       |
| `color`         | `TLDefaultColorStyle`           | Stroke/outline color                                        |
| `labelColor`    | `TLDefaultColorStyle`           | Text label color (separate from stroke)                     |
| `fill`          | `TLDefaultFillStyle`            | Fill style, e.g. `none`, `semi`, `solid`, `pattern`         |
| `dash`          | `TLDefaultDashStyle`            | Stroke pattern: `draw`, `solid`, `dashed`, `dotted`, `none` |
| `size`          | `TLDefaultSizeStyle`            | Size preset affecting stroke width                          |
| `font`          | `TLDefaultFontStyle`            | Font family for the label                                   |
| `align`         | `TLDefaultHorizontalAlignStyle` | Horizontal text alignment                                   |
| `verticalAlign` | `TLDefaultVerticalAlignStyle`   | Vertical text alignment                                     |
| `growY`         | `number`                        | Additional vertical space for text overflow                 |
| `url`           | `string`                        | Optional hyperlink URL                                      |
| `scale`         | `number`                        | Scale factor applied to the shape                           |

#### Configuration options

Configure the geo shape utility to adjust rendering behavior:

| Option            | Type      | Default | Description                                                                                      |
| ----------------- | --------- | ------- | ------------------------------------------------------------------------------------------------ |
| `showTextOutline` | `boolean` | `true`  | Whether to show a text outline (using the canvas background color) to improve label readability. |

```tsx
import { GeoShapeUtil } from 'tldraw'

const ConfiguredGeoUtil = GeoShapeUtil.configure({
	showTextOutline: false,
})
```

Pass the configured utility to the `shapeUtils` prop:

```tsx
<Tldraw shapeUtils={[ConfiguredGeoUtil]} />
```

#### Custom geo types

Register custom geo types via `customGeoTypes` to add new shapes that inherit all standard geo behavior — labels, fill/dash/color styling, resizing, SVG export, and hyperlinks — while providing their own path geometry, snap behavior, creation size, and style panel icon. This lets you extend the built-in geo enum without forking `GeoShapeUtil`.

```tsx
import { GeoShapeUtil, PathBuilder } from 'tldraw'

const MyGeoShapeUtil = GeoShapeUtil.configure({
	customGeoTypes: {
		'rounded-rect': {
			getPath: (w, h, shape, strokeWidth) => {
				const r = Math.min(w, h) * 0.2
				return new PathBuilder()
					.moveTo(r, 0, { geometry: { isFilled: shape.props.fill !== 'none' } })
					.lineTo(w - r, 0)
					.circularArcTo(r, false, true, w, r)
					.lineTo(w, h - r)
					.circularArcTo(r, false, true, w - r, h)
					.lineTo(r, h)
					.circularArcTo(r, false, true, 0, h - r)
					.lineTo(0, r)
					.circularArcTo(r, false, true, r, 0)
					.close()
			},
			snapType: 'polygon',
			icon: 'geo-rectangle',
			defaultSize: { w: 200, h: 150 },
		},
	},
})
```

Each entry in `customGeoTypes` is a `GeoTypeDefinition`:

| Field           | Type                                        | Description                                                                                          |
| --------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `getPath`       | `(w, h, shape, strokeWidth) => PathBuilder` | Returns the path geometry for this type at the given dimensions.                                     |
| `snapType`      | `'polygon' \| 'blobby'`                     | `'polygon'` snaps to vertices and center; `'blobby'` snaps to center only.                           |
| `icon`          | `string`                                    | Icon name used in the style panel's geo picker.                                                      |
| `defaultSize`   | `{ w: number; h: number }` (optional)       | Size used when the shape is created via click rather than drag. Defaults to 200 × 200.               |
| `onDoubleClick` | `(shape) => { props } \| void` (optional)   | Custom double-click handler. Return a partial props update to mutate the shape, or nothing to no-op. |

Custom types appear in the style panel's geo picker alongside the built-in shapes. See the [custom geo types example](https://tldraw.dev/examples/shapes/tools/custom-geo-types) for a full implementation.

#### Label positioning

The `align` and `verticalAlign` properties control where labels appear within the shape:

##### Horizontal alignment

| Value    | Position              |
| -------- | --------------------- |
| `start`  | Left edge of shape    |
| `middle` | Horizontally centered |
| `end`    | Right edge of shape   |

##### Vertical alignment

| Value    | Position            |
| -------- | ------------------- |
| `start`  | Top of shape        |
| `middle` | Vertically centered |
| `end`    | Bottom of shape     |

When the label text exceeds the shape's height, the shape automatically grows by adding to `growY`. The shape never shrinks below its original `h` value to accommodate shorter text—instead, `growY` returns to 0.

#### Resizing behavior

Geo shapes resize from any corner or edge handle. When resizing a shape with a label:

- The shape respects a minimum unscaled size of 51 × 51 pixels when it contains text
- The shape won't shrink smaller than the label's required dimensions
- `growY` resets to 0 when you resize, letting the shape recalculate the needed height

#### Special interactions

##### Rectangle/checkbox toggle

Double-click a rectangle while holding Alt to convert it to a checkbox. Double-click the checkbox with Alt held to convert it back to a rectangle. This lets you quickly add checkmarks to items.

##### Cloud shape variation

The cloud shape generates its bumps procedurally based on the shape's ID. Each cloud has unique bump positions, giving visual variety while maintaining a consistent style. Larger clouds have more bumps; smaller clouds have fewer but at least six.

##### Handle snap geometry

Arrows snap to geo shapes at meaningful positions:

- **Polygon-based shapes** (rectangle, triangle, pentagon, etc.): Arrows snap to each vertex and the center
- **Curved shapes** (ellipse, oval, cloud, heart): Arrows snap only to the center point

#### Dynamic resize mode

When dynamic resize mode is enabled in user preferences, new geo shapes scale inversely with zoom level. Drawing while zoomed out creates shapes that appear the same size on screen as they would at 100% zoom.

```tsx
// Enable dynamic resize mode
editor.user.updateUserPreferences({ isDynamicSizeMode: true })
```

#### Hyperlinks

Geo shapes can link to URLs. When a shape has a URL, a link button appears on the shape:

```tsx
editor.createShape({
	type: 'geo',
	x: 100,
	y: 100,
	props: {
		geo: 'rectangle',
		w: 200,
		h: 100,
		url: 'https://tldraw.dev',
	},
})
```

Click the link button to open the URL in a new tab.

#### Path rendering

Geo shapes use a path-based rendering system. Each geometric form—built-in or custom—has a `GeoTypeDefinition` whose `getPath` method generates the outline used for both fills and strokes. The same path drives solid, semi-transparent, and pattern fills as well as solid, dashed, and dotted stroke styles. Path calculations are cached per shape.

When the `dash` property is set to `'draw'`, the shape renders with organic, hand-drawn strokes. The randomness is seeded by the shape's ID, so each shape's hand-drawn look stays stable across renders.

#### Geometry

The shape's geometry returns a `Group2d` containing:

1. The outline path geometry (polygon or curve depending on the form)
2. A label rectangle for hit testing text interactions

This compound geometry enables accurate point-in-shape detection for both the shape outline and its text label.

#### Related articles

- [Default shapes](https://tldraw.dev/sdk-features/default-shapes) — Overview of all built-in shapes
- [Rich text](https://tldraw.dev/sdk-features/rich-text) — Working with formatted text content
- [Styles](https://tldraw.dev/sdk-features/styles) — Working with shape styles like color and fill
- [Tools](https://tldraw.dev/sdk-features/tools) — How tools handle user input

### Geometry

Geometry in tldraw is a mathematical description of a shape's form. Each shape has a `Geometry2d` that defines its outline, bounds, and spatial properties. When you click to select a shape, the geometry determines whether the click hit. When you brush a selection box, the geometry calculates intersections. When you snap an arrow to a shape's edge, the geometry provides the nearest point. For the broader shape system, see [Shapes](https://tldraw.dev/docs/shapes).

#### How geometry works

Each `ShapeUtil` implements a `ShapeUtil#getGeometry` method that returns a `Geometry2d` instance. The editor calls this method to calculate bounds, test hits, find intersections, and measure distances.

```typescript
class MyShapeUtil extends ShapeUtil<MyShape> {
	getGeometry(shape: MyShape): Geometry2d {
		return new Rectangle2d({
			width: shape.props.w,
			height: shape.props.h,
			isFilled: true,
		})
	}
}
```

The `isFilled` property controls hit testing behavior. A filled geometry registers hits inside its area. An unfilled geometry only responds to hits on its outline—useful for shapes like frames where you want to click through the middle.

Geometry also powers [snapping](https://tldraw.dev/sdk-features/snapping). When you drag shapes, the snapping system uses geometry to find edges, centers, and corners to align to. Custom shapes can provide additional snap points by implementing `ShapeUtil#getBoundsSnapGeometry`.

#### Geometry primitives

The SDK includes geometry classes for common shapes.

##### Rectangle2d

Axis-aligned rectangles. The most common geometry for box-shaped elements.

```typescript
new Rectangle2d({
	width: 200,
	height: 100,
	isFilled: true,
})
```

You can offset the rectangle from the origin:

```typescript
new Rectangle2d({
	x: 10,
	y: 10,
	width: 200,
	height: 100,
	isFilled: true,
})
```

##### Ellipse2d

Circles and ellipses.

```typescript
new Ellipse2d({
	width: 100,
	height: 100, // circle
	isFilled: true,
})

new Ellipse2d({
	width: 200,
	height: 100, // ellipse
	isFilled: true,
})
```

##### Circle2d

A specialized circle geometry that stores radius directly. The `x` and `y` parameters offset the circle's bounding box, not its center.

```typescript
new Circle2d({
	radius: 50,
	isFilled: true,
})

// Offset from origin
new Circle2d({
	x: 10,
	y: 10,
	radius: 50,
	isFilled: true,
})
```

##### Polygon2d

Arbitrary closed polygons defined by vertices.

```typescript
new Polygon2d({
	points: [new Vec(0, 50), new Vec(100, 0), new Vec(100, 100), new Vec(0, 100)],
	isFilled: true,
})
```

Polygon2d requires at least three points and automatically closes the path.

##### Polyline2d

Open paths defined by vertices. Use this for lines that don't form closed shapes. Requires at least two points.

```typescript
new Polyline2d({
	points: [new Vec(0, 0), new Vec(50, 100), new Vec(100, 0)],
})
```

Polylines are never filled since they don't enclose an area. Polygon2d extends Polyline2d but sets `isClosed` to true.

##### Edge2d

A single line segment between two points.

```typescript
new Edge2d({
	start: new Vec(0, 0),
	end: new Vec(100, 100),
})
```

Arrows use Edge2d for straight arrow bodies.

##### Arc2d

A circular arc defined by center, start, end, and arc flags. All parameters are required.

```typescript
new Arc2d({
	center: new Vec(50, 50),
	start: new Vec(0, 50),
	end: new Vec(100, 50),
	sweepFlag: 1,
	largeArcFlag: 0,
})
```

The `sweepFlag` and `largeArcFlag` follow SVG arc conventions: `sweepFlag` controls clockwise vs counterclockwise direction, and `largeArcFlag` chooses between the two possible arcs. Arrows use Arc2d for curved arrow bodies.

##### Stadium2d

A pill or capsule shape (rectangle with semicircular ends). The shorter dimension determines the radius of the rounded ends.

```typescript
new Stadium2d({
	width: 200,
	height: 50,
	isFilled: true,
})
```

##### CubicBezier2d

A single cubic bezier curve segment.

```typescript
new CubicBezier2d({
	start: new Vec(0, 0),
	cp1: new Vec(30, 100),
	cp2: new Vec(70, 100),
	end: new Vec(100, 0),
})
```

##### CubicSpline2d

A smooth curve through multiple points, automatically generating smooth cubic bezier segments between them.

```typescript
new CubicSpline2d({
	points: [new Vec(0, 0), new Vec(50, 100), new Vec(100, 50), new Vec(150, 100)],
})
```

##### Point2d

A single point. The constructor requires both `point` and `margin` parameters.

```typescript
new Point2d({
	point: new Vec(50, 50),
	margin: 10,
})
```

The `margin` option doesn't expand the hit area: hit-test margins are supplied by the editor at query time, like every other geometry.

##### Group2d

Combines multiple geometries into a single composite geometry. The children don't need to be the same type.

```typescript
new Group2d({
	children: [
		new Rectangle2d({ width: 100, height: 80, isFilled: true }),
		new Circle2d({ x: 50, y: -20, radius: 20, isFilled: true }),
	],
})
```

Group2d is essential for shapes with multiple parts. The geo shape uses it to combine its outline with its label bounds. The arrow shape uses it to combine the arrow body with its label.

#### Geometry operations

All Geometry2d classes provide methods for spatial queries.

##### Bounds and center

Get the axis-aligned bounding box:

```typescript
const geometry = editor.getShapeGeometry(shape)
const bounds = geometry.bounds // Box { x, y, w, h, ... }
const center = geometry.center // Vec at center of bounds
```

##### Vertices

Get the points that define the geometry's outline:

```typescript
const vertices = geometry.vertices // Vec[]
```

For curves, this returns a discretized approximation with enough points to represent the curve accurately.

##### Hit testing

Test if a point hits the geometry:

```typescript
geometry.hitTestPoint(point, margin, hitInside)
```

The `margin` expands the hit area. The `hitInside` parameter controls whether points inside unfilled shapes count as hits.

Test if a line segment intersects the geometry:

```typescript
geometry.hitTestLineSegment(A, B, distance)
```

##### Distance and intersection

Find the nearest point on the geometry to a given point:

```typescript
const nearest = geometry.nearestPoint(point)
```

Get the distance from a point to the geometry:

```typescript
const distance = geometry.distanceToPoint(point)
```

Negative distances mean the point is inside a filled geometry.

Get intersection points with a line segment:

```typescript
const intersections = geometry.intersectLineSegment(A, B)
```

##### Length, area, and interpolation

Get the perimeter length and area:

```typescript
const length = geometry.length // perimeter length
const area = geometry.area // enclosed area (0 for open paths)
```

Find a point at a fraction along the edge:

```typescript
const point = geometry.interpolateAlongEdge(0.5) // midpoint
```

Convert a point back to a fraction:

```typescript
const t = geometry.uninterpolateAlongEdge(point)
```

Generate an SVG path:

```typescript
const pathData = geometry.toSimpleSvgPath() // "M0,0 L100,0 L100,100 L0,100 Z"
```

#### Implementing getGeometry

The `getGeometry` method receives the shape and returns geometry in shape-local coordinates (origin at top-left of shape).

##### Simple shapes

For shapes with a single outline:

```typescript
getGeometry(shape: MyShape) {
	return new Rectangle2d({
		width: shape.props.w,
		height: shape.props.h,
		isFilled: shape.props.fill !== 'none',
	})
}
```

##### Shapes with labels

Shapes that have text labels typically return a Group2d with the main geometry and a label rectangle:

```typescript
getGeometry(shape: MyShape) {
	const outline = new Rectangle2d({
		width: shape.props.w,
		height: shape.props.h,
		isFilled: shape.props.fill !== 'none',
	})

	const label = new Rectangle2d({
		x: labelX,
		y: labelY,
		width: labelWidth,
		height: labelHeight,
		isFilled: true,
		isLabel: true,
	})

	return new Group2d({
		children: [outline, label],
	})
}
```

The `isLabel` property marks geometry that represents text labels. This affects filtering in some operations.

##### Custom polygons

For non-rectangular shapes, calculate vertices and use Polygon2d:

```typescript
getGeometry(shape: HouseShape) {
	const { w, h } = shape.props
	const roofPeak = h * 0.3

	return new Polygon2d({
		points: [
			new Vec(0, roofPeak),
			new Vec(w / 2, 0),
			new Vec(w, roofPeak),
			new Vec(w, h),
			new Vec(0, h),
		],
		isFilled: true,
	})
}
```

##### Composite shapes

For shapes with multiple distinct parts:

```typescript
getGeometry(shape: HouseShape) {
	const house = new Polygon2d({
		points: getHouseVertices(shape),
		isFilled: true,
	})

	const door = new Rectangle2d({
		x: shape.props.w / 2 - 15,
		y: shape.props.h - 40,
		width: 30,
		height: 40,
		isFilled: true,
	})

	return new Group2d({
		children: [house, door],
	})
}
```

#### Geometry caching

The editor caches geometry computations. Without caching, dragging a selection box over hundreds of shapes would recompute each shape's geometry on every frame.

Access cached geometry through the editor:

```typescript
const geometry = editor.getShapeGeometry(shape)
const pageBounds = editor.getShapePageBounds(shape)
```

The cache invalidates automatically when shape props change. You don't need to manage invalidation yourself.

#### Geometry filtering

Group2d supports filtering to include or exclude certain geometry children during operations. This lets you mark parts of a shape's geometry for different purposes.

The `isLabel` flag marks geometry that represents text label bounds. Label geometry participates in click-to-edit detection but is typically excluded from outline calculations and snapping.

The `isInternal` flag marks geometry that exists for internal calculations but shouldn't be part of the shape's visible outline.

```typescript
// Mark geometry as a label
new Rectangle2d({
	// ...
	isLabel: true,
})

// Mark geometry as internal (not part of main outline)
new Rectangle2d({
	// ...
	isInternal: true,
})
```

The geometry system provides filter presets for common scenarios:

| Filter                 | Includes labels | Includes internal |
| ---------------------- | --------------- | ----------------- |
| `EXCLUDE_NON_STANDARD` | No              | No                |
| `INCLUDE_ALL`          | Yes             | Yes               |
| `EXCLUDE_LABELS`       | No              | Yes               |
| `EXCLUDE_INTERNAL`     | Yes             | No                |

Most operations use `EXCLUDE_NON_STANDARD` by default, which gives you the shape's main outline without labels or internal geometry.

#### Advanced options

Geometry2d has additional options for special cases.

##### excludeFromShapeBounds

When set, the geometry won't contribute to the shape's bounding box calculation. The geometry still participates in hit testing and other operations, but `getBoundsVertices()` returns an empty array for it.

```typescript
const label = new Rectangle2d({
	x: labelX,
	y: labelY,
	width: labelWidth,
	height: labelHeight,
	isFilled: true,
	isLabel: true,
	excludeFromShapeBounds: true, // label won't affect shape bounds
})
```

This is useful for labels and other auxiliary geometry that shouldn't change the shape's overall size.

##### ignore

When set on geometry inside a Group2d, that geometry is placed in an `ignoredChildren` array and won't participate in the group's operations like hit testing, bounds calculation, or rendering.

```typescript
new Group2d({
	children: [
		mainGeometry,
		new Rectangle2d({
			// ...
			ignore: true, // won't participate in group operations
		}),
	],
})
```

##### debugColor

A color string used when rendering geometry in the debug view. Defaults to red if not specified.

```typescript
new Rectangle2d({
	width: 100,
	height: 100,
	isFilled: true,
	debugColor: 'blue', // shows as blue in geometry debugging view
})
```

Enable the geometry debug view through the debug panel to visualize shape geometry during development.

#### Transformed geometry

The `TransformedGeometry2d` class wraps a geometry with a transformation matrix. This is useful when you need geometry in a different coordinate space without creating new geometry objects.

```typescript
const transformed = geometry.transform(matrix)
```

All operations on the transformed geometry apply the transformation automatically. One limitation: transformed geometry doesn't support `getSvgPathData()`—you'll need to transform the path data yourself if you need it.

#### Related examples

- **[Custom shape geometry](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-geometry)** - A house-shaped custom shape using Polygon2d and Group2d geometry.
- **[Cubic bezier curve shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/cubic-bezier-shape)** - Interactive bezier curve editing with CubicBezier2d geometry and custom handles.
- **[Custom bounds snapping](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/bounds-snapping-shape)** - Playing card shapes with custom snap geometry so they stack with visible icons.

### Groups

Groups are logical containers that combine multiple shapes into a single selectable unit. Unlike frames, groups have no visual representation—they exist purely to organize shapes. Their geometry is the union of their children's geometries, their bounds update automatically as children change, and they clean themselves up when their contents are deleted. Groups can be nested inside other groups.

#### Creating groups

Use `Editor#groupShapes` to combine shapes into a group. Pass an array of shape IDs or shape objects, and the method creates a new group containing those shapes:

```typescript
// Group selected shapes
editor.groupShapes(editor.getSelectedShapeIds())

// Group specific shapes
editor.groupShapes([shapeId1, shapeId2, shapeId3])

// Group with options
editor.groupShapes([shapeId1, shapeId2], {
	groupId: createShapeId('my-group'), // Custom ID for the group
	select: false, // Don't select the group after creation
})
```

Grouping requires at least two shapes. The method does nothing if you pass fewer. Users can also group shapes with Ctrl+G (Cmd+G on Mac).

The new group is positioned at the top-left of the combined bounds of all grouped shapes. Its z-index matches the highest z-index among the grouped shapes, so it appears at the front of the layer stack.

#### Ungrouping

Use `Editor#ungroupShapes` to dissolve groups and release their children:

```typescript
// Ungroup selected groups
editor.ungroupShapes(editor.getSelectedShapeIds())

// Ungroup specific groups
editor.ungroupShapes([groupId])

// Ungroup without selecting the released children
editor.ungroupShapes([groupId], { select: false })
```

Ungrouping moves children to the group's parent, preserving their exact page positions and rotations. The layer order is maintained—children appear where the group was in the z-stack. If you pass a mix of groups and non-groups, only the groups are ungrouped; the non-groups remain selected. Users can ungroup with Ctrl+Shift+G (Cmd+Shift+G on Mac).

Ungrouping is not recursive. If a group contains other groups, those inner groups remain intact. Ungroup them separately if needed.

#### Focused groups

The editor tracks a **focused group** that defines the current editing scope. When you're focused inside a group, you can select and manipulate the shapes within it. Without focus, clicking a shape inside a group selects the group itself, not the individual shape.

```typescript
// Get the current focused group
const focusedGroup = editor.getFocusedGroup()

// Get the focused group ID (returns page ID if no group is focused)
const focusedId = editor.getFocusedGroupId()

// Focus a specific group
editor.setFocusedGroup(groupId)

// Exit the current focused group
editor.popFocusedGroupId()
```

The editor manages focus automatically based on selection:

- Selecting a shape inside a group focuses that group
- Selecting shapes across multiple groups focuses their common ancestor
- Pressing Escape pops focus one level up (or back to the page)
- Clearing selection keeps the current focus

##### Layered selection

Clicking shapes inside groups follows a layered pattern:

1. First click selects the outermost group
2. Second click focuses the group and selects the parent of the target shape (or the shape if directly inside)
3. Further clicks drill down through nested groups

This lets you work at different levels of the hierarchy without keyboard modifiers.

#### Nested groups

Groups can contain other groups, creating hierarchies:

```typescript
// Create a nested structure
editor.select(boxA, boxB)
editor.groupShapes(editor.getSelectedShapeIds())
const innerGroupId = editor.getOnlySelectedShapeId()

editor.select(innerGroupId, boxC, boxD)
editor.groupShapes(editor.getSelectedShapeIds())
const outerGroupId = editor.getOnlySelectedShapeId()

// Result:
// outerGroup
// ├── innerGroup
// │   ├── boxA
// │   └── boxB
// ├── boxC
// └── boxD
```

When grouping shapes that already have different parents, the editor finds their common ancestor and creates the group there. This prevents orphaning shapes from their natural hierarchy.

#### Automatic cleanup

Groups maintain themselves automatically through the `onChildrenChange` lifecycle hook:

- **Empty groups delete themselves.** If you delete all children of a group, the group is removed.
- **Single-child groups ungroup themselves.** If a group ends up with only one child (after deleting others), it dissolves and reparents that child to the group's parent.

This cleanup happens immediately when children change, so you never end up with degenerate groups.

```typescript
// Start with a group containing boxA and boxB
editor.deleteShapes([boxA])
// Group now has only boxB, so it auto-ungroups
// boxB is now a direct child of the page (or the group's former parent)
```

#### Bounds and transforms

Group bounds are computed from their children's geometries. The `Group2d` geometry class aggregates all child geometries for hit testing and bounds calculation:

```typescript
// Get a group's bounds
const bounds = editor.getShapePageBounds(groupId)

// Bounds update automatically when children move
editor.updateShape({ id: childId, x: newX, y: newY })
const updatedBounds = editor.getShapePageBounds(groupId)
```

When you transform a group (move, rotate, resize), all children transform with it. The group's position and rotation are applied to children through the standard parent-child transform composition.

Position preservation works both ways. When you group shapes, their page positions are preserved—only their `parentId` and local coordinates change. When you ungroup, shapes return to their original page positions even if the group was rotated.

#### Creating shapes inside groups

When you create new shapes while focused inside a group, those shapes automatically become children of the focused group:

```typescript
// Focus a group by selecting a shape inside it
editor.select(shapeInsideGroup)
// Now getFocusedGroupId() returns the group's ID

// Create a new shape - it becomes a child of the focused group
editor.createShape({
	type: 'geo',
	x: 100,
	y: 100,
	props: { w: 50, h: 50 },
})
// The new shape's parentId is the focused group
```

If no group is focused, new shapes are created on the current page.

#### Limitations

Groups have a few constraints worth knowing about.

Arrows can't bind to groups. The `canBind()` method returns `false` for group shapes, so arrows must bind to individual shapes within the group.

Groups have no visual properties—they're purely structural containers. You can't style a group itself, only its children.

Both grouping and ungrouping require the select tool to be active. If you're in the middle of another interaction, the editor cancels it before running the operation.

#### Related examples

- **[Layer panel](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/layer-panel)** - Build a hierarchical layer panel that shows shape and group structure with visibility controls.

### Handles

In tldraw, handles are interactive control points on shapes that let users manipulate shape geometry. Arrows have handles at their endpoints, lines have handles at each vertex, and notes have clone handles for quick duplication.

#### Handle basics

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`:

```tsx
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.

#### Handle types

There are four handle types:

| Type      | Description                                                                    |
| --------- | ------------------------------------------------------------------------------ |
| `vertex`  | A primary control point that defines part of the shape's geometry              |
| `virtual` | A secondary handle between vertices. Use it to add new points to a path.       |
| `create`  | A handle for extending geometry. Line shapes use these to add endpoints.       |
| `clone`   | A handle for duplicating the shape. Note shapes use these for adjacent copies. |

Most custom shapes use `vertex` handles. The `virtual` and `create` types are used by the line shape to let users add points to a path.

#### Responding to handle drags

When a user drags a handle, tldraw calls `onHandleDrag` with the updated handle position. Return the new shape props:

```tsx
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.

##### Lifecycle callbacks

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) |

#### Handle snapping

Handles can snap to other shapes' geometry. Set `snapType` on the handle:

```tsx
{
	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            |

##### Angle snapping

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 snap relative to a specific handle by setting `snapReferenceHandleId`:

```tsx
{
	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
}
```

Bezier curves use this so control points snap to angles relative to their associated endpoint.

##### Custom snap geometry

By default, handles snap to a shape's outline and key points. Override `getHandleSnapGeometry` to customize what handles snap to:

```tsx
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                |

#### Complete example

Here's a speech bubble shape with a draggable tail handle:

```tsx
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())
	}
}
```

#### Reading handles

Use `Editor#getShapeHandles` to get the handles for any shape:

```ts
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.

#### Examples

- [Custom shape with handles](https://tldraw.dev/examples/shapes/tools/speech-bubble) — A speech bubble shape with a draggable tail handle
- [Cubic bezier curve shape](https://tldraw.dev/examples/shapes/tools/cubic-bezier-shape) — Multiple handles with custom snapping and control point behavior

### Highlighting

Shape highlighting shows visual indicators on shapes to provide feedback during user interactions. The editor tracks two types of highlighting: **hover** (the shape under the pointer) and **hints** (shapes you want to emphasize programmatically).

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					// Highlight specific shapes with a visual indicator
					const shapes = editor.getCurrentPageShapes()
					if (shapes.length > 0) {
						editor.setHintingShapes([shapes[0]])
					}
				}}
			/>
		</div>
	)
}
```

#### Hover highlighting

The editor automatically tracks which shape is under the pointer and displays a selection-style indicator around it. This happens during idle states when the pointer moves over the canvas.

##### Reading hover state

Use `Editor#getHoveredShapeId` or `Editor#getHoveredShape` to check which shape is currently hovered:

```typescript
// Get the hovered shape ID
const hoveredId = editor.getHoveredShapeId()

// Get the full shape object
const hoveredShape = editor.getHoveredShape()

if (hoveredShape) {
	console.log('Hovering over:', hoveredShape.type)
}
```

##### Setting hover manually

Use `Editor#setHoveredShape` to override the automatic hover detection:

```typescript
// Set hover by shape or ID
editor.setHoveredShape(myShape)
editor.setHoveredShape(myShape.id)

// Clear hover
editor.setHoveredShape(null)
```

Use this to highlight a shape that isn't directly under the pointer, such as when implementing custom interaction logic.

##### Automatic hover detection

The editor's select tool automatically updates hover state as the pointer moves. The hover indicator appears when:

- The editor is idle or editing a shape
- The pointer is over the canvas (not UI elements)
- The input is not coarse (not touch input)
- The editor is not changing styles

Touch devices don't show hover indicators because touch has no hovering concept.

#### Hint highlighting

Hints let you highlight multiple shapes programmatically. Unlike hover (single shape, automatic), hints are manually controlled and can include any number of shapes.

##### Reading hints

Use `Editor#getHintingShapeIds` or `Editor#getHintingShape` to get currently hinted shapes:

```typescript
// Get array of hinted shape IDs
const hintedIds = editor.getHintingShapeIds()

// Get array of hinted shape objects
const hintedShapes = editor.getHintingShape()
```

##### Setting hints

Use `Editor#setHintingShapes` to highlight shapes:

```typescript
// Highlight shapes by ID or shape object
editor.setHintingShapes([shape1, shape2])
editor.setHintingShapes([shape1.id, shape2.id])

// Clear all hints
editor.setHintingShapes([])
```

Hinted shapes render with a thicker stroke (2.5px) than selected or hovered shapes (1.5px). This makes them stand out when you need to draw attention to specific shapes.

##### Common use cases

**Draw attention to shapes during a tutorial:**

```typescript
function highlightNextStep(editor: Editor, shapeId: TLShapeId) {
	editor.setHintingShapes([shapeId])
	// Clear after 3 seconds
	setTimeout(() => {
		editor.setHintingShapes([])
	}, 3000)
}
```

**Show connected arrows when a shape is selected:**

```typescript
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
	const selected = next.selectedShapeIds
	if (selected.length === 1) {
		// Highlight the arrows bound to the selected shape
		const bindings = editor.getBindingsToShape(selected[0], 'arrow')
		const arrowIds = bindings.map((b) => b.fromId)
		editor.setHintingShapes(arrowIds)
	} else {
		editor.setHintingShapes([])
	}
})
```

#### Visual rendering

Both hover and hint indicators use the theme's selection color. The editor renders them on the canvas using the shape's `ShapeUtil#getIndicatorPath` implementation.

| State    | Stroke width | Condition                          |
| -------- | ------------ | ---------------------------------- |
| Selected | 1.5px        | Shape is in selection              |
| Hovered  | 1.5px        | Pointer is over shape (automatic)  |
| Hinted   | 2.5px        | Shape is in hinting array (manual) |

Collaborator selections render at 1.5px with their user color at reduced opacity.

#### Page state storage

Hover and hint state are stored in `TLInstancePageState`, which tracks per-page interaction state. Each page maintains its own hover and hint values.

```typescript
const pageState = editor.getCurrentPageState()

pageState.hoveredShapeId // TLShapeId | null
pageState.hintingShapeIds // TLShapeId[]
```

Both properties are ephemeral—they don't persist across sessions or sync between collaborators.

#### Related articles

- [Instance state](https://tldraw.dev/sdk-features/instance-state) — Session state including page state
- [Selection](https://tldraw.dev/sdk-features/selection) — Working with selected shapes
- [Cursors](https://tldraw.dev/sdk-features/cursors) — Cursor types and customization

#### API reference

| Method                         | Description                            |
| ------------------------------ | -------------------------------------- |
| `Editor#getHoveredShapeId`  | Get the ID of the shape under pointer  |
| `Editor#getHoveredShape`    | Get the shape object under pointer     |
| `Editor#setHoveredShape`    | Manually set or clear hover state      |
| `Editor#getHintingShapeIds` | Get IDs of shapes with hint indicators |
| `Editor#getHintingShape`    | Get shapes with hint indicators        |
| `Editor#setHintingShapes`   | Set shapes to show hint indicators     |

### History (undo/redo)

The editor's history system tracks changes to the [store](https://tldraw.dev/sdk-features/store) and provides undo/redo functionality. Changes are organized into batches separated by marks, which act as stopping points. This lets complex interactions be undone as single atomic operations rather than individual edits.

The history manager captures all user-initiated changes automatically. Multiple rapid changes are compressed into cohesive undo steps, and you can control which changes are recorded using history options.

#### How it works

The history manager maintains two stacks: one for undos and one for redos. Each stack contains entries that are either diffs (record changes) or marks (stopping points).

When you modify the store, the history manager captures the change as a diff. Changes accumulate until you create a mark, then the pending changes are flushed to the undo stack as a single entry. This batching prevents every keystroke or mouse movement from becoming a separate undo step.

```typescript
editor.updateShape({ id: myShapeId, type: 'geo', x: 100, y: 100 })
editor.updateShape({ id: myShapeId, type: 'geo', x: 110, y: 100 })
editor.updateShape({ id: myShapeId, type: 'geo', x: 120, y: 100 })
// All three updates are batched together until a mark is created
```

When you undo, the manager reverses all changes back to the previous mark, moves them to the redo stack, and applies the reversed diff atomically. Redo does the inverse.

#### Marks and stopping points

Marks define where undo and redo operations stop. Create marks at the start of user interactions so that complex operations can be undone in one step.

```typescript
const markId = editor.markHistoryStoppingPoint('rotate shapes')
editor.rotateShapesBy(editor.getSelectedShapeIds(), Math.PI / 4)
// Undoing will return to this mark
```

Each mark has a unique identifier that you can use with [bailToMark](#bailing) or [squashToMark](#squashing). Creating a mark flushes pending changes and clears the redo stack.

#### Basic operations

##### Undo and redo

Use `Editor#undo` and `Editor#redo` to move through history marks.

```typescript
editor.undo() // Reverse to previous mark
editor.redo() // Reapply changes
```

Both methods return the editor instance for chaining.

The `Editor#canUndo` and `Editor#canRedo` methods are reactive, so you can use them to update UI button states automatically:

```typescript
function UndoButton() {
	const editor = useEditor()
	const canUndo = useValue('canUndo', () => editor.canUndo(), [editor])
	return (
		<button disabled={!canUndo} onClick={() => editor.undo()}>
			Undo
		</button>
	)
}
```

##### Running operations with history options

The `Editor#run` method executes a function while controlling how changes affect history. Use it to make changes that don't pollute the undo stack or that preserve the redo stack for special operations.

```typescript
// Ignore changes (don't add to undo stack)
editor.run(
	() => {
		editor.updateShape({ id: myShapeId, type: 'geo', x: 100 })
	},
	{ history: 'ignore' }
)

// Record but preserve redo stack
editor.run(
	() => {
		editor.updateShape({ id: myShapeId, type: 'geo', x: 100 })
	},
	{ history: 'record-preserveRedoStack' }
)
```

The three history modes are:

| Mode                       | Undo stack | Redo stack |
| -------------------------- | ---------- | ---------- |
| `record`                   | Add        | Clear      |
| `record-preserveRedoStack` | Add        | Keep       |
| `ignore`                   | Skip       | Keep       |

> We use `record-preserveRedoStack` when selecting shapes. This way you can undo, select some shapes, copy them, and then redo back to where you were. The selection goes on the undo stack, but existing redos aren't cleared.

> We use `ignore` for live cursor positions. Showing where collaborators' pointers are doesn't need to be undoable.

#### Advanced features

##### Bailing

Bailing reverses changes without adding them to the redo stack. The changes are discarded entirely. Use this when canceling an interaction.

```typescript
const markId = editor.markHistoryStoppingPoint('begin drag')
// User drags shapes around
// User presses escape to cancel
editor.bailToMark(markId) // Roll back and discard all changes since mark
```

`Editor#bail` reverts to the most recent mark. `Editor#bailToMark` reverts to a specific mark by ID.

> We use bailing while cloning shapes. A user can switch between translating and cloning by pressing or releasing the control modifier key during a drag. When this changes, we bail on the changes since the interaction started, then apply the new mode's changes.

##### Squashing

`Editor#squashToMark` combines all changes since a mark into a single undo step. Intermediate marks are removed. This simplifies the undo experience for complex multi-step operations.

```typescript
const markId = editor.markHistoryStoppingPoint('bump shapes')
editor.nudgeShapes(shapes, { x: 10, y: 0 })
editor.nudgeShapes(shapes, { x: 0, y: 10 })
editor.nudgeShapes(shapes, { x: -5, y: -5 })
editor.squashToMark(markId) // All three nudges become one undo step
```

Squashing doesn't change the current state, only how history is organized.

> We use squashing during image cropping. As the user adjusts the crop, each change is recorded, allowing undo/redo of individual adjustments. When the user finishes cropping and exits this mode, we squash all the intermediate changes into one history entry. A single undo restores the image to its state before cropping began.

##### Clearing history

`Editor#clearHistory` removes all undo and redo entries. Use this when loading new documents or resetting the editor state.

```typescript
editor.loadSnapshot(snapshot)
editor.clearHistory() // Start with clean history
```

#### Integration with the store

The history manager listens to [store](https://tldraw.dev/sdk-features/store) changes through a history interceptor. It only captures changes marked with source `'user'`, ignoring internal updates and external synchronization.

Internally, the manager has three states:

| State                        | Captures changes | Clears redo stack |
| ---------------------------- | ---------------- | ----------------- |
| `Recording`                  | Yes              | Yes               |
| `RecordingPreserveRedoStack` | Yes              | No                |
| `Paused`                     | No               | No                |

The `Paused` state is used during undo/redo operations, which prevents them from creating new history entries while they apply diffs.

#### Related examples

- [Timeline scrubber](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/use-cases/timeline-scrubber) - A visual timeline that lets users scrub through document history.
- [Store events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/store-events) - Listen to store changes, which is how the history manager tracks modifications.

### Image export

The export system converts shapes to SVG and raster image formats for download, embedding, or integration with external tools. The editor handles the full pipeline from rendering shapes as SVG to converting those SVGs into PNG, JPEG, or WebP images. Exports are fully self-contained: the editor embeds fonts, inlines styles, and converts media elements to data URLs.

#### How it works

Export has two stages: SVG generation and optional raster conversion. The SVG stage renders shapes into a self-contained SVG document, while the raster stage converts that SVG into a bitmap image.

##### SVG generation

The editor gathers the shapes to export, calculates their bounding box, creates a React tree representing the SVG, renders it into a temporary DOM element, and processes it to be self-contained.

Each shape defines how it renders to SVG through its `ShapeUtil`:

- If the shape implements `ShapeUtil#toSvg` or `ShapeUtil#toBackgroundSvg`, those methods generate native SVG elements
- If the shape doesn't implement SVG methods, the editor renders its normal HTML representation inside an SVG `<foreignObject>` element

The temporary render step is necessary because CSS and layout aren't computed until elements are in the document. `<foreignObject>` elements in particular need their styles and content inlined to work when the SVG is extracted.

##### Making SVG self-contained

SVG files must be self-contained to work outside the document. The editor processes the rendered SVG to embed all external resources:

**Font embedding**: The `FontEmbedder` traverses document stylesheets to find `@font-face` declarations, fetches font files, converts them to data URLs, and inlines them in the SVG. Text renders identically regardless of what fonts the viewer has installed.

**Style inlining**: The `StyleEmbedder` reads computed styles from every element in `<foreignObject>` sections and applies them as inline styles. This removes reliance on external stylesheets. Pseudo-elements like `::before` and `::after` can't be inlined, so the editor extracts their styles into a `<style>` tag within the SVG.

**Media conversion**: The `embedMedia` function converts images, videos, and canvas elements to embedded formats. Images become data URLs, videos become single-frame images, and canvas elements become images via `toDataURL()`.

##### Raster conversion

Once the editor generates the SVG, it can convert it to a raster image. The `getSvgAsImage` function loads the SVG into an `Image` element, draws it to a canvas at the requested resolution, and exports the canvas as a blob.

The `pixelRatio` option controls output resolution. For high-DPI displays, use a pixel ratio of 2 or higher for sharp rendering. The editor automatically clamps dimensions to browser canvas limits to avoid out-of-memory errors.

#### Export options

SVG export methods (`getSvgElement`, `getSvgString`) accept `TLSvgExportOptions`. Raster export methods (`toImage`, `toImageDataUrl`) accept `TLImageExportOptions`, which extends `TLSvgExportOptions` with format-specific options.

**Shared options (all export methods):**

| Option                | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bounds`              | The bounding box in page coordinates to export. If omitted, the editor calculates bounds from the shapes.                                                                                                                                                                                                                                                                                                                                                                 |
| `scale`               | Logical scale multiplier. A scale of 2 doubles the SVG size.                                                                                                                                                                                                                                                                                                                                                                                                              |
| `pixelRatio`          | For SVG exports, passed to the asset store so it can provide appropriately sized assets. For raster exports, multiplies output dimensions. Defaults to `undefined` for SVG and `2` for raster formats.                                                                                                                                                                                                                                                                    |
| `background`          | Whether to include the background color. If `false`, the export is transparent (for formats that support it).                                                                                                                                                                                                                                                                                                                                                             |
| `padding`             | Space around the shape bounds. Accepts `number` (fixed pixels) or `'auto'` (default). In `'auto'` mode, the export trims to visual content bounds, capturing overflow like thick strokes and arrowheads without extra whitespace. A numeric value adds fixed padding and clips overflow beyond it. When exporting a single frame, padding is not applied (the frame itself serves as the boundary). When exporting shapes inside an image shape, padding is also skipped. |
| `darkMode`            | Whether to render in dark mode. Defaults to the current theme setting.                                                                                                                                                                                                                                                                                                                                                                                                    |
| `preserveAspectRatio` | The SVG `preserveAspectRatio` attribute.                                                                                                                                                                                                                                                                                                                                                                                                                                  |

**Raster-only options (toImage, toImageDataUrl):**

| Option    | Description                                                                     |
| --------- | ------------------------------------------------------------------------------- |
| `format`  | Output format: `'svg'`, `'png'`, `'jpeg'`, or `'webp'`. Defaults to `'png'`.    |
| `quality` | Compression quality for lossy formats (JPEG, WebP) as a number between 0 and 1. |

#### Editor export methods

The `Editor` class provides four methods for exporting shapes. All methods accept either shape IDs or shape objects, and an empty array exports all shapes on the current page.

##### getSvgElement

`Editor#getSvgElement` returns the SVG as a DOM element along with its dimensions. Use this when you need to manipulate the SVG programmatically or insert it into the DOM.

```typescript
const result = await editor.getSvgElement(shapes, { scale: 2 })
if (result) {
	document.body.appendChild(result.svg)
}
```

##### getSvgString

`Editor#getSvgString` returns the SVG as a serialized string. Use this for saving to a file or sending to a server.

```typescript
const result = await editor.getSvgString(shapes, { background: true })
if (result) {
	console.log(result.svg) // SVG markup as string
}
```

##### toImage

`Editor#toImage` returns a blob of the exported image in the specified format. This is the primary method for raster images.

```typescript
const result = await editor.toImage(shapes, {
	format: 'png',
	pixelRatio: 2,
	background: true,
})

// Download the image
const link = document.createElement('a')
link.href = URL.createObjectURL(result.blob)
link.download = 'export.png'
link.click()
```

##### toImageDataUrl

`Editor#toImageDataUrl` returns the exported image as a data URL string. Use this when you need the image as a base64-encoded string, for example to display in an `<img>` element or store in a database.

```typescript
const result = await editor.toImageDataUrl(shapes, { format: 'png' })
const img = document.createElement('img')
img.src = result.url
```

#### Error handling

The SVG methods (`Editor#getSvgElement` and `Editor#getSvgString`) return `undefined` when there's nothing to export. Check the result before using it:

```typescript
const result = await editor.getSvgString(shapes)
if (!result) {
	console.error('Nothing to export')
	return
}
// result.svg is available
```

The raster methods (`Editor#toImage` and `Editor#toImageDataUrl`) throw instead. Wrap them in a try/catch when the export might fail:

```typescript
try {
	const { blob } = await editor.toImage(shapes, { format: 'png' })
} catch (e) {
	console.error('Export failed', e)
}
```

The raster conversion automatically clamps dimensions to stay within browser canvas limits, which vary by browser. Very large exports at high pixel ratios are scaled down to fit rather than failing.

#### Related examples

- [Export canvas as image](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/data/assets/export-canvas-as-image) - Export the entire canvas using `Editor#toImage` and download it.
- [Export canvas as image (with settings)](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/data/assets/export-canvas-settings) - Export with configurable format, scale, background, and other options.
- [Custom shape SVG export](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/toSvg-method-example) - Define how custom shapes render when exported using `ShapeUtil#toSvg` and `ShapeUtil#toBackgroundSvg`.

### Indicators

Indicators are the colored outlines that appear around shapes when they're selected, hovered, or being interacted with. Every shape defines its own indicator through the `ShapeUtil#getIndicatorPath` method.

#### How indicators work

When you hover over or select a shape, tldraw draws an outline that matches the shape's geometry. The indicator is separate from the shape itself—it renders in an overlay layer above the canvas.

Indicators appear in three contexts:

| Context  | When it appears                               | Stroke weight |
| -------- | --------------------------------------------- | ------------- |
| Selected | Shape is in the current selection             | 1.5px         |
| Hovered  | Pointer is over an unselected shape           | 1.5px         |
| Hinting  | Shape is a drop target during drag operations | 2.5px         |

For collaborative editing, indicators also show which shapes other users have selected. These render in each collaborator's cursor color. Locked shapes never show indicators.

#### Defining an indicator

Every `ShapeUtil` must implement the `getIndicatorPath` method. This method returns a `Path2D`, or a richer `TLIndicatorPath` object for indicators that need clipping or additional stroked paths:

```tsx
import { ShapeUtil, TLBaseShape, Rectangle2d, T, RecordProps } from 'tldraw'

type MyShape = TLBaseShape<'myshape', { w: number; h: number }>

class MyShapeUtil extends ShapeUtil<MyShape> {
	static override type = 'myshape' as const
	static override props: RecordProps<MyShape> = {
		w: T.number,
		h: T.number,
	}

	getDefaultProps() {
		return { w: 100, h: 100 }
	}

	getGeometry(shape: MyShape) {
		return new Rectangle2d({
			width: shape.props.w,
			height: shape.props.h,
			isFilled: true,
		})
	}

	component(shape: MyShape) {
		return <div style={{ width: shape.props.w, height: shape.props.h }} />
	}

	getIndicatorPath(shape: MyShape) {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}
}
```

The `getIndicatorPath` method receives the shape and returns paths in the shape's local coordinate space. You don't need to set stroke color or width—tldraw applies those automatically based on context.

##### Common indicator patterns

For rectangular shapes, return a rectangle path:

```tsx
getIndicatorPath(shape: MyShape) {
	const path = new Path2D()
	path.rect(0, 0, shape.props.w, shape.props.h)
	return path
}
```

For circular shapes, use an ellipse path:

```tsx
getIndicatorPath(shape: MyShape) {
	const { w, h } = shape.props
	const path = new Path2D()
	path.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2)
	return path
}
```

For complex paths, use the shape's geometry:

```tsx
getIndicatorPath(shape: MyShape) {
	const geometry = this.editor.getShapeGeometry(shape)
	return new Path2D(geometry.toSimpleSvgPath())
}
```

##### Indicators with labels

Shapes with labels may need to clip the indicator where the label appears. Arrow shapes do this to prevent the indicator from overlapping label text. Return an object with additional path information:

```tsx
override getIndicatorPath(shape: MyShape) {
	return {
		path: mainPath,
		clipPath: labelClipPath,
		additionalPaths: [extraPath],
	}
}
```

#### Hinting shapes

Hinting shapes are shapes that receive a highlighted indicator during drag operations. Use `Editor#setHintingShapes` to mark shapes as drop targets:

```tsx
// Highlight a shape as a potential drop target
editor.setHintingShapes([targetShapeId])

// Clear hinting
editor.setHintingShapes([])
```

Hinting indicators render with a thicker stroke (2.5px vs 1.5px) to distinguish them from regular selection.

#### Related articles

- [Shapes](https://tldraw.dev/sdk-features/shapes) - Learn how to create custom shapes with their own indicators
- [Selection](https://tldraw.dev/sdk-features/selection) - Understand how selection state controls indicator visibility
- [UI components](https://tldraw.dev/sdk-features/ui-components) - Customize canvas components including indicators

#### Related examples

- [Custom shape](https://tldraw.dev/examples/shapes/tools/custom-shape) - Create a custom shape with an indicator
- [Custom indicators](https://tldraw.dev/examples/ui/indicators-logic) - Control which shapes show indicators

### Input handling

The `InputsManager` class tracks pointer and keyboard state for the editor. It stores pointer positions in both screen space and page space, tracks pressed keys and buttons, detects device types (mouse, touch, pen), and calculates pointer velocity. Access it through `editor.inputs`.

All input state is reactive. The manager stores values as atoms from `@tldraw/state`, so components that read input state automatically update when those values change. The manager updates on every input event, converting coordinates between screen space and page space.

#### Pointer position tracking

The manager tracks pointer positions in two coordinate spaces:

- **Screen space**: Pixels relative to the canvas container's origin
- **Page space**: Coordinates in the infinite canvas, adjusted for camera position and zoom

For each space, the manager maintains three position snapshots: current, previous, and origin.

##### Current and previous positions

```typescript
editor.inputs.getCurrentScreenPoint() // Current position in screen space
editor.inputs.getCurrentPagePoint() // Current position in page space

editor.inputs.getPreviousScreenPoint() // Previous position in screen space
editor.inputs.getPreviousPagePoint() // Previous position in page space
```

The current position updates on every pointer event. The previous position stores where the pointer was before the most recent update. You can use these together to calculate deltas for dragging and panning:

```typescript
const delta = Vec.Sub(editor.inputs.getCurrentPagePoint(), editor.inputs.getPreviousPagePoint())
```

##### Origin positions

The origin position captures where the most recent `pointer_down` event occurred:

```typescript
editor.inputs.getOriginScreenPoint() // Where pointer_down occurred in screen space
editor.inputs.getOriginPagePoint() // Where pointer_down occurred in page space
```

Tools use the origin to calculate drag distances and determine whether an interaction has moved far enough to trigger behaviors like dragging. The origin resets on every `pointer_down` event and when pinch gestures start.

##### Coordinate space conversion

The manager converts screen coordinates to page coordinates using the camera's position and zoom:

```typescript
// Screen to page conversion
const pageX = screenX / camera.z - camera.x
const pageY = screenY / camera.z - camera.y
```

#### Pointer velocity

The manager tracks pointer velocity for gesture detection:

```typescript
editor.inputs.getPointerVelocity() // Vec with x/y velocity in pixels per millisecond
```

Velocity is calculated on each animation frame tick (not on each pointer event). The `TickManager` calls `updatePointerVelocity()` every frame, calculating the distance traveled since the last tick. The velocity is smoothed by interpolating with the previous value, and very small values (below 0.01) are clamped to zero to prevent jitter.

Tools use velocity to distinguish between slow, precise interactions and fast flick gestures. Velocity resets to zero on `pointer_down` events and when pinch gestures start.

#### Input device detection

The manager tracks whether the current input comes from a pen device:

```typescript
editor.inputs.getIsPen() // true for stylus input
```

This enables pen-specific behaviors like pen mode, which ignores non-pen input to prevent accidental touch interactions while using a stylus.

> Many stylus devices identify as 'mouse' rather than 'pen'. We use heuristics to detect these devices and correctly account for pressure.

#### Modifier keys and button states

The manager tracks modifier key states:

```typescript
editor.inputs.getShiftKey()
editor.inputs.getAltKey()
editor.inputs.getCtrlKey()
editor.inputs.getMetaKey()
editor.inputs.getAccelKey() // Cmd on Mac, Ctrl elsewhere
```

The `getAccelKey()` method returns true for Command on macOS and Control on other platforms. Use this for cross-platform shortcuts.

##### Button tracking

The manager tracks currently pressed pointer buttons in a reactive set:

```typescript
editor.inputs.buttons.has(0) // Primary button (left click)
editor.inputs.buttons.has(1) // Middle button
editor.inputs.buttons.has(2) // Secondary button (right click)
```

Buttons are added on `pointer_down` events and removed on `pointer_up` events.

##### Keyboard key tracking

The manager tracks pressed keyboard keys in a reactive set:

```typescript
editor.inputs.keys.has('Space')
editor.inputs.keys.has('ShiftLeft')
```

Keys are added on `key_down` and removed on `key_up`. Tools can use this to detect held keys during pointer operations. For example, the select tool detects when Space is held to temporarily activate the hand tool.

#### Interaction state flags

The manager tracks the current interaction state:

```typescript
editor.inputs.getIsPointing() // Pointer button is down
editor.inputs.getIsDragging() // Pointer moved beyond drag threshold while pointing
editor.inputs.getIsPinching() // Two-finger pinch gesture active
editor.inputs.getIsEditing() // Editing text or other content
editor.inputs.getIsPanning() // Panning the canvas
editor.inputs.getIsSpacebarPanning() // Panning via spacebar (vs. other panning modes)
```

The editor sets these flags during event processing. For example, `isPointing` becomes true on `pointer_down` and false on `pointer_up`. The `isDragging` flag becomes true when the pointer moves beyond the drag distance threshold while pointing.

#### Event processing flow

When an input event occurs, the editor processes it through these stages:

1. The browser fires a native DOM event
2. The editor's UI layer captures the event and transforms it into a typed event info object
3. For pointer, pinch, and wheel events, the editor calls `updateFromEvent()` on the `InputsManager`
4. For pointer events, the editor dispatches to the `ClickManager` for double-click detection and overflow suppression
5. The editor sends the event to the state machine via `root.handleEvent()`
6. The state machine propagates the event through active tool states

The typed event info objects are:

| Event type      | Info type             |
| --------------- | --------------------- |
| Pointer events  | `TLPointerEventInfo`  |
| Click events    | `TLClickEventInfo`    |
| Keyboard events | `TLKeyboardEventInfo` |
| Wheel events    | `TLWheelEventInfo`    |
| Pinch events    | `TLPinchEventInfo`    |

In collaborative sessions, `updateFromEvent()` also updates the user's pointer presence record in the store, broadcasting pointer position to other users.

#### Input normalization

The editor normalizes input across different device types. Touch events convert to pointer events, and the manager tracks the device type through the `isPen` flag.

Pointer positions include a z coordinate representing pressure or hover distance, defaulting to 0.5 for devices that don't report pressure. The normalization layer accounts for the canvas container's position in the document, so the editor works correctly in nested layouts and scrolled containers.

#### State serialization

The manager provides a `toJson()` method for debugging:

```typescript
const state = editor.inputs.toJson()
// Returns all position vectors, modifier key states, interaction flags,
// device type, and the contents of the keys and buttons sets
```

We use this serialized form when generating crash reports.

#### Related examples

- [Reactive inputs](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/reactive-inputs) - Display pointer positions, velocity, and other input state reactively
- [Canvas events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/canvas-events) - Log pointer, keyboard, and wheel events to see the event flow

### Instance state

Instance state is the per-tab state that tracks your current session. It includes which page you're viewing, whether the grid is visible, if debug mode is on, and transient interaction state like the current cursor position. Unlike document state (shapes, pages, assets), instance state belongs to a single browser tab and isn't synced between collaborators.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					// Enable grid mode and debug mode
					editor.updateInstanceState({
						isGridMode: true,
						isDebugMode: true,
					})
				}}
			/>
		</div>
	)
}
```

#### Reading instance state

Use `Editor#getInstanceState` to access the current instance state. The returned object contains all session properties:

```typescript
const instance = editor.getInstanceState()

// Check modes
instance.isGridMode // true if grid is visible
instance.isDebugMode // true if debug overlays are shown
instance.isFocusMode // true if UI is minimized
instance.isPenMode // true if stylus input detected
instance.isToolLocked // true if tool stays active after creating shapes
instance.isReadonly // true if editing is disabled

// Current state
instance.currentPageId // which page is active
instance.cursor // cursor type and rotation
instance.isFocused // whether the editor has focus

// Screen information
instance.screenBounds // viewport dimensions
instance.devicePixelRatio // display scaling factor
instance.isCoarsePointer // true for touch input
```

The result is reactive. When you access it inside a `track` component or `react` callback, your code re-runs when the state changes:

```tsx
import { track, useEditor } from 'tldraw'

const DebugIndicator = track(function DebugIndicator() {
	const editor = useEditor()
	const { isDebugMode, isGridMode } = editor.getInstanceState()

	return (
		<div>
			Debug: {isDebugMode ? 'on' : 'off'}, Grid: {isGridMode ? 'on' : 'off'}
		</div>
	)
})
```

#### Updating instance state

Use `Editor#updateInstanceState` to modify instance properties:

```typescript
// Enable grid mode
editor.updateInstanceState({ isGridMode: true })

// Enable multiple options at once
editor.updateInstanceState({
	isDebugMode: true,
	isToolLocked: true,
})

// Toggle a mode
const { isGridMode } = editor.getInstanceState()
editor.updateInstanceState({ isGridMode: !isGridMode })
```

Changes take effect immediately and persist for the session. When using a `persistenceKey`, some properties persist across sessions (see [Session persistence](#session-persistence) below).

Note that `currentPageId` cannot be changed through `updateInstanceState`. Use `Editor#setCurrentPage` instead.

#### Available properties

##### Mode flags

These boolean flags control major editor behaviors:

| Property       | Description                                     | Persists |
| -------------- | ----------------------------------------------- | -------- |
| `isGridMode`   | Show grid overlay on the canvas                 | Yes      |
| `isDebugMode`  | Show debug information overlays                 | Yes      |
| `isFocusMode`  | Minimize the UI to just the canvas              | Yes      |
| `isToolLocked` | Keep current tool active after creating a shape | Yes      |
| `isReadonly`   | Prevent all document modifications              | Yes      |
| `isPenMode`    | Track whether stylus input has been detected    | No       |
| `isFocused`    | Whether the editor currently has keyboard focus | Yes      |

##### Display state

Properties that track the current display environment:

| Property           | Description                                                   | Persists |
| ------------------ | ------------------------------------------------------------- | -------- |
| `screenBounds`     | Viewport position and dimensions (x, y, w, h)                 | Yes      |
| `devicePixelRatio` | Display scaling factor (e.g., 2 for Retina)                   | Yes      |
| `isCoarsePointer`  | Indicates touch or low-precision input is active              | Yes      |
| `isHoveringCanvas` | Whether pointer is over the canvas (null if no hover support) | No       |
| `insets`           | Safe area insets as `[top, right, bottom, left]` booleans     | Yes      |

##### Navigation state

| Property        | Description                      | Persists |
| --------------- | -------------------------------- | -------- |
| `currentPageId` | ID of the currently active page  | No       |
| `openMenus`     | Array of currently open menu IDs | No       |

##### Interaction state

Temporary state used during user interactions:

| Property          | Description                                      | Persists |
| ----------------- | ------------------------------------------------ | -------- |
| `cursor`          | Current cursor type and rotation                 | No       |
| `brush`           | Selection brush bounds during drag selection     | No       |
| `zoomBrush`       | Zoom brush bounds during zoom-to-area            | No       |
| `scribbles`       | Active scribble animations (eraser trails, etc.) | No       |
| `isChangingStyle` | True while style picker is active                | No       |
| `cameraState`     | Whether the camera is `'idle'` or `'moving'`     | No       |

##### Shape creation

Settings that affect newly created shapes:

| Property              | Description                         | Persists |
| --------------------- | ----------------------------------- | -------- |
| `opacityForNextShape` | Opacity applied to new shapes (0-1) | No       |
| `stylesForNextShape`  | Style values applied to new shapes  | No       |
| `duplicateProps`      | State for smart duplicate offset    | No       |

##### Collaboration

Properties for multiplayer features:

| Property             | Description                                | Persists |
| -------------------- | ------------------------------------------ | -------- |
| `followingUserId`    | ID of user being followed, or null         | No       |
| `highlightedUserIds` | IDs of users whose cursors are highlighted | No       |
| `chatMessage`        | Current chat message being composed        | No       |
| `isChatting`         | Whether chat input is active               | No       |

##### Custom data

| Property           | Description                              | Persists |
| ------------------ | ---------------------------------------- | -------- |
| `meta`             | Arbitrary JSON data for your application | No       |
| `exportBackground` | Include background color when exporting  | Yes      |

#### Common patterns

##### Focus mode

Focus mode hides most of the UI for presentation or distraction-free editing:

```typescript
editor.updateInstanceState({ isFocusMode: true })
```

##### Grid mode

Show a grid overlay to help with alignment:

```typescript
editor.updateInstanceState({ isGridMode: true })
```

The grid respects the camera zoom level. You can customize the grid appearance by overriding the `Grid` component.

##### Debug mode

Debug mode shows useful overlays including shape bounds, geometry points, and performance information:

```typescript
editor.updateInstanceState({ isDebugMode: true })
```

##### Tool lock

When tool lock is enabled, the current tool stays active after creating a shape instead of returning to the select tool:

```typescript
// Enable tool lock
editor.updateInstanceState({ isToolLocked: true })

// Check if locked
if (editor.getInstanceState().isToolLocked) {
	// Tool will stay active after creating shapes
}
```

Custom tools should check this property to decide whether to return to select after completing their action. See [Tools](https://tldraw.dev/sdk-features/tools#tool-lock) for implementation details.

##### Cursor customization

Set a custom cursor type and rotation:

```typescript
// Use the setCursor helper
editor.setCursor({ type: 'cross', rotation: 0 })

// Or update directly
editor.updateInstanceState({
	cursor: { type: 'grab', rotation: 0 },
})
```

See [Cursors](https://tldraw.dev/sdk-features/cursors) for the full list of cursor types and rotation handling.

##### Detecting touch input

Check `isCoarsePointer` to adapt your UI for touch:

```tsx
const { isCoarsePointer } = editor.getInstanceState()
const buttonSize = isCoarsePointer ? 48 : 32 // Larger touch targets
```

The value updates automatically when the user switches between mouse and touch.

#### Session persistence

When you use a `persistenceKey` on the `Tldraw` component, some instance properties persist across browser sessions. Properties marked "Persists: Yes" in the tables above are saved and restored.

Temporary state like cursor position, selection brushes, and open menus always reset when the page reloads. Navigation state like `currentPageId` doesn't persist because the referenced page might not exist in a fresh session.

#### Instance state vs page state

Instance state (`TLInstance`) is global to the editor—there's one instance record per browser tab. Page state (`TLInstancePageState`) is per-page and tracks page-specific information like selection:

```typescript
// Instance state: global to the editor
const instance = editor.getInstanceState()
instance.currentPageId // which page is active
instance.isGridMode // applies to all pages

// Page state: specific to the current page
const pageState = editor.getCurrentPageState()
pageState.selectedShapeIds // selection on this page
pageState.editingShapeId // shape being edited on this page
pageState.hoveredShapeId // shape under cursor on this page
```

When you switch pages, the instance's `currentPageId` changes, but each page retains its own selection and editing state.

#### Related articles

- [Cursors](https://tldraw.dev/sdk-features/cursors) — Cursor types and rotation handling
- [Readonly mode](https://tldraw.dev/sdk-features/readonly) — Disable editing with `isReadonly`
- [Focus](https://tldraw.dev/sdk-features/focus) — Editor focus state with `isFocused`
- [Tools](https://tldraw.dev/sdk-features/tools#tool-lock) — Tool lock behavior with `isToolLocked`
- [User preferences](https://tldraw.dev/sdk-features/user-preferences) — Global preferences that persist across editor instances

#### Related examples

- **[Focus mode](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/focus-mode)** — Enable focus mode to minimize the UI.
- **[Custom grid](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/custom-grid)** — Use `isGridMode` with a custom grid component.
- **[Read-only](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/readonly)** — Disable editing with `isReadonly`.

### Internationalization

Tldraw's UI supports 49 languages out of the box, including right-to-left languages like Arabic, Hebrew, Farsi, and Urdu. The translation system loads language files on demand, detects the user's browser language, and lets you override any translation string or add custom ones.

#### Setting the locale

The user's locale is stored in [user preferences](https://tldraw.dev/sdk-features/user-preferences). By default, tldraw detects the browser's language and selects the closest match from supported languages.

The simplest way to set the locale is with the `locale` prop on the `Tldraw` component:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw locale="fr" />
		</div>
	)
}
```

The `locale` prop takes priority over both the browser's language preferences and the user's locale preference. When set, your application controls the displayed language.

You can also set the locale imperatively after the editor mounts:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					// Change the locale to French
					editor.user.updateUserPreferences({ locale: 'fr' })

					// Get the current locale
					const locale = editor.user.getLocale() // "fr"
				}}
			/>
		</div>
	)
}
```

The `locale` value uses standard language codes: `'en'`, `'fr'`, `'de'`, `'ja'`, `'zh-cn'`, `'ar'`, and so on.

##### Automatic detection

When no locale is set, tldraw uses `getDefaultTranslationLocale()` to detect the user's preferred language from the browser:

```ts
import { getDefaultTranslationLocale } from '@tldraw/tlschema'

// Returns 'fr', 'en', 'zh-cn', etc. based on browser settings
const locale = getDefaultTranslationLocale()
```

The detection algorithm:

1. Reads the browser's `navigator.languages` array
2. Tries an exact match against supported languages
3. Falls back to language-only match (e.g., `'fr-CA'` → `'fr'`)
4. Applies region defaults for Chinese (`'zh'` → `'zh-cn'`), Portuguese (`'pt'` → `'pt-br'`), Korean (`'ko'` → `'ko-kr'`), and Hindi (`'hi'` → `'hi-in'`)
5. Defaults to `'en'` if no match is found

#### Using translations in components

The `useTranslation` hook returns a function for looking up translation strings:

```tsx
import { useTranslation } from 'tldraw'

function CopyButton() {
	const msg = useTranslation()
	return <button>{msg('action.copy')}</button>
}
```

For access to the full translation object including locale and text direction:

```tsx
import { useCurrentTranslation } from 'tldraw'

function LocaleInfo() {
	const translation = useCurrentTranslation()
	return (
		<div dir={translation.dir}>
			<p>Locale: {translation.locale}</p>
			<p>Label: {translation.label}</p>
		</div>
	)
}
```

The `TLUiTranslation` object contains a `locale` code (e.g., `'fr'`), a `label` in the native script (e.g., `'Français'`), a `messages` record with all translation strings, and a `dir` indicating text direction (`'ltr'` or `'rtl'`).

#### Overriding translations

Pass translation overrides through the `overrides` prop on `Tldraw`:

```tsx
import { Tldraw } from 'tldraw'

function App() {
	return (
		<Tldraw
			overrides={{
				translations: {
					en: {
						'action.copy': 'Copy to clipboard',
						'action.paste': 'Paste from clipboard',
					},
					fr: {
						'action.copy': 'Copier dans le presse-papiers',
					},
				},
			}}
		/>
	)
}
```

Overrides are merged with the base translations for each language. English serves as the fallback—any key missing from the target language uses the English string.

##### Translation keys

Translation keys follow a hierarchical naming convention. Common prefixes include `action.*` for user actions like copy and paste, `tool.*` for tool names, `menu.*` for menu labels, `style-panel.*` for style panel UI, and `a11y.*` for accessibility announcements.

The `TLUiTranslationKey` type provides autocomplete for all available keys:

```ts
import type { TLUiTranslationKey } from 'tldraw'

const key: TLUiTranslationKey = 'action.copy'
```

#### Supported languages

Import `LANGUAGES` from `@tldraw/tlschema` for the complete list of supported languages:

```tsx
import { LANGUAGES } from '@tldraw/tlschema'

function LanguageSelector() {
	return (
		<select>
			{LANGUAGES.map(({ locale, label }) => (
				<option key={locale} value={locale}>
					{label}
				</option>
			))}
		</select>
	)
}
```

Each entry in `LANGUAGES` has a `locale` code and a `label` in that language's native script.

The supported languages include: English, Spanish, French, German, Italian, Portuguese (Brazilian and European), Dutch, Russian, Polish, Czech, Danish, Finnish, Swedish, Hungarian, Norwegian, Romanian, Turkish, Ukrainian, Greek, Croatian, Slovenian, Arabic, Hebrew, Farsi, Urdu, Hindi, Tamil, Telugu, Malayalam, Kannada, Bengali, Gujarati, Nepali, Marathi, Punjabi, Thai, Khmer, Vietnamese, Indonesian, Malay, Filipino, Somali, Japanese, Korean, Simplified Chinese, Traditional Chinese (Taiwan), Catalan, and Galician.

#### Right-to-left support

Languages like Arabic, Hebrew, Farsi, and Urdu automatically set `dir: 'rtl'` in the translation object. The tldraw UI respects this direction. Layout and text alignment mirror automatically. The `dir` attribute is set on the editor's root container, and the built-in components use CSS logical properties (`margin-inline-start`, `inset-inline-end`, and so on) so they flip without per-component code.

When building custom UI components, use the `useDirection` hook to get the current text direction. It returns `'ltr'` or `'rtl'` from the active translation context — the same value you'd read from `useCurrentTranslation().dir`, but without pulling the rest of the translation object into the component:

```tsx
import { useDirection } from 'tldraw'

function CustomPanel() {
	const dir = useDirection()
	return <aside dir={dir}>{/* Panel content */}</aside>
}
```

Use `useCurrentTranslation` instead when you also need the locale, label, or `messages` from the same render.

#### Building a language picker

Here's a language picker that updates the user's locale preference:

```tsx
import { useEditor, useValue } from 'tldraw'
import { LANGUAGES } from '@tldraw/tlschema'

function LanguagePicker() {
	const editor = useEditor()
	const currentLocale = useValue('locale', () => editor.user.getLocale(), [editor])

	return (
		<select
			value={currentLocale}
			onChange={(e) => {
				editor.user.updateUserPreferences({ locale: e.target.value })
			}}
		>
			{LANGUAGES.map(({ locale, label }) => (
				<option key={locale} value={locale}>
					{label}
				</option>
			))}
		</select>
	)
}
```

The language change applies immediately without a page reload. The preference persists to localStorage and synchronizes across browser tabs.

#### Translation loading

Translations load asynchronously when the locale changes. The system:

1. Fetches the translation JSON file for the selected locale
2. Merges it with English translations (ensuring all keys have values)
3. Applies any custom overrides
4. Updates the translation context

During loading, the UI uses the previous translations to avoid flicker. If loading fails, English remains as the fallback.

#### Related examples

- **[Custom translations and overrides](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/custom-language-translations)** — Override translation strings and use them in custom UI.

### License key

The tldraw SDK requires a license key to work in production. Without a valid key, the SDK runs in development mode only. License keys unlock production deployment and, depending on your license type, may remove the "made with tldraw" watermark.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw licenseKey="your-license-key" />
		</div>
	)
}
```

#### How license keys work

License keys are validated on the client. They can be public—you can safely include them in your frontend code. The SDK decodes and verifies the key locally without making network requests to a license server.

Each key encodes:

- **Allowed hosts**: The domains where the license is valid
- **License type**: Trial, commercial, or hobby
- **Expiration date**: When the license stops working

The SDK determines development vs production by checking the protocol and hostname. HTTPS on a non-localhost domain is considered production; HTTP or localhost is development.

#### License types

| Type       | Watermark | Duration | Purpose                           |
| ---------- | --------- | -------- | --------------------------------- |
| Trial      | No        | 100 days | Evaluate before purchasing        |
| Commercial | No        | Annual   | Production use in commercial apps |
| Hobby      | Yes       | Varies   | Non-commercial projects           |

##### Trial licenses

Get a free 100-day trial by completing the [trial license form](https://tldraw.dev/get-a-license/trial). You'll receive a license key immediately by email. One trial per company or project.

##### Commercial licenses

Request a commercial license through the [plans form](https://tldraw.dev/get-a-license/plans). The sales team will discuss your requirements and pricing. Commercial licenses remove the watermark and are required for any commercial use in production.

##### Hobby licenses

For non-commercial projects, request a [hobby license](https://tldraw.dev/get-a-license/hobby). Hobby licenses keep the "made with tldraw" watermark visible. They're discretionary. The team reviews each request.

#### Development vs production

In development environments, the SDK works without a license key. It detects development by checking:

- **Protocol**: HTTP (not HTTPS) indicates development
- **Hostname**: `localhost` indicates development
- **Build mode**: `NODE_ENV !== 'production'` indicates development

If any of these conditions are true, you're in development mode and the SDK works normally without a key.

In production (HTTPS on a non-localhost domain with `NODE_ENV=production`), the SDK requires a valid license key. Without one, you'll see console errors about the missing or invalid license.

#### Domain validation

License keys specify which domains they work on. The SDK validates the current hostname against the allowed hosts in the key.

Domain matching supports:

- **Exact matches**: `example.com` matches `example.com` and `www.example.com`
- **Wildcards**: `*.example.com` matches any subdomain
- **All domains**: Some enterprise licenses allow `*` for any domain

If you deploy to a domain not covered by your license, the SDK treats it as unlicensed.

#### Grace period

Annual and perpetual licenses have a 30-day grace period after expiration. During this period, the SDK continues working but logs warnings to the console. This gives you time to renew without service interruption.

Evaluation (trial) licenses have no grace period—they stop working immediately on expiration.

#### Perpetual licenses

Perpetual licenses don't have a time-based expiration. Instead, they're tied to a version: they work with any patch release indefinitely, but major or minor versions released after the license expiration date require renewal.

Early versions of tldraw were sold with a perpetual license. While we no longer sell these licenses (except in exceptional cases), we still support them for existing customers.

#### Using the license key

##### Automatic environment variable detection

The SDK automatically checks for license keys in common environment variables. If you set one of these, you don't need to pass the `licenseKey` prop:

- `TLDRAW_LICENSE_KEY`
- `NEXT_PUBLIC_TLDRAW_LICENSE_KEY` (Next.js)
- `REACT_APP_TLDRAW_LICENSE_KEY` (Create React App)
- `GATSBY_TLDRAW_LICENSE_KEY` (Gatsby)
- `VITE_TLDRAW_LICENSE_KEY` (Vite)
- `PUBLIC_TLDRAW_LICENSE_KEY` (SvelteKit, etc.)

The SDK checks both `process.env` and `import.meta.env` versions of each variable.

##### Passing the key directly

Pass the key to the `licenseKey` prop on `<Tldraw>` or `<TldrawEditor>`:

```tsx
<Tldraw licenseKey="tldraw-abc123..." />
```

For `<TldrawImage>`, the same prop works:

```tsx
<TldrawImage snapshot={snapshot} licenseKey="tldraw-abc123..." />
```

You can also reference an environment variable explicitly:

```tsx
<Tldraw licenseKey={process.env.NEXT_PUBLIC_TLDRAW_LICENSE_KEY} />
```

Since keys are validated client-side and are domain-restricted, exposing them in your bundle is safe.

#### Data collection

Data collection differs by license type:

| License type | Data sent                                  |
| ------------ | ------------------------------------------ |
| Commercial   | None                                       |
| Hobby        | License ID, SDK version, and page URL      |
| Trial        | License ID, SDK version, and page URL      |
| Unlicensed   | SDK version and page URL (production only) |

Trial and hobby licenses ping tldraw's servers with the license ID, SDK version, and deployment URL for analytics. No user data, canvas content, or personally identifiable information is collected. Nothing is sent from development environments.

#### Troubleshooting

**Console shows "No tldraw license key provided" and "A license is required for production deployments"**: You're in production without a key. Add a valid `licenseKey` prop or set an environment variable.

**Console shows "License key is not valid for this domain"**: Your key doesn't include the current domain. Contact sales@tldraw.com to update your allowed domains.

**Console shows "Your tldraw evaluation license has expired"**: Trial licenses have no grace period. Contact sales@tldraw.com to purchase a full license.

**Console shows "Your tldraw license has been expired for more than 30 days"**: Your license is past the 30-day grace period. Contact sales@tldraw.com to renew.

**Key works locally but not in production**: Development mode doesn't require a key. Make sure you're passing the key correctly in your production build and that the domain matches your license.

#### Learn more

See the [License](https://tldraw.dev/community/license) page for full details on the tldraw license terms, including information about open source usage and trademark guidelines.

### Locked shapes

Locked shapes can't be selected, moved, resized, edited, or deleted through normal interactions. Lock a shape when you want it to stay exactly where it is: background elements, reference images, or layout guides that shouldn't move while you work on other parts of the canvas.

#### The isLocked property

Every [shape](https://tldraw.dev/docs/shapes) has an `isLocked` boolean property. When `true`, the shape is protected from most modifications:

```typescript
// Create a locked shape
editor.createShape({
	type: 'geo',
	x: 100,
	y: 100,
	isLocked: true,
	props: { w: 200, h: 150, geo: 'rectangle' },
})

// Check if a shape is locked
const shape = editor.getShape(shapeId)
if (shape.isLocked) {
	// Shape is locked
}
```

#### Toggling lock state

Use `Editor#toggleLock` to flip the lock state of shapes. If shapes have mixed lock states, they all become locked:

```typescript
// Lock selected shapes
editor.toggleLock(editor.getSelectedShapeIds())

// Lock specific shapes by ID
editor.toggleLock([shapeId1, shapeId2])
```

When you lock shapes that were all previously unlocked, they automatically deselect. This prevents accidentally manipulating them while they're protected.

#### What locking prevents

Locked shapes resist most operations:

| Operation    | Behavior                                                                                                 |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| Selection    | Clicking a locked shape doesn't select it. Brush selection and `Editor#selectAll` skip locked shapes. |
| Movement     | Pointer drags pass through locked shapes to the canvas.                                                  |
| Modification | `Editor#updateShape` calls on locked shapes are ignored.                                              |
| Deletion     | `Editor#deleteShapes` skips locked shapes.                                                            |
| Grouping     | Locked shapes can't be added to groups.                                                                  |
| Editing      | Double-clicking a locked shape doesn't enter edit mode.                                                  |

#### Ancestor locking

Lock state inherits through the shape hierarchy. If a shape's ancestor is locked, the shape behaves as locked too. Check this with `Editor#isShapeOrAncestorLocked`:

```typescript
// True if the shape or any of its parents is locked
const isProtected = editor.isShapeOrAncestorLocked(shape)
```

Locking a frame or group protects all its children without needing to lock each shape individually.

#### Bypassing locks programmatically

Sometimes you need to modify locked shapes from code: migrations, admin tools, or automated operations. Wrap your operations in `Editor#run` with `ignoreShapeLock: true`:

```typescript
editor.run(
	() => {
		// These operations affect locked shapes
		editor.updateShape({ id: lockedShapeId, type: 'geo', x: 200 })
		editor.deleteShapes([lockedShapeId])
	},
	{ ignoreShapeLock: true }
)
```

This bypasses the lock check for all operations inside the callback. Use it carefully since the lock exists to protect shapes from unintended changes.

Even with `ignoreShapeLock: true`, some behaviors remain unchanged: locked shapes still can't be selected by clicking, pointer events still pass through to the canvas, and `selectAll()` still skips locked shapes. The flag affects store operations (create, update, delete), not the selection and interaction model.

#### Shapes that can be edited while locked

Some shapes have interactive content that should remain usable even when locked. Embed shapes (YouTube videos, Figma files, interactive maps) are a good example: you might want the embed locked in place but still playable or navigable.

[ShapeUtils](https://tldraw.dev/sdk-features/shapes#shapeutil) can override `ShapeUtil#canEditWhileLocked` to allow editing interactions on locked shapes:

```typescript
class MyInteractiveShapeUtil extends ShapeUtil<MyShape> {
	override canEditWhileLocked(shape: MyShape): boolean {
		return true
	}
}
```

When this returns `true`, double-clicking the locked shape enters edit mode, letting users interact with the shape's content without being able to move or resize it.

#### Locking in the UI

In the default tldraw UI, users can lock shapes through the context menu or the keyboard shortcut (`Shift+L`). Users can right-click on a locked shape to access the context menu, which shows an unlock option.

#### Related examples

- **[Locked shapes](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/locked-shapes)** — Create locked shapes and modify them with `ignoreShapeLock`.

### Note shape

The note shape is one of the default shapes in tldraw. It renders as a sticky note with a colored background and text content. Notes have special interaction behaviors that make them ideal for brainstorming and clustering ideas: you can quickly spawn new notes adjacent to existing ones using clone handles or keyboard shortcuts.

```tsx
import { toRichText } from 'tldraw'

editor.createShape({
	type: 'note',
	x: 100,
	y: 100,
	props: {
		color: 'yellow',
		labelColor: 'black',
		richText: toRichText('My note'),
		size: 'm',
		font: 'draw',
		align: 'middle',
		verticalAlign: 'middle',
	},
})
```

#### Sizing behavior

Notes have a fixed base width of 200 pixels. Unlike most shapes, you can't manually resize a note by default. Instead, notes automatically grow vertically to fit their text content. The `growY` property tracks how much extra height the note needs beyond its base size.

When text is too wide, the note shrinks the font size (down to a minimum of 14px) before allowing text to wrap. This keeps notes compact without hiding content.

#### Clone handles

Notes display clone handles on their edges—small plus buttons at the top, right, bottom, and left sides. These handles let you quickly create adjacent notes.

**Click a clone handle** to either:

- Create a new note in that direction, or
- If a note already exists there, select it and start editing

**Drag a clone handle** to create a new note and immediately begin moving it. When you release, the new note enters edit mode. Spawn notes and position them freely.

Clone handles only appear when the note is selected and the zoom level is high enough. At very low zoom levels (below 25%), handles are hidden entirely. Between 25-50% zoom, only the bottom handle appears to reduce visual clutter. Touch input never shows clone handles.

```tsx
// Clone handles are returned by getHandles on the note shape
const handles = editor.getShapeHandles(noteShape)
// Each handle has type: 'clone' and an id like 'top', 'right', 'bottom', 'left'
```

#### Adjacent snapping

When you create a new note with the note tool, tldraw checks if you're clicking near an "adjacent position"—an empty slot next to an existing note. If you're within 10 pixels of one of these slots, the new note snaps into place with consistent spacing.

This snapping only works between notes with the same rotation and scale. The spacing between adjacent notes uses `editor.options.adjacentShapeMargin` (10 pixels by default).

#### Keyboard navigation

When editing a note, you can use keyboard shortcuts to create adjacent notes:

| Shortcut                 | Action                      |
| ------------------------ | --------------------------- |
| **Tab**                  | Create or select note right |
| **Shift+Tab**            | Create or select note left  |
| **Cmd/Ctrl+Enter**       | Create or select note below |
| **Shift+Cmd/Ctrl+Enter** | Create or select note above |

These shortcuts respect the current note's rotation. They also handle right-to-left text: in RTL content, Tab moves left instead of right.

When moving down, the keyboard shortcut accounts for the current note's `growY`. This keeps notes from overlapping when one note has grown taller than the base size.

#### Visual appearance

Notes render with a subtle drop shadow that varies based on the shape's ID. Each note has slightly different lift and rotation. The shadow responds to the note's rotation on the canvas.

In dark mode, shadows are replaced with a simple bottom border since shadows don't render well against dark backgrounds. Shadows are also hidden when zoomed out below 25% to improve performance.

The note's text color is determined by its `labelColor` property. When set to `'black'` (the default), the note uses a color that contrasts well with the background. Other label colors override this automatic selection.

#### Properties

| Property             | Type                            | Description                                                                      |
| -------------------- | ------------------------------- | -------------------------------------------------------------------------------- |
| `color`              | `TLDefaultColorStyle`           | Background color of the note                                                     |
| `labelColor`         | `TLDefaultColorStyle`           | Text color (independent of background)                                           |
| `richText`           | `TLRichText`                    | Note content with formatting                                                     |
| `size`               | `TLDefaultSizeStyle`            | Size preset (`s`, `m`, `l`, `xl`) affecting base font                            |
| `font`               | `TLDefaultFontStyle`            | Font family (`draw`, `sans`, `serif`, `mono`)                                    |
| `align`              | `TLDefaultHorizontalAlignStyle` | Horizontal text alignment                                                        |
| `verticalAlign`      | `TLDefaultVerticalAlignStyle`   | Vertical text alignment                                                          |
| `fontSizeAdjustment` | `number \| null`                | Ratio applied to the base font size when text shrinks to fit (set automatically) |
| `growY`              | `number`                        | Additional height beyond the base 200px (set automatically)                      |
| `url`                | `string`                        | Optional hyperlink URL                                                           |
| `scale`              | `number`                        | Scale factor applied to the shape                                                |
| `textFirstEditedBy`  | `string \| null`                | ID of the user who first edited the note's text                                  |

#### Configuration

Notes support the following configuration options:

| Option       | Type                  | Default  | Description                                                       |
| ------------ | --------------------- | -------- | ----------------------------------------------------------------- |
| `resizeMode` | `'none'` \| `'scale'` | `'none'` | How the note resizes. Set to `'scale'` for manual resize handles. |

By default, notes can't be manually resized—they only grow based on text content. To allow user resizing with locked aspect ratio:

```tsx
import { Tldraw, NoteShapeUtil } from 'tldraw'
import 'tldraw/tldraw.css'

const ConfiguredNoteUtil = NoteShapeUtil.configure({
	resizeMode: 'scale',
})

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw shapeUtils={[ConfiguredNoteUtil]} />
		</div>
	)
}
```

See the [note resizing example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/resize-note) for a working demo.

#### Dynamic resize mode

When `editor.user.getIsDynamicResizeMode()` is true, new notes are created at a scale inversely proportional to the current zoom level. Notes stay visually consistent regardless of zoom level when created.

```tsx
// If dynamic resize mode is on and zoom is 2x, new notes get scale: 0.5
// If zoom is 0.5x, new notes get scale: 2
const scale = editor.getResizeScaleFactor()
```

#### Related articles

- [Attribution](https://tldraw.dev/sdk-features/attribution) — How notes display "first edited by" labels in multiplayer sessions
- [Default shapes](https://tldraw.dev/sdk-features/default-shapes) — Overview of all built-in shapes
- [Rich text](https://tldraw.dev/sdk-features/rich-text) — Working with formatted text content
- [Styles](https://tldraw.dev/sdk-features/styles) — Working with shape styles like color and size

### Options

The tldraw editor accepts two kinds of configuration: **editor options** for core behavior (timing, limits, performance) and **component props** for features like persistence and custom shapes. Editor options are immutable after initialization; component props configure how the editor sets up.

#### Editor options

The `options` prop accepts a `Partial<TldrawOptions>` object that configures core editor behavior: limits like maximum pages and shapes, timing for interactions and animations, sizing for handles and hit testing, and feature toggles.

Options are set once when the editor initializes and cannot change afterward. Pass a partial options object to override specific values; everything else uses sensible defaults.

```tsx
import { Tldraw, TldrawOptions } from 'tldraw'

const options: Partial<TldrawOptions> = {
	maxPages: 3,
	maxShapesPerPage: 1000,
}

function App() {
	return <Tldraw options={options} />
}
```

The `TldrawOptions` type defines all available options. Since you typically only need to customize a few values, pass `Partial<TldrawOptions>` to override specific options while accepting defaults for the rest.

#### Reading options at runtime

After the editor initializes, access options through `editor.options`:

```typescript
const maxPages = editor.options.maxPages
const animationDuration = editor.options.animationMediumMs
```

The options object is readonly. Attempting to modify it has no effect and TypeScript will flag the error.

#### Editor option categories

##### Limits

Control maximum quantities to prevent performance issues or enforce business rules:

| Option             | Default | Description                         |
| ------------------ | ------- | ----------------------------------- |
| `maxShapesPerPage` | 4000    | Maximum shapes allowed on one page  |
| `maxPages`         | 40      | Maximum pages in a document         |
| `maxFilesAtOnce`   | 100     | Maximum files to handle in one drop |

Setting `maxPages` to 1 effectively disables multi-page functionality and removes the page menu from the UI.

##### Interaction timing

Configure how the editor interprets user input timing:

| Option                  | Default | Description                                       |
| ----------------------- | ------- | ------------------------------------------------- |
| `doubleClickDurationMs` | 450     | Maximum interval for double-click detection       |
| `multiClickDurationMs`  | 200     | Double-click settle delay and overflow window     |
| `longPressDurationMs`   | 500     | Duration to trigger long press                    |
| `animationMediumMs`     | 320     | Duration for camera animations (zoom, pan to fit) |

##### Drag detection

Control when pointer movement becomes a drag operation:

| Option                        | Default | Description                              |
| ----------------------------- | ------- | ---------------------------------------- |
| `dragDistanceSquared`         | 16      | Distance² threshold for mouse drag (4px) |
| `coarseDragDistanceSquared`   | 36      | Distance² threshold for touch drag (6px) |
| `uiDragDistanceSquared`       | 16      | Distance² for UI element drag (4px)      |
| `uiCoarseDragDistanceSquared` | 625     | Distance² for touch UI drag (25px)       |

Values are squared to avoid computing square roots during hit testing. The larger touch thresholds prevent accidental drags on mobile devices.

##### Handle and hit testing

Configure selection handles and click target areas:

| Option               | Default | Description                                   |
| -------------------- | ------- | --------------------------------------------- |
| `handleRadius`       | 12      | Radius of selection handles                   |
| `coarseHandleRadius` | 20      | Handle radius for touch input                 |
| `coarsePointerWidth` | 12      | Expanded pointer size for touch               |
| `hitTestMargin`      | 8       | Additional margin around shapes for hit tests |

##### Edge scrolling

Configure auto-scroll behavior when dragging near viewport edges:

| Option                   | Default | Description                              |
| ------------------------ | ------- | ---------------------------------------- |
| `edgeScrollDelay`        | 200     | Milliseconds before scroll starts        |
| `edgeScrollEaseDuration` | 200     | Milliseconds to accelerate to full speed |
| `edgeScrollSpeed`        | 25      | Base scroll speed in pixels per tick     |
| `edgeScrollDistance`     | 8       | Width of the edge scroll trigger zone    |

See [Edge scrolling](https://tldraw.dev/sdk-features/edge-scrolling) for details on how these options affect behavior.

##### Camera

Control camera movement and viewport behavior:

| Option                    | Default | Description                                       |
| ------------------------- | ------- | ------------------------------------------------- |
| `cameraSlideFriction`     | 0.09    | Friction applied to camera momentum               |
| `cameraMovingTimeoutMs`   | 64      | Time before camera is considered stopped          |
| `followChaseViewportSnap` | 2       | Snap threshold when following collaborators       |
| `spacebarPanning`         | true    | Enable spacebar to activate pan mode              |
| `rightClickPanning`       | true    | Enable right-click and drag to pan the camera     |
| `zoomToFitPadding`        | 128     | Padding around content when zooming to fit bounds |

##### Snapping

| Option          | Default | Description                                  |
| --------------- | ------- | -------------------------------------------- |
| `snapThreshold` | 8       | Distance in pixels at which snapping engages |

##### Collaboration

Configure timing for collaborator presence:

| Option                          | Default | Description                               |
| ------------------------------- | ------- | ----------------------------------------- |
| `collaboratorInactiveTimeoutMs` | 60000   | Time before collaborator marked inactive  |
| `collaboratorIdleTimeoutMs`     | 3000    | Time before collaborator marked idle      |
| `collaboratorCheckIntervalMs`   | 1200    | Interval for checking collaborator status |

##### Export

Configure image and SVG export behavior:

| Option                      | Default  | Description                              |
| --------------------------- | -------- | ---------------------------------------- |
| `defaultSvgPadding`         | 32       | Padding around exported SVG content      |
| `maxExportDelayMs`          | 5000     | Maximum wait time for export completion  |
| `flattenImageBoundsExpand`  | 64       | Expansion when flattening images         |
| `flattenImageBoundsPadding` | 16       | Padding when flattening images           |
| `exportProvider`            | Fragment | React provider wrapping exported content |

The `exportProvider` option wraps exported content in a React component. Use this when your custom shapes depend on context providers that must be present during rendering:

```tsx
const options: Partial<TldrawOptions> = {
	exportProvider: ({ children }) => <ThemeProvider theme={myTheme}>{children}</ThemeProvider>,
}
```

##### Grid

Configure the alignment grid:

```typescript
gridSteps: [
	{ min: -1, mid: 0.15, step: 64 },
	{ min: 0.05, mid: 0.375, step: 16 },
	{ min: 0.15, mid: 1, step: 4 },
	{ min: 0.7, mid: 2.5, step: 1 },
]
```

Each entry defines a grid step size based on zoom level. The `min` and `mid` values define the zoom range where the `step` applies. At lower zoom levels, larger grid steps are used; at higher zoom levels, finer grid steps appear.

##### Performance

Options that affect rendering performance:

| Option                       | Default  | Description                              |
| ---------------------------- | -------- | ---------------------------------------- |
| `debouncedZoom`              | true     | Use cached zoom while camera is moving   |
| `debouncedZoomThreshold`     | 500      | Shape count threshold for debounced zoom |
| `maxFontsToLoadBeforeRender` | Infinity | Fonts to load before showing canvas      |
| `textShadowLod`              | 0.35     | Zoom threshold for text shadow rendering |

When `debouncedZoom` is enabled and the page has more shapes than `debouncedZoomThreshold`, the editor returns a cached zoom level during camera movement. This reduces re-renders of complex documents.

##### UI and features

| Option                           | Default   | Description                                    |
| -------------------------------- | --------- | ---------------------------------------------- |
| `createTextOnCanvasDoubleClick`  | true      | Create text shape on empty canvas double-click |
| `enableToolbarKeyboardShortcuts` | true      | Enable number keys (1-9) for toolbar items     |
| `actionShortcutsLocation`        | 'swap'    | Where to show keyboard shortcuts in menus      |
| `tooltipDelayMs`                 | 700       | Delay before showing tooltips                  |
| `laserDelayMs`                   | 1200      | Duration laser pointer remains visible         |
| `laserFadeoutMs`                 | 500       | Duration for laser pointer fadeout animation   |
| `quickZoomPreservesScreenBounds` | true      | Whether quick zoom brush keeps viewport scale  |
| `branding`                       | undefined | App name for accessibility labels              |
| `nonce`                          | undefined | CSP nonce for inline styles                    |

The `actionShortcutsLocation` option controls where keyboard shortcuts appear:

- `'menu'` - Show shortcuts in menu items only
- `'toolbar'` - Show shortcuts in toolbar tooltips only
- `'swap'` - Show in menus when toolbar is collapsed, toolbar when expanded

##### Asset handling

| Option                            | Default | Description                                       |
| --------------------------------- | ------- | ------------------------------------------------- |
| `temporaryAssetPreviewLifetimeMs` | 180000  | How long temporary asset previews persist (3 min) |
| `adjacentShapeMargin`             | 10      | Margin between auto-positioned shapes             |

##### Clipboard hooks

These callbacks let you intercept and customize clipboard operations:

| Option                       | Description                                                                                                                                                                                          |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onBeforeCopyToClipboard`    | Called before content is written to the clipboard during copy or cut. Return modified `TLContent` to transform, `false` to cancel, or `void` to pass through.                                        |
| `onBeforePasteFromClipboard` | Called before pasted content is processed and shapes are created. Return `false` to cancel, a modified content object to transform, or `void` to pass through. Only fires for paste, not file drops. |
| `onClipboardPasteRaw`        | Called first for keyboard and menu paste, before tldraw parses clipboard data. Return `false` to cancel default paste handling, or `void` to continue.                                               |

See [Clipboard](https://tldraw.dev/sdk-features/clipboard) for more details on clipboard operations.

##### Drop handling

| Option                         | Description                                                                                                                              |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `experimental__onDropOnCanvas` | Called when content is dropped on the canvas. Receives the page position and drag event. Return `true` to prevent default drop handling. |

#### Default values

The `defaultTldrawOptions` export provides all default values:

```typescript
import { defaultTldrawOptions } from 'tldraw'

console.log(defaultTldrawOptions.maxPages) // 40
```

Use this to check defaults or spread into your own options:

```typescript
const options: Partial<TldrawOptions> = {
	...defaultTldrawOptions,
	maxPages: 10,
}
```

#### Tldraw component props

The `<Tldraw>` component accepts additional props beyond those available on `<TldrawEditor>`. These props configure the UI layer, external content handling, and other features that the full SDK provides.

##### UI configuration

| Prop          | Type                    | Description                                        |
| ------------- | ----------------------- | -------------------------------------------------- |
| `hideUi`      | `boolean`               | Hide all UI elements, showing only the canvas      |
| `forceMobile` | `boolean`               | Force mobile breakpoints regardless of screen size |
| `overrides`   | `TLUiOverrides`         | Override actions, tools, and translations          |
| `onUiEvent`   | `TLUiEventHandler`      | Callback for UI interaction events                 |
| `components`  | `TLComponents`          | Override or disable UI and canvas components       |
| `assetUrls`   | `TLUiAssetUrlOverrides` | Custom URLs for fonts, icons, and other UI assets  |

The `hideUi` prop is useful when building custom interfaces around the canvas:

```tsx
function CustomEditor() {
	return (
		<Tldraw hideUi>
			<MyCustomToolbar />
		</Tldraw>
	)
}
```

##### External content handling

These props control how the editor handles dropped or pasted files:

| Prop                     | Default                         | Description                                             |
| ------------------------ | ------------------------------- | ------------------------------------------------------- |
| `maxImageDimension`      | 5000                            | Maximum width/height for images (larger images resized) |
| `maxAssetSize`           | 10485760                        | Maximum file size in bytes (10 MB)                      |
| `acceptedImageMimeTypes` | `DEFAULT_SUPPORTED_IMAGE_TYPES` | Allowed image MIME types                                |
| `acceptedVideoMimeTypes` | `DEFAULT_SUPPORT_VIDEO_TYPES`   | Allowed video MIME types                                |

```tsx
function App() {
	return (
		<Tldraw
			maxImageDimension={2000}
			maxAssetSize={5 * 1024 * 1024} // 5 MB
			acceptedImageMimeTypes={['image/png', 'image/jpeg']}
		/>
	)
}
```

##### Embeds

Customize which embed types the editor recognizes using `EmbedShapeUtil.configure()`:

```tsx
import { Tldraw, EmbedShapeUtil, DEFAULT_EMBED_DEFINITIONS } from 'tldraw'

const shapeUtils = [
	EmbedShapeUtil.configure({
		embedDefinitions: [
			...DEFAULT_EMBED_DEFINITIONS,
			{
				type: 'custom-video',
				title: 'Custom Video',
				hostnames: ['videos.example.com'],
				width: 560,
				height: 315,
				doesResize: true,
				toEmbedUrl: (url) => url.replace('/watch/', '/embed/'),
				fromEmbedUrl: (url) => url.replace('/embed/', '/watch/'),
			},
		],
	}),
]

function App() {
	return <Tldraw shapeUtils={shapeUtils} />
}
```

See [Embed shape](https://tldraw.dev/sdk-features/embed-shape#custom-embed-definitions) for the full embed definition API.

##### Editor setup

These props configure how the editor initializes:

| Prop                 | Type                                                    | Description                                             |
| -------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
| `autoFocus`          | `boolean`                                               | Automatically focus the editor on mount                 |
| `initialState`       | `string`                                                | Initial tool state (defaults to `'select'`)             |
| `shapeUtils`         | `TLShapeUtilConstructor[]`                              | Custom shape utilities                                  |
| `bindingUtils`       | `TLBindingUtilConstructor[]`                            | Custom binding utilities                                |
| `overlayUtils`       | `TLAnyOverlayUtilConstructor[]`                         | Custom [overlay utilities](https://tldraw.dev/sdk-features/overlay-utils) |
| `tools`              | `TLStateNodeConstructor[]`                              | Custom tools                                            |
| `onMount`            | `TLOnMountHandler`                                      | Callback when editor mounts                             |
| `getShapeVisibility` | `(shape, editor) => 'visible' \| 'hidden' \| 'inherit'` | Conditionally hide shapes                               |
| `user`               | `TLCurrentUser`                                         | Current user information                                |
| `colorScheme`        | `'light' \| 'dark' \| 'system'`                         | Color scheme (defaults to `'light'`)                    |
| `licenseKey`         | `string`                                                | License key to remove watermark                         |

The `getShapeVisibility` callback lets you conditionally hide shapes based on their properties:

```tsx
<Tldraw
	getShapeVisibility={(shape, editor) => {
		// Hide shapes marked as hidden in meta
		if (shape.meta.hidden) return 'hidden'
		// Force-show shapes regardless of parent visibility
		if (shape.meta.alwaysVisible) return 'visible'
		// Default: visible unless parent is hidden
		return 'inherit'
	}}
/>
```

##### Store configuration

When not providing your own store, these props configure automatic store creation:

| Prop             | Type                  | Description                                           |
| ---------------- | --------------------- | ----------------------------------------------------- |
| `persistenceKey` | `string`              | Key for IndexedDB persistence (enables local storage) |
| `sessionId`      | `string`              | Session identifier for persistence                    |
| `snapshot`       | `TLEditorSnapshot`    | Initial document state                                |
| `migrations`     | `MigrationSequence[]` | Additional migrations for custom schemas              |

```tsx
function App() {
	return <Tldraw persistenceKey="my-document" snapshot={savedSnapshot} />
}
```

Alternatively, provide your own store with the `store` prop for full control over data management.

#### Examples

- **[Editor options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/custom-options)** - Override default options like max pages and animation speed.
- **[Disable pages](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/disable-pages)** - Set `maxPages` to 1 to create a single-page editor.

### Overlay utils

Overlay utils render ephemeral UI on the canvas — selection handles, brush rectangles, snap lines, scribbles, and shape handles. They draw directly to a `<canvas>` element using the Canvas 2D API and can optionally provide hit-test geometry for pointer interactions.

Each overlay util defines a specific type of canvas UI. The editor queries all registered overlay utils reactively: when the editor state changes, overlay utils determine whether they are active, produce overlay instances, and render them.

#### How it works

An `OverlayUtil` is an abstract class with four responsibilities:

1. **Activation** — `isActive()` returns whether the overlay should render right now
2. **Overlay instances** — `getOverlays()` returns the current set of overlay objects
3. **Rendering** — `render()` draws overlays into a canvas 2D context
4. **Hit testing** (optional) — `getGeometry()` returns geometry for interactive overlays

The editor calls these methods reactively. When `isActive()` returns `false`, the overlay is skipped entirely. When it returns `true`, the editor calls `getOverlays()` and `render()` whenever the editor state they depend on changes, including camera movement.

#### Default overlay utils

The `tldraw` package includes these overlay utils by default:

| Overlay util                               | Purpose                                       |
| ------------------------------------------ | --------------------------------------------- |
| `ShapeIndicatorOverlayUtil`             | Selection, hover, and hint outlines on shapes |
| `SelectionForegroundOverlayUtil`        | Selection box, resize handles, corners        |
| `ShapeHandleOverlayUtil`                | Shape handles (arrows, lines, etc.)           |
| `BrushOverlayUtil`                      | Selection brush rectangle                     |
| `ZoomBrushOverlayUtil`                  | Zoom brush rectangle                          |
| `SnapIndicatorOverlayUtil`              | Snap alignment guides                         |
| `ScribbleOverlayUtil`                   | Eraser and lasso scribbles                    |
| `ArrowHintOverlayUtil`                  | Target hints while drawing arrows             |
| `ArrowBindingHintOverlayUtil`           | Binding hints when dragging arrow handles     |
| `CollaboratorBrushOverlayUtil`          | Remote users' brush rectangles                |
| `CollaboratorScribbleOverlayUtil`       | Remote users' scribbles                       |
| `CollaboratorHintOverlayUtil`           | Remote users' viewport-edge hint arrows       |
| `CollaboratorShapeIndicatorOverlayUtil` | Remote users' selection indicators            |

These are exported as `defaultOverlayUtils` from `tldraw`.

#### Creating an overlay util

Extend the `OverlayUtil` class and implement at least `isActive()`, `getOverlays()`, and `render()`:

```tsx
import { OverlayUtil, TLOverlay } from '@tldraw/editor'

interface MyHighlightOverlay extends TLOverlay {
	props: {
		x: number
		y: number
		radius: number
	}
}

class HighlightOverlayUtil extends OverlayUtil<MyHighlightOverlay> {
	static override type = 'highlight'

	override isActive(): boolean {
		// Active when there's exactly one selected shape
		return this.editor.getSelectedShapeIds().length === 1
	}

	override getOverlays(): MyHighlightOverlay[] {
		const shape = this.editor.getOnlySelectedShape()
		if (!shape) return []

		const bounds = this.editor.getShapePageBounds(shape)
		if (!bounds) return []

		return [
			{
				id: 'highlight',
				type: 'highlight',
				props: {
					x: bounds.midX,
					y: bounds.midY,
					radius: Math.max(bounds.width, bounds.height) / 2 + 20,
				},
			},
		]
	}

	override render(ctx: CanvasRenderingContext2D, overlays: MyHighlightOverlay[]): void {
		const zoom = this.editor.getZoomLevel()
		for (const overlay of overlays) {
			const { x, y, radius } = overlay.props
			ctx.beginPath()
			ctx.arc(x, y, radius, 0, Math.PI * 2)
			ctx.strokeStyle = 'dodgerblue'
			ctx.lineWidth = 2 / zoom
			ctx.stroke()
		}
	}
}
```

##### The overlay interface

Each overlay is a plain object with `id`, `type`, and `props`:

```tsx
interface TLOverlay {
	id: string // Unique identifier for this instance
	type: string // Matches the overlay util's static type
	props: Record<string, unknown> // Data needed for rendering and hit testing
}
```

Define a custom interface extending `TLOverlay` to type your props. The `id` must be globally unique across all overlay utils — hit testing and hover lookup use the `id` alone. Namespace your ids to avoid collisions: a fixed string like `'highlight'` works for single-instance overlays, while per-item overlays should include the item's identity (e.g. `'handle:<shapeId>:<handleId>'`).

##### Rendering

The `render()` method receives a `CanvasRenderingContext2D` already transformed to page space (camera offset and zoom applied). Scale line widths and radii by `1 / zoom` to keep them constant on screen:

```tsx
override render(ctx: CanvasRenderingContext2D, overlays: MyOverlay[]): void {
    const zoom = this.editor.getEfficientZoomLevel()
    ctx.lineWidth = 1 / zoom
    // ...draw your overlays
}
```

##### Hit testing

By default, overlays are non-interactive. To make an overlay respond to pointer events, implement `getGeometry()` to return a `Geometry2d` in page coordinates:

```tsx
import { Circle2d, Geometry2d } from '@tldraw/editor'

class MyInteractiveOverlayUtil extends OverlayUtil<MyOverlay> {
	// ...isActive, getOverlays, render...

	override getGeometry(overlay: MyOverlay): Geometry2d | null {
		return new Circle2d({
			x: overlay.props.x - 10,
			y: overlay.props.y - 10,
			radius: 10,
			isFilled: true,
		})
	}

	override getCursor(): TLCursorType | undefined {
		return 'pointer'
	}
}
```

When the pointer moves over an overlay with geometry, the editor updates `OverlayManager#getHoveredOverlayId` and applies the cursor from `getCursor()`.

#### Registering overlay utils

Pass your overlay utils through the `overlayUtils` prop on `Tldraw` or `TldrawEditor`:

```tsx
import { Tldraw } from 'tldraw'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw overlayUtils={[HighlightOverlayUtil]} />
		</div>
	)
}
```

When using the `Tldraw` component, your custom overlay utils are merged with the defaults — you don't need to re-include them. If your custom overlay util has the same `type` as a default one, it replaces the default.

#### Customizing default overlays

To replace a default overlay, extend it and override the methods you want to change. Give it the same static `type` so it replaces the built-in:

```tsx
import { Tldraw, BrushOverlayUtil, type TLBrushOverlay } from 'tldraw'

class BlueBrushOverlayUtil extends BrushOverlayUtil {
	override render(ctx: CanvasRenderingContext2D, overlays: TLBrushOverlay[]): void {
		const overlay = overlays[0]
		if (!overlay) return

		const { x, y, w, h } = overlay.props
		const zoom = this.editor.getZoomLevel()

		ctx.beginPath()
		ctx.rect(x, y, w, h)
		ctx.fillStyle = 'rgba(0, 0, 255, 0.1)'
		ctx.fill()
		ctx.lineWidth = 1 / zoom
		ctx.strokeStyle = 'blue'
		ctx.stroke()
	}
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw overlayUtils={[BlueBrushOverlayUtil]} />
		</div>
	)
}
```

Since `BlueBrushOverlayUtil` inherits the same `type` as `BrushOverlayUtil`, it replaces the default brush overlay.

##### Options via configure

Overlay utils can define an `options` property for configuration. Use the static `OverlayUtil#configure` method to create a customized version without subclassing:

```tsx
class MyOverlayUtil extends OverlayUtil<MyOverlay> {
	static override type = 'my_overlay'
	override options = { color: 'red', radius: 10 }

	// ...use this.options.color and this.options.radius in render()
}

// Create a variant with different options
const BlueOverlay = MyOverlayUtil.configure({ color: 'blue' })
```

#### Accessing overlay utils at runtime

Use `Editor#overlays` to interact with the overlay system:

```tsx
// Get a specific overlay util
const brushUtil = editor.overlays.getOverlayUtil<BrushOverlayUtil>('brush')

// Get all currently active overlays
const activeOverlays = editor.overlays.getCurrentOverlays()

// Hit test at a page point
const overlay = editor.overlays.getOverlayAtPoint({ x: 100, y: 200 })

// Check what's hovered
const hoveredId = editor.overlays.getHoveredOverlayId()
```

#### Related articles

- [Shapes](https://tldraw.dev/sdk-features/shapes) - The shape system that overlays interact with
- [Indicators](https://tldraw.dev/sdk-features/indicators) - Shape indicators rendered alongside overlays
- [Scribble](https://tldraw.dev/sdk-features/scribble) - Scribble system rendered via overlay utils

#### Related examples

- **[Custom overlay](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/custom-overlay)** — Draw a custom canvas overlay on top of the editor.
- **[Replace a built-in overlay](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/replace-brush-overlay)** — Swap out the brush overlay for a custom implementation.
- **[Hovered overlay](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/hovered-overlay)** — Read the overlay that the user is hovering.

### Pages

The pages system provides multiple independent sub-documents within a single tldraw document. Each page acts as a separate scene graph root with its own shapes, camera position, and selection state.

When you switch pages, the editor preserves your camera position and selected shapes on the previous page and restores the state you left on the new page. The page system integrates with collaboration, letting users see which pages their collaborators are viewing and follow them across page boundaries.

#### How it works

Each page is a record in the store with a unique ID, a name for display, and an index for ordering. Pages belong to the document scope, meaning they persist across sessions and sync in collaborative environments. When you create a page, the editor automatically creates associated records for the camera position and instance page state.

The camera record associated with a page tracks viewport position and zoom for that page only. The editor's current camera is based on the current page: when you navigate to a different page, the editor automatically switches to the camera for that page. This per-page camera state lets each user maintain their own view of each page, independent of other users and other pages.

The instance page state record tracks selection, editing state, focused groups, and other transient UI state unique to both a page and a browser session. This state belongs to the session scope, meaning it doesn't sync in collaborative environments. Each user maintains their own instance page state for each page they visit.

Pages are ordered. As with shapes, the page record's `index` property determines its order among other pages.

#### Page methods

##### Access

Get the current page or page ID:

```typescript
const currentPage = editor.getCurrentPage()
const currentPageId = editor.getCurrentPageId()
```

Access any page by ID:

```typescript
import { TLPageId } from 'tldraw'

const page = editor.getPage('page:page1' as TLPageId)
const allPages = editor.getPages()
```

##### Navigation

Switch to a different page using `Editor#setCurrentPage`:

```typescript
editor.setCurrentPage('page:page2' as TLPageId)
```

When switching pages, the editor completes any in-progress interactions and stops following other users. The camera constraints are reapplied to ensure the new page's camera respects its configured bounds.

##### Creating and deleting pages

Create new pages with `Editor#createPage`, which ensures unique page names and proper index ordering:

```typescript
editor.createPage({ name: 'Wireframes' })
```

The editor enforces a maximum page count through the `maxPages` option (default: 40). Attempts to create pages beyond this limit are ignored. Set `maxPages` to 1 to disable multi-page UI entirely.

Delete pages with `Editor#deletePage`:

```typescript
editor.deletePage('page:page1' as TLPageId)
```

When deleting the current page, the editor switches to an adjacent page automatically. The last remaining page cannot be deleted. When a page is deleted, all shapes on that page are removed and the associated camera and instance page state records are cleaned up.

##### Duplicating pages

Use `Editor#duplicatePage` to copy an entire page including all its shapes and camera position:

```typescript
editor.duplicatePage('page:main' as TLPageId)
```

The duplicated page receives a copy of all shapes from the source page, preserving their positions, properties, and bindings between copied shapes. The camera position is copied from the source page. The new page's name appends " Copy" to the original page name.

##### Renaming and updating pages

Rename a page with `Editor#renamePage`:

```typescript
editor.renamePage('page:page1' as TLPageId, 'New Name')
```

For more complex updates like changing metadata, use `Editor#updatePage`:

```typescript
editor.updatePage({ id: 'page:page1' as TLPageId, name: 'Updated Name' })
```

#### Working with shapes across pages

##### Page-specific shape queries

Each page maintains its own shape hierarchy. Get shapes on the current page:

```typescript
const shapes = editor.getCurrentPageShapes()
const shapeIds = editor.getCurrentPageShapeIds()
```

Get shapes from any page:

```typescript
const pageShapeIds = editor.getPageShapeIds('page:page2' as TLPageId)
```

These queries return only the top-level and nested shapes that belong to the specified page. Shapes are parented to pages through their `parentId` field.

##### Moving shapes between pages

Transfer shapes from one page to another using `Editor#moveShapesToPage`:

```typescript
import { TLShapeId } from 'tldraw'

editor.moveShapesToPage(
	['shape:rect1' as TLShapeId, 'shape:circle2' as TLShapeId],
	'page:page2' as TLPageId
)
```

The operation copies the shapes to the destination page at the same position and then removes them from the source page. The editor then switches to the destination page and selects the moved shapes. Bindings between moved shapes are preserved, but bindings to shapes not being moved are removed and receive isolation callbacks. The editor enforces per-page shape limits through the `maxShapesPerPage` option.

#### Collaboration and pages

In collaborative sessions, each user's current page is tracked through the presence system. The editor provides methods to see which pages collaborators are viewing:

```typescript
const collaboratorsOnThisPage = editor.getCollaboratorsOnCurrentPage()
```

When following the viewport of a user who switches pages, your editor switches pages automatically to maintain the follow relationship. See [User following](https://tldraw.dev/sdk-features/user-following) for details on cross-page following behavior.

#### Integration with other systems

The pages system interacts with several editor features.

In collaborative sessions, if a collaborator deletes a page you're viewing, the editor automatically moves you to the next available page.

Each page has its own camera record, preserving zoom and position independently. Camera constraints and options apply per-page.

Bindings can only connect shapes on the same page. When shapes move to different pages, their bindings are automatically removed.

Undo and redo operations are document-wide, not page-specific. Undoing a page creation removes the page and all its shapes.

URLs can encode specific page IDs with the deep links API, allowing direct navigation to a particular page when loading a document.

#### Related examples

- [Disable pages](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/disable-pages) - Disable page-related UI for single-page use cases by setting the `maxPages` option to 1.
- [Deep links](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/deep-links) - Create URLs that navigate to specific pages using the deep links API.

### Parenting and ancestors

Every shape in tldraw has a parent. A shape's parent is either the page it lives on or another shape that contains it. This parent-child relationship creates a hierarchy that affects how shapes move, transform, and render. Groups and frames use this hierarchy to contain other shapes; the editor tracks this hierarchy to manage transforms, selection, and rendering order.

#### The parentId property

Every shape record has a `parentId` property that points to its parent. For shapes directly on the canvas, this is a page ID. For shapes inside groups or frames, it's the containing shape's ID:

```typescript
// A shape on the page
const shape = editor.getShape(myShapeId)
console.log(shape.parentId) // "page:somePage"

// A shape inside a group
const groupedShape = editor.getShape(childShapeId)
console.log(groupedShape.parentId) // "shape:someGroup"
```

You can check what type of parent a shape has using `isPageId` and `isShapeId`, both exported from `tldraw`:

```typescript
import { isPageId, isShapeId } from 'tldraw'

if (isPageId(shape.parentId)) {
	// Shape is directly on a page
}

if (isShapeId(shape.parentId)) {
	// Shape is inside another shape (group, frame, etc.)
}
```

#### Getting a shape's parent

Use `Editor#getShapeParent` to get the parent shape. It returns `undefined` if the shape is directly on a page:

```typescript
const parent = editor.getShapeParent(myShape)

if (parent) {
	console.log('Parent shape:', parent.type)
} else {
	console.log('Shape is on the page')
}
```

#### Getting ancestors

The ancestor chain is the path from a shape up to the page. Use `Editor#getShapeAncestors` to get all ancestors in order from the root to the immediate parent:

```typescript
// For a deeply nested shape:
// page > frameA > groupB > myShape
const ancestors = editor.getShapeAncestors(myShapeId)
// Returns: [frameA, groupB]
```

The array is ordered from root ancestor to immediate parent. The page itself is never included—ancestors only contain shapes.

##### Finding a specific ancestor

Use `Editor#findShapeAncestor` to find the first ancestor matching a condition:

```typescript
// Find the containing frame
const frame = editor.findShapeAncestor(myShape, (ancestor) => ancestor.type === 'frame')

// Find the first locked ancestor
const lockedAncestor = editor.findShapeAncestor(myShape, (ancestor) => ancestor.isLocked)
```

##### Checking for a specific ancestor

Use `Editor#hasAncestor` to check if a shape is inside a specific container:

```typescript
if (editor.hasAncestor(myShape, frameId)) {
	// myShape is somewhere inside this frame
}
```

##### Finding the common ancestor

When working with multiple shapes, use `Editor#findCommonAncestor` to find their nearest shared parent:

```typescript
const shapeIds = [shapeA, shapeB, shapeC]
const commonAncestorId = editor.findCommonAncestor(shapeIds)

if (commonAncestorId) {
	// All shapes share this ancestor
} else {
	// Shapes are on the page with no common parent shape
}
```

You can also filter by a predicate:

```typescript
// Find the common frame ancestor
const commonFrame = editor.findCommonAncestor(shapeIds, (shape) => shape.type === 'frame')
```

#### Getting children

Use `Editor#getSortedChildIdsForParent` to get a shape's children in z-index order:

```typescript
const childIds = editor.getSortedChildIdsForParent(groupId)
// Returns child IDs sorted from back to front
```

This works for pages too:

```typescript
const topLevelShapes = editor.getSortedChildIdsForParent(editor.getCurrentPageId())
```

##### Visiting descendants

For recursive traversal, use `Editor#visitDescendants`:

```typescript
editor.visitDescendants(frameId, (childId) => {
	const child = editor.getShape(childId)
	console.log('Found:', child.type)
	// Return false to skip this shape's children
})
```

To collect all descendants including the shape itself, use `Editor#getShapeAndDescendantIds`:

```typescript
const allIds = editor.getShapeAndDescendantIds([frameId])
// Returns a Set containing frameId and all nested shape IDs
```

#### Reparenting shapes

Use `Editor#reparentShapes` to move shapes into a new parent. This preserves the shapes' page positions—only their local coordinates change to match the new parent's coordinate space:

```typescript
// Move shapes into a frame
editor.reparentShapes([shapeA, shapeB], frameId)

// Move shapes to the page root
editor.reparentShapes([shapeA, shapeB], editor.getCurrentPageId())
```

The method handles coordinate transformation automatically. If the parent is rotated, children's positions and rotations are adjusted so they appear in the same place on the page.

You can optionally specify an insert index to control z-ordering:

```typescript
// Insert at a specific position in the parent's child stack
editor.reparentShapes([newChild], parentId, insertIndex)
```

#### Transforms and coordinates

Parent-child relationships affect coordinate systems. A child shape's `x` and `y` are relative to its parent, not the page.

To convert between coordinate systems:

```typescript
// Convert a page point to a shape's local space ([Editor#getPointInShapeSpace](?))
const localPoint = editor.getPointInShapeSpace(parentShape, pagePoint)

// Get a shape's position in page coordinates ([Editor#getShapePageTransform](?))
const pageTransform = editor.getShapePageTransform(childShape)
const pagePoint = pageTransform.point()
```

When you move a parent, all children move with it. Their local coordinates stay the same, but their page coordinates change.

For more about coordinate systems and transforms, see [Coordinates](https://tldraw.dev/sdk-features/coordinates).

#### Checking page membership

Use `Editor#isShapeInPage` to check if a shape is on a specific page (even if nested):

```typescript
if (editor.isShapeInPage(myShape, pageId)) {
	// Shape is on this page (directly or nested)
}
```

To get the page a shape belongs to, use `Editor#getAncestorPageId`:

```typescript
const pageId = editor.getAncestorPageId(myShape)
```

#### Locked ancestors

A shape is effectively locked if any of its ancestors are locked. Use `Editor#isShapeOrAncestorLocked` to check:

```typescript
if (editor.isShapeOrAncestorLocked(myShape)) {
	// Shape can't be interacted with
}
```

This is what the editor uses internally to determine if shapes should respond to interactions.

#### Hidden shapes

The editor can track shape visibility through `Editor#isShapeHidden`. This only works if you provide a `getShapeVisibility` callback when creating the editor:

```typescript
const editor = new Editor({
	getShapeVisibility: (shape, editor) => {
		// Return 'hidden', 'visible', or 'inherit'
		return shape.meta.hidden ? 'hidden' : 'inherit'
	},
	// ... other options
})

// Now you can check visibility
if (editor.isShapeHidden(myShape)) {
	// Shape won't render (either it or an ancestor is hidden)
}
```

A shape is hidden if its visibility is `'hidden'` or if any ancestor is hidden (unless the shape explicitly overrides with `'visible'`). Without a `getShapeVisibility` callback, `isShapeHidden()` always returns `false`.

#### The focused group

The editor tracks a "focused group" that determines which level of the hierarchy you're working in. When you're focused inside a group, new shapes are created as children of that group. See [Groups](https://tldraw.dev/sdk-features/groups) for details on focused groups.

#### Related examples

- **[Layer panel](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/layer-panel)** - Build a hierarchical layer panel that shows parent-child relationships.
- **[Drag and drop](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/drag-and-drop)** - Handle reparenting when dropping shapes onto containers.

### Performance

The tldraw SDK uses several techniques to maintain smooth performance even with thousands of shapes on the canvas. Understanding these systems helps you build custom shapes that perform well and avoid common pitfalls.

#### How tldraw optimizes rendering

##### Viewport culling

Shapes outside the viewport don't need to render. The editor maintains a spatial index that tracks which shapes are visible, and hides off-screen shapes by setting `display: none` on their DOM elements. This means a canvas with 10,000 shapes might only render 50 if the rest are out of view.

Culling happens automatically for all shapes. The shapes remain in the store and can still be selected or updated—they just don't incur rendering cost. See [Culling](https://tldraw.dev/sdk-features/culling) for details on how to control this behavior for custom shapes.

##### Reactive signals

The SDK uses reactive [signals](https://tldraw.dev/sdk-features/signals) instead of React's built-in state management. Signals automatically track dependencies and update only the parts of your application that actually depend on changed data.

When a shape's props change, only that shape's component re-renders—not the entire canvas. The system tracks dependencies at a granular level, so changing a shape's color won't trigger updates for shapes that don't care about color.

This is why methods like `editor.getSelectedShapeIds()` return reactive values. If you access them inside a `track()` component or `useValue()` hook, your code automatically re-runs when the underlying data changes.

##### Batched store updates

The [store](https://tldraw.dev/sdk-features/store) batches multiple changes into single updates. When you call methods like `editor.createShapes()` or `editor.updateShapes()` with multiple shapes, observers receive one notification with all changes rather than one per shape:

```ts
// These changes are batched automatically
editor.updateShapes([
	{ id: shape1.id, type: 'geo', x: 100 },
	{ id: shape2.id, type: 'geo', x: 200 },
	{ id: shape3.id, type: 'geo', x: 300 },
])
```

For complex operations spanning multiple calls, wrap them in `editor.run()`:

```ts
editor.run(() => {
	editor.createShapes([...])
	editor.updateShapes([...])
	editor.deleteShapes([...])
})
// All changes applied together, listeners notified once
```

##### Debounced zoom

When the camera moves, shape components receive the new zoom level to scale stroke widths and other visual properties. On documents with many shapes, recalculating everything mid-zoom causes jank.

The editor provides `editor.getEfficientZoomLevel()` which returns a stable value during camera movement when the document has more than 500 shapes (configurable via the `debouncedZoomThreshold` option). Once the camera stops, the value updates to the true zoom level.

Shape components should use this value rather than `editor.getZoomLevel()` for properties that affect rendering:

```tsx
function MyShapeComponent({ shape }) {
	const editor = useEditor()
	const zoom = useValue('zoom', () => editor.getEfficientZoomLevel(), [editor])

	// Stroke width stays stable during camera movement
	const strokeWidth = 2 / zoom

	return <path strokeWidth={strokeWidth} ... />
}
```

##### Geometry caching

Computing a shape's geometry—bounds, hit test regions, outline—can be expensive. The editor caches these computations and invalidates them only when a shape's props change.

Access cached geometry through `editor.getShapeGeometry(shapeId)` rather than calling `shapeUtil.getGeometry()` directly. The editor handles caching, transforms, and bounds calculation.

##### Level of detail

tldraw adjusts rendering fidelity based on zoom level and on-screen size, a technique called level of detail (LOD). When a shape is small on screen, rendering every pixel of a high-resolution image or every detail of a complex shape is wasted work.

**Image resolution scaling**

When you zoom out or resize an image shape, tldraw requests a lower-resolution version from your asset store. The `resolve` method on `TLAssetStore` receives a `TLAssetContext` with `steppedScreenScale`—the ratio of the shape's on-screen size to the image's native size, rounded up to the nearest power of two. Use this to serve appropriately sized images:

```ts
const assetStore: TLAssetStore = {
	async resolve(asset, context) {
		if (asset.type !== 'image') return asset.props.src

		// Request a version of the image scaled to what's actually visible on screen
		const width = Math.ceil(asset.props.w * context.steppedScreenScale * context.dpr)
		return `${asset.props.src}?w=${width}`
	},
}
```

A 4000px-wide photo zoomed out to take up 200px on screen only needs 200 device pixels worth of data. This reduces memory usage and decoding cost. Resolution updates are debounced so images don't thrash between sizes during zooming.

**Built-in shape simplifications**

The built-in shapes reduce rendering complexity at low zoom levels:

- **Note shadows** — Box shadows on sticky notes are hidden when zoomed out far enough, replaced by a simple border
- **Draw shapes** — Freehand strokes switch from their detailed "draw" style to solid paths
- **Pattern fills** — Hatch and cross-hatch fills switch to solid colors
- **Text outlines** — Text shadow outlines disable below the `textShadowLod` threshold (default 0.35) to reduce compositing cost

These transitions use `Editor#getEfficientZoomLevel` so they stay stable during camera movement rather than updating every frame. Custom shapes can use the same technique—see [Simplify at small sizes](#simplify-at-small-sizes) below.

#### Tips for custom shapes

##### Simplify at small sizes

When shapes are very small on screen, fine details become invisible. Rendering simpler geometry at low zoom levels improves performance without visible quality loss.

Use `editor.getEfficientZoomLevel()` to detect when shapes are small enough to simplify:

```tsx
function MyShapeComponent({ shape }) {
	const editor = useEditor()
	const isSmall = useValue(
		'is small',
		() => {
			const zoom = editor.getEfficientZoomLevel()
			// Shape is small if its screen size is under 50px
			return shape.props.w * zoom < 50
		},
		[editor, shape.props.w]
	)

	if (isSmall) {
		// Render simplified version
		return <rect width={shape.props.w} height={shape.props.h} fill="currentColor" />
	}

	// Render full detail version
	return <ComplexShapeContent shape={shape} />
}
```

The built-in shapes use this pattern. Pattern fills switch to solid colors when zoomed out far enough, and text shadows disable at low zoom levels (controlled by the `textShadowLod` option).

##### Avoid shape animations

Animating shape properties causes continuous re-renders. A spinning shape triggers updates every frame. If you have many shapes or complex rendering, this adds up quickly.

If you need animation, consider:

- **CSS animations** for purely visual effects that don't change shape data
- **Canvas rendering** for particle systems or complex animations
- **Limiting concurrent animations** to a small number of shapes

The [Animation](https://tldraw.dev/sdk-features/animation) article covers the editor's animation system. The animation system handles camera movement and occasional shape transitions. It's not designed for continuous per-shape animation.

##### Use stable values for zoom-dependent calculations

When calculating values that depend on zoom (stroke widths, font sizes, handle positions), use `getEfficientZoomLevel()` rather than `getZoomLevel()`. This prevents recalculations during camera movement:

```tsx
// Avoid: causes re-renders during zoom
const strokeWidth = 2 / editor.getZoomLevel()

// Better: stable during camera movement
const strokeWidth = 2 / editor.getEfficientZoomLevel()
```

##### Keep component functions cheap

Shape components render frequently. Avoid expensive operations inside them:

```tsx
// Avoid: expensive calculation every render
function MyShapeComponent({ shape }) {
	const complexData = computeExpensiveData(shape) // runs every render
	return <div>{complexData}</div>
}

// Better: use memoization or move to getGeometry
function MyShapeComponent({ shape }) {
	const complexData = useMemo(() => computeExpensiveData(shape), [shape.props.relevantProp])
	return <div>{complexData}</div>
}
```

For calculations that affect hit testing or bounds, put them in `getGeometry()` instead. The editor caches geometry automatically.

##### Disable culling only when necessary

By default, all shapes participate in culling. Override `canCull()` to return `false` only for shapes that genuinely need to stay rendered off-screen:

```ts
class MyShapeUtil extends ShapeUtil<MyShape> {
	override canCull(shape: MyShape): boolean {
		// Only disable culling for shapes that measure their DOM
		return !shape.props.dynamicSize
	}
}
```

Common reasons to disable culling:

- Shapes that measure their DOM content to determine size
- Shapes with visual effects (shadows, glows) that extend beyond bounds
- Shapes running animations that should continue off-screen

For most shapes, leave culling enabled.

#### Editor options for performance

Several [editor options](https://tldraw.dev/sdk-features/options) affect performance:

| Option                   | Default | Description                                      |
| ------------------------ | ------- | ------------------------------------------------ |
| `debouncedZoom`          | `true`  | Use stable zoom during camera movement           |
| `debouncedZoomThreshold` | 500     | Shape count above which debounced zoom activates |
| `maxShapesPerPage`       | 4000    | Maximum shapes allowed per page                  |
| `textShadowLod`          | 0.35    | Zoom threshold below which text shadows disable  |

```tsx
import { Tldraw } from 'tldraw'

function App() {
	return (
		<Tldraw
			options={{
				debouncedZoomThreshold: 1000, // Higher threshold for simpler documents
				maxShapesPerPage: 10000, // Allow more shapes if needed
			}}
		/>
	)
}
```

#### Measuring performance

When investigating performance issues:

1. **Check shape count** — `editor.getCurrentPageShapeIds().size` tells you how many shapes are on the current page
2. **Check culled shapes** — `editor.getCulledShapes().size` shows how many are hidden by culling
3. **Use browser profiler** — React DevTools and Chrome's Performance tab help identify slow components
4. **Test with production builds** — Development mode has overhead that production builds don't

If performance degrades with many shapes, look for:

- Shapes that disable culling unnecessarily
- Components that use `getZoomLevel()` instead of `getEfficientZoomLevel()`
- Expensive calculations inside component render functions
- Continuous animations on many shapes

##### Subscribing to performance events

For programmatic monitoring — telemetry, RUM, or in-app dashboards — the editor exposes `PerformanceManager` at `editor.performance`. Subscribe to events and you'll get aggregated frame-time stats from real interactions, with no overhead when no listeners are attached:

```ts
const unsub = editor.performance.on('interaction-end', (event) => {
	console.log(`${event.name}: ${event.fps.toFixed(1)} fps, p95=${event.p95FrameTime.toFixed(1)}ms`)
})
// later: unsub()
```

The `'interaction-end'` event fires when an interaction state exits, with `fps`, `p95FrameTime`, and (in supporting browsers) Long Animation Frame attribution. `'camera-end'` fires after pan/zoom debounce with the same shape. `'shapes-created'`, `'shapes-updated'`, and `'shapes-deleted'` carry per-type counts. `'frame'` fires every animation frame while a listener is attached. See `TLPerfEventMap` for the full set.

Custom tools opt into interaction tracking by setting `StateNode#trackPerformance` on the state node class. When the state is entered, the manager starts a tracking window; when it exits, it emits `'interaction-start'` / `'interaction-end'` with the state path. Built-in interactions like `select.translating` and `draw.drawing` already track, so you only need this for custom states.

If you're profiling in Chrome DevTools, `PerformanceApiAdapter` wires the same events into native `performance.mark()` / `performance.measure()` calls so they show up on the Performance timeline:

```ts
import { PerformanceApiAdapter } from 'tldraw'

const adapter = new PerformanceApiAdapter(editor.performance)
// later: adapter.dispose()
```

#### Related

- [Culling](https://tldraw.dev/sdk-features/culling) — How viewport culling works and how to control it
- [Signals](https://tldraw.dev/sdk-features/signals) — The reactive state system
- [Store](https://tldraw.dev/sdk-features/store) — How the reactive database batches changes
- [Options](https://tldraw.dev/sdk-features/options) — All available editor options
- [Animation](https://tldraw.dev/sdk-features/animation) — The shape and camera animation systems

### Persistence

In tldraw, persistence means storing the editor's state to a database and restoring it later. The SDK provides several approaches: automatic local persistence with a single prop, manual snapshots for custom storage backends, and a migration system for handling schema changes.

#### The persistenceKey prop

The simplest way to persist an editor is with the `persistenceKey` prop:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw persistenceKey="my-document" />
		</div>
	)
}
```

With this prop, the editor saves to IndexedDB whenever it changes, loads from IndexedDB on mount, synchronizes across browser tabs with the same key, and stores assets alongside the document.

Each persistence key represents a separate document:

```tsx
<Tldraw persistenceKey="document-a" />
<Tldraw persistenceKey="document-b" />
```

Two editors with the same key share the same document and stay in sync. Each editor still maintains its own session state (camera position, selection, current page).

#### Snapshots

For custom storage backends, use snapshots to save and load editor state. A snapshot is a JSON-serializable object containing the full document.

##### Getting a snapshot

Call `getSnapshot` with the editor's store to get the current state:

```ts
import { getSnapshot } from 'tldraw'

const { document, session } = getSnapshot(editor.store)
```

The snapshot has two parts:

| Part       | Contents                                  | When to share                 |
| ---------- | ----------------------------------------- | ----------------------------- |
| `document` | Shapes, pages, bindings, assets           | Save to server in multiplayer |
| `session`  | Camera, current page, selection, UI state | Keep per-user locally         |

For single-user apps, save both together:

```ts
localStorage.setItem('my-drawing', JSON.stringify({ document, session }))
```

For multiplayer, save them separately:

```ts
await saveToServer(documentId, document)
localStorage.setItem(`session-${documentId}`, JSON.stringify(session))
```

##### Loading a snapshot

Call `loadSnapshot` to restore state into an existing editor:

```ts
import { loadSnapshot } from 'tldraw'

const saved = JSON.parse(localStorage.getItem('my-drawing'))
loadSnapshot(editor.store, saved)
```

You can load document and session separately:

```ts
// Load document from server
const document = await fetchFromServer(documentId)
loadSnapshot(editor.store, { document })

// Optionally load session from local storage
const session = JSON.parse(localStorage.getItem(`session-${documentId}`))
if (session) {
	loadSnapshot(editor.store, { session })
}
```

##### Initial state

Pass a snapshot to the `Tldraw` component to initialize with saved data:

```tsx
import { useState, useEffect } from 'react'
import { Tldraw, TLEditorSnapshot } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	const [snapshot, setSnapshot] = useState<TLEditorSnapshot | null>(null)

	useEffect(() => {
		async function load() {
			const document = await fetchDocument(documentId)
			const session = getLocalSession(documentId)
			setSnapshot({ document, session })
		}
		load()
	}, [])

	if (!snapshot) return <div>Loading...</div>

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw snapshot={snapshot} />
		</div>
	)
}
```

#### Custom persistence with store

For more control, create your own store and pass it to the editor. This lets you load data before mounting and implement custom sync logic.

##### Creating a store

Use `createTLStore` to create a standalone store:

```tsx
import { useState } from 'react'
import { createTLStore, loadSnapshot, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	const [store] = useState(() => {
		const store = createTLStore()

		const saved = localStorage.getItem('my-drawing')
		if (saved) {
			loadSnapshot(store, JSON.parse(saved))
		}

		return store
	})

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw store={store} />
		</div>
	)
}
```

##### Async loading with TLStoreWithStatus

When loading data asynchronously, use `TLStoreWithStatus` to handle loading and error states:

```tsx
import { useState, useEffect } from 'react'
import { createTLStore, loadSnapshot, Tldraw, TLStoreWithStatus } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
		status: 'loading',
	})

	useEffect(() => {
		let cancelled = false

		async function load() {
			try {
				const snapshot = await fetchSnapshot()
				if (cancelled) return

				const store = createTLStore()
				loadSnapshot(store, snapshot)

				setStoreWithStatus({ status: 'synced-local', store })
			} catch (error) {
				if (cancelled) return
				setStoreWithStatus({ status: 'error', error: error as Error })
			}
		}

		load()
		return () => {
			cancelled = true
		}
	}, [])

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw store={storeWithStatus} />
		</div>
	)
}
```

The editor shows appropriate UI for each status. The possible values are:

| Status          | Meaning                                                             |
| --------------- | ------------------------------------------------------------------- |
| `loading`       | The store is loading                                                |
| `error`         | Loading failed                                                      |
| `not-synced`    | Store created without persistence                                   |
| `synced-local`  | Store loaded from local storage                                     |
| `synced-remote` | Store synced with remote server (includes `connectionStatus` field) |

#### Listening for changes

Subscribe to store changes to implement auto-save or sync:

```ts
const cleanup = editor.store.listen((entry) => {
	for (const record of Object.values(entry.changes.added)) {
		console.log('Added:', record.typeName, record.id)
	}

	for (const [prev, next] of Object.values(entry.changes.updated)) {
		console.log('Updated:', next.id)
	}

	for (const record of Object.values(entry.changes.removed)) {
		console.log('Removed:', record.id)
	}
})
```

The `listen` method returns a cleanup function you should call when unmounting.

##### Filtering changes

Filter by source and scope to listen for specific changes:

```ts
// Only user changes (not remote sync)
editor.store.listen(handleChanges, { source: 'user', scope: 'all' })

// Only document records (not session state)
editor.store.listen(handleChanges, { source: 'all', scope: 'document' })
```

| Filter   | Values                                           | Purpose                 |
| -------- | ------------------------------------------------ | ----------------------- |
| `source` | `'user'`, `'remote'`, `'all'`                    | Where changes came from |
| `scope`  | `'document'`, `'session'`, `'presence'`, `'all'` | Type of records         |

##### Throttled auto-save

Here's a pattern for auto-saving with throttling:

```ts
import { throttle } from 'lodash'
import { getSnapshot } from 'tldraw'

const saveToStorage = throttle(() => {
	const snapshot = getSnapshot(editor.store)
	localStorage.setItem('my-drawing', JSON.stringify(snapshot))
}, 500)

const cleanup = editor.store.listen(saveToStorage)
```

#### Remote changes

When synchronizing with a multiplayer backend, use `Store#mergeRemoteChanges` to apply updates from other users:

```ts
myRemoteSource.on('change', (changes) => {
	editor.store.mergeRemoteChanges(() => {
		for (const change of changes) {
			if (change.type === 'add' || change.type === 'update') {
				editor.store.put([change.record])
			} else if (change.type === 'remove') {
				editor.store.remove([change.id])
			}
		}
	})
})
```

Changes inside `mergeRemoteChanges` are tagged with `source: 'remote'`. This lets you filter them out when listening, so you don't create an infinite sync loop:

```ts
// Only save user changes, not remote changes
editor.store.listen(saveToServer, { source: 'user', scope: 'document' })
```

For production multiplayer apps, consider using the [@tldraw/sync](https://tldraw.dev/docs/sync) package instead of building your own sync layer.

#### Migrations

Snapshots include schema version information. When you load a snapshot from an older version, the store migrates it automatically. You don't need to do anything for tldraw's built-in types.

##### Shape props migrations

If you have custom shapes, define migrations to handle changes to their props over time:

```ts
import { createShapePropsMigrationIds, createShapePropsMigrationSequence, ShapeUtil } from 'tldraw'

// Version IDs must start at 1 and increment
const versions = createShapePropsMigrationIds('my-shape', {
	AddColor: 1,
	RenameSize: 2,
})

const migrations = createShapePropsMigrationSequence({
	sequence: [
		{
			id: versions.AddColor,
			up(props) {
				props.color = 'black'
			},
			down(props) {
				delete props.color
			},
		},
		{
			id: versions.RenameSize,
			up(props) {
				props.dimensions = props.size
				delete props.size
			},
			down(props) {
				props.size = props.dimensions
				delete props.dimensions
			},
		},
	],
})

// Attach migrations to your shape util
class MyShapeUtil extends ShapeUtil<MyShape> {
	static override type = 'my-shape' as const
	static override migrations = migrations
	// ...
}
```

The `down` migrations are used in multiplayer when a peer needs an older schema version.

##### General migrations

For migrating other data like `meta` properties, use the general migration API:

```ts
import { createMigrationIds, createMigrationSequence } from 'tldraw'

const sequenceId = 'com.example.my-app'

const versions = createMigrationIds(sequenceId, {
	RemoveLegacyField: 1,
})

const migrations = createMigrationSequence({
	sequenceId,
	sequence: [
		{
			id: versions.RemoveLegacyField,
			scope: 'record',
			filter: (record) => record.typeName === 'page',
			up(page: any) {
				delete page.meta.legacyField
			},
		},
	],
})
```

Pass migrations to the `Tldraw` component or when creating a store:

```tsx
<Tldraw migrations={[migrations]} />
```

```ts
const store = createTLStore({ migrations: [migrations] })
```

##### Migration scopes

Migrations support different scopes depending on what you need to change:

| Scope    | Use case                                                      |
| -------- | ------------------------------------------------------------- |
| `record` | Runs on individual records matching an optional filter        |
| `store`  | Receives the entire serialized store for cross-record changes |

Most migrations use `record` scope. Use `store` when you need to read or modify multiple records together.

#### Examples

- [Persistence key](https://tldraw.dev/examples/configuration/persistence-key) - Automatic local persistence with a single prop
- [Snapshots](https://tldraw.dev/examples/editor-api/snapshots) - Saving and loading editor state
- [Local storage](https://tldraw.dev/examples/data/assets/local-storage) - Custom persistence with throttled auto-save
- [Store events](https://tldraw.dev/examples/events/store-events) - Listening to store changes
- [Shape with migrations](https://tldraw.dev/examples/shapes/tools/shape-with-migrations) - Migrations for custom shape props
- [Meta migrations](https://tldraw.dev/examples/data/assets/meta-migrations) - General migrations for meta properties

### Readonly mode

Readonly mode turns the editor into a viewer. Users can pan, zoom, and select shapes to inspect them, but they can't create, modify, or delete anything. Use readonly mode when you want to display canvas content without allowing changes: embedding documents in a presentation, sharing a design for feedback, or showing a preview of saved work.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function ReadOnlyViewer() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					editor.updateInstanceState({ isReadonly: true })
				}}
			/>
		</div>
	)
}
```

#### Enabling readonly mode

Readonly state lives in the editor's instance state. Toggle it with `updateInstanceState`:

```typescript
// Enable readonly mode
editor.updateInstanceState({ isReadonly: true })

// Disable readonly mode
editor.updateInstanceState({ isReadonly: false })

// Check current state
const isReadonly = editor.getIsReadonly()
```

The state persists across sessions when using a `persistenceKey`. A document saved in readonly mode opens in readonly mode next time.

#### What readonly mode blocks

When readonly is enabled, the editor prevents all document mutations.

| Category   | Blocked methods                                                                                |
| ---------- | ---------------------------------------------------------------------------------------------- |
| Shapes     | `createShape`, `deleteShapes`, `updateShape`, `groupShapes`, `ungroupShapes`                   |
| Pages      | `createPage`, `deletePage`, `renamePage`, `moveShapesToPage`                                   |
| Assets     | `createAssets`, `updateAssets`, `deleteAssets`                                                 |
| Transforms | `flipShapes`, `packShapes`, `alignShapes`, `distributeShapes`, `rotateShapesBy`, `resizeShape` |
| Styles     | `setStyleForSelectedShapes`, `setOpacityForSelectedShapes`, `setOpacityForNextShapes`          |
| Clipboard  | `putExternalContent`, `replaceExternalContent`, plus the `cut` and `paste` UI actions          |

The toolbar automatically hides editing tools. Only the select tool, hand tool, and laser pointer remain visible. UI actions like undo and redo are also disabled in readonly mode.

#### What readonly mode allows

Navigation and viewing operations work normally. You can pan, zoom, and use camera methods like `zoomIn`, `zoomOut`, `zoomToFit`, and `zoomToSelection`. Selection works too: clicking shapes, brush selection, `selectAll`, and `selectNone` all function as expected.

You can also hover over shapes to inspect them and switch between pages. Exporting with the `exportAs` or `copyAs` helper functions works because exporting doesn't modify the document.

#### Using the useReadonly hook

In React components, the `useReadonly` hook provides reactive access to the readonly state. Import it from `tldraw`:

```tsx
import { useReadonly } from 'tldraw'

function ReadonlyIndicator() {
	const isReadonly = useReadonly()

	if (!isReadonly) return null

	return <div className="readonly-badge">View only</div>
}
```

The component re-renders automatically when readonly state changes. Use this hook when building custom UI that needs to respond to the readonly state.

#### Actions in readonly mode

Actions have a `readonlyOk` property that determines whether they work in readonly mode. When an action has `readonlyOk: false` (the default), triggering it in readonly mode does nothing.

Built-in actions that work in readonly mode include zoom controls (`zoom-in`, `zoom-out`, `zoom-to-fit`, `zoom-to-selection`), selection actions (`select-all`, `select-none`, `copy`), export actions (`export-as-svg`, `export-as-png`, `print`), navigation (`back-to-content`, `change-page-prev`, `change-page-next`), and preference toggles like `toggle-dark-mode`.

When defining custom actions, set `readonlyOk: true` if the action should work in readonly mode:

```typescript
const overrides: TLUiOverrides = {
	actions(editor, actions, helpers) {
		actions['share-link'] = {
			id: 'share-link',
			label: 'action.share-link',
			readonlyOk: true,
			onSelect() {
				// This works in readonly mode
				navigator.clipboard.writeText(window.location.href)
			},
		}
		return actions
	},
}
```

#### Shapes that remain interactive

Some shapes have interactive content that works even when the document is readonly. Embed shapes (YouTube videos, Figma files, interactive maps) are a good example. The embed itself is locked in place, but users can still play videos or interact with the embedded content.

ShapeUtils can override `canEditInReadonly` to allow editing interactions on their shapes:

```typescript
class InteractiveWidgetUtil extends ShapeUtil<InteractiveWidget> {
	override canEditInReadonly(shape: InteractiveWidget): boolean {
		return true
	}
}
```

When this returns `true`, double-clicking the shape enters edit mode even in readonly mode. The shape's interactive content becomes usable while the shape itself stays fixed.

#### Forcing operations in readonly mode

For programmatic use cases like migrations or admin tools, you can bypass the readonly check by passing `force: true` to certain operations:

```typescript
// This works even in readonly mode
editor.putExternalContent({ type: 'files', files: myFiles, point: { x: 0, y: 0 } }, { force: true })
```

Only `putExternalContent` and `replaceExternalContent` support this option. Use it sparingly since readonly mode exists to prevent unintended changes.

#### Collaboration and readonly mode

When using tldraw's sync packages for collaboration, readonly mode integrates with room permissions. A user with read-only access to a shared document sees the editor in readonly mode automatically. The sync layer communicates the mode through a reactive signal, and the editor updates `isReadonly` in response.

Changes from other collaborators still appear (the document updates in real time) but the readonly user can't contribute changes themselves.

#### Related examples

- **[Read-only](https://tldraw.dev/examples/configuration/readonly)** — Set up a readonly editor that disables all editing functionality.

### Rich text

Rich text lets you add formatted text to tldraw shapes. You get inline formatting like bold, italic, code, and highlighting, plus structural features like lists and links. Text, note, geo, and arrow label shapes all support rich text editing.

Under the hood, tldraw uses TipTap (a React wrapper around ProseMirror) as the rich text engine. TipTap stores text as structured JSON rather than plain strings, which enables reliable formatting operations, custom extensions, and consistent serialization.

#### How it works

Rich text content is represented as a JSON tree. The root document contains paragraphs, and paragraphs contain text nodes with optional formatting marks. This structure aligns with TipTap's document model and makes it easy to extend.

##### Document structure

A rich text document has three main components: the document root, content blocks, and text nodes with marks.

```typescript
const richText: TLRichText = {
	type: 'doc',
	content: [
		{
			type: 'paragraph',
			content: [
				{ type: 'text', text: 'Hello ' },
				{
					type: 'text',
					text: 'world',
					marks: [{ type: 'bold' }],
				},
			],
		},
	],
}
```

The `type` field identifies the node kind. The `content` array holds child nodes. Text nodes include a `marks` array for formatting information. You can combine formatting marks in any way you need.

##### Converting between formats

Use `toRichText` to convert plain text strings to rich text documents. Each line becomes a separate paragraph:

```typescript
import { toRichText } from 'tldraw'

const richText = toRichText('First line\nSecond line')
// Creates two paragraphs
```

Note that `toRichText` treats all input as plain text—it doesn't parse markdown or other formatting. To create formatted content programmatically, build the rich text JSON structure directly or use the TipTap editor API.

To extract plain text from a rich text document, use `renderPlaintextFromRichText`. It strips all formatting and preserves line breaks:

```typescript
import { renderPlaintextFromRichText } from 'tldraw'

const text = renderPlaintextFromRichText(editor, shape.props.richText)
// Returns: "First line\nSecond line"
```

For HTML output, use `renderHtmlFromRichText`. It preserves all styling and structure, which is useful for rendering rich text outside the editor or exporting content:

```typescript
import { renderHtmlFromRichText } from 'tldraw'

const html = renderHtmlFromRichText(editor, shape.props.richText)
// Returns: '<p dir="auto">First line</p><p dir="auto">Second line</p>'
```

#### TipTap integration

TipTap handles the rich text editing experience. The editor appears when users double-click text shapes or press Enter while a text shape is selected. It manages focus, keyboard shortcuts, and formatting commands automatically.

##### Default extensions

The `tipTapDefaultExtensions` array includes TipTap's StarterKit plus customizations for tldraw. The StarterKit provides basic formatting like bold, italic, and lists. Additional extensions add code highlighting and custom keyboard behavior.

```typescript
export const tipTapDefaultExtensions: Extensions = [
	StarterKit.configure({
		blockquote: false,
		codeBlock: false,
		horizontalRule: false,
		link: {
			openOnClick: false,
			autolink: true,
		},
		// Prevent trailing paragraph insertion after lists
		trailingNode: {
			notAfter: ['paragraph', 'bulletList', 'orderedList', 'listItem'],
		},
	}),
	Highlight,
	KeyboardShiftEnterTweakExtension,
	extensions.TextDirection.configure({ direction: 'auto' }),
]
```

This configuration disables blockquotes, code blocks, and horizontal rules to keep the interface focused on inline formatting. Links don't open on click during editing, which prevents accidental navigation. Text direction is set to automatic for right-to-left language support.

##### Custom extensions

You can add custom TipTap extensions through the `options` prop on the Tldraw component. This lets you add new formatting options, custom keyboard shortcuts, or specialized behavior:

```tsx
import { Mark, mergeAttributes } from '@tiptap/core'
import { StarterKit } from '@tiptap/starter-kit'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

const CustomMark = Mark.create({
	name: 'custom',
	parseHTML() {
		return [{ tag: 'span.custom' }]
	},
	renderHTML({ HTMLAttributes }) {
		return ['span', mergeAttributes({ class: 'custom' }, HTMLAttributes), 0]
	},
	addCommands() {
		return {
			toggleCustom:
				() =>
				({ commands }) =>
					commands.toggleMark(this.name),
		}
	},
})

const options = {
	text: {
		tipTapConfig: {
			extensions: [StarterKit, CustomMark],
		},
	},
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw options={options} />
		</div>
	)
}
```

Note that you must provide a complete list of extensions. If you include custom extensions, also include any default extensions you want to keep. The example above replaces the entire extension list, so you control exactly which features are available.

##### Rich text toolbar

The rich text toolbar appears when editing text shapes. It gives you quick access to formatting commands like bold, italic, and lists. The toolbar updates dynamically to show which formats are active at the current cursor position.

You can customize the toolbar by overriding the `RichTextToolbar` component. Use `editor.getRichTextEditor()` to get the TipTap editor instance and execute formatting commands:

```tsx
import {
	DefaultRichTextToolbar,
	TLComponents,
	Tldraw,
	TldrawUiButton,
	preventDefault,
	useEditor,
	useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'

const components: TLComponents = {
	RichTextToolbar: () => {
		const editor = useEditor()
		const textEditor = useValue('textEditor', () => editor.getRichTextEditor(), [editor])

		return (
			<DefaultRichTextToolbar>
				<TldrawUiButton
					type="icon"
					onClick={() => textEditor?.chain().focus().toggleBold().run()}
					onPointerDown={preventDefault}
				>
					B
				</TldrawUiButton>
			</DefaultRichTextToolbar>
		)
	},
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw components={components} />
		</div>
	)
}
```

The `DefaultRichTextToolbar` component provides the default toolbar layout. Nest custom buttons inside it to extend the toolbar, or replace it entirely for complete control over the formatting interface.

#### Shapes with rich text

Four shape types support rich text: text shapes, note shapes, geo shapes, and arrow labels. Each renders rich text through the `RichTextLabel` component, which handles both display and editing modes.

##### Text shapes

Text shapes are standalone text blocks you can place anywhere on the canvas. They support auto-sizing (where the shape grows to fit content) or fixed-width mode with text wrapping.

```typescript
editor.createShape({
	type: 'text',
	x: 100,
	y: 100,
	props: {
		richText: toRichText('Sample text'),
		font: 'draw',
		size: 'm',
		textAlign: 'start',
		autoSize: true,
	},
})
```

The `autoSize` prop controls whether the shape expands automatically. When true, text never wraps and the shape width matches the content. When false, text wraps at the shape's width boundary.

##### Note shapes

Note shapes display text on colored backgrounds. They always have fixed dimensions and wrap text to fit within those bounds.

```typescript
editor.createShape({
	type: 'note',
	x: 100,
	y: 100,
	props: {
		richText: toRichText('Note content'),
		font: 'draw',
		size: 'm',
		color: 'yellow',
	},
})
```

Notes work well for annotations, comments, or highlighting specific information on the canvas. The colored background provides visual distinction from regular text shapes.

##### Geo shapes

Geo shapes include rectangles, ellipses, and other geometric forms that can contain text labels. Text appears centered within the shape bounds, with configurable alignment.

```typescript
editor.createShape({
	type: 'geo',
	x: 100,
	y: 100,
	props: {
		geo: 'rectangle',
		w: 200,
		h: 100,
		richText: toRichText('Label'),
		font: 'draw',
		size: 'm',
		align: 'middle',
		verticalAlign: 'middle',
	},
})
```

The `align` and `verticalAlign` props control text positioning within the shape. Text wraps when it exceeds the available width minus padding.

##### Arrow labels

Arrows can have a text label that appears along the arrow path. The label is editable like other rich text and moves with the arrow when repositioned.

```typescript
editor.createShape({
	type: 'arrow',
	x: 100,
	y: 100,
	props: {
		start: { x: 0, y: 0 },
		end: { x: 200, y: 0 },
		richText: toRichText('Arrow label'),
		font: 'draw',
		size: 'm',
	},
})
```

Arrow labels automatically position themselves based on the arrow's path and curvature. The label remains readable regardless of arrow orientation.

#### Font management

Rich text can include multiple fonts and font styles within a single text block. The `FontManager` tracks which fonts are needed and loads them before rendering. This prevents layout shifts.

Use `getFontsFromRichText` to collect all required font faces based on the text content and formatting marks:

```typescript
import { getFontsFromRichText } from 'tldraw'

const fonts = getFontsFromRichText(editor, richText, {
	family: 'tldraw_draw',
	weight: 'normal',
	style: 'normal',
})
```

The function accepts an initial font state representing the base font. It walks the document tree, examining marks on text nodes to determine when bold or italic variants are needed. When code marks are present, it switches to the monospace font family.

##### Custom font resolution

You can override font resolution by providing a custom `addFontsFromNode` function through `options.text`. This lets you use custom fonts or alternative font mapping strategies:

```typescript
const options = {
	text: {
		tipTapConfig: {
			extensions: [StarterKit],
		},
		addFontsFromNode: (node, state, addFont) => {
			// Custom font resolution logic
			if (node.marks.some((m) => m.type.name === 'bold')) {
				state = { ...state, weight: 'bold' }
			}
			// Call addFont() with required font faces
			return state
		},
	},
}
```

The function receives the current node, font state, and a callback to register required fonts. It returns the updated state, which is passed down when processing the node's children. This lets you walk the document tree while keeping track of the font context.

#### Programmatic formatting

You can apply formatting to rich text programmatically by accessing the TipTap editor instance. This enables bulk operations like applying formatting to multiple shapes or implementing custom formatting commands:

```typescript
const textEditor = editor.getRichTextEditor()
if (textEditor) {
	// Make all selected text bold
	textEditor.chain().focus().selectAll().toggleBold().run()
}
```

The chain API lets you compose multiple operations. Each command returns a chainable object, and `run()` executes the composed command sequence.

For operations outside the editing context, you can manipulate the rich text JSON directly. This is useful when programmatically generating or transforming content:

```typescript
function makeAllTextBold(richText: TLRichText): TLRichText {
	const content = richText.content.map((paragraph) => {
		if (!paragraph.content) return paragraph

		return {
			...paragraph,
			content: paragraph.content.map((node) => {
				if (node.type !== 'text') return node

				const marks = node.marks || []
				if (marks.some((m) => m.type === 'bold')) return node

				return {
					...node,
					marks: [...marks, { type: 'bold' }],
				}
			}),
		}
	})

	return { ...richText, content }
}
```

This approach requires understanding the TipTap document structure but gives you precise control over content transformation.

#### Measurement and rendering

Rich text measurement uses the same system as plain text, with HTML rendering replacing plain text content. The `TextManager` measures rich text by generating HTML, applying it to the measurement element, and reading the computed dimensions:

```typescript
import { renderHtmlFromRichTextForMeasurement } from 'tldraw'

const html = renderHtmlFromRichTextForMeasurement(editor, richText)
// Returns HTML wrapped in measurement container
```

The measurement system accounts for formatting that affects layout, like bold text or lists. Font loading completes before measurement to ensure accurate dimensions.

For SVG export, the `RichTextSVG` component renders rich text as a foreignObject element. Exported images keep the same formatting and layout as the canvas.

#### Extension points

The rich text system offers several ways to customize behavior:

| Extension point              | Description                                                                    |
| ---------------------------- | ------------------------------------------------------------------------------ |
| **Custom TipTap extensions** | Add new marks, nodes, keyboard shortcuts, or commands                          |
| **Custom toolbar**           | Replace or extend the rich text toolbar with different formatting controls     |
| **Font resolution**          | Override font resolution to use custom fonts or alternative loading strategies |

The `options.text` field accepts a `tipTapConfig` object that passes through to TipTap's editor configuration. All TipTap configuration options are available here:

```typescript
const options = {
	text: {
		tipTapConfig: {
			extensions: [...],
			editorProps: {
				attributes: {
					class: 'custom-editor',
				},
			},
		},
		addFontsFromNode: customFontResolver,
	},
}
```

#### Related examples

- **[Rich text with custom extension](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/rich-text-custom-extension)** - Adding a custom TipTap extension and toolbar button.

- **[Rich text with font extensions](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/rich-text-font-extensions)** - Extending the editor with font-family and font-size controls.

- **[Format rich text on multiple shapes](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/rich-text-on-multiple-shapes)** - Applying formatting to multiple selected shapes programmatically.

### Scribble

The scribble system draws temporary freehand paths for pointer-based interactions. Use scribbles to show visual feedback during tool operations like erasing, laser pointer drawing, or scribble-brush selection. Access the system through `Editor#scribbles`.

Scribbles exist only in instance state and fade out automatically after the tool operation completes. They're never persisted to the document.

#### How it works

##### Scribble lifecycle

Every scribble moves through five states:

| State    | Description                                                                                                                    |
| -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Starting | The scribble collects points until it has more than 8. This prevents flickering for very short strokes.                        |
| Paused   | Drawing pauses temporarily.                                                                                                    |
| Active   | The scribble accumulates points as the pointer moves.                                                                          |
| Complete | Drawing finishes but fading hasn't started yet. This allows taper effects to apply when the user lifts the pointer.            |
| Stopping | The scribble fades out by progressively removing points from its tail. The manager deletes the scribble once all points clear. |

The `ScribbleManager#tick` method updates all scribbles on every animation frame, handling state transitions, point management, and fade-out timing.

##### Fade-out behavior

During fade-out, the scribble shrinks from the tail by removing points at regular intervals. Set `shrink` above zero for a smooth disappearance effect. The stroke width decreases along with the point count.

The `delay` property controls how long a scribble stays at full length before shrinking. Self-consuming scribbles (the default) remove points from the start as you draw, maintaining a constant length.

#### Using scribbles

The `ScribbleManager` provides two APIs: a direct API for basic use cases and a session-based API for complex scenarios requiring grouped behavior or custom fade modes.

##### Direct API

The direct API works well for tools like the eraser that use self-consuming scribbles:

```typescript
import { StateNode, TLPointerEventInfo } from '@tldraw/editor'

export class Erasing extends StateNode {
	static override id = 'erasing'

	private scribbleId = ''

	override onEnter(info: TLPointerEventInfo) {
		const scribble = this.editor.scribbles.addScribble({
			color: 'muted-1',
			size: 12,
		})
		this.scribbleId = scribble.id
		this.pushPointToScribble()
	}

	override onExit() {
		this.editor.scribbles.stop(this.scribbleId)
	}

	override onPointerMove() {
		this.pushPointToScribble()
	}

	private pushPointToScribble() {
		const { x, y } = this.editor.inputs.getCurrentPagePoint()
		this.editor.scribbles.addPoint(this.scribbleId, x, y)
	}
}
```

**Creating a scribble**: Call `ScribbleManager#addScribble` with optional configuration. The method returns a `ScribbleItem` containing the scribble's ID:

```typescript
const scribble = this.editor.scribbles.addScribble({
	color: 'muted-1',
	size: 12,
})
```

**Adding points**: As the pointer moves, add points using the scribble ID. The `ScribbleManager#addPoint` method automatically deduplicates points that are too close together:

```typescript
const { x, y } = this.editor.inputs.getCurrentPagePoint()
this.editor.scribbles.addPoint(scribble.id, x, y)
```

Pass an optional `z` value after the coordinates (defaults to `0.5`) to control point pressure for variable-width strokes.

**Stopping a scribble**: When the tool operation completes, stop the scribble to begin fade-out:

```typescript
this.editor.scribbles.stop(scribble.id)
```

The scribble transitions to stopping state and removes itself once all points clear.

##### Session API

For more complex scenarios, use sessions to group multiple scribbles together and control their behavior. The laser pointer uses sessions to create a trailing effect where all scribbles fade together:

```typescript
import { StateNode } from '@tldraw/editor'

export class LaserTool extends StateNode {
	static override id = 'laser'
	static override initial = 'idle'

	private sessionId: string | null = null

	getSessionId(): string {
		// Reuse existing session if it's still active
		if (this.sessionId && this.editor.scribbles.isSessionActive(this.sessionId)) {
			return this.sessionId
		}

		// Create a new session
		this.sessionId = this.editor.scribbles.startSession({
			selfConsume: false,
			idleTimeoutMs: this.editor.options.laserDelayMs,
			fadeMode: 'grouped',
			fadeEasing: 'ease-in',
		})

		return this.sessionId
	}

	override onCancel() {
		if (this.sessionId && this.editor.scribbles.isSessionActive(this.sessionId)) {
			this.editor.scribbles.clearSession(this.sessionId)
			this.sessionId = null
		}
	}
}
```

Child states add scribbles to the session and add points:

```typescript
export class Lasering extends StateNode {
	static override id = 'lasering'

	private scribbleId = ''
	private sessionId = ''

	override onEnter(info: { sessionId: string; scribbleId: string }) {
		this.sessionId = info.sessionId
		this.scribbleId = info.scribbleId
		this.pushPointToScribble()
	}

	override onPointerMove() {
		this.pushPointToScribble()
	}

	private pushPointToScribble() {
		const { x, y } = this.editor.inputs.getCurrentPagePoint()
		this.editor.scribbles.addPointToSession(this.sessionId, this.scribbleId, x, y)
	}

	override onTick() {
		// Reset idle timeout on activity
		this.editor.scribbles.extendSession(this.sessionId)
	}

	override onPointerUp() {
		// Mark complete to apply taper, then let session handle fade
		this.editor.scribbles.complete(this.scribbleId)
		this.parent.transition('idle')
	}
}
```

#### Scribble properties

Scribbles support these visual properties:

| Property  | Default        | Description                                                                                                         |
| --------- | -------------- | ------------------------------------------------------------------------------------------------------------------- |
| `id`      | auto-generated | Unique identifier for the scribble                                                                                  |
| `color`   | `'accent'`     | Canvas UI color: `'accent'`, `'white'`, `'black'`, `'selection-stroke'`, `'selection-fill'`, `'laser'`, `'muted-1'` |
| `size`    | `20`           | Stroke width in pixels                                                                                              |
| `opacity` | `0.8`          | Transparency from 0 to 1                                                                                            |
| `delay`   | `0`            | Milliseconds before shrinking starts (for self-consuming scribbles)                                                 |
| `shrink`  | `0.1`          | Rate at which stroke width decreases during fade-out (0 to 1)                                                       |
| `taper`   | `true`         | Whether the stroke tapers at the ends                                                                               |

All properties have defaults, so you only need to specify what you want to change.

#### Session options

When using the session API, you can configure how scribbles behave:

| Property         | Default                   | Description                                                                         |
| ---------------- | ------------------------- | ----------------------------------------------------------------------------------- |
| `id`             | auto-generated            | Session identifier                                                                  |
| `selfConsume`    | `true`                    | Whether scribbles eat their own tail as you draw                                    |
| `idleTimeoutMs`  | `0`                       | Auto-stop session after this many milliseconds of inactivity (0 disables)           |
| `fadeMode`       | `'individual'`            | How scribbles fade: `'individual'` (each on its own) or `'grouped'` (fade together) |
| `fadeEasing`     | `'linear'` or `'ease-in'` | Easing for grouped fade. Defaults to `'ease-in'` when fadeMode is `'grouped'`       |
| `fadeDurationMs` | `laserFadeoutMs` (500ms)  | Duration of the fade in milliseconds                                                |

By default, points are removed from the start as you draw, maintaining a constant scribble length. This works well for tools like the eraser that need immediate visual feedback without a persistent trail.

When `selfConsume` is `false`, points accumulate while the session is active and only fade after the session stops. The laser pointer uses this with grouped fade mode. All strokes from a drawing session disappear together.

#### Customizing scribble rendering

Scribbles are rendered on an HTML Canvas overlay via the `ScribbleOverlayUtil`. You can customize scribble rendering by extending this class and passing your custom overlay util when creating the editor. See the `OverlayUtil` documentation for details on the overlay system.

#### Related articles

- [Tools](https://tldraw.dev/sdk-features/tools) - Learn how tools use state nodes and handle pointer events
- [UI components](https://tldraw.dev/sdk-features/ui-components) - Customize canvas components

### Selection

The editor tracks which shapes are selected and gives you methods to change the selection, get the selected shapes, and compute their collective bounds and rotation. It automatically enforces rules like "you can't select both a group and its children at the same time" and manages focus groups when you select shapes inside groups.

#### Selected shape IDs

The editor tracks selection through the `selectedShapeIds` array in the current page's instance state. This array holds the IDs of all currently selected shapes. Everything else about the selection (bounds, rotation, the selected shape records) is derived from it.

```typescript
// Get currently selected shape IDs
const selectedIds = editor.getSelectedShapeIds()

// Get the actual shape objects
const selectedShapes = editor.getSelectedShapes()
```

The `getSelectedShapes()` method resolves the IDs to actual shape records, filtering out any IDs that no longer exist in the store.

#### Selection methods

##### Basic selection

The editor provides several methods for changing the selection:

```typescript
// Select specific shapes (replaces current selection)
editor.select(shapeId1, shapeId2)
editor.setSelectedShapes([shapeId1, shapeId2])

// Deselect specific shapes (removes from current selection)
editor.deselect(shapeId1)

// Clear all selection
editor.selectNone()
```

Both `select()` and `setSelectedShapes()` replace the current selection entirely. Use `deselect()` to remove specific shapes while keeping others selected.

##### Select all

The `selectAll()` method selects all unlocked shapes, with smart scoping based on the current selection:

```typescript
editor.selectAll()
```

The behavior adapts to context:

- If nothing is selected, it selects all shapes on the current page
- If the selected shapes share a common parent (like shapes inside a group), it selects all shapes within that parent
- If the selected shapes have different parents, it does nothing

##### Adjacent selection

Select the next or previous shape in reading order, or navigate by cardinal direction:

```typescript
editor.selectAdjacentShape('next')
editor.selectAdjacentShape('prev')
editor.selectAdjacentShape('left')
editor.selectAdjacentShape('right')
editor.selectAdjacentShape('up')
editor.selectAdjacentShape('down')
```

When selecting by cardinal direction, the system uses geometric distance and directional scoring to find the most appropriate adjacent shape.

##### Hierarchical selection

Navigate the shape hierarchy:

```typescript
// Select the parent of the currently selected shape
editor.selectParentShape()

// Select the first child of the currently selected shape
editor.selectFirstChildShape()
```

Both methods automatically zoom to the selected shape if it's offscreen.

#### Single shape helpers

When you need to work with exactly one selected shape, use these convenience methods:

```typescript
// Get the ID if exactly one shape is selected, null otherwise
const id = editor.getOnlySelectedShapeId()

// Get the shape if exactly one shape is selected, null otherwise
const shape = editor.getOnlySelectedShape()
```

Both methods return `null` if zero shapes or multiple shapes are selected.

##### Selected shape at point

To find which selected shape is at a specific point (useful for hit testing during interactions), use `getSelectedShapeAtPoint()`:

```typescript
const shape = editor.getSelectedShapeAtPoint({ x: 100, y: 200 })
```

This returns the top-most selected shape at the given point, ignoring groups. It returns `undefined` if no selected shape is at that point.

#### Selection bounds

The editor computes bounds for the current selection in two ways: axis-aligned and rotated.

##### Axis-aligned bounds

The `getSelectionPageBounds()` method returns the axis-aligned bounding box that contains all selected shapes:

```typescript
const bounds = editor.getSelectionPageBounds()
if (bounds) {
	console.log(bounds.x, bounds.y, bounds.width, bounds.height)
}
```

If the selection includes rotated shapes, these bounds represent the smallest axis-aligned box that contains the rotated shapes. The method returns `null` if nothing is selected.

##### Rotated bounds

The `getSelectionRotatedPageBounds()` method returns bounds that respect the shared rotation of the selection:

```typescript
const rotatedBounds = editor.getSelectionRotatedPageBounds()
```

The selection box UI uses this for display. If all selected shapes share the same rotation, the bounds rotate with them. If shapes have different rotations, this falls back to axis-aligned bounds.

You can access the shared rotation angle via `getSelectionRotation()`, which returns `0` if shapes have different rotations.

##### Screen space bounds

Both bound types have screen-space equivalents that account for the camera's zoom and pan:

```typescript
const screenBounds = editor.getSelectionScreenBounds()
const rotatedScreenBounds = editor.getSelectionRotatedScreenBounds()
```

#### Selection rules

The editor automatically enforces selection consistency through store side effects.

##### Ancestor-descendant filtering

When the selection changes, the editor filters out any shape whose ancestor is also selected:

```typescript
// If you try to select a shape and its parent, only the parent remains selected
editor.select(groupId, childOfGroupId)
// Result: only groupId is selected
```

This prevents ambiguous situations where both a container and its contents are selected. The filtering happens in the `instance_page_state` after-change side effect.

##### Focused group management

When you select shapes that are children of a group, the editor automatically updates the focused group. A **focused group** is the group shape that defines the current editing scope—it determines which shapes are available for selection and manipulation. When you enter a group by selecting its children, that group becomes focused, restricting your editing context to shapes within that group.

```typescript
// Selecting shapes inside a group focuses that group
editor.select(shapeInsideGroup)
// The group becomes the focused group
```

If all selected shapes share a common group ancestor, that group becomes focused. If you clear the selection or select shapes without a common group ancestor, the editor clears the focused group.

#### Locked shapes

The editor excludes locked shapes from bulk selection operations:

```typescript
// selectAll only selects unlocked shapes
editor.selectAll()

// Operations like delete and duplicate also respect locks
editor.deleteShapes(shapeIds) // Only deletes unlocked shapes
```

Locks do not restrict individual shape selection through `select()`. You can still select locked shapes explicitly when needed.

#### Ancestor checking

To determine if a shape's ancestor is selected, use `isAncestorSelected()`:

```typescript
const hasSelectedAncestor = editor.isAncestorSelected(shape)
```

This walks up the shape's parent chain and returns `true` if any ancestor is in the current selection. This is useful for determining whether a shape is implicitly selected through its parent.

#### Related examples

- **[Selection UI](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/selection-ui)** - Add custom UI elements that appear around the current selection using selection bounds.
- **[Prevent multi-shape selection](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/prevent-multi-shape-selection)** - Use side effects to restrict selection to a single shape at a time.
- **[Lasso select tool](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/lasso-select-tool)** - Create a custom freehand selection tool.

### Shape clipping

Shape clipping lets parent shapes mask their children so content outside the parent's boundary is hidden. Frames are the primary example—drop shapes into a frame and they're cropped to the frame's edges. Custom shapes can define their own clip boundaries using any polygon.

#### How clipping works

The clipping system uses two ShapeUtil methods that work together: `getClipPath` defines the clipping boundary as an array of points, and `shouldClipChild` controls which children get clipped.

When a shape is a child of a clipping parent, the editor:

1. Gets the parent's clip path from `getClipPath`
2. Checks if this child should be clipped via `shouldClipChild`
3. Transforms the clip path to page coordinates
4. Applies it as a CSS clip-path during rendering

If a shape has multiple clipping ancestors, their clip paths are intersected. A shape nested inside two clipping parents is clipped by both—only the overlapping region shows.

#### Implementing clipping

To make a custom shape clip its children, implement `getClipPath` in your ShapeUtil:

```tsx
import { Rectangle2d, ShapeUtil, TLBaseShape, Vec } from 'tldraw'

type MyClipShape = TLBaseShape<'my-clip', { w: number; h: number }>

class MyClipShapeUtil extends ShapeUtil<MyClipShape> {
	static override type = 'my-clip' as const

	override getDefaultProps() {
		return { w: 200, h: 200 }
	}

	override getGeometry(shape: MyClipShape) {
		return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true })
	}

	override component(shape: MyClipShape) {
		return <rect width={shape.props.w} height={shape.props.h} fill="transparent" stroke="black" />
	}

	override getIndicatorPath(shape: MyClipShape) {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}

	override getClipPath(shape: MyClipShape): Vec[] | undefined {
		// Return polygon vertices in local coordinates
		return [
			new Vec(0, 0),
			new Vec(shape.props.w, 0),
			new Vec(shape.props.w, shape.props.h),
			new Vec(0, shape.props.h),
		]
	}

	override canReceiveNewChildrenOfType() {
		return true
	}
}
```

The returned points define a polygon in the shape's local coordinate space. The editor transforms these points to page space before applying the clip. Return `undefined` to disable clipping entirely.

> **Note for stroked shapes:** If your clipping shape has a stroke, consider insetting the clip path by half the stroke width so children are clipped to the inner boundary rather than the outer edge of the stroke. This prevents children from overlapping with the stroke itself.

##### Selective clipping

By default, all children of a clipping parent are clipped. Override `shouldClipChild` to change this:

```typescript
override shouldClipChild(child: TLShape): boolean {
	// Don't clip text shapes
	if (child.type === 'text') return false
	return true
}
```

This lets you create clipping shapes where some content types break out of the boundary. You might clip geometric shapes but let labels extend beyond the edge.

#### Frame clipping

Frames are the primary built-in example of clipping. FrameShapeUtil extends `BaseFrameLikeShapeUtil`, which implements clipping by returning its geometry vertices:

```typescript
override getClipPath(shape: Shape): Vec[] | undefined {
	return this.editor.getShapeGeometry(shape.id).vertices
}
```

This clips to the frame's rectangular boundary. Content that extends beyond the frame's edges is hidden during rendering but still exists in the document—you can ungroup or move shapes out of the frame to see the clipped portions.

#### Reading shape masks

The editor computes masks for any clipped shape. Use these methods to access mask data:

| Method                     | Returns                  | Description                      |
| -------------------------- | ------------------------ | -------------------------------- |
| `getShapeMask`             | `VecLike[] \| undefined` | Mask polygon in page coordinates |
| `getShapeClipPath`         | `string \| undefined`    | CSS `polygon(...)` string        |
| `getShapeMaskedPageBounds` | `Box \| undefined`       | Bounds clipped by the mask       |

```typescript
const mask = editor.getShapeMask(shapeId)
// Returns array of points in page space, or undefined if not clipped

const clipPath = editor.getShapeClipPath(shapeId)
// Returns a "polygon(...)" CSS string, or undefined

const clippedBounds = editor.getShapeMaskedPageBounds(shapeId)
// Returns the shape's page bounds intersected with its mask
```

These methods are useful for custom rendering, SVG export, or building UI that needs to understand clipping relationships.

#### Non-rectangular clip paths

Clip paths can be any polygon. For a circular clip, approximate the circle with polygon segments:

```typescript
override getClipPath(shape: CircleShape): Vec[] | undefined {
	const centerX = shape.props.w / 2
	const centerY = shape.props.h / 2
	const radius = Math.min(shape.props.w, shape.props.h) / 2
	const segments = 48

	const points: Vec[] = []
	for (let i = 0; i < segments; i++) {
		const angle = (i / segments) * Math.PI * 2
		points.push(
			new Vec(
				centerX + Math.cos(angle) * radius,
				centerY + Math.sin(angle) * radius
			)
		)
	}
	return points
}
```

More segments create smoother curves. The performance cost is minimal since clip paths are cached and only recomputed when the shape changes.

#### Backgrounds and clipping

Shapes that clip typically also provide backgrounds for their children. Override `providesBackgroundForChildren` to enable this:

```typescript
override providesBackgroundForChildren(): boolean {
	return true
}
```

When this returns true, child shapes with `backgroundComponent` methods have their backgrounds rendered above this shape rather than above the canvas background. This creates proper visual layering within clipping containers.

#### Clipping and hit testing

Clipping affects both rendering and hit testing. The `Editor#getShapeAtPoint` method filters out shapes when the click point falls outside the shape's mask—you can't select a clipped shape by clicking its hidden portions.

Snapping and bounds calculations use the shape's full geometry, so a clipped shape's bounds may extend beyond what's visible. Only rendering and hit testing are masked.

For an example of custom clipping shapes, see the [custom clipping shape example](https://tldraw.dev/examples/editor-api/custom-clipping-shape).

### Shape indexing

Every shape in tldraw has an `index` property that determines its visual stacking order (z-order) on the canvas. Shapes with higher index values appear in front of shapes with lower values. The index system uses string-based fractional indexing, which allows efficient reordering and supports real-time collaboration.

#### Why fractional indexing?

Integer-based indexing has problems. If you have shapes at indices `[0, 1, 2]` and want to insert between 0 and 1, you'd need to renumber subsequent shapes or use floats that eventually lose precision.

Fractional indexing uses lexicographically sortable strings. You can always generate a new index between any two existing indices. Standard JavaScript string comparison sorts them correctly, and reordering only updates the moved shapes - not every shape on the canvas.

#### Index structure

Indices are strings like `'a1'`, `'a2'`, or `'a1V'`. The first letter is the integer part (base-62 encoded), and optional following characters are the fractional part for inserting between existing indices. The `@tldraw/utils` package uses the [jittered fractional indexing](https://www.npmjs.com/package/jittered-fractional-indexing) algorithm to generate these strings.

#### Generating indices

The `@tldraw/utils` package exports functions for generating indices:

```ts
import {
	getIndexBetween,
	getIndexAbove,
	getIndexBelow,
	getIndicesAbove,
	getIndicesBelow,
	getIndicesBetween,
	sortByIndex,
	IndexKey,
} from '@tldraw/utils'

// Generate a single index between two existing indices
const between = getIndexBetween('a1' as IndexKey, 'a3' as IndexKey) // e.g. 'a2'

// Generate indices above or below an existing index
const above = getIndexAbove('a1' as IndexKey) // e.g. 'a2'
const below = getIndexBelow('a2' as IndexKey) // e.g. 'a1'

// Generate multiple indices at once (exact values vary due to jittering)
const multipleAbove = getIndicesAbove('a0' as IndexKey, 3)
const multipleBelow = getIndicesBelow('a2' as IndexKey, 2)
const multipleBetween = getIndicesBetween('a0' as IndexKey, 'a2' as IndexKey, 2)

// Sort objects by their index property
const shapes = [{ index: 'a2' as IndexKey }, { index: 'a1' as IndexKey }]
shapes.sort(sortByIndex) // [{ index: 'a1' }, { index: 'a2' }]
```

The algorithm includes jittering (randomization) to reduce conflicts in collaborative environments. When two users insert shapes at the same position simultaneously, jittering makes them generate different indices instead of identical ones.

#### Reordering shapes

The `Editor` provides four methods for changing shape z-order. You can pass either shape IDs or shape objects to any of these methods.

##### Send to back and bring to front

```ts
// Move shapes to the very back or front
editor.sendToBack(['shape:abc123' as TLShapeId, 'shape:def456' as TLShapeId])
editor.bringToFront(['shape:abc123' as TLShapeId])
```

These move shapes to the bottom or top of the z-order within their parent.

##### Send backward and bring forward

```ts
// Move shapes one step back or forward
editor.sendBackward(['shape:abc123' as TLShapeId])
editor.bringForward(['shape:abc123' as TLShapeId])
```

By default, these methods only move shapes past other shapes they visually overlap. This makes keyboard shortcuts feel intuitive - pressing "send backward" moves a shape behind the shape it's actually covering, not behind some distant shape.

To move past any shape regardless of overlap:

```ts
editor.sendBackward(['shape:abc123' as TLShapeId], { considerAllShapes: true })
```

##### Order preservation

All reordering methods preserve relative order. If you select shapes A, B, and C (stacked A-B-C from back to front) and bring them forward, they stay in A-B-C order at their new position.

#### How reordering works internally

Shape indices are always relative to siblings within the same parent. When you reorder shapes, tldraw:

1. Groups shapes by parent
2. Finds the insertion point (front, back, or adjacent to an overlapping shape)
3. Generates new indices using `getIndicesBetween`
4. Updates only the shapes that actually need new indices

If shapes are already at the target position, no updates occur.

#### Collaboration

Fractional indexing works well for real-time collaboration. When two users simultaneously reorder shapes, jittering means they generate different indices in the same region of index space. Both operations succeed and merge cleanly.

Since reordering only updates the moved shapes' indices, it doesn't interfere with other concurrent edits to different shapes.

#### Index validation

`IndexKey` is a branded type - you can't accidentally pass an arbitrary string as an index. Use `validateIndexKey` to check if a string is valid:

```ts
import { validateIndexKey } from '@tldraw/utils'

validateIndexKey('a1') // passes, 'a1' is a valid index
validateIndexKey('invalid!') // throws an error
```

The store validates indices when you create or update shapes, so the editor won't enter an invalid state.

#### Related

- [Layer panel example](https://tldraw.dev/examples/ui/layer-panel) - Shows shape hierarchy and z-order in a custom panel

### Shape transforms

Shape transforms are operations that manipulate multiple shapes together: grouping, aligning, distributing, stacking, packing, flipping, and rotating. Each operation has a dedicated method on the Editor class.

All transform operations respect the editor's parent-child coordinate system and work correctly with shapes that have different parents or rotations. Shapes connected by arrow bindings move together as clusters, so transforms preserve diagram relationships.

#### Transform operations

Transform methods accept either shape IDs or shape objects and typically operate on the current selection. Grouping changes the shape hierarchy. The spatial operations (alignment, distribution, stacking, packing, flipping, rotation) reposition shapes without changing their parent relationships.

##### Grouping and ungrouping

Grouping creates a new group shape that becomes the parent of the selected shapes. The editor calculates a common ancestor for the shapes being grouped and creates the group at the appropriate position in the hierarchy. The grouped shapes keep their visual positions on the page, but their coordinates become relative to the group.

```typescript
editor.groupShapes([shape1, shape2, shape3])
editor.ungroupShapes([groupShape])
```

Ungrouping reverses this process by moving the group's children back to the group's parent and removing the group shape itself. The shapes maintain their page positions but return to using their original parent's coordinate space.

##### Alignment

Alignment moves shapes so they share a common edge or center line. The editor supports six alignment operations: left, right, top, bottom, center-horizontal, and center-vertical. When aligning shapes, the editor first calculates the common bounding box of all selected shapes, then moves each shape to align with the appropriate edge or center of that common box.

```typescript
editor.alignShapes(editor.getSelectedShapeIds(), 'left')
editor.alignShapes([box1, box2], 'center-vertical')
```

Selected shapes connected by arrow bindings move together as a cluster during alignment. If shapes A and B are connected by an arrow and you align them with shape C, A and B move together as a single unit.

##### Distribution

Distribution spaces shapes evenly between the outermost shapes in a selection. The editor identifies the first and last shapes based on their positions, then calculates the gap needed to distribute the remaining shapes evenly in the space between them. This can create negative gaps if shapes overlap.

```typescript
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
editor.distributeShapes([box1, box2, box3], 'vertical')
```

Distribution requires at least three shape clusters. Like alignment, shapes connected by arrows form clusters that move together, so the actual number of moveable units may be less than the number of selected shapes.

##### Stacking

Stacking arranges shapes in a sequence with consistent gaps between them. Unlike distribution, which spaces shapes within a fixed range, stacking positions each shape relative to the previous one with a specified gap.

```typescript
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 16)
editor.stackShapes([box1, box2, box3], 'vertical')
```

If you don't pass a gap, the editor uses its `adjacentShapeMargin` option. Pass a gap of `0` for automatic gap detection: the editor analyzes the current spacing between shapes and uses the most common gap, or the average gap if no pattern exists.

##### Packing

Packing arranges shapes into a compact grid layout using a bin-packing algorithm based on potpack. The editor groups shapes by their parent containers, then arranges each group into an efficient rectangular grid. The packed arrangement is centered on the shapes' original center point, which minimizes how far shapes move.

```typescript
editor.packShapes(editor.getSelectedShapeIds(), 8)
editor.packShapes([box1, box2, box3, box4])
```

Packing is useful for cleaning up scattered shapes. The gap parameter controls the padding between packed shapes and defaults to the editor's `adjacentShapeMargin` option.

##### Flipping

Flipping mirrors shapes along either the horizontal or vertical axis. The operation uses the center point of all selected shapes' common bounding box as the flip origin. Shapes are scaled by -1 on the appropriate axis, which inverts their position and visual appearance while maintaining their size.

```typescript
editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal')
editor.flipShapes([box1, box2], 'vertical')
```

When flipping groups, the editor automatically includes all children of the group to ensure the entire hierarchy is flipped together. Some shapes may opt out of flipping by returning false from their ShapeUtil's canBeLaidOut method.

##### Rotation

Rotation spins shapes around a common center point. The editor calculates the collective center of all selected shapes, then rotates each shape both around that center point and around its own center. This produces the expected result where shapes orbit the selection center while also rotating individually.

```typescript
editor.rotateShapesBy(editor.getSelectedShapeIds(), Math.PI / 4)
```

The rotation system maintains a snapshot of the initial shape positions and rotations, which supports smooth incremental updates during drag operations. Shape utilities can respond to rotation events through the onRotateStart, onRotate, and onRotateEnd methods.

#### Parent coordinate transforms

When a shape has a rotated parent, the editor converts page-space movement deltas back into the parent's local coordinate space before updating the shape's position. Moving a child shape produces the correct visual result regardless of parent rotation or nesting depth.

The conversion process uses the parent's page transform matrix to inverse-rotate the delta vector. For example, if a parent is rotated 45 degrees and you want to move a child 10 pixels to the right in page space, the editor calculates what movement in the parent's coordinate space would produce that page-space result.

```typescript
const parent = editor.getShapeParent(shape) // [1]
if (parent) {
	const parentTransform = editor.getShapePageTransform(parent) // [2]
	if (parentTransform) shapeDelta.rot(-parentTransform.rotation()) // [3]
}
```

1. Get the shape's parent to check if coordinate conversion is needed
2. Retrieve the parent's full page transform matrix (position, rotation, scale)
3. Rotate the movement delta by the negative of the parent's rotation to convert from page space to parent space

Align, distribute, and stack all rely on this conversion: they calculate movement in page space but must apply it in each shape's local space.

#### Shape clustering via arrow bindings

Several transform operations group shapes into clusters based on arrow bindings. When shapes are connected by arrows, they form a logical unit that should move together during transforms. The editor uses a recursive algorithm to collect all shapes connected through arrow bindings, starting from each selected shape and traversing the binding graph.

The clustering algorithm maintains a visited set to avoid processing shapes multiple times and only includes shapes that were part of the initial selection. This means that if A connects to B via an arrow, but only A is selected, then B will not be included in the transform unless B is also explicitly selected.

Each cluster is treated as a single unit with a common bounding box. When the transform calculates movement for the cluster, it applies that movement to all shapes in the cluster. Their relative positions and arrow relationships stay intact.

#### Shape utility integration

Shape utilities can control whether their shapes participate in transforms through the canBeLaidOut method. This method receives the transform type and the full list of shapes being transformed, so the utility can make context-aware decisions.

```typescript
canBeLaidOut(shape: MyShape, info: TLShapeUtilCanBeLaidOutOpts): boolean {
	// info.type is one of: 'align' | 'distribute' | 'pack' | 'stack' | 'flip' | 'stretch' | 'resize_to_bounds'
	return true
}
```

For rotation operations, shape utilities can respond to rotation lifecycle events. The editor calls onRotateStart when rotation begins, onRotate for each update, and onRotateEnd when rotation completes. These methods can return shape partials to modify the shape during rotation.

#### Related examples

- **[Keyboard shortcuts](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/keyboard-shortcuts)** - Customize shortcuts for align, distribute, and other transform operations.
- **[Selection UI](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/selection-ui)** - Build custom controls that can trigger transform operations on selected shapes.

### Shapes

Shapes are the fundamental content elements on the tldraw canvas. Every rectangle, arrow, text box, and freehand stroke is a shape. The shape system separates data from behavior: shape records store immutable data in the store, while ShapeUtil classes define how each shape type renders, responds to interaction, and computes its geometry. This separation keeps the data layer simple and portable.

Shapes support parent-child hierarchies for grouping and frames, participate in a reactive rendering pipeline, and integrate with the binding system to form relationships with other shapes.

#### Shape records

A shape record is a plain object stored in the editor's reactive store. All shape types extend `TLBaseShape`, which defines the common properties every shape has: a unique identifier, position and rotation, z-ordering index, parent reference, lock state, opacity, and a `props` field for shape-specific properties. The `props` field contains data unique to each shape type. A geo shape stores its width, height, and geometry type. A text shape stores its text content and font size. Each shape type defines its own props structure.

Shapes also have a `meta` field for your own application data, which tldraw stores but doesn't use itself. See [Meta](https://tldraw.dev/docs/shapes#Meta) for how to set, type, and validate it.

##### Shape types in tldraw

The default tldraw installation includes these shape types:

| Category   | Types                                 |
| ---------- | ------------------------------------- |
| Basic      | `geo`, `text`, `note`                 |
| Drawing    | `draw`, `line`, `highlight`           |
| Media      | `image`, `video`, `bookmark`, `embed` |
| Structural | `frame`, `group`                      |
| Connectors | `arrow`                               |

Each type has a corresponding ShapeUtil that implements its behavior.

##### Creating and accessing shapes

The editor provides methods for working with shape records. Create shapes with `createShape`, passing the shape type, position, and props. Get shapes by ID with `getShape`, or get all shapes on the current page with `getCurrentPageShapes`. Update shapes with `updateShape`, passing the shape ID and properties to change. Delete shapes with `deleteShape`.

```typescript
// Create a geo shape
editor.createShape({
	type: 'geo',
	x: 100,
	y: 100,
	props: { w: 200, h: 150, geo: 'rectangle' },
})

// Get a shape by ID
const shape = editor.getShape(shapeId)

// Update a shape's position
editor.updateShape({ id: shape.id, type: shape.type, x: 200 })
```

Shapes are immutable records. When you update a shape, the editor creates a new record with the changes and stores it, replacing the old one. This immutability enables efficient change detection and undo/redo.

#### ShapeUtil

A ShapeUtil class defines how a shape type behaves. The editor maintains one ShapeUtil instance per shape type, and uses it for all shapes of that type. ShapeUtil is an abstract class with required and optional methods that control rendering, geometry, and interaction.

##### Required methods

Every ShapeUtil must implement four methods: `getDefaultProps` returns default property values for new shapes, `getGeometry` returns a mathematical representation for hit testing and bounds calculation, `component` returns a React component that renders the shape, and `getIndicatorPath` returns paths for the selection outline.

```typescript
class MyShapeUtil extends ShapeUtil<MyShape> {
	static override type = 'my-shape' as const

	getDefaultProps(): MyShape['props'] {
		return { w: 100, h: 100 }
	}

	getGeometry(shape: MyShape): Geometry2d {
		return new Rectangle2d({
			width: shape.props.w,
			height: shape.props.h,
			isFilled: true,
		})
	}

	component(shape: MyShape): JSX.Element {
		return <div style={{ width: shape.props.w, height: shape.props.h }} />
	}

	getIndicatorPath(shape: MyShape): Path2D | undefined {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}
}
```

##### Capability methods

ShapeUtils can override capability methods to declare what interactions the shape supports. These methods return booleans indicating whether the shape can be edited, resized, cropped, scrolled, or bound to other shapes. The `canReceiveNewChildrenOfType` method controls whether the shape can contain other shapes as children.

##### Lifecycle hooks

ShapeUtils can respond to shape changes through lifecycle hooks. The `onBeforeCreate` and `onBeforeUpdate` hooks intercept shape creation and updates before they reach the store, where you can modify or validate the shape. The `onResize`, `onRotate`, and `onTranslate` hooks respond to transformation operations. The `onChildrenChange` hook responds to changes in a shape's children. Interaction hooks like `onDoubleClick`, `onDragShapesOver`, and `onDropShapesOver` enable custom behavior for user interactions.

The `onResize` hook requires careful implementation. When a shape resizes, the hook receives resize information including the scale factors and the handle being dragged. It returns a partial update containing just the props you want to change (without the `id` or `type`):

```typescript
onResize(shape: MyShape, info: TLResizeInfo<MyShape>) {
	return {
		props: {
			w: shape.props.w * info.scaleX,
			h: shape.props.h * info.scaleY,
		},
	}
}
```

##### Static properties

ShapeUtil classes use static properties for type registration and schema configuration:

```typescript
const versions = createShapePropsMigrationIds('my-shape', {
	AddColor: 1,
})

class MyShapeUtil extends ShapeUtil<MyShape> {
	static override type = 'my-shape' as const

	// Define props validators (including style props)
	static override props = {
		w: T.number,
		h: T.number,
		color: DefaultColorStyle, // StyleProp instances are recognized automatically
	}

	// Define migrations for schema evolution
	static override migrations = createShapePropsMigrationSequence({
		sequence: [
			{
				id: versions.AddColor,
				up(props) {
					props.color = 'black'
				},
				down(props) {
					delete props.color
				},
			},
		],
	})
}
```

See [Persistence](https://tldraw.dev/sdk-features/persistence) for more on migrations.

##### Configuring built-in shapes

Use `ShapeUtil#configure` to customize the options of built-in shape utilities without subclassing them:

```typescript
import { FrameShapeUtil, NoteShapeUtil, Tldraw } from 'tldraw'

const shapeUtils = [
	// Enable colors for frame shapes
	FrameShapeUtil.configure({ showColors: true }),

	// Enable resizing for note shapes
	NoteShapeUtil.configure({ resizeMode: 'scale' }),
]

function App() {
	return <Tldraw shapeUtils={shapeUtils} />
}
```

Each shape util declares its own `options` object, and `configure` returns a new class with your overrides merged in. Custom shape utils can declare their own options the same way.

##### Registering ShapeUtils

Register custom ShapeUtils when initializing tldraw by passing them to the `shapeUtils` prop. The editor creates one instance of each ShapeUtil and uses it for all shapes of that type.

#### Geometry system

The geometry system provides mathematical representations of shapes for hit testing, bounds calculation, snapping, and collision detection. Every ShapeUtil returns a `Geometry2d` instance from its `getGeometry` method. The system includes geometry classes for common shapes: `Rectangle2d` for axis-aligned rectangles, `Circle2d` for true circles (with center and radius), `Ellipse2d` for ellipses with different width/height, `Polygon2d` for arbitrary closed polygons, `Polyline2d` for open paths, `Arc2d` for circular arcs, `Stadium2d` for rounded rectangles, and `Group2d` for composite geometry.

##### Key geometry operations

Geometry2d provides methods for spatial queries. Get the axis-aligned bounding box with the `bounds` property. Get boundary vertices with `getVertices`. Find the nearest point on the shape boundary with `nearestPoint`. Test if a point hits the shape with `hitTestPoint`, which accepts a `margin` parameter that expands the hit area and a `hitInside` parameter that controls whether points inside the shape count as hits for unfilled shapes. Test line segment intersection with `hitTestLineSegment` or get intersection points with `intersectLineSegment`.

##### Geometry caching

The editor caches geometry computations to avoid recalculating bounds and hit test data on every frame. Without caching, dragging a selection box over hundreds of shapes would recompute each shape's geometry repeatedly, causing noticeable lag. The cache invalidates automatically when a shape's props change. Access cached geometry with `getShapeGeometry` or cached page bounds (geometry combined with transforms) with `getShapePageBounds`.

#### Shape rendering

The editor renders shapes through a React component hierarchy. Each shape is wrapped in a container that handles positioning, transforms, and culling. When a shape renders, the editor computes the shape's page transform by combining its local position and rotation with all ancestor transforms, extracts bounds from the shape's geometry, skips rendering if the shape is outside the viewport and supports culling, renders the shape's visual content through the ShapeUtil's `component` method, and if selected, renders the selection outline through the `getIndicatorPath` method.

##### Transform composition

Shapes position relative to their parent's coordinate space. For a shape nested inside a rotated frame, the editor composes transforms. Get the transform from shape space to page space with `getShapePageTransform`. Get just the local transform with `getShapeLocalTransform`. Convert a page point to shape-local coordinates with `getPointInShapeSpace`.

##### Opacity

Shape opacity multiplies with parent opacity. A shape at 50% opacity inside a frame at 50% opacity renders at 25% opacity. Access a shape's opacity directly from the shape record via `shape.opacity`. The editor computes the final rendered opacity by combining the shape's opacity with all ancestor opacities.

#### Shape lifecycle

Shapes go through creation, updates, and deletion. The editor provides hooks and events at each stage.

##### Creation flow

When `createShape` is called, the editor assigns an ID if none provided, determines the parent either explicitly, inferred from position, or defaults to the current page, calculates the fractional index for z-ordering, calls `ShapeUtil.onBeforeCreate` for any modifications, validates the shape against the schema, and stores the shape in the store.

##### Update flow

When `updateShape` is called, the editor checks if the shape or an ancestor is locked, merges the partial update with the existing shape, calls `ShapeUtil.onBeforeUpdate` for any modifications, validates and stores the updated shape, and emits update events.

##### Deletion flow

When `deleteShape` is called, the editor collects all descendant shapes, removes bindings involving the shapes, calls deletion side effects, and removes all shapes from the store. Deleting a frame or group deletes all its children. Bindings are automatically cleaned up, and connected shapes receive isolation callbacks to update their state.

#### Parent-child relationships

Shapes can be parented to pages or other shapes. This creates a hierarchy used for grouping, frames, and coordinate transforms.

##### Frames

Frames are container shapes that clip their children and provide a visual boundary. Shapes inside a frame position relative to the frame's origin. Moving the frame moves all its children. The frame clips content at its boundaries during rendering. See [Shape clipping](https://tldraw.dev/sdk-features/shape-clipping) for details on implementing custom clipping shapes.

To build your own container shape that behaves like a frame, extend `BaseFrameLikeShapeUtil` instead of `ShapeUtil` directly. It provides defaults for the full set of frame behaviors (clipping children, full-brush selection, blocking erasure from inside, drag-and-drop reparenting, providing a background for children) and any of them can be overridden. Custom shapes that don't extend the base class can still opt into the same behavior by overriding the `isFrameLike()` capability method to return `true`. See the [portal shapes example](https://tldraw.dev/examples/shapes/tools/portal-shapes) for a custom shape that behaves like a frame and teleports its children between instances.

##### Groups

Groups are logical containers without visual representation. Group selected shapes with `groupShapes`. Ungroup with `ungroupShapes`. Groups exist only to organize shapes. Their geometry is the union of their children's geometry. When you delete the last child of a group, the group deletes itself.

##### Focused groups

The editor tracks a **focused group** that defines the current editing scope. When you double-click a group, it becomes focused, and you can select and edit shapes inside it. Get the current focused group with `getFocusedGroup`. Focus a specific group with `setFocusedGroup`. Exit the focused group with `popFocusedGroupId`.

#### Coordinate spaces

The editor works with multiple coordinate spaces. Screen space uses the browser viewport top-left as the origin for mouse events and UI positioning. Page space uses the canvas origin at (0,0) for shape positions and bounds. Parent space uses the parent shape's top-left for nested shape positions. Local space uses the shape's own top-left for shape-internal coordinates.

The editor provides methods to convert between spaces. Convert screen points to page points with `screenToPage`. Convert page points to screen points with `pageToScreen`. Convert page points to shape-local coordinates with `getPointInShapeSpace`. Get a shape's page-space transform matrix with `getShapePageTransform`.

#### Shape derivations

The editor maintains computed derivations that update automatically as shapes change.

##### Parents to children index

Maps parent IDs to sorted arrays of child shape IDs. Updated incrementally as shapes are added, removed, or reparented. Get children of a shape or page, sorted by z-index, with `getSortedChildIdsForParent`.

##### Culled shapes

Tracks which shapes are outside the viewport. Shapes whose ShapeUtil returns `true` from `canCull()` are candidates for culling. Culled shapes are still in the DOM but have their `display` set to `none` for performance. Check if a shape is currently culled with `getCulledShapes().has(shapeId)`.

##### Shape geometry cache

Caches geometry computations per shape. Invalidates when shape props change. Access through `getShapeGeometry`, which returns cached geometry and only recomputes when needed.

#### Related examples

- **[Custom shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/custom-shape)** - A simple custom shape demonstrating basic ShapeUtil implementation.
- **[Editable custom shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/editable-shape)** - A custom shape that can be edited by double-clicking it, showing how to use the editing state.
- **[Clickable custom shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/interactive-shape)** - A custom shape with onClick interactions demonstrating pointer event handling.
- **[Custom shape geometry](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-geometry)** - A house-shaped custom shape demonstrating custom geometry implementation.
- **[Custom shape with custom styles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-custom-styles)** - Shows how to create your own styles and use them in custom shapes.
- **[Custom shape with tldraw styles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-tldraw-styles)** - Use tldraw's default styles in your custom shapes.
- **[Custom shape migrations](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-migrations)** - Migrate shapes and their data between versions using the migrations system.
- **[Custom shape with handles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/speech-bubble)** - A speech bubble shape with custom handles for interaction.
- **[Shape options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/configure-shape-util)** - Change the behavior of built-in shapes by setting their options via ShapeUtil.configure.
- **[Custom snapping](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/bounds-snapping-shape)** - Custom shapes with special bounds snapping behavior, demonstrated with playing cards.
- **[Cubic bezier curve shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/cubic-bezier-shape)** - A custom shape with interactive bezier curve editing using draggable control handles.
- **[Data grid shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/ag-grid-shape)** - A custom shape that renders AG Grid, showing complex component integration.
- **[Popup shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/popup-shape)** - Create a 3D illusion of depth with dynamic shadows and CSS transforms.
- **[Custom clipping shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/custom-clipping-shape)** - Custom shapes that can clip their children with any polygon geometry.
- **[DOM-based shape size](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/size-from-dom)** - A custom shape whose size is derived from its rendered DOM.

### Side effects

Side effects are lifecycle hooks that run when records are created, updated, or deleted. You can use them to intercept and modify records, validate changes, or react to completed operations by updating related data.

The editor uses side effects internally to keep data consistent. When you delete a shape, the editor automatically removes its bindings. When a binding changes, the connected shapes get notified to update. These hooks let independent parts of the system stay in sync without being directly coupled.

#### How it works

##### Before and after handlers

Side effects provide six handler types organized around three operations: create, change, and delete. Each operation has a "before" and "after" phase.

**Before handlers** run during the operation and can modify or block changes:

- `beforeCreate` transforms records before creation. Return a modified record to change what gets stored.
- `beforeChange` intercepts updates. Return the previous record to block the change, or return a modified record.
- `beforeDelete` can return `false` to prevent deletion.

**After handlers** run once the operation completes. They can't modify the record that triggered them, but they can update other records:

- `afterCreate` reacts to new records by updating related data.
- `afterChange` responds to updates by maintaining relationships.
- `afterDelete` cleans up orphaned references or cascades deletions.

The key distinction: use before handlers to modify the record being operated on, and after handlers to update other records in response.

##### Source tracking

Every handler receives a `source` parameter indicating whether the change came from user interaction (`'user'`) or remote synchronization (`'remote'`). This lets you handle local and synced changes differently:

```typescript
editor.sideEffects.registerAfterCreateHandler('shape', (shape, source) => {
	if (source === 'user') {
		logUserAction('created shape', shape.type)
	}
})
```

You might auto-save only after user operations, or skip validation for trusted remote data.

##### Registration and cleanup

Register side effects using the type-specific methods on `editor.sideEffects`. Each method returns a cleanup function you can call to remove the handler:

```typescript
const cleanup = editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => {
	// Clean up bindings involving the deleted shape
	const bindings = editor.getBindingsInvolvingShape(shape.id)
	if (bindings.length) {
		editor.deleteBindings(bindings)
	}
})

// Later, when no longer needed
cleanup()
```

##### Execution order

Handlers execute in registration order. If you register three `afterCreate` handlers for shapes, they run in the sequence they were registered. This matters when handlers depend on each other's effects.

Side effects run within store transactions. All before handlers complete before any after handlers run. The `operationComplete` handler runs last, after all individual record handlers finish.

#### Use cases

##### Constraining shape positions

Before handlers can enforce constraints on records. This example blocks moves into negative coordinates by returning the previous record:

```typescript
editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {
	if (next.x < 0 || next.y < 0) {
		return prev // Block the change by returning the previous record
	}
	return next
})
```

##### Cascading deletions

You can cascade deletions to related records. This example deletes a frame when its last child is removed:

```typescript
editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => {
	const parent = editor.getShape(shape.parentId)
	if (parent && parent.type === 'frame') {
		const siblings = editor.getSortedChildIdsForParent(parent.id)
		if (siblings.length === 0) {
			editor.deleteShape(parent.id)
		}
	}
})
```

##### Batch processing with operationComplete

The `operationComplete` handler runs once after all changes in a transaction finish. Use it for expensive operations that should happen once per batch rather than on every record change:

```typescript
editor.sideEffects.registerOperationCompleteHandler((source) => {
	if (source === 'user') {
		scheduleAutosave()
	}
})
```

#### Related examples

- [Before create/update shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/before-create-update-shape) - Constrain shapes to a circular area
- [Before delete shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/before-delete-shape) - Prevent deletion of certain shapes
- [After create/update shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/after-create-update-shape) - Ensure only one red shape exists at a time
- [After delete shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/after-delete-shape) - Delete empty frames automatically
- [Shape meta (on create)](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/meta-on-create) - Add creation timestamps to shapes
- [Shape meta (on change)](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/meta-on-change) - Track modification history

### Signals

The tldraw SDK uses signals for state management. Signals automatically track dependencies and update efficiently: when state changes, only the parts of your application that depend on that state will update.

The editor exposes many of its internal values as signals. Methods like `editor.getSelectedShapeIds()` and `editor.getCurrentPageShapes()` return reactive values that update automatically when the underlying state changes. The React bindings connect these signals to components, so your UI stays in sync without manual subscription management.

#### Core concepts

##### Atoms

An atom holds a mutable value. When you change the value, any computeds or effects that depend on the atom will update.

```ts
import { atom } from '@tldraw/state'

const count = atom('count', 0)

count.get() // 0
count.set(5)
count.get() // 5

// Update based on current value
count.update((n) => n + 1)
count.get() // 6
```

The first argument is a name for debugging. The second is the initial value. Atoms support a few options:

- `isEqual` — Custom equality function to determine if a value has changed
- `historyLength` — Number of diffs to retain for incremental updates
- `computeDiff` — Function to compute diffs between values

##### Computed values

A computed derives its value from other signals. It recomputes only when its dependencies change.

```ts
import { atom, computed } from '@tldraw/state'

const firstName = atom('firstName', 'Jane')
const lastName = atom('lastName', 'Doe')

const fullName = computed('fullName', () => {
	return `${firstName.get()} ${lastName.get()}`
})

fullName.get() // 'Jane Doe'

firstName.set('John')
fullName.get() // 'John Doe' — recomputed automatically
```

Computed values are lazy: they don't calculate until you call `.get()`. They also cache their result. If you call `.get()` multiple times without any dependencies changing, the derivation function runs only once.

The `@computed` decorator provides the same functionality for class methods:

```ts
import { atom, computed } from '@tldraw/state'

class Counter {
	count = atom('count', 0)

	@computed getDoubled() {
		return this.count.get() * 2
	}
}
```

##### Effects and reactors

Effects run side effects in response to signal changes. There are two ways to create them:

```ts
import { atom, react, reactor } from '@tldraw/state'

const count = atom('count', 0)

// react() starts immediately and returns a cleanup function
const stop = react('log count', () => {
	console.log('Count is:', count.get())
})

count.set(1) // logs: Count is: 1
count.set(2) // logs: Count is: 2

stop() // Stop listening

// reactor() gives you control over when to start
const r = reactor('log count', () => {
	console.log('Count is:', count.get())
})

r.start() // Begin listening
r.stop() // Stop listening
```

Effects track which signals they read and re-run when those signals change. The `scheduleEffect` option lets you batch updates, for example using `requestAnimationFrame`:

```ts
react(
	'update-dom',
	() => {
		// DOM updates based on signal state
	},
	{
		scheduleEffect: (execute) => requestAnimationFrame(execute),
	}
)
```

##### Transactions

Transactions batch multiple changes into a single update. Effects only run once after all changes complete:

```ts
import { atom, react, transact } from '@tldraw/state'

const a = atom('a', 1)
const b = atom('b', 2)

react('sum', () => {
	console.log('Sum:', a.get() + b.get())
})

// Without transaction: effect runs twice
a.set(10) // logs: Sum: 12
b.set(20) // logs: Sum: 30

// With transaction: effect runs once
transact(() => {
	a.set(100)
	b.set(200)
})
// logs: Sum: 300
```

The `transaction` function supports rollback:

```ts
import { transaction } from '@tldraw/state'

transaction((rollback) => {
	a.set(999)
	if (somethingWentWrong) {
		rollback() // Restores original values
	}
})
```

#### React integration

The `@tldraw/state-react` package connects signals to React components.

##### useValue

The most common hook. It reads a signal value and subscribes the component to changes:

```tsx
import { atom } from '@tldraw/state'
import { useValue } from '@tldraw/state-react'

const count = atom('count', 0)

function Counter() {
	const value = useValue(count)
	return <div>Count: {value}</div>
}
```

You can also compute a value inline with a dependency array:

```tsx
function ShapeInfo({ editor }) {
	const selectedCount = useValue('selected count', () => editor.getSelectedShapeIds().length, [
		editor,
	])
	return <div>{selectedCount} shapes selected</div>
}
```

##### track

The `track` higher-order component automatically tracks signal access during render:

```tsx
import { track } from '@tldraw/state-react'

const Counter = track(function Counter() {
	return <div>Count: {count.get()}</div>
})
```

Tracked components re-render when any signal accessed during render changes. This is the pattern used throughout tldraw's internal components. The component is also wrapped in `React.memo`, so it only re-renders when props change or tracked signals update.

##### useAtom and useComputed

Create component-local signals that persist across renders. Reading `.get()` during render only subscribes the component inside `track` (or `useValue`), so wrap the component:

```tsx
import { track, useAtom, useComputed } from '@tldraw/state-react'

const Counter = track(function Counter() {
	const count = useAtom('count', 0)
	const doubled = useComputed('doubled', () => count.get() * 2, [count])

	return (
		<div>
			<button onClick={() => count.update((n) => n + 1)}>Increment</button>
			<div>Count: {count.get()}</div>
			<div>Doubled: {doubled.get()}</div>
		</div>
	)
})
```

##### useReactor and useQuickReactor

Run effects tied to component lifecycle. The effect automatically tracks which signals it reads:

```tsx
import { useReactor, useQuickReactor } from '@tldraw/state-react'

function SelectionLogger({ editor }) {
	// Throttled to next animation frame — good for DOM updates
	useReactor(
		'update title',
		() => {
			const count = editor.getSelectedShapeIds().length
			document.title = `${count} shapes selected`
		},
		[editor]
	)

	// Runs immediately — for critical state synchronization
	useQuickReactor(
		'sync selection',
		() => {
			syncSelectionToServer(editor.getSelectedShapeIds())
		},
		[editor]
	)

	return null
}
```

##### useStateTracking

Lower-level hook for manual tracking. This is what `track` uses internally:

```tsx
import { useStateTracking } from '@tldraw/state-react'

function CustomComponent() {
	return useStateTracking('CustomComponent', () => {
		return <div>{someSignal.get()}</div>
	})
}
```

#### Signals in the editor

The editor uses signals extensively. Most getter methods return reactive values:

```tsx
function SelectionInfo({ editor }) {
	const selectedShapes = useValue('shapes', () => editor.getSelectedShapes(), [editor])
	const currentPage = useValue('page', () => editor.getCurrentPage(), [editor])
	const zoomLevel = useValue('zoom', () => editor.getZoomLevel(), [editor])

	return (
		<div>
			<div>Page: {currentPage.name}</div>
			<div>Zoom: {Math.round(zoomLevel * 100)}%</div>
			<div>{selectedShapes.length} shapes selected</div>
		</div>
	)
}
```

You can use `track` for cleaner syntax when accessing many signals:

```tsx
const SelectionInfo = track(function SelectionInfo({ editor }) {
	const shapes = editor.getSelectedShapes()
	const page = editor.getCurrentPage()
	const zoom = editor.getZoomLevel()

	return (
		<div>
			<div>Page: {page.name}</div>
			<div>Zoom: {Math.round(zoom * 100)}%</div>
			<div>{shapes.length} shapes selected</div>
		</div>
	)
})
```

#### Debugging

The `whyAmIRunning` function helps trace what triggered an update. Call it inside an effect or computed to see which signals changed:

```ts
import { atom, react, whyAmIRunning } from '@tldraw/state'

const name = atom('name', 'Bob')

react('greeting', () => {
	whyAmIRunning()
	console.log('Hello', name.get())
})

name.set('Alice')
// Console output:
// Effect(greeting) is executing because:
//  ↳ Atom(name) changed
```

For nested dependencies, the output shows the full chain:

```ts
const firstName = atom('firstName', 'Jane')
const lastName = atom('lastName', 'Doe')
const fullName = computed('fullName', () => `${firstName.get()} ${lastName.get()}`)

react('log name', () => {
	whyAmIRunning()
	console.log(fullName.get())
})

firstName.set('John')
// Console output:
// Effect(log name) is executing because:
//  ↳ Computed(fullName) changed
//    ↳ Atom(firstName) changed
```

All signals have a `name` property (the first argument when creating them) that appears in debug output.

#### Reading without tracking

Sometimes you want to read a signal's value without creating a dependency. Use `unsafe__withoutCapture` to read signals without triggering re-runs:

```ts
import { atom, react, unsafe__withoutCapture } from '@tldraw/state'

const name = atom('name', 'Sam')
const time = atom('time', Date.now())

// Update time every second
setInterval(() => time.set(Date.now()), 1000)

react('log name changes', () => {
	// Only re-run when name changes, not when time changes
	const currentTime = unsafe__withoutCapture(() => time.get())
	console.log(name.get(), 'was changed at', currentTime)
})
```

#### API reference

##### @tldraw/state

| Export                       | Description                                                     |
| ---------------------------- | --------------------------------------------------------------- |
| `atom(name, value, options)` | Create a mutable signal                                         |
| `computed(name, fn)`         | Create a derived signal                                         |
| `@computed`                  | Decorator for computed class methods                            |
| `react(name, fn, options)`   | Run an effect immediately, returns cleanup function             |
| `reactor(name, fn, options)` | Create a controllable effect with `start()` and `stop()`        |
| `transact(fn)`               | Batch changes into a single update                              |
| `transaction(fn)`            | Batch changes with rollback support                             |
| `isAtom(value)`              | Type guard for atoms                                            |
| `isSignal(value)`            | Type guard for any signal                                       |
| `getComputedInstance(o, p)`  | Get the underlying computed for a `@computed` decorated method  |
| `whyAmIRunning()`            | Debug helper to trace update triggers                           |
| `unsafe__withoutCapture(fn)` | Read signals without creating dependencies                      |
| `RESET_VALUE`                | Symbol returned by `getDiffSince` when history is insufficient  |
| `isUninitialized(value)`     | Check if a computed is running its first derivation             |
| `withDiff(value, diff)`      | Manually provide a diff when returning from a computed          |
| `localStorageAtom(name, v)`  | Returns `[atom, cleanup]` tuple; atom persists to localStorage  |
| `deferAsyncEffects(fn)`      | Queue effects for async operations (used internally for stores) |

##### @tldraw/state-react

| Export                             | Description                                 |
| ---------------------------------- | ------------------------------------------- |
| `useValue(signal)`                 | Subscribe to a signal, returns its value    |
| `useValue(name, fn, deps)`         | Compute and subscribe to a derived value    |
| `useAtom(name, initialValue)`      | Create a component-local atom               |
| `useComputed(name, fn, deps)`      | Create a component-local computed           |
| `useReactor(name, fn, deps)`       | Effect throttled to animation frames        |
| `useQuickReactor(name, fn, deps)`  | Effect that runs immediately                |
| `useStateTracking(name, renderFn)` | Manual signal tracking for render functions |
| `track(Component)`                 | HOC that tracks signal access during render |

#### Related examples

- **[Reactive inputs](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/reactive-inputs)** — Using `useValue` with editor input state.

### Snapping

When you move or resize shapes, tldraw snaps them to key geometry on nearby shapes. This helps users achieve precise alignment without manual measurement.

The editor provides three types of snapping. Bounds snapping aligns edges, centers, and corners during movement and resizing. Handle snapping connects endpoints to outlines and key points, like arrow tips to shape edges. Gap snapping maintains consistent spacing between shapes.

Snap lines appear when shapes come within the snap threshold.

#### SnapManager

The `SnapManager` coordinates all snapping behavior. Access it at `editor.snaps`:

```typescript
// The two snap systems
editor.snaps.shapeBounds // BoundsSnaps - edge and center alignment
editor.snaps.handles // HandleSnaps - precise point connections

// Shared utilities
editor.snaps.getSnapThreshold() // Distance threshold (8px / zoom)
editor.snaps.getSnappableShapes() // Which shapes can be snapped to
editor.snaps.setIndicators(indicators) // Update visual snap lines
editor.snaps.clearIndicators() // Remove all snap indicators
```

The snap threshold is 8 screen pixels, 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).

##### Snappable shape filtering

The `getSnappableShapes()` method determines which shapes can be snapped to. It excludes currently selected shapes (you don't snap to what you're dragging), includes only shapes visible in the viewport for performance, and respects the shape utility's `canSnap()` method for opt-out behavior. Frame shapes are included as snap targets. For groups, the method recurses into children and snaps to them, but not to the group itself.

The method returns a computed set that updates reactively as shapes move, selection changes, or the viewport pans.

#### Bounds snapping

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.

##### Snap points

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:

```typescript
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 },
			],
		}
	}
}
```

To disable snapping to a shape entirely, return `{ points: [] }`.

##### Translation snapping

When moving shapes, `snapTranslateShapes()` finds the nearest snap alignment in each axis:

```typescript
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.

##### Resize snapping

When resizing, `snapResizeShapes()` snaps the corners and edges being moved. Which snap points are used depends on the resize handle:

- Corner handles snap both x and y axes using that corner
- Edge handles snap only the perpendicular axis using both corners on that edge
- When aspect ratio is locked, the dominant snap axis determines both

```typescript
const snapData = editor.snaps.shapeBounds.snapResizeShapes({
	initialSelectionPageBounds: selectionBounds,
	dragDelta: delta,
	handle: 'bottom_right',
	isAspectRatioLocked: false,
	isResizingFromCenter: false,
})
```

##### Gap snapping

Gap snapping maintains consistent spacing between shapes. The system detects gaps between adjacent shapes and provides three types of snapping.

Gap center snapping centers the selection within a gap larger than itself, with equal spacing on both sides. Gap duplication snapping duplicates an existing gap on the opposite side of a shape. For example, if two shapes have a 100px gap between them, dragging a third shape snaps to create another 100px gap. Adjacent gap detection finds all gaps with matching lengths and displays them together, so spacing stays consistent across many shapes.

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

Handle snapping enables precise connections between shapes. When dragging a handle (like an arrow endpoint), the `HandleSnaps` system snaps to nearby geometry.

##### Handle snap geometry

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.                                                                                    |

```typescript
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.

##### Snap types

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. This magnetizes to a single best target.

Align snapping (`snapType: 'align'`) aligns the handle with nearby points on the x and y axes independently, with perpendicular snap lines in each direction.

Each handle uses one snap type based on its `snapType` property. Snap points always have higher priority than outlines.

> **Note:** The older `canSnap` property on handles is deprecated. Use `snapType: 'point'` or `snapType: 'align'` instead.

##### Snapping handles

Tools call `snapHandle()` to snap a handle position:

```typescript
// 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

Snap indicators provide visual feedback when snapping occurs. The `SnapManager` maintains a reactive atom of current indicators that the UI renders as SVG overlays.

Two types of indicators exist. Points indicators (`type: 'points'`) display as lines connecting aligned points. When multiple snap points align on the same axis, they appear as a single continuous line spanning all aligned points. Gaps indicators (`type: 'gaps'`) display spacing between shapes with measurement lines at each gap. When multiple equal-sized gaps exist, all matching gaps are shown to highlight the consistent spacing pattern.

The manager automatically deduplicates gap indicators to reduce visual noise. When gap breadths overlap and one is larger than another, only the smaller gap displays because it provides more specific information.

Indicators are cleared automatically when dragging stops or when you call `clearIndicators()`.

#### Related examples

For working examples of custom snapping, see:

- [Custom bounds snapping](https://tldraw.dev/examples/shapes/tools/bounds-snapping-shape): Create shapes with custom snap geometry so they snap to specific points, like playing cards that stack with visible icons.
- [Custom relative snapping](https://tldraw.dev/examples/shapes/tools/custom-relative-snapping): Customize snapping behavior between shapes using getBoundsSnapGeometry.

### Store

The store is tldraw's reactive database. It holds all shapes, pages, bindings, assets, and other records that make up your document. The store is reactive: when data changes, the UI updates automatically. It validates all records against a schema and tracks every change for undo/redo, persistence, and synchronization.

In most cases you won't interact with the store directly—the editor wraps it with higher-level methods like `Editor#createShapes` and `Editor#getCurrentPageShapes`. But understanding the store helps when you need snapshots for persistence, want to listen for changes, or need direct access to records.

#### Records

Everything in the store is a record. A record is a JSON object with an `id` and a `typeName`. Here's what a shape record looks like:

```ts
{
  id: 'shape:abc123',
  typeName: 'shape',
  type: 'geo',
  x: 100,
  y: 200,
  props: {
    geo: 'rectangle',
    w: 300,
    h: 150,
    color: 'blue',
  },
  // ... other fields
}
```

The `id` is a branded string that includes the type prefix (`shape:`, `page:`, `binding:`). This prevents accidentally mixing up IDs from different record types.

##### Record scopes

Records have a scope that determines how they're persisted and synchronized:

| Scope      | Persisted | Synced to other users | Example                          |
| ---------- | --------- | --------------------- | -------------------------------- |
| `document` | Yes       | Yes                   | Shapes, pages, bindings          |
| `session`  | Optional  | No                    | Current page, camera position    |
| `presence` | No        | Yes                   | Cursor positions, user selection |

Document records are your actual drawing data—saved to storage and synced across instances. Session records are local to one editor instance, like which page you're viewing. Presence records sync to other users in real-time but aren't saved—they're for showing cursors and selections in multiplayer.

#### Custom record types

Shapes, bindings, and assets cover most drawing use cases, but some data doesn't fit any of them—comments threaded against a shape, per-user annotations, application state that should ride along with the document. Register a custom record type and these records live in the store like any other: validated, migrated, persisted, and synced according to the scope you pick.

Pass record definitions to `createTLSchema` under the `records` option:

```ts
import { createTLSchema, T, defaultShapeSchemas } from 'tldraw'

const schema = createTLSchema({
	shapes: defaultShapeSchemas,
	records: {
		comment: {
			scope: 'document',
			validator: T.object({
				id: T.string,
				typeName: T.literal('comment'),
				shapeId: T.string,
				authorId: T.string,
				text: T.string,
				createdAt: T.number,
			}),
			createDefaultProperties: () => ({ createdAt: Date.now() }),
		},
	},
})
```

Each entry is a `CustomRecordInfo`:

| Field                     | Type                                     | Description                                                                                                                 |
| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `scope`                   | `'document' \| 'session' \| 'presence'`  | Same scopes as built-in records — `document` for synced/persisted data, `session` for local-only, `presence` for ephemeral. |
| `validator`               | `T.Validatable`                          | Validates the full record (including `id` and `typeName`) on every write.                                                   |
| `createDefaultProperties` | `() => Record<string, unknown>`          | Optional. Default props applied when a record is created without them.                                                      |
| `migrations`              | `MigrationSequence \| TLPropsMigrations` | Optional. Schema evolution for the record type. An empty sequence is created automatically if you omit it.                  |

For type-safe IDs and TypeScript narrowing across your app, augment `TLGlobalRecordPropsMap` so the rest of the SDK's `TLRecord` union knows about your record types:

```ts
import { BaseRecord, RecordId } from 'tldraw'

interface TLComment extends BaseRecord<'comment', RecordId<TLComment>> {
	shapeId: string
	authorId: string
	text: string
	createdAt: number
}

declare module 'tldraw' {
	export interface TLGlobalRecordPropsMap {
		comment: TLComment
	}
}
```

##### Creating and reading custom records

Use `createCustomRecordId` to mint IDs that match the `typeName:suffix` convention the store expects. The companion guards `isCustomRecordId` and `isCustomRecord` narrow values to your custom type:

```ts
import { createCustomRecordId, isCustomRecord } from 'tldraw'

const commentId = createCustomRecordId('comment')

editor.store.put([
	{
		id: commentId,
		typeName: 'comment',
		shapeId: 'shape:abc123',
		authorId: 'user:alice',
		text: 'Looks good',
		createdAt: Date.now(),
	},
])

for (const record of editor.store.allRecords()) {
	if (isCustomRecord('comment', record)) {
		console.log(record.text) // narrowed to your comment shape
	}
}
```

Custom records appear in `store.listen` diffs, can be queried via `store.query.records('comment')`, and participate in undo/redo when written through the usual store APIs. Sync clients automatically include `document`-scoped records in the synced document; `presence`-scoped records are broadcast but not persisted.

##### Custom record migrations

Define migrations the same way you would for shape props. Use `createCustomRecordMigrationIds` to generate the canonical `com.tldraw.<type>/<version>` IDs, then `createCustomRecordMigrationSequence` for the sequence itself:

```ts
import { createCustomRecordMigrationIds, createCustomRecordMigrationSequence } from 'tldraw'

const commentVersions = createCustomRecordMigrationIds('comment', {
	AddAuthorId: 1,
})

const commentMigrations = createCustomRecordMigrationSequence({
	sequence: [
		{
			id: commentVersions.AddAuthorId,
			up: (record) => ({ ...record, authorId: record.authorId ?? 'unknown' }),
			down: ({ authorId, ...rest }) => rest,
		},
	],
})
```

Pass `commentMigrations` as the `migrations` field on the comment's `CustomRecordInfo` and the store will run them when loading older snapshots.

#### Basic operations

##### Reading records

The store provides reactive and non-reactive access to records:

```ts
// Reactive — creates a dependency, component will re-render when record changes
const shape = editor.store.get(shapeId)

// Non-reactive — for hot paths where you don't want re-renders
const shape = editor.store.unsafeGetWithoutCapture(shapeId)

// Check if a record exists
const exists = editor.store.has(shapeId)

// Get all records
const allRecords = editor.store.allRecords()
```

The reactive `Store#get` integrates with tldraw's signals system. When you call it inside a tracked component or computed, the component re-renders when that record changes.

##### Creating and updating records

The `Store#put` method handles both creation and updates:

```ts
// Create a new record
editor.store.put([
	{
		id: 'shape:my-shape' as TLShapeId,
		typeName: 'shape',
		type: 'geo',
		x: 0,
		y: 0,
		// ... all required fields
	},
])

// Update an existing record (put with same id)
const shape = editor.store.get(shapeId)
editor.store.put([{ ...shape, x: 100 }])
```

The `Store#update` helper is more convenient for single-record updates:

```ts
editor.store.update(shapeId, (shape) => ({
	...shape,
	x: shape.x + 50,
}))
```

##### Deleting records

```ts
// Remove specific records
editor.store.remove([shapeId])

// Clear everything
editor.store.clear()
```

#### Listening to changes

Subscribe to store changes with `Store#listen`. The callback receives a diff describing what changed:

```ts
const cleanup = editor.store.listen((entry) => {
	// Records that were created
	for (const record of Object.values(entry.changes.added)) {
		console.log('Added:', record.typeName, record.id)
	}

	// Records that were updated [before, after]
	for (const [prev, next] of Object.values(entry.changes.updated)) {
		console.log('Updated:', next.id)
	}

	// Records that were deleted
	for (const record of Object.values(entry.changes.removed)) {
		console.log('Removed:', record.id)
	}
})

// Stop listening
cleanup()
```

##### Filtering listeners

You can filter by source and scope:

```ts
// Only listen to user changes (not remote sync)
editor.store.listen(handleChanges, { source: 'user', scope: 'all' })

// Only document records
editor.store.listen(handleChanges, { source: 'all', scope: 'document' })
```

The `source` indicates where the change came from: `'user'` for local edits, `'remote'` for synchronized changes from other users.

For maintaining internal consistency—like cleaning up bindings when a shape is deleted—use [side effects](https://tldraw.dev/sdk-features/side-effects) instead. Side effects are lifecycle hooks that can intercept and modify records during operations.

#### Snapshots

Snapshots serialize the store for persistence or transfer.

##### Saving state

```ts
import { getSnapshot } from 'tldraw'

// Get a snapshot of document and session state
const { document, session } = getSnapshot(editor.store)

// Save to storage
localStorage.setItem('my-drawing', JSON.stringify({ document, session }))
```

The `document` snapshot contains shapes, pages, bindings, and assets—everything that makes up the drawing itself. The `session` snapshot contains per-instance state like the current page and camera position.

For multiplayer apps, you typically save document state to your server and session state per-user locally.

##### Loading state

```ts
import { loadSnapshot } from 'tldraw'

const saved = JSON.parse(localStorage.getItem('my-drawing'))
loadSnapshot(editor.store, saved)
```

See `getSnapshot` and `loadSnapshot` for more details.

You can load document and session separately:

```ts
// Load just the document
loadSnapshot(editor.store, { document: saved.document })

// Later, restore session state
loadSnapshot(editor.store, { session: saved.session })
```

##### Initial state

Pass a snapshot to the `Tldraw` component to initialize with saved data:

```tsx
function App() {
	return <Tldraw snapshot={savedSnapshot} />
}
```

##### Migrations

Snapshots include schema version information. When you load a snapshot from an older schema version, the store migrates it automatically:

```ts
// Migrate a snapshot without loading it
const migrated = editor.store.migrateSnapshot(oldSnapshot)
```

The migration system handles schema changes between tldraw versions. You can also define custom migrations for your own record types—see [persistence](https://tldraw.dev/docs/persistence) for details.

#### Queries

The store provides indexed queries for efficient lookups through `Store#query`:

```ts
// Create an index by property value
const shapesByParent = editor.store.query.index('shape', 'parentId')

// Get all shapes with a specific parent
const childShapes = shapesByParent.get().get(frameId) ?? new Set()
```

Indexes are reactive computed values. They update automatically when records change and track dependencies like any other signal.

```ts
// Filter by type and query expression
const textShapes = editor.store.query.records('shape', () => ({
	type: { eq: 'text' },
}))

// Get all records of a type
const allShapes = editor.store.query.records('shape')
```

Query expressions support `eq` (equals), `neq` (not equals), and `gt` (greater than, for numbers). The `records()` method returns a computed array that updates when matching records change, while `index()` returns a map from property values to record IDs.

#### Transactions

Batch multiple changes into a single update with `Store.atomic`:

```ts
editor.store.atomic(() => {
	editor.store.put([shape1, shape2])
	editor.store.update(shape3Id, (s) => ({ ...s, x: 100 }))
	editor.store.remove([shape4Id])
})
// All changes applied together, listeners notified once
```

Without batching, each operation triggers listeners separately. Transactions ensure observers see a consistent state and reduce re-renders.

#### Computed caches

For expensive derived data, use `Store#createComputedCache`:

```ts
const boundsCache = editor.store.createComputedCache('shape-bounds', (shape: TLShape) => {
	return calculateBounds(shape)
})

// Get cached value (recalculates only when shape changes)
const bounds = boundsCache.get(shapeId)
```

The cache lazily computes values when accessed and invalidates them when the underlying record changes. This is how the editor efficiently maintains shape bounds, geometry, and other derived data.

#### Creating a standalone store

Most of the time you use the store through the editor. But you can create a standalone store for testing or headless scenarios using `createTLStore`:

```ts
import { createTLStore, loadSnapshot } from 'tldraw'

// Create a store and load saved data
const store = createTLStore()
loadSnapshot(store, savedSnapshot)

// Pass the pre-loaded store to Tldraw
function App() {
	return <Tldraw store={store} />
}
```

Creating your own store is useful when you need to load data before mounting the editor, share a store between multiple components, or work with tldraw data without rendering the editor at all.

#### Related examples

- **[Store events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/events/store-events)** — Listening to store changes and displaying them in real-time.
- **[Snapshots](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/snapshots)** — Saving and loading editor state with `getSnapshot` and `loadSnapshot`.
- **[Local storage](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/data/assets/local-storage)** — Persisting to localStorage with throttled saves.

### Styles

The styles system manages visual properties like color, size, font, fill, and dash patterns across shapes. Style properties differ from regular shape properties in two key ways: they apply consistently across multiple shapes at once, and the editor remembers the last-used value to automatically apply it to newly created shapes.

Styles are defined using `StyleProp` instances that specify valid values and defaults. The editor tracks "shared styles" across the current selection—computing whether all selected shapes share the same value or have different values—to drive the UI and enable batch updates.

#### How it works

##### StyleProp

A `StyleProp` represents a reusable style property that can be applied across different shape types. Each `StyleProp` has a unique identifier, a default value, and optional validation. The editor treats `StyleProp` instances specially, automatically saving their values and applying them to new shapes.

You define a `StyleProp` using one of two static methods:

```typescript
import { StyleProp, T } from 'tldraw'

// Define a numeric style property
const LineWidthStyle = StyleProp.define('myApp:lineWidth', {
	defaultValue: 2,
	type: T.number,
})

// Define an enumerated style property
const CapStyle = StyleProp.defineEnum('myApp:cap', {
	defaultValue: 'round',
	values: ['round', 'square', 'butt'],
})
```

The unique identifier should be namespaced to avoid conflicts with other style properties. Use your app or library name as a prefix.

##### Shape integration

To use a style property in your shape, include the `StyleProp` instance in your shape's props definition. The editor recognizes `StyleProp` instances and handles them specially: it saves their values, applies them to new shapes, and tracks them across selections.

```typescript
import { DefaultColorStyle, DefaultSizeStyle, RecordProps, T, TLBaseShape } from 'tldraw'

// Define your shape type with the value types for styles
type TLMyShape = TLBaseShape<
	'my-shape',
	{
		w: number
		h: number
		color: string // Will be one of the default color names
		size: string // Will be one of the default size values
	}
>

// Pass StyleProp instances in the props object for validation
const myShapeProps: RecordProps<TLMyShape> = {
	w: T.number,
	h: T.number,
	color: DefaultColorStyle,
	size: DefaultSizeStyle,
}
```

When you create a shape, provide the actual style values. If you omit a style prop, the editor uses its saved value from previous shapes:

```typescript
editor.createShape({
	type: 'my-shape',
	props: {
		w: 100,
		h: 100,
		color: 'red',
		size: 'm',
	},
})
```

##### Shared styles

The editor computes shared styles across the current selection. Use `Editor#getSharedStyles` to get a map of each style property to its status: either "shared" (all shapes have the same value) or "mixed" (shapes have different values).

```typescript
const sharedStyles = editor.getSharedStyles()
const colorStyle = sharedStyles.get(DefaultColorStyle)

if (colorStyle && colorStyle.type === 'shared') {
	console.log('All shapes are', colorStyle.value)
} else if (colorStyle && colorStyle.type === 'mixed') {
	console.log('Shapes have different colors')
}
```

For convenience, use `getAsKnownValue` when you only care about the shared case:

```typescript
const sharedStyles = editor.getSharedStyles()
const color = sharedStyles.getAsKnownValue(DefaultColorStyle)
// Returns the color if all shapes share it, undefined otherwise
```

The `getSharedStyles` method examines each selected shape, extracts its style values, and compares them. For groups, it recursively examines the group's children rather than the group itself, since groups don't have visual styles.

When no shapes are selected, `getSharedStyles` returns the styles for the current tool if that tool creates shapes. This lets the UI show and modify the styles that will be applied to the next shape.

##### Style persistence

When you set a style on selected shapes, the editor saves that value as the "style for next shapes." The next shape you create will automatically have the same style value.

```typescript
// Set color for selected shapes - also saves it for next shapes
editor.setStyleForSelectedShapes(DefaultColorStyle, 'blue')

// Create a new shape - it will be blue because 'blue' was saved
editor.createShape({ type: 'geo', props: { w: 100, h: 100 } })
```

You can set the style for next shapes without affecting the current selection using `Editor#setStyleForNextShapes`:

```typescript
editor.setStyleForNextShapes(DefaultColorStyle, 'green')
```

#### Default styles

The `@tldraw/tlschema` package provides a set of default style properties that the built-in shapes use. These styles cover the most common visual properties and integrate with tldraw's theme system for consistent appearance.

**Color styles** control the visual color of shapes and their labels. Colors reference theme values rather than raw hex codes, so shapes adapt to light and dark modes.

- `DefaultColorStyle` - Primary shape color (black, red, blue, green, etc.)

**Appearance styles** affect how shapes are drawn and filled. These work together to create the hand-drawn aesthetic that defines tldraw's visual style.

- `DefaultFillStyle` - Fill pattern (none, semi, solid, pattern, fill, lined-fill)
- `DefaultDashStyle` - Stroke style (draw, solid, dashed, dotted, none)
- `DefaultSizeStyle` - Relative size scale (s, m, l, xl)

**Text styles** control typography for text shapes and labels. The alignment styles handle both the text itself and how content positions within shape bounds.

- `DefaultFontStyle` - Font family (draw, sans, serif, mono)
- `DefaultTextAlignStyle` - Horizontal text alignment (start, middle, end)
- `DefaultHorizontalAlignStyle` - Horizontal content alignment within bounds
- `DefaultVerticalAlignStyle` - Vertical content alignment within bounds

**Shape-specific styles** apply to particular shape types rather than being universal. These are defined alongside their respective shape utilities.

- `GeoShapeGeoStyle` - Geometric shape type (rectangle, ellipse, triangle, etc.)
- `ArrowShapeArrowheadStartStyle` - Start arrowhead type
- `ArrowShapeArrowheadEndStyle` - End arrowhead type

Note that opacity is not a style property. It's a regular property on the base shape (`TLBaseShape`) that all shapes inherit, and it doesn't persist to new shapes or sync across selections like style properties do.

#### Using styles

##### Getting styles

Use `getSharedStyles` to examine the current selection's styles:

```typescript
const styles = editor.getSharedStyles()

// Check if all shapes share a color
const color = styles.get(DefaultColorStyle)
if (color?.type === 'shared') {
	console.log('Shared color:', color.value)
}
```

To get the style value for the next shape to be created, use `Editor#getStyleForNextShape`:

```typescript
const nextColor = editor.getStyleForNextShape(DefaultColorStyle)
```

##### Setting styles

Use `Editor#setStyleForSelectedShapes` to change styles on the current selection:

```typescript
// Change color for all selected shapes
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red')

// Change size
editor.setStyleForSelectedShapes(DefaultSizeStyle, 'l')
```

This method recursively applies the style to all shapes in the selection, including shapes nested inside groups. It only updates shapes that support the given style property.

Use `Editor#setStyleForNextShapes` to change the style for subsequently created shapes:

```typescript
// Next shapes will be blue
editor.setStyleForNextShapes(DefaultColorStyle, 'blue')
```

#### Custom styles

You can create custom style properties for your shapes using `StyleProp#define` or `StyleProp#defineEnum`.

##### Defining a custom style

For numeric or complex types, use `StyleProp.define`:

```typescript
import { StyleProp, T } from 'tldraw'

export const MyLineWidthStyle = StyleProp.define('myApp:lineWidth', {
	defaultValue: 2,
	type: T.number,
})
```

For enumerated values, use `StyleProp.defineEnum`:

```typescript
export const MyPatternStyle = StyleProp.defineEnum('myApp:pattern', {
	defaultValue: 'solid',
	values: ['solid', 'striped', 'dotted', 'checkered'],
})
```

##### Using custom styles in shapes

Include your custom style in your shape's props definition:

```typescript
import { RecordProps, T, TLBaseShape } from 'tldraw'

type TLMyShape = TLBaseShape<
	'my-shape',
	{
		w: number
		h: number
		lineWidth: number
		pattern: string
	}
>

const myShapeProps: RecordProps<TLMyShape> = {
	w: T.number,
	h: T.number,
	lineWidth: MyLineWidthStyle,
	pattern: MyPatternStyle,
}
```

The editor automatically recognizes `StyleProp` instances in your props and handles them during shape creation, selection, and updates.

#### Related examples

- **[Custom shape with custom styles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-custom-styles)** - Create your own custom styles and use them in custom shapes.
- **[Custom shape with tldraw styles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/shape-with-tldraw-styles)** - Use tldraw's default styles in your custom shapes and integrate with the style panel.
- **[Change default styles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/changing-default-style)** - Change the default value for a style property (e.g., setting size to small by default).
- **[Change default colors](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/changing-default-colors)** - Customize the color values in the tldraw theme.
- **[Easter egg styles](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/easter-egg-styles)** - Access hidden styles like white color, special fill variants, and label colors programmatically.

### Text measurement

The editor measures text to calculate shape bounds, handle text wrapping, and enable precise hit testing. Two managers handle this: `TextManager` measures text dimensions using a hidden DOM element, and `FontManager` loads custom fonts before measurement so dimensions are accurate.

#### How text measurement works

The `TextManager` creates a hidden measurement element on initialization and appends it to the editor container. This element stays in the DOM throughout the editor's lifecycle, so repeated measurements don't pay the cost of creating and removing elements.

```typescript
// Simplified for clarity - see TextManager.ts for full implementation
const elm = document.createElement('div')
elm.classList.add('tl-text-measure')
elm.setAttribute('dir', 'auto')
this.editor.getContainer().appendChild(elm)
```

The element is hidden from users but remains part of the document flow so the browser's layout engine computes accurate dimensions.

##### Measuring text

The `measureText` method calculates text dimensions. Pass in text content and styling options, and it returns a box model with width and height.

```typescript
const dimensions = editor.textMeasure.measureText('Hello world', {
	fontFamily: 'Inter',
	fontSize: 16,
	fontWeight: 'normal',
	fontStyle: 'normal',
	lineHeight: 1.35,
	maxWidth: null, // No wrapping
	padding: '4px',
})
// Returns: { x: 0, y: 0, w: 85, h: 22, scrollWidth: 0 }
```

The method applies styles to the measurement element, reads the computed dimensions, then restores the previous styles. This means rapid successive measurements don't interfere with each other.

You can also use `measureHtml` to measure HTML content directly instead of plain text.

##### Text wrapping

Set `maxWidth` to a number and the browser wraps text to fit within that width. The `TextManager` uses the browser's native text layout algorithm rather than implementing its own wrapping logic.

```typescript
const wrapped = editor.textMeasure.measureText('This is a long line of text', {
	fontFamily: 'Inter',
	fontSize: 16,
	fontWeight: 'normal',
	fontStyle: 'normal',
	lineHeight: 1.35,
	maxWidth: 100, // Wrap at 100px
	padding: '4px',
})
// Returns dimensions accounting for multiple lines
```

Set `maxWidth: null` to preserve explicit line breaks and spaces without wrapping. This is useful for measuring single-line text or when wrapping is handled elsewhere.

#### Measuring text spans

For SVG export or precise text selection, `measureTextSpans` breaks text into individual spans based on line breaks and word boundaries.

```typescript
const spans = editor.textMeasure.measureTextSpans('Hello world\nSecond line', {
	width: 200,
	height: 100,
	padding: 8,
	fontSize: 16,
	fontWeight: 'normal',
	fontFamily: 'Inter',
	fontStyle: 'normal',
	lineHeight: 1.35,
	textAlign: 'start',
	overflow: 'wrap',
})
```

Each span includes the text content and its bounding box:

```typescript
;[
	{ text: 'Hello ', box: { x: 0, y: 0, w: 45, h: 22 } },
	{ text: 'world', box: { x: 45, y: 0, w: 40, h: 22 } },
	{ text: 'Second ', box: { x: 0, y: 22, w: 52, h: 22 } },
	{ text: 'line', box: { x: 52, y: 22, w: 32, h: 22 } },
]
```

The algorithm creates a `Range` object for each character, measures its position using `getClientRects()`, then groups characters into spans based on line position and word boundaries.

##### Truncation handling

The `overflow` option controls how text exceeding the available space is handled:

| Value               | Behavior                                              |
| ------------------- | ----------------------------------------------------- |
| `wrap`              | Text wraps to multiple lines (default)                |
| `truncate-clip`     | Text truncates to the first line, no visual indicator |
| `truncate-ellipsis` | Text truncates with an ellipsis character             |

When using `truncate-ellipsis`, the algorithm first measures the ellipsis width, then subtracts it from the available width and remeasures to find the cut point.

#### Font loading

The `FontManager` loads custom fonts before text measurement. If you measure text before its font loads, you get incorrect dimensions and layout shifts when the font finally becomes available.

##### Declaring font requirements

Shapes declare which fonts they need through the `getFontFaces` method on their `ShapeUtil`:

```typescript
class MyTextShapeUtil extends ShapeUtil<MyTextShape> {
	getFontFaces(shape: MyTextShape): TLFontFace[] {
		return [
			{
				family: 'MyCustomFont',
				src: { url: '/fonts/my-custom-font.woff2', format: 'woff2' },
				weight: 'normal',
				style: 'normal',
			},
		]
	}
}
```

The `FontManager` tracks these font requirements and loads them before the shape renders.

##### Loading fonts

Use `ensureFontIsLoaded` to load a specific font, or `requestFonts` to batch multiple font loading requests:

```typescript
// Load a single font
await editor.fonts.ensureFontIsLoaded(fontFace)

// Batch load multiple fonts (batched into a single microtask)
editor.fonts.requestFonts([fontFace1, fontFace2])
```

The manager caches font loading state to avoid redundant loading. Multiple concurrent requests for the same font share a single loading promise.

##### Tracking fonts reactively

For shapes that need reactive font tracking (so they re-render when fonts load), use `trackFontsForShape`:

```typescript
// In your ShapeUtil's getGeometry or component method
editor.fonts.trackFontsForShape(shape)
```

This sets up reactive tracking so the shape re-renders once its fonts are ready.

##### Loading fonts for the current page

Use `loadRequiredFontsForCurrentPage` to load all fonts needed by shapes on the current page. This is useful before exporting or taking screenshots:

```typescript
await editor.fonts.loadRequiredFontsForCurrentPage()
// All fonts for visible shapes are now loaded
```

Pass a `limit` parameter to avoid loading too many fonts at once on pages with many different fonts.

#### Performance considerations

The `TextManager` doesn't cache measurements itself. Shape utilities typically cache their own results using reactive computed values, so text is only remeasured when font properties or content actually change.

The `FontManager` optimizes font loading in several ways: font faces are computed per-shape and cached (only recalculating when shape props or meta change), multiple requests for the same font share a single loading promise, and `requestFonts` batches requests into a single microtask to reduce overhead.

The measurement element uses specific CSS properties for consistent measurements: `overflow-wrap: break-word` allows long words to break when needed, `width` and `max-width` control wrapping, unitless `line-height` ensures consistent spacing, and `dir="auto"` handles mixed LTR/RTL content.

#### Related examples

- **[Rich text with font options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/rich-text-font-extensions)** - Extend the TipTap text editor with font-family and font-size options.
- **[Rich text custom extension](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/rich-text-custom-extension)** - Create custom TipTap extensions for rich text shapes.

### Text shape

The text shape displays formatted text content on the canvas. Text shapes support rich text formatting, automatic sizing, and two distinct modes: auto-sized (where the shape expands to fit content) and fixed-width (where text wraps at a specified boundary). The text tool creates text shapes and manages text entry interactions.

#### Auto-size vs fixed-width

Text shapes operate in two modes controlled by the `autoSize` property:

| Mode        | Behavior                                                         | Use case                                  |
| ----------- | ---------------------------------------------------------------- | ----------------------------------------- |
| Auto-size   | Shape width expands to fit content; text never wraps             | Labels, titles, short annotations         |
| Fixed-width | Text wraps at the shape's width boundary; height grows as needed | Paragraphs, longer descriptions, callouts |

##### Auto-size mode

When `autoSize` is true (the default), the text shape automatically adjusts its width to accommodate the text content. The shape grows horizontally as you type, and text never wraps to a new line unless you press Enter.

```tsx
import { toRichText } from 'tldraw'

editor.createShape({
	type: 'text',
	x: 100,
	y: 100,
	props: {
		richText: toRichText('This text will never wrap'),
		autoSize: true,
	},
})
```

Auto-sized text shapes maintain their position based on the `textAlign` property. When text expands:

- **Start** alignment: Shape grows to the right; left edge stays fixed
- **Middle** alignment: Shape grows equally in both directions; center stays fixed
- **End** alignment: Shape grows to the left; right edge stays fixed

##### Fixed-width mode

When `autoSize` is false, the shape uses a fixed width specified by the `w` property. Text wraps when it reaches this boundary, and the shape grows vertically to accommodate additional lines.

```tsx
editor.createShape({
	type: 'text',
	x: 100,
	y: 100,
	props: {
		richText: toRichText('This text will wrap when it reaches the specified width'),
		autoSize: false,
		w: 200, // Width in pixels
	},
})
```

To convert an auto-sized shape to fixed-width, resize it by dragging the left or right edge handles. The shape switches to fixed-width mode and maintains that width as you continue typing.

#### Text alignment

The `textAlign` property controls horizontal text alignment:

| Value    | Description                                    |
| -------- | ---------------------------------------------- |
| `start`  | Left-aligned (or right-aligned in RTL locales) |
| `middle` | Center-aligned                                 |
| `end`    | Right-aligned (or left-aligned in RTL locales) |

```tsx
editor.createShape({
	type: 'text',
	x: 100,
	y: 100,
	props: {
		richText: toRichText('Centered text'),
		textAlign: 'middle',
		autoSize: false,
		w: 300,
	},
})
```

Text shapes always align vertically to the middle of the shape's geometry.

#### Creating text with the text tool

The text tool (`T` key) provides two ways to create text shapes:

##### Click to create auto-sized text

Click anywhere on the canvas to create an auto-sized text shape. The shape appears centered at your click position and immediately enters edit mode. Start typing to add content.

##### Drag to create fixed-width text

Click and drag horizontally to create a fixed-width text shape. The drag distance determines the initial width. After dragging far enough (approximately 6× the base drag distance), the tool switches to resize mode—continue dragging to adjust the width, then release to start editing.

The drag must exceed a minimum threshold (based on pointer type and timing) to trigger fixed-width creation. Quick, short drags create auto-sized shapes instead.

##### Tool shortcuts

| Action                     | Result                                      |
| -------------------------- | ------------------------------------------- |
| **Click**                  | Create auto-sized text at click position    |
| **Drag horizontally**      | Create fixed-width text with dragged width  |
| **Enter** (shape selected) | Enter edit mode for the selected text shape |
| **Escape**                 | Exit text tool, return to select tool       |
| **Cmd/Ctrl+Enter**         | Confirm text and exit edit mode             |

#### Editing text

Double-click a text shape or press Enter while it's selected to enter edit mode. The shape displays a cursor and you can type, select, and format text using the rich text editor.

Text shapes use the same rich text system as notes, geo shapes, and arrow labels. You get formatting options like bold, italic, code, and highlighting through keyboard shortcuts or the rich text toolbar.

##### Empty text deletion

When you exit edit mode (by clicking outside or pressing Escape), the shape checks if it contains only whitespace. Empty text shapes delete themselves automatically—you don't end up with invisible shapes cluttering the canvas.

#### Scaling and resize

Text shapes support two resize behaviors:

##### Aspect-ratio locked scaling

Dragging corner handles scales the entire shape proportionally. The `scale` property tracks this multiplier. Scaling preserves the text's appearance while making it larger or smaller.

```tsx
// A text shape at 2x scale
editor.createShape({
	type: 'text',
	props: {
		richText: toRichText('Scaled up'),
		scale: 2,
	},
})
```

##### Width adjustment

Dragging the left or right edge handles adjusts only the width. For auto-sized shapes, this converts them to fixed-width mode. For already fixed-width shapes, this changes where text wraps.

#### Dynamic resize mode

When `editor.user.getIsDynamicResizeMode()` is true, new text shapes are created with a scale inversely proportional to the current zoom level. At 200% zoom, new shapes get `scale: 0.5`; at 50% zoom, they get `scale: 2`. This keeps text visually consistent regardless of your zoom level when creating it.

```tsx
const scale = editor.user.getIsDynamicResizeMode() ? 1 / editor.getZoomLevel() : 1
```

#### Arrow bindings

Arrows can bind to text shapes just like other shapes. The text shape's geometry includes extra horizontal padding (configurable via `extraArrowHorizontalPadding`) to prevent arrowheads from overlapping the text content.

```tsx
const ConfiguredTextUtil = TextShapeUtil.configure({
	extraArrowHorizontalPadding: 20, // More space for arrows (default: 10)
})
```

#### Text outline

Text shapes can display an outline effect using the canvas background color. This improves readability when text overlaps other shapes or complex backgrounds.

The outline is implemented using CSS text-shadow and is enabled by default. On Safari, the outline is skipped because text-shadow is not performant on that browser.

```tsx
const ConfiguredTextUtil = TextShapeUtil.configure({
	showTextOutline: false, // Disable the outline effect
})
```

#### Properties

| Property    | Type                      | Description                                     |
| ----------- | ------------------------- | ----------------------------------------------- |
| `richText`  | `TLRichText`              | Text content with formatting                    |
| `color`     | `TLDefaultColorStyle`     | Text color                                      |
| `size`      | `TLDefaultSizeStyle`      | Font size preset (`s`, `m`, `l`, `xl`)          |
| `font`      | `TLDefaultFontStyle`      | Font family (`draw`, `sans`, `serif`, `mono`)   |
| `textAlign` | `TLDefaultTextAlignStyle` | Horizontal alignment (`start`, `middle`, `end`) |
| `autoSize`  | `boolean`                 | When true, shape resizes to fit content         |
| `w`         | `number`                  | Width when autoSize is false                    |
| `scale`     | `number`                  | Scale factor applied to the shape               |

#### Configuration

| Option                        | Type      | Default | Description                                                              |
| ----------------------------- | --------- | ------- | ------------------------------------------------------------------------ |
| `extraArrowHorizontalPadding` | `number`  | `10`    | Additional horizontal padding for arrow binding geometry                 |
| `showTextOutline`             | `boolean` | `true`  | Display text outline for readability (skipped on Safari for performance) |

```tsx
import { Tldraw, TextShapeUtil } from 'tldraw'
import 'tldraw/tldraw.css'

const ConfiguredTextUtil = TextShapeUtil.configure({
	extraArrowHorizontalPadding: 20,
	showTextOutline: false,
})

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw shapeUtils={[ConfiguredTextUtil]} />
		</div>
	)
}
```

#### Grid snapping

When grid mode is enabled (`editor.getInstanceState().isGridMode`), new text shapes snap to grid positions. The tool calculates the snapped position after creating the shape and adjusts for the shape's alignment-based offset.

#### Programmatic text formatting

To create text with formatting, construct the rich text JSON structure directly:

```tsx
editor.createShape({
	type: 'text',
	x: 100,
	y: 100,
	props: {
		richText: {
			type: 'doc',
			content: [
				{
					type: 'paragraph',
					content: [
						{ type: 'text', text: 'Regular and ' },
						{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
						{ type: 'text', text: ' text' },
					],
				},
			],
		},
		autoSize: true,
	},
})
```

Available marks include `bold`, `italic`, `code`, `link`, and `highlight`. See [Rich text](https://tldraw.dev/sdk-features/rich-text) for complete documentation on the formatting system.

#### Related articles

- [Rich text](https://tldraw.dev/sdk-features/rich-text) — Text formatting system and TipTap integration
- [Default shapes](https://tldraw.dev/sdk-features/default-shapes) — Overview of all built-in shapes
- [Tools](https://tldraw.dev/sdk-features/tools) — How tools handle user input
- [Styles](https://tldraw.dev/sdk-features/styles) — Working with shape styles like color and size

#### Related examples

- [Programmatic text shape creation](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/text-shape-configuration) — Creating text shapes with various configurations
- [Outlined text with TipTap mark](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/outlined-text) — Adding text outline styling via a custom TipTap mark extension

### Themes

Themes control the color palette used to render shapes in tldraw. A theme definition bundles color palettes for both light and dark modes alongside shared properties like font size and stroke width. The editor automatically selects the right color mode based on the user's preference.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<Tldraw
			onMount={(editor) => {
				// Read the current theme and resolve colors for the active mode
				const theme = editor.getCurrentTheme()
				const colors = theme.colors[editor.getColorMode()]
				console.log(colors.red.solid) // e.g. '#e03131'

				// Customize the default theme's light colors
				const defaultTheme = editor.getTheme('default')!
				editor.updateTheme({
					...defaultTheme,
					colors: {
						...defaultTheme.colors,
						light: {
							...defaultTheme.colors.light,
							black: { ...defaultTheme.colors.light.black, solid: 'navy' },
						},
					},
				})
			}}
		/>
	)
}
```

#### Theme structure

A theme definition is a `TLTheme` object with an `id`, color palettes for both light and dark modes, font definitions, and shared `fontSize`, `lineHeight`, and `strokeWidth` values:

```typescript
import { DEFAULT_THEME, TLTheme } from 'tldraw'

const myTheme: TLTheme = {
	id: 'custom',
	fontSize: 16,
	lineHeight: 1.35,
	strokeWidth: 2,
	fonts: DEFAULT_THEME.fonts,
	colors: {
		light: {
			text: '#000000',
			background: '#f8f8f8',
			negativeSpace: '#f8f8f8',
			solid: '#fcfcfc',
			cursor: '#000000',
			noteBorder: '#e8e8e8',
			black: {
				solid: '#1d1d1d',
				semi: '#e8e8e8',
				pattern: '#494949',
				fill: '#1d1d1d',
				linedFill: '#e8e8e8',
				frameHeadingStroke: '#1d1d1d',
				frameHeadingFill: '#f5f5f5',
				frameStroke: '#e2e2e2',
				frameFill: '#fcfcfc',
				frameText: '#1d1d1d',
				noteFill: '#fddd00',
				noteText: '#000000',
				highlightSrgb: '#fddd00',
				highlightP3: 'color(display-p3 0.972 0.8705 0.05)',
			},
			blue: {
				solid: '#4263eb',
				// ... other variants
			},
			// ... other colors
		},
		dark: {
			// ... dark mode equivalents
		},
	},
}
```

Each `TLTheme` has a unique `id` used as the key in the theme registry, and always contains both `light` and `dark` palettes under `colors`. To get the palette for the current color mode, index into `theme.colors` with the result of `Editor#getColorMode`.

Each color in the palette is a `TLDefaultColor` with variants for different contexts:

| Variant              | Purpose                                        |
| -------------------- | ---------------------------------------------- |
| `solid`              | Full-opacity color for strokes and solid fills |
| `semi`               | Muted color for the semi fill style            |
| `pattern`            | Color used in pattern/hatch fills              |
| `fill`               | Explicit fill color (usually same as solid)    |
| `linedFill`          | Slightly lighter fill for lined patterns       |
| `frameHeadingStroke` | Stroke color for frame headings                |
| `frameHeadingFill`   | Fill color for frame headings                  |
| `frameStroke`        | Stroke color for frame borders                 |
| `frameFill`          | Fill color for frame backgrounds               |
| `frameText`          | Text color inside frames                       |
| `noteFill`           | Fill color for note shapes                     |
| `noteText`           | Text color inside note shapes                  |
| `highlightSrgb`      | Highlighter color in sRGB color space          |
| `highlightP3`        | Highlighter color in Display P3 color space    |

The palette also includes thirteen base properties: `text` for default text color, `background` for the canvas background color used in SVG exports, `negativeSpace` for areas that should appear to "cut through" to the background (e.g. frame heading knockouts and text outlines), `solid` for the default solid surface color, `cursor` for the cursor color, `noteBorder` for note shape borders, `selectionStroke` and `selectionFill` for the selection box, `selectedContrast` for indicators drawn on top of selected shapes, `brushStroke` and `brushFill` for the selection-brush rectangle, `snap` for snap guides, and `laser` for the laser pointer. In the default themes, `background` and `negativeSpace` have the same value, but custom themes can set them independently.

> Note: The `background` property is used in SVG exports to set the canvas background, but the actual visible canvas background is set separately via the `--tl-color-background` CSS variable. If you customize `background` in your theme, make sure the CSS variable matches — otherwise exports may use a different background color than what's shown on the canvas.

In addition to colors, each theme has a `fontSize` (default `16`), `lineHeight` (default `1.35`), and `strokeWidth` (default `2`). All default shape font sizes are derived by multiplying the base `fontSize` by a per-shape-type multiplier, and stroke widths work the same way with `strokeWidth`, so changing these base values scales text and strokes proportionally. Because these are shared across light and dark modes, you only set them once per theme definition.

#### Reading the current theme

Use `Editor#getCurrentTheme` to get the resolved theme. This is reactive: components that read it will re-render when the theme changes.

```tsx
function MyComponent() {
	const editor = useEditor()
	const theme = editor.getCurrentTheme()
	const colors = theme.colors[editor.getColorMode()]

	return <div style={{ color: colors.blue.solid }}>Blue text</div>
}
```

Inside a shape util, access the theme through `this.editor`:

```typescript
class MyShapeUtil extends ShapeUtil<MyShape> {
	component(shape: MyShape) {
		const theme = this.editor.getCurrentTheme()
		// ...
	}
}
```

Use `getColorValue` to resolve a color style name to its actual color value:

```typescript
import { getColorValue } from 'tldraw'

const theme = editor.getCurrentTheme()
const colors = theme.colors[editor.getColorMode()]
const color = getColorValue(colors, 'red', 'solid') // '#e03131'
```

#### Color mode

The editor selects `'light'` or `'dark'` colors based on its `colorScheme` setting. Set the initial color scheme via the `colorScheme` prop:

```tsx
<Tldraw colorScheme="dark" />
```

The `colorScheme` prop accepts `'light'` (default), `'dark'`, or `'system'` (follows the OS preference).

Use `Editor#getColorMode` to read the resolved mode at runtime:

```typescript
const colorMode = editor.getColorMode() // 'light' or 'dark'
```

Users can also override the color scheme via their preferences:

```typescript
editor.user.updateUserPreferences({ colorScheme: 'dark' })
```

When set, the user preference takes priority over the `colorScheme` prop. See [User preferences](https://tldraw.dev/sdk-features/user-preferences) for more.

The `useColorMode` hook provides a reactive color mode for use in React components:

```tsx
function MyComponent() {
	const colorMode = useColorMode() // reactive, re-renders on change
	return <div>Current mode: {colorMode}</div>
}
```

#### Customizing colors

Use `Editor#updateTheme` to override specific colors in a theme. Get the current theme, modify it, and pass it back:

```typescript
const theme = editor.getTheme('default')!
editor.updateTheme({
	...theme,
	colors: {
		...theme.colors,
		light: { ...theme.colors.light, black: { ...theme.colors.light.black, solid: 'navy' } },
	},
})
```

You can also override other properties:

```typescript
const theme = editor.getTheme('default')!
editor.updateTheme({ ...theme, fontSize: 18, strokeWidth: 3 })
```

You can also pass theme definitions as a prop on the `Tldraw` or `TldrawEditor` component. The prop is reactive: when it changes, the editor's themes update automatically.

```tsx
const themes: Partial<TLThemes> = {
	default: {
		id: 'default',
		fontSize: 16,
		lineHeight: 1.35,
		strokeWidth: 2,
		fonts: DEFAULT_THEME.fonts,
		colors: {
			light: { /* ... */ },
			dark: { /* ... */ },
		},
	},
}

<Tldraw themes={themes} />
```

#### Adding custom colors

You can add new colors beyond the built-in palette. Extend the `TLThemeDefaultColors` interface with module augmentation and include the color in your theme definitions:

```typescript
import { TLDefaultColor } from 'tldraw'

declare module '@tldraw/tlschema' {
	interface TLThemeDefaultColors {
		pink: TLDefaultColor
	}
}
```

```tsx
import { DEFAULT_THEME, TLTheme } from 'tldraw'

const themes: Partial<TLThemes> = {
	default: {
		...DEFAULT_THEME,
		colors: {
			light: {
				...DEFAULT_THEME.colors.light,
				pink: {
					solid: '#e91e8c',
					semi: '#fce4f2',
					pattern: '#f06baf',
					fill: '#e91e8c',
					linedFill: '#fce4f2',
					frameHeadingStroke: '#e91e8c',
					frameHeadingFill: '#fce4f2',
					frameStroke: '#e91e8c',
					frameFill: '#fce4f2',
					frameText: '#e91e8c',
					noteFill: '#fce4f2',
					noteText: '#e91e8c',
					highlightSrgb: '#e91e8c',
					highlightP3: '#e91e8c',
				},
			},
			dark: {
				...DEFAULT_THEME.colors.dark,
				pink: {
					solid: '#f06baf',
					semi: '#3d1a2e',
					// ... other variants
				},
			},
		},
	},
}

<Tldraw themes={themes} />
```

tldraw validates shape properties (including colors) when data enters the store, for example when loading from IndexedDB or syncing from a server. Passing `themes` tells tldraw about your custom colors _before_ data is loaded, so they pass validation.

Every theme should include an entry for every custom color in both light and dark palettes. If a shape uses a color that doesn't exist in the active theme, it won't render correctly.

See the [Custom theme](https://tldraw.dev/examples/ui/custom-theme) example for a complete demo.

#### Removing colors

To remove built-in palette colors, augment the `TLRemovedDefaultThemeColors` interface. Any key you add will be omitted from `TLThemeColors`, so TypeScript will no longer expect it in theme definitions:

```typescript
declare module '@tldraw/tlschema' {
	interface TLRemovedDefaultThemeColors {
		'light-violet': true
		'light-blue': true
		'light-green': true
		'light-red': true
	}
}
```

Then omit those colors when building your theme object:

```typescript
const {
	'light-violet': _,
	'light-blue': __,
	'light-green': ___,
	'light-red': ____,
	...kept
} = DEFAULT_THEME.colors.light
```

Colors removed this way won't appear in the style panel. UI infrastructure colors (`text`, `background`, `solid`, `cursor`, `noteBorder`, `negativeSpace`, `selectionStroke`, `selectionFill`, `selectedContrast`, `brushStroke`, `brushFill`, `snap`, `laser`) cannot be removed.

#### Using themes in shape utils

For built-in shapes, override how theme colors are applied using `getCustomDisplayValues` on a configured shape util. The `theme` and `colorMode` parameters provide the theme definition and active color mode for the current context (which may differ from the editor's active theme during SVG export):

```typescript
import { GeoShapeUtil, type GeoShapeUtilDisplayValues } from 'tldraw'

const CustomGeoShapeUtil = GeoShapeUtil.configure({
	getCustomDisplayValues(_editor, shape, theme, colorMode): Partial<GeoShapeUtilDisplayValues> {
		if (shape.isLocked) {
			return { fillColor: theme.colors[colorMode].red.solid }
		}

		return {}
	},
})
```

Frame, note, and highlight shapes read their colors from the theme's color palette. To customize them, override the relevant color variants in your theme definition:

```typescript
import { DEFAULT_THEME, TLTheme } from 'tldraw'

const myTheme: TLTheme = {
	...DEFAULT_THEME,
	id: 'custom',
	colors: {
		light: {
			...DEFAULT_THEME.colors.light,
			noteBorder: '#A5D6A7',
			black: {
				...DEFAULT_THEME.colors.light.black,
				noteFill: '#E8F5E9', // green-tinted notes
				noteText: '#1B5E20',
			},
			blue: {
				...DEFAULT_THEME.colors.light.blue,
				noteFill: '#E3F2FD',
				noteText: '#0D47A1',
			},
			// ... other colors
		},
		dark: DEFAULT_THEME.colors.dark,
	},
}
```

The same approach works for frame colors (`frameFill`, `frameStroke`, `frameHeadingFill`, `frameHeadingStroke`, `frameText`) and highlight colors (`highlightSrgb`, `highlightP3`).

For custom shapes, call `this.editor.getCurrentTheme()` or `useEditor().getCurrentTheme()` directly in your `component` and `indicator` methods:

```tsx
class MyShapeUtil extends ShapeUtil<MyShape> {
	component(shape: MyShape) {
		const theme = this.editor.getCurrentTheme()
		const colors = theme.colors[this.editor.getColorMode()]
		const color = getColorValue(colors, shape.props.color, 'solid')

		return (
			<HTMLContainer>
				<div style={{ color }}>Hello</div>
			</HTMLContainer>
		)
	}
}
```

#### Editor API reference

| Method                        | Description                                       |
| ----------------------------- | ------------------------------------------------- |
| `Editor#getCurrentTheme`   | Get the current theme definition                  |
| `Editor#getCurrentThemeId` | Get the id of the current theme                   |
| `Editor#setCurrentTheme`   | Set the current theme by id                       |
| `Editor#getThemes`         | Get all registered themes                         |
| `Editor#getTheme`          | Get a theme by id                                 |
| `Editor#updateTheme`       | Register or update a theme (keyed by `id`)        |
| `Editor#updateThemes`      | Replace all themes or update via callback         |
| `Editor#getColorMode`      | Get the active color mode (`'light'` or `'dark'`) |
| `Editor#setColorMode`      | Set the color mode (`'light'` or `'dark'`)        |

#### Related examples

- [Custom theme](https://tldraw.dev/examples/ui/custom-theme) - Add a custom color, and adjust theme values with sliders.
- [Changing default colors](https://tldraw.dev/examples/ui/changing-default-colors) - Customize the default theme's color palette.
- [Display options](https://tldraw.dev/examples/configuration/display-options) - Override how built-in shapes render using `getCustomDisplayValues`.

### Ticks

The tick system provides a frame-synchronized update loop for the editor. The editor emits `tick` and `frame` events on every animation frame using `requestAnimationFrame`, passing the elapsed time since the last frame. This enables smooth animations, edge scrolling during drag operations, and time-based state updates. The `frame` event fires immediately before `tick` on each animation frame; the editor uses it internally, and `tick` is the one intended for application code.

While pointer and keyboard events fire in response to user input, tick events fire continuously. Use them when you need updates that run every frame regardless of user interaction.

#### Subscribing to tick events

The most common way to use ticks is by subscribing to the `tick` event on the editor. The callback receives the elapsed time in milliseconds since the last frame:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function TickExample() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				onMount={(editor) => {
					const handleTick = (elapsed: number) => {
						// elapsed is typically ~16ms at 60fps
						updateAnimation(elapsed)
					}

					editor.on('tick', handleTick)

					// Clean up when done
					return () => editor.off('tick', handleTick)
				}}
			/>
		</div>
	)
}
```

Remember to unsubscribe when your component unmounts or when you no longer need tick updates. The elapsed time lets you create frame-rate-independent animations by scaling movement based on actual time passed rather than assuming a fixed framerate.

#### Tick events in tools

When building custom tools using the state machine pattern, you can handle tick events by implementing the `onTick` method on your `StateNode`. The editor dispatches tick events through the state tree, so your active tool states receive them automatically:

```typescript
import { StateNode, TLTickEventInfo } from '@tldraw/editor'

export class MyDraggingState extends StateNode {
	static override id = 'dragging'

	override onTick({ elapsed }: TLTickEventInfo) {
		// Update something every frame while this state is active
		this.updateDragPosition(elapsed)
	}

	private updateDragPosition(elapsed: number) {
		// Your frame-based logic here
	}
}
```

The `TLTickEventInfo` contains the elapsed time in milliseconds. Use this for time-based calculations rather than assuming a fixed frame duration.

#### Edge scrolling example

The most common use of `onTick` in tools is edge scrolling during drag operations. When you drag near the edge of the viewport, the canvas scrolls automatically. Here's how tldraw's built-in `Translating` state handles it:

```typescript
import { StateNode, TLTickEventInfo } from '@tldraw/editor'

export class Translating extends StateNode {
	static override id = 'translating'

	override onTick({ elapsed }: TLTickEventInfo) {
		this.editor.edgeScrollManager.updateEdgeScrolling(elapsed)
	}
}
```

The `EdgeScrollManager` accumulates elapsed time to create a smooth acceleration effect. After a short delay, scrolling begins slowly and speeds up the longer you hold near the edge. This creates a natural feel where small adjustments are easy but you can also scroll quickly when needed.

For more details on edge scrolling, see the [edge scrolling](https://tldraw.dev/sdk-features/edge-scrolling) documentation.

#### How the editor uses ticks internally

The editor uses tick events for several internal features:

**Scribble animations**: The `ScribbleManager` animates the visual trails you see during brush selection and laser pointer usage. On each tick, it adds new points to active scribbles and shrinks them from the tail, creating a fading trail effect.

**Pointer velocity**: The editor tracks pointer velocity by measuring movement between ticks. This data is available via `editor.inputs.getPointerVelocity()` and is used for features like gesture detection.

**Camera animations**: Methods like `editor.zoomIn()` and `editor.resetZoom()` animate smoothly by subscribing to tick events for the duration of the animation, then unsubscribing when complete.

#### When to use tick events

Tick events are appropriate when you need continuous updates that run every frame:

- Animations that should run smoothly regardless of user input
- Edge scrolling during drag operations
- Physics simulations or particle systems
- Smooth interpolation between values over time
- Debouncing based on frame counts rather than timeouts

Don't use tick events for responding to user input. Pointer, keyboard, and wheel events are better for that since they fire immediately when the user acts. Tick events add a frame of latency.

#### Related

- [Events](https://tldraw.dev/sdk-features/events) - Overview of all editor events including tick
- [Edge scrolling](https://tldraw.dev/sdk-features/edge-scrolling) - Detailed documentation on edge scrolling behavior
- [Snowstorm example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/use-cases/snowstorm) - Uses tick events to animate falling snowflakes

### Tools

Tools in tldraw define how the editor responds to user input. Each tool handles a specific interaction mode—selecting shapes, drawing, or panning the canvas. You implement tools as state machines using the `StateNode` class, which gives you a structured way to manage complex interactions through a hierarchy of states. The editor maintains a single active tool at any time and routes all input events through it. When you click the hand icon in the toolbar, the editor transitions from the select tool to the hand tool, changing how the canvas responds to your mouse movements and clicks.

The state machine architecture lets you handle multi-step interactions cleanly. For example, when you use the select tool to resize a shape, the tool transitions through multiple states: idle, pointing the resize handle, and actively resizing. Each state handles different events and can transition to other states based on user input.

#### How it works

Tools are organized in a hierarchical state machine where each node can handle events and contain child states. The editor creates a root state that contains all tools as children. When an input event occurs, it flows down from the root through the currently active tool and its active child state.

The `StateNode` class provides the foundation for this system. Each state node has an id, optional children, and methods for handling events. State nodes come in three types: root nodes that contain tools, branch nodes that have child states, and leaf nodes that perform actual work. Tools themselves are typically branch nodes with child states representing different phases of an interaction.

When a state becomes active, its `onEnter` method runs. When it becomes inactive, its `onExit` method runs. Between these lifecycle events, the state handles input through event methods like `onPointerDown`, `onPointerMove`, and `onKeyDown`. If a state doesn't handle an event, it passes through without effect. Child states receive events after their parent, so both can respond.

You trigger transitions between states explicitly through the `transition` method. When the select tool's idle state detects a pointer down on a shape, it calls `this.parent.transition('pointing_shape', info)` to move to the pointing state. The transition triggers the appropriate exit and enter handlers.

#### Key concepts

##### State hierarchy

Tools exist in a tree structure starting from a root node. The root contains all available tools like select, hand, eraser, and draw. Each tool can contain child states for different phases of its interaction. For example, the select tool has children including idle, pointing, translating, resizing, and rotating. When the select tool is active and the user starts dragging a shape, the active path becomes `select.translating`.

The hierarchy allows tools to share common behavior at higher levels while specializing at lower levels. The select tool handles keyboard shortcuts at its top level, while child states handle specific mouse interactions. This organization prevents duplicate logic across related states.

##### Event handling

State nodes implement event handler methods that match input event types. The handlers receive an info object containing event details like pointer position, keyboard modifiers, and the event target. Common handlers include `onPointerDown`, `onPointerMove`, `onPointerUp`, `onKeyDown`, and `onTick` for animation frame updates.

Events flow through the state hierarchy. When a pointer move occurs, the root receives it first, then the current tool, then the tool's active child state. Each node can handle the event by implementing the corresponding method. The hand tool's dragging state implements `onPointerMove` to update the camera position as the user drags.

##### State transitions

The `transition` method moves between states by id. You can transition to a direct child using just its id, or to deeper descendants using dot notation like `'crop.pointing_crop_handle'`. Transitions are atomic - the old state's `onExit` runs, the new state's `onEnter` runs, and the state is updated.

Transitions carry information through their second parameter. When transitioning from idle to pointing, the pointer event info passes along so the pointing state knows where the interaction started. This data is available in both the exit handler of the old state and the enter handler of the new state.

##### Tool registration

Tools are registered with the editor through the root state. The `@tldraw/editor` package provides only the root state with no tools. The `tldraw` package extends this with a full suite of tools. Custom tools are added by creating a custom root state that includes them as children.

The editor's `setCurrentTool` method transitions the root state to a different tool by id. The `getCurrentTool` method returns the currently active tool state node. These methods provide the public API for tool management while the state machine handles the internal transitions.

##### Event target detection

Event info objects include a target property indicating what the user interacted with. Possible targets include canvas, shape, handle, and selection. The select tool's idle state uses this to determine which child state to transition to. A pointer down on a shape transitions to pointing_shape, while a pointer down on the canvas transitions to pointing_canvas.

Target detection happens before events reach tools, using the editor's geometry system to determine what's under the pointer. This separation means tools can focus on interaction logic without implementing hit testing.

##### Tool lock

Tool lock keeps the current tool active after completing an action. Normally, tools like draw or geo return to the select tool after creating a shape. With tool lock enabled, the tool stays active so you can create multiple shapes without reselecting the tool each time.

Tool lock is stored in instance state:

```ts
// Check if tool lock is enabled
editor.getInstanceState().isToolLocked

// Enable tool lock
editor.updateInstanceState({ isToolLocked: true })

// Toggle tool lock
const current = editor.getInstanceState().isToolLocked
editor.updateInstanceState({ isToolLocked: !current })
```

Custom tools should check `isToolLocked` when deciding whether to return to the select tool after completing their action.

#### Creating custom tools

To create a custom tool, extend the `StateNode` class and implement the required static properties and event handlers.

```typescript
import { StateNode, TLPointerEventInfo } from '@tldraw/editor'

export class StampTool extends StateNode {
	static override id = 'stamp'
	static override initial = 'idle'
	static override children() {
		return [StampIdle, StampPointing]
	}

	override onEnter() {
		this.editor.setCursor({ type: 'cross', rotation: 0 })
	}
}
```

The `StateNode` class has these static properties:

| Property             | Description                                                                                                 |
| -------------------- | ----------------------------------------------------------------------------------------------------------- |
| `id`                 | Required. The unique identifier for this state                                                              |
| `initial`            | The id of the initial child state (required if the tool has children)                                       |
| `children()`         | A function returning an array of child state constructors                                                   |
| `isLockable`         | Whether tool lock applies to this tool (default: `true`)                                                    |
| `useCoalescedEvents` | Whether to receive the browser's coalesced pointer move events for higher-fidelity input (default: `false`) |

For a simple tool without child states, implement event handlers directly on the tool class:

```typescript
export class MeasureTool extends StateNode {
	static override id = 'measure'

	override onPointerDown(info: TLPointerEventInfo) {
		const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
		// Start measuring from this point
	}

	override onPointerMove(info: TLPointerEventInfo) {
		// Update measurement as pointer moves
	}

	override onPointerUp(info: TLPointerEventInfo) {
		// Finalize measurement and return to select tool
		this.editor.setCurrentTool('select')
	}
}
```

Child states follow the same pattern but focus on specific phases of the interaction. A drawing tool might have idle, pointing, and drawing states. The pointing state waits to see if the user is clicking or starting a drag, then transitions accordingly:

```typescript
export class DrawingPointing extends StateNode {
	static override id = 'pointing'

	override onPointerMove(info: TLPointerEventInfo) {
		if (this.editor.inputs.getIsDragging()) {
			this.parent.transition('drawing', info)
		}
	}

	override onPointerUp(info: TLPointerEventInfo) {
		this.parent.transition('idle', info)
	}
}
```

Access the editor through `this.editor` to read input state, manipulate shapes, or transition tools. Access the parent state through `this.parent` to transition between sibling states. The editor instance provides the full API for querying and modifying the document.

To register a custom tool, pass it to the Tldraw component via the `tools` prop:

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

const customTools = [StampTool, MeasureTool]

export default function App() {
	return <Tldraw tools={customTools} />
}
```

The tools array should be defined outside the component to avoid recreation on each render.

#### Overriding default tools

The tldraw component provides several ways to customize which tools are available. You can remove tools from the toolbar, add new tools, or dynamically register and unregister tools at runtime.

##### Removing tools from the toolbar

Use the `overrides` prop to modify which tools appear in the UI. The `tools` function receives the current tools object and returns a modified version:

```typescript
import { Tldraw, TLUiOverrides } from 'tldraw'

const overrides: TLUiOverrides = {
	tools(editor, tools, helpers) {
		// Remove the text tool from the toolbar
		delete tools.text
		return tools
	},
}

function App() {
	return <Tldraw overrides={overrides} />
}
```

This removes the tool from the UI but doesn't remove it from the editor's state machine. Users can still activate the tool programmatically or via keyboard shortcuts. To fully disable a tool, you'd also need to remove its keyboard shortcut binding.

##### Adding custom tools to the toolbar

When you create a custom tool, you need to add it both to the editor's state machine and to the UI. The `tools` prop registers the tool with the state machine, while `overrides.tools` adds it to the UI context:

```typescript
import { Tldraw, TLUiOverrides, StateNode } from 'tldraw'

class MyTool extends StateNode {
	static override id = 'my-tool'
	// ... implementation
}

const overrides: TLUiOverrides = {
	tools(editor, tools, helpers) {
		tools['my-tool'] = {
			id: 'my-tool',
			icon: 'my-icon',
			label: 'My Tool',
			kbd: 'm',
			onSelect: () => editor.setCurrentTool('my-tool'),
		}
		return tools
	},
}

function App() {
	return <Tldraw tools={[MyTool]} overrides={overrides} />
}
```

To make the tool appear in the toolbar, override the `Toolbar` component and include your tool item. See the "Add a tool to the toolbar" example for the complete implementation.

##### Dynamic tool registration

Tools can be added or removed at runtime using the `setTool` and `removeTool` methods on the editor. This is useful when tool availability depends on user permissions, feature flags, or application state.

```tsx
import { useState } from 'react'
import { Editor, StateNode, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

class HeartTool extends StateNode {
	static override id = 'heart'
	override onPointerDown() {
		// Create a heart shape at click position
	}
}

function App() {
	const [editor, setEditor] = useState<Editor | null>(null)
	const [isEnabled, setIsEnabled] = useState(false)

	const toggleTool = () => {
		if (!editor) return
		if (isEnabled) {
			// Switch away first if currently using the tool
			if (editor.getCurrentToolId() === 'heart') {
				editor.setCurrentTool('select')
			}
			editor.removeTool(HeartTool)
		} else {
			editor.setTool(HeartTool)
		}
		setIsEnabled(!isEnabled)
	}

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw onMount={setEditor} />
			<button style={{ position: 'absolute', top: 64, left: 8, zIndex: 1000 }} onClick={toggleTool}>
				{isEnabled ? 'Remove heart tool' : 'Add heart tool'}
			</button>
		</div>
	)
}
```

When removing a tool, check whether the user is currently using it. If so, transition to a different tool like select to avoid leaving the editor in an invalid state. The `setTool` method adds a tool constructor to the state chart, while `removeTool` removes it.

#### Related examples

- **[Custom tool (sticker)](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/custom-tool)** - A simple custom tool that adds a heart emoji sticker to the canvas when you click, demonstrating the basics of extending StateNode.
- **[Custom tool with child states](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/tool-with-child-states)** - Expands on the sticker tool to show how to create a tool with complex interactions using child states in the state machine.
- **[Screenshot tool](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/screenshot-tool)** - A custom tool that takes a screenshot of a specific area of the canvas, demonstrating how to handle multi-step interactions.
- **[Lasso select tool](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/lasso-select-tool)** - A custom selection tool that uses freehand drawing to select shapes, showing how to build alternative selection tools with reactive atoms and overlays.
- **[Add a tool to the toolbar](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/add-tool-to-toolbar)** - Shows how to make your custom tool icon appear on tldraw's toolbar by overriding the toolbar component and providing custom assets.
- **[Remove a tool from the toolbar](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/ui/remove-tool)** - Shows how to remove a default tool from the toolbar using UI overrides.
- **[Dynamic tools with setTool and removeTool](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-api/dynamic-tools)** - Demonstrates how to dynamically add and remove tools from the editor's state chart after initialization, useful for conditional tool availability.

### UI components

The `tldraw` package includes a complete React-based UI. It provides the menus, toolbars, panels, and dialogs that users interact with when creating and editing content. The UI is composed of named component slots that you can selectively override or hide, so you can customize the interface while still benefiting from the editor's reactive state management.

The UI connects to the editor through React hooks and context providers. Components automatically update when editor state changes. You can replace individual parts of the interface without reimplementing the logic that connects UI actions to editor operations.

#### How it works

##### Component slot architecture

The UI divides the screen into distinct layout zones:

```
┌─────────────────────────────────────────────────────────┐
│                    Top Panel                             │
├────────────┬──────────────────────────┬─────────────────┤
│   Left     │         Canvas           │     Right       │
│   Panel    │                          │     Panel       │
├────────────┴──────────────────────────┴─────────────────┤
│                   Bottom Panel                           │
└─────────────────────────────────────────────────────────┘
```

The top zone contains the main menu, helper buttons (like "Back to content"), an empty top panel slot, and the share and style panels. The bottom zone houses navigation controls, the main toolbar with drawing tools, and the help menu. On desktop, the style panel appears in the top-right zone; on mobile it moves to a modal overlay.

Each zone can host multiple components. The toolbar includes the tool selector, tool-specific options, and the tool lock button. These components share context and coordinate through the editor's state.

##### Context providers and state management

The UI establishes a hierarchy of React context providers that manage different aspects of the interface. At the root, `TldrawUiContextProvider` coordinates all other providers and merges your overrides. Specialized providers handle translations, tooltips, dialogs, toasts, breakpoints for responsive behavior, and the component registry.

The actions and tools providers transform raw editor methods into UI-friendly actions with labels, icons, and keyboard shortcuts. When you click a toolbar button, the component calls an action from context, which invokes the appropriate editor method. This indirection means the same action can be triggered from multiple places (toolbar, menu, keyboard shortcut) with consistent behavior.

##### Reactive UI updates

UI components read editor state through hooks like `useEditor`, `useValue`, and `useReactor`. These hooks use the editor's reactive signal system to automatically re-render when relevant state changes. The style panel uses `useRelevantStyles` to determine which style controls to show based on the current selection—when you select a different shape, the hook detects the change and the panel updates.

This reactive approach means you don't need to manually manage subscriptions or worry about stale state. Components declare their dependencies, and the reactivity system handles the rest.

#### Key components

##### Component slots

The UI defines several component slots you can override or hide.

The **Toolbar** contains the primary tool selector with buttons for each available tool (select, draw, shapes, etc.). On mobile, it hides automatically when editing text to make room for the virtual keyboard.

The **TopPanel** is an empty slot in the top-center of the screen. It has no default component, so use it for your own UI like a document title or sync status.

The **StylePanel** shows style controls for selected shapes: color, fill, dash, size, and opacity. It appears in the top-right on desktop and as a modal on mobile.

The **MenuPanel** sits in the top-left corner. It groups the main menu, the page menu, and quick actions.

The **NavigationPanel** provides zoom controls and the minimap toggle. It sits in the bottom-left area.

**HelperButtons** are context-sensitive buttons that appear based on editor state—"Back to content" when the camera is far from shapes, "Exit pen mode" on touch devices.

**ActionsMenu**, **ContextMenu**, and **HelpMenu** provide access to actions and information through different interaction patterns.

Each slot is optional. Pass `null` as an override to hide a component entirely, or provide your own React component to replace the default implementation.

##### Slot props

The UI portion of the `components` prop is shaped by `TLUiComponents`. Every key is optional: use `null` to hide that slot, or pass a React component. When a slot has a documented props type in the table below, import that type from `tldraw` and type your replacement as `React.ComponentType<…>` (or implement the matching props). When the props column says **none**, the SDK does not declare extra props for that slot beyond what a plain `ComponentType` allows.

| Slot                      | Props (import from `tldraw`)                                                  |
| ------------------------- | ----------------------------------------------------------------------------- |
| `ContextMenu`             | `TLUiContextMenuProps`                                                     |
| `ActionsMenu`             | `TLUiActionsMenuProps`                                                     |
| `HelpMenu`                | `TLUiHelpMenuProps`                                                        |
| `ZoomMenu`                | `TLUiZoomMenuProps`                                                        |
| `MainMenu`                | `TLUiMainMenuProps`                                                        |
| `Minimap`                 | none                                                                          |
| `StylePanel`              | `TLUiStylePanelProps`                                                      |
| `PageMenu`                | none                                                                          |
| `NavigationPanel`         | none                                                                          |
| `Toolbar`                 | none                                                                          |
| `RichTextToolbar`         | `TLUiRichTextToolbarProps`                                                 |
| `ImageToolbar`            | none                                                                          |
| `VideoToolbar`            | none                                                                          |
| `KeyboardShortcutsDialog` | `TLUiKeyboardShortcutsDialogProps`                                         |
| `QuickActions`            | `TLUiQuickActionsProps`                                                    |
| `HelperButtons`           | `TLUiHelperButtonsProps`                                                   |
| `DebugPanel`              | none                                                                          |
| `DebugMenu`               | none                                                                          |
| `MenuPanel`               | none                                                                          |
| `TopPanel`                | none                                                                          |
| `SharePanel`              | none                                                                          |
| `CursorChatBubble`        | none                                                                          |
| `Dialogs`                 | none                                                                          |
| `Toasts`                  | none                                                                          |
| `A11y`                    | none                                                                          |
| `FollowingIndicator`      | none                                                                          |
| `PeopleMenu`              | none (default component: optional `children` via `DefaultPeopleMenuProps`) |
| `PeopleMenuAvatar`        | `TLUiPeopleMenuAvatarProps`                                                |
| `PeopleMenuFacePile`      | `TLUiPeopleMenuFacePileProps`                                              |
| `PeopleMenuItem`          | `TLUiPeopleMenuItemProps`                                                  |
| `UserPresenceEditor`      | none                                                                          |

This table is an index; the authoritative list and types remain `TLUiComponents` in the API reference and in the package typings.

##### UI hooks

Components access editor functionality through specialized hooks.

`useEditor` returns the editor instance, providing direct access to all editor methods and state.

`useActions` returns a collection of UI actions (copy, paste, delete) with their labels, icons, and keyboard shortcuts. Each action is a function you can call from your custom UI.

`useTools` returns the available tools with their metadata. The toolbar uses this to render tool buttons.

`useRelevantStyles` determines which styles are relevant to the current selection and returns their values. It powers the style panel.

`useBreakpoint` returns a numeric breakpoint index (0-7) that maps to the `PORTRAIT_BREAKPOINT` enum. Compare against values like `PORTRAIT_BREAKPOINT.MOBILE` or `PORTRAIT_BREAKPOINT.TABLET_SM` to adapt layout for different screen sizes.

These hooks encapsulate common UI patterns and keep your custom components in sync with editor state.

#### Hiding the UI

You can hide the default tldraw user interface entirely using the `hideUi` prop. This turns off both the visuals and the keyboard shortcuts.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw hideUi />
		</div>
	)
}
```

When the UI is hidden, you can't select tools using keyboard shortcuts. You can still control the editor programmatically through `Editor` methods. Open the console and try:

```ts
editor.setCurrentTool('draw')
```

All of tldraw's user interface works by controlling the editor via its methods. If you hide the user interface, you can still use these same methods to control the editor. See the [custom user interface example](https://tldraw.dev/examples/ui/custom-ui) for this in action.

#### Extension points

##### Overriding components

Override individual components by passing them to the `components` prop:

```tsx
import { Tldraw, useEditor, useTools } from 'tldraw'
import 'tldraw/tldraw.css'

function CustomToolbar() {
	const editor = useEditor()
	const tools = useTools()

	return (
		<div className="my-toolbar">
			{Object.values(tools).map((tool) => (
				<button key={tool.id} onClick={() => editor.setCurrentTool(tool.id)}>
					{tool.label}
				</button>
			))}
		</div>
	)
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				components={{
					Toolbar: CustomToolbar,
				}}
			/>
		</div>
	)
}
```

The `useTools` hook returns an object mapping tool IDs to [TLUiToolItem](https://tldraw.dev/reference/tldraw/TLUiToolItem) objects. Each tool item contains metadata like `id`, `label`, `icon`, and `kbd` (keyboard shortcut).

##### Hiding components

Pass `null` to hide a component entirely. This is useful for focused experiences that don't need the full default UI:

```tsx
<Tldraw
	components={{
		HelpMenu: null,
		DebugMenu: null,
		SharePanel: null,
	}}
/>
```

See the [UI components hidden example](https://tldraw.dev/examples/ui/ui-components-hidden) for a complete list of hideable components.

#### Overrides

Control tldraw's menu content with the `overrides` prop. This prop accepts a [TLUiOverrides](https://tldraw.dev/reference/tldraw/TLUiOverrides) object, which has methods for `actions` and `tools`, and a `translations` property.

##### Actions

The user interface has a set of shared actions used in the menus and keyboard shortcuts. Override these by providing an `actions` method that receives the editor, the [default actions](https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/ui/context/actions.tsx), and a helpers object, then returns a mutated actions object.

```tsx
import { Tldraw, TLUiOverrides } from 'tldraw'
import 'tldraw/tldraw.css'

const myOverrides: TLUiOverrides = {
	actions(editor, actions, helpers) {
		// Delete an action (remember to also delete any menu items that reference it)
		delete actions['insert-embed']

		// Create a new action or replace an existing one
		actions['my-new-action'] = {
			id: 'my-new-action',
			label: 'My new action',
			readonlyOk: true,
			kbd: 'cmd+shift+u,ctrl+shift+u',
			onSelect(source) {
				window.alert('My new action just happened!')
			},
		}
		return actions
	},
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw overrides={myOverrides} />
		</div>
	)
}
```

The `actions` object is a map of [TLUiActionItem](https://tldraw.dev/reference/tldraw/TLUiActionItem)s, keyed by their `id`. See the [action overrides example](https://tldraw.dev/examples/ui/action-overrides) for more.

##### Tools

Override tools the same way you override actions. Provide a `tools` method that accepts the editor, the [default tools object](https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/ui/hooks/useTools.tsx), and a helpers object, then returns a mutated version.

```tsx
const myOverrides: TLUiOverrides = {
	tools(editor, tools, helpers) {
		// Create a tool item in the UI's context
		tools.card = {
			id: 'card',
			icon: 'color',
			label: 'tools.card',
			kbd: 'c',
			onSelect: () => {
				editor.setCurrentTool('card')
			},
		}
		return tools
	},
}
```

The `tools` object is a map of [TLUiToolItem](https://tldraw.dev/reference/tldraw/TLUiToolItem)s, keyed by their `id`. See the [add tool to toolbar example](https://tldraw.dev/examples/ui/add-tool-to-toolbar) for a complete implementation.

##### Translations

The `translations` property accepts a table of new translations. If you add a tool with `label: 'tools.card'`, you need to provide an English translation for that key:

```tsx
const myOverrides: TLUiOverrides = {
	translations: {
		en: {
			'tools.card': 'Card',
		},
	},
}
```

See [internationalization](https://tldraw.dev/sdk-features/internationalization) for more about tldraw's translation system.

#### UI events

The `Tldraw` component has an `onUiEvent` prop that fires when users interact with the UI:

```tsx
import { Tldraw, TLUiEventHandler } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	const handleUiEvent: TLUiEventHandler = (name, data) => {
		console.log('UI event:', name, data)
	}

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw onUiEvent={handleUiEvent} />
		</div>
	)
}
```

The callback receives the event name as a string and an object with information about the event's source (e.g. `menu` or `context-menu`) and other data specific to each event, such as the direction in an `align-shapes` event.

Note that `onUiEvent` only fires for UI interactions. Calling `Editor#alignShapes` directly won't trigger this callback. See the [UI events example](https://tldraw.dev/examples/events/ui-events) for more.

#### Related articles

- [UI primitives](https://tldraw.dev/sdk-features/ui-primitives) - Use tldraw's button, menu, dialog, and other UI components in your custom interfaces
- [Overlay utils](https://tldraw.dev/sdk-features/overlay-utils) - Customize canvas overlays like brushes, indicators, snaps, scribbles, and collaborator cursors
- [Internationalization](https://tldraw.dev/sdk-features/internationalization) - Customize translations and add new languages
- [Tools](https://tldraw.dev/sdk-features/tools) - Learn how tools work and create your own

#### Related examples

- [Replace the entire UI](https://tldraw.dev/examples/ui/custom-ui) - Build a completely custom toolbar UI while using the editor's functionality
- [Custom canvas components](https://tldraw.dev/examples/ui/custom-components) - Override individual canvas components like the background and grid
- [Hide the entire UI](https://tldraw.dev/examples/ui/hide-ui) - Use hideUi to hide the entire default interface
- [Hide UI components](https://tldraw.dev/examples/ui/ui-components-hidden) - Selectively hide specific UI components
- [Changing menus](https://tldraw.dev/examples/ui/custom-menus) - Create custom menus using tldraw's menu system
- [Vertical toolbar](https://tldraw.dev/examples/ui/vertical-toolbar) - Switch to a vertical toolbar layout
- [Add tool to toolbar](https://tldraw.dev/examples/ui/add-tool-to-toolbar) - Add a custom tool to the toolbar
- [UI events](https://tldraw.dev/examples/events/ui-events) - Listen to UI events like tool selection and menu interactions

### UI primitives

The `tldraw` package exports a set of UI primitive components that you can use when building custom interfaces. These components match the look and feel of tldraw's default UI and integrate with the editor's theming, translations, and accessibility features.

Use these primitives when you want your custom UI to feel like a natural part of tldraw rather than something bolted on.

#### Buttons

The button system consists of `TldrawUiButton` and its companion components for icons, labels, and state indicators.

##### TldrawUiButton

The base button component with several visual variants:

```tsx
import { TldrawUiButton, TldrawUiButtonIcon, TldrawUiButtonLabel } from 'tldraw'

function MyButtons() {
	return (
		<>
			<TldrawUiButton type="normal" onClick={() => console.log('clicked')}>
				<TldrawUiButtonLabel>Normal</TldrawUiButtonLabel>
			</TldrawUiButton>

			<TldrawUiButton type="primary" onClick={() => console.log('clicked')}>
				<TldrawUiButtonLabel>Primary</TldrawUiButtonLabel>
			</TldrawUiButton>

			<TldrawUiButton type="danger" onClick={() => console.log('clicked')}>
				<TldrawUiButtonLabel>Danger</TldrawUiButtonLabel>
			</TldrawUiButton>

			<TldrawUiButton type="icon" onClick={() => console.log('clicked')}>
				<TldrawUiButtonIcon icon="plus" />
			</TldrawUiButton>
		</>
	)
}
```

The `type` prop controls the button's appearance:

| Type      | Description                              |
| --------- | ---------------------------------------- |
| `normal`  | Standard button for general actions      |
| `primary` | Emphasized button for primary actions    |
| `danger`  | Red button for destructive actions       |
| `low`     | Subtle button with minimal visual weight |
| `icon`    | Square button sized for a single icon    |
| `tool`    | Tool button style used in the toolbar    |
| `menu`    | Button style used inside menus           |
| `help`    | Style used for help/info buttons         |

Use `isActive` to indicate a selected or active state:

```tsx
<TldrawUiButton type="tool" isActive={true}>
	<TldrawUiButtonIcon icon="draw" />
</TldrawUiButton>
```

##### Button sub-components

Build up button contents using these components:

```tsx
import {
	TldrawUiButton,
	TldrawUiButtonIcon,
	TldrawUiButtonLabel,
	TldrawUiButtonCheck,
	TldrawUiButtonSpinner,
} from 'tldraw'

// Icon button
<TldrawUiButton type="icon">
	<TldrawUiButtonIcon icon="trash" />
</TldrawUiButton>

// Button with icon and label
<TldrawUiButton type="menu">
	<TldrawUiButtonIcon icon="plus" />
	<TldrawUiButtonLabel>Add item</TldrawUiButtonLabel>
</TldrawUiButton>

// Button with checkmark (for toggles in menus)
<TldrawUiButton type="menu">
	<TldrawUiButtonCheck checked={true} />
	<TldrawUiButtonLabel>Show grid</TldrawUiButtonLabel>
</TldrawUiButton>

// Button with loading spinner
<TldrawUiButton type="normal" disabled>
	<TldrawUiButtonSpinner />
	<TldrawUiButtonLabel>Loading...</TldrawUiButtonLabel>
</TldrawUiButton>
```

#### Icons

`TldrawUiIcon` renders icons from tldraw's icon set. Icons are SVG-based and inherit the current text color.

```tsx
import { TldrawUiIcon } from 'tldraw'

<TldrawUiIcon icon="draw" label="Draw tool" />
<TldrawUiIcon icon="arrow-left" label="Go back" small />
<TldrawUiIcon icon="check" label="Complete" color="green" />
```

The `label` prop is required for accessibility—it becomes the icon's `aria-label`. Use `small` for a smaller icon size.

You can also pass a custom React element instead of an icon name:

```tsx
<TldrawUiIcon icon={<div className="my-custom-icon">★</div>} label="Favorite" />
```

#### Menu primitives

When adding items to tldraw's menus, use these components to match the default menu styling and behavior.

##### TldrawUiMenuItem

The main component for menu items. It automatically adapts its rendering based on which menu it's in (dropdown, context menu, toolbar, etc.):

```tsx
import { TldrawUiMenuItem, TldrawUiMenuGroup } from 'tldraw'
;<TldrawUiMenuGroup id="my-actions">
	<TldrawUiMenuItem
		id="my-action"
		label="Do something"
		icon="plus"
		kbd="cmd+shift+d"
		onSelect={() => {
			console.log('action triggered')
		}}
	/>
</TldrawUiMenuGroup>
```

Props:

| Prop         | Description                                            |
| ------------ | ------------------------------------------------------ |
| `id`         | Unique identifier for the menu item                    |
| `label`      | Display text (supports translation keys)               |
| `icon`       | Icon to display (on right in menus)                    |
| `iconLeft`   | Icon to display on the left side                       |
| `kbd`        | Keyboard shortcut to display                           |
| `onSelect`   | Called when the item is clicked                        |
| `disabled`   | Whether the item is disabled                           |
| `readonlyOk` | If true, item is shown even in readonly mode           |
| `isSelected` | Whether the item shows as selected (for toolbar items) |
| `spinner`    | Show a loading spinner                                 |
| `noClose`    | Prevent the menu from closing when clicked             |

##### TldrawUiMenuGroup

Groups related menu items together. In dropdown menus, groups are separated by dividers:

```tsx
<TldrawUiMenuGroup id="clipboard">
	<TldrawUiMenuItem id="cut" label="Cut" kbd="cmd+x" onSelect={handleCut} />
	<TldrawUiMenuItem id="copy" label="Copy" kbd="cmd+c" onSelect={handleCopy} />
	<TldrawUiMenuItem id="paste" label="Paste" kbd="cmd+v" onSelect={handlePaste} />
</TldrawUiMenuGroup>
```

##### TldrawUiMenuSubmenu

Creates a nested submenu:

```tsx
import { TldrawUiMenuSubmenu } from 'tldraw'
;<TldrawUiMenuSubmenu id="export" label="Export as...">
	<TldrawUiMenuGroup id="formats">
		<TldrawUiMenuItem id="png" label="PNG" onSelect={exportPng} />
		<TldrawUiMenuItem id="svg" label="SVG" onSelect={exportSvg} />
		<TldrawUiMenuItem id="json" label="JSON" onSelect={exportJson} />
	</TldrawUiMenuGroup>
</TldrawUiMenuSubmenu>
```

##### TldrawUiMenuCheckboxItem

A menu item with a checkbox:

```tsx
import { TldrawUiMenuCheckboxItem } from 'tldraw'
;<TldrawUiMenuCheckboxItem
	id="snap-to-grid"
	label="Snap to grid"
	checked={snapEnabled}
	onSelect={() => {
		setSnapEnabled(!snapEnabled)
	}}
/>
```

The `onSelect` callback receives a `source` parameter indicating where the action was triggered from (e.g., 'context-menu', 'menu'). You can ignore it if you don't need to differentiate between sources.

#### Dialogs

Build modal dialogs using tldraw's dialog primitives. These components handle accessibility, focus management, and styling.

```tsx
import {
	TldrawUiDialogHeader,
	TldrawUiDialogTitle,
	TldrawUiDialogCloseButton,
	TldrawUiDialogBody,
	TldrawUiDialogFooter,
	TldrawUiButton,
	TldrawUiButtonLabel,
	useDialogs,
} from 'tldraw'

function MyDialog({ onClose }: { onClose(): void }) {
	return (
		<>
			<TldrawUiDialogHeader>
				<TldrawUiDialogTitle>Confirm deletion</TldrawUiDialogTitle>
				<TldrawUiDialogCloseButton />
			</TldrawUiDialogHeader>
			<TldrawUiDialogBody style={{ maxWidth: 350 }}>
				Are you sure you want to delete this item? This action cannot be undone.
			</TldrawUiDialogBody>
			<TldrawUiDialogFooter className="tlui-dialog__footer__actions">
				<TldrawUiButton type="normal" onClick={onClose}>
					<TldrawUiButtonLabel>Cancel</TldrawUiButtonLabel>
				</TldrawUiButton>
				<TldrawUiButton
					type="danger"
					onClick={() => {
						deleteItem()
						onClose()
					}}
				>
					<TldrawUiButtonLabel>Delete</TldrawUiButtonLabel>
				</TldrawUiButton>
			</TldrawUiDialogFooter>
		</>
	)
}

// Show the dialog using the useDialogs hook
function MyComponent() {
	const { addDialog } = useDialogs()

	return <button onClick={() => addDialog({ component: MyDialog })}>Delete item</button>
}
```

The `onClose` function is passed to your dialog component automatically. Call it to dismiss the dialog.

#### Input

`TldrawUiInput` is a styled text input with built-in handling for Enter (confirm) and Escape (cancel):

```tsx
import { TldrawUiInput } from 'tldraw'
;<TldrawUiInput
	label="Name"
	defaultValue="Untitled"
	onComplete={(value) => {
		// Called when user presses Enter
		saveName(value)
	}}
	onCancel={(value) => {
		// Called when user presses Escape
		// Value is reset to initial value
	}}
	onValueChange={(value) => {
		// Called on every keystroke
	}}
	autoSelect // Select all text on focus
	autoFocus
/>
```

Add icons to the input using `iconLeft` (left side) or `icon` (right side):

```tsx
<TldrawUiInput iconLeft="search" placeholder="Search shapes..." onValueChange={setSearchQuery} />
<TldrawUiInput icon="check" placeholder="Confirmed value" />
```

#### Layout

Layout primitives help organize UI controls with proper spacing and orientation-aware tooltips.

```tsx
import { TldrawUiRow, TldrawUiColumn, TldrawUiGrid } from 'tldraw'

// Horizontal row of buttons
<TldrawUiRow>
	<TldrawUiButton type="icon"><TldrawUiButtonIcon icon="align-left" /></TldrawUiButton>
	<TldrawUiButton type="icon"><TldrawUiButtonIcon icon="align-center" /></TldrawUiButton>
	<TldrawUiButton type="icon"><TldrawUiButtonIcon icon="align-right" /></TldrawUiButton>
</TldrawUiRow>

// Vertical column
<TldrawUiColumn>
	<TldrawUiButton type="menu"><TldrawUiButtonLabel>Option 1</TldrawUiButtonLabel></TldrawUiButton>
	<TldrawUiButton type="menu"><TldrawUiButtonLabel>Option 2</TldrawUiButtonLabel></TldrawUiButton>
</TldrawUiColumn>

// 4-column grid (useful for color pickers, shape selectors, etc.)
<TldrawUiGrid>
	{colors.map(color => (
		<TldrawUiButton key={color} type="icon" onClick={() => setColor(color)}>
			<div style={{ background: color, width: 16, height: 16 }} />
		</TldrawUiButton>
	))}
</TldrawUiGrid>
```

These components automatically set up a `tooltipSide` context—tooltips appear below items in rows and to the right of items in columns, and nested components inherit this positioning.

#### Other primitives

##### TldrawUiKbd

Displays a keyboard shortcut:

```tsx
import { TldrawUiKbd } from 'tldraw'
;<TldrawUiKbd>cmd+shift+d</TldrawUiKbd>
```

##### TldrawUiSlider

A slider control. The slider uses discrete steps rather than a continuous range:

```tsx
import { TldrawUiSlider } from 'tldraw'
;<TldrawUiSlider
	title="Opacity"
	label="style-panel.opacity"
	value={5}
	steps={10}
	onValueChange={(value) => console.log(value)}
/>
```

Props:

| Prop            | Description                                     |
| --------------- | ----------------------------------------------- |
| `title`         | Tooltip title text                              |
| `label`         | Translation key for the label                   |
| `value`         | Current value (0 to steps), or null             |
| `steps`         | Maximum value (the slider goes from 0 to steps) |
| `min`           | Optional minimum value (defaults to 0)          |
| `onValueChange` | Called with the new value when it changes       |

##### TldrawUiPopover

A popover that appears next to a trigger element:

```tsx
import { TldrawUiPopover, TldrawUiPopoverTrigger, TldrawUiPopoverContent } from 'tldraw'
;<TldrawUiPopover id="my-popover">
	<TldrawUiPopoverTrigger>
		<TldrawUiButton type="icon">
			<TldrawUiButtonIcon icon="dots-vertical" />
		</TldrawUiButton>
	</TldrawUiPopoverTrigger>
	<TldrawUiPopoverContent side="bottom">
		<div style={{ padding: 8 }}>Popover content here</div>
	</TldrawUiPopoverContent>
</TldrawUiPopover>
```

The `id` prop is required and used to track the popover's open state. The `side` prop on `TldrawUiPopoverContent` controls which side of the trigger the popover appears on.

##### TldrawUiDropdownMenu

A dropdown menu built on Radix UI:

```tsx
import {
	TldrawUiDropdownMenuRoot,
	TldrawUiDropdownMenuTrigger,
	TldrawUiDropdownMenuContent,
	TldrawUiDropdownMenuItem,
	TldrawUiDropdownMenuGroup,
	TldrawUiButton,
	TldrawUiButtonLabel,
} from 'tldraw'
;<TldrawUiDropdownMenuRoot id="my-dropdown">
	<TldrawUiDropdownMenuTrigger>
		<TldrawUiButton type="normal">
			<TldrawUiButtonLabel>Options</TldrawUiButtonLabel>
		</TldrawUiButton>
	</TldrawUiDropdownMenuTrigger>
	<TldrawUiDropdownMenuContent>
		<TldrawUiDropdownMenuGroup>
			<TldrawUiDropdownMenuItem>
				<TldrawUiButton type="menu" onClick={handleEdit}>
					<TldrawUiButtonLabel>Edit</TldrawUiButtonLabel>
				</TldrawUiButton>
			</TldrawUiDropdownMenuItem>
			<TldrawUiDropdownMenuItem>
				<TldrawUiButton type="menu" onClick={handleDuplicate}>
					<TldrawUiButtonLabel>Duplicate</TldrawUiButtonLabel>
				</TldrawUiButton>
			</TldrawUiDropdownMenuItem>
			<TldrawUiDropdownMenuItem>
				<TldrawUiButton type="menu" onClick={handleDelete}>
					<TldrawUiButtonLabel>Delete</TldrawUiButtonLabel>
				</TldrawUiButton>
			</TldrawUiDropdownMenuItem>
		</TldrawUiDropdownMenuGroup>
	</TldrawUiDropdownMenuContent>
</TldrawUiDropdownMenuRoot>
```

The `id` prop is required on `TldrawUiDropdownMenuRoot`. Each `TldrawUiDropdownMenuItem` wraps a child element (typically a button) and handles the dropdown behavior.

#### Related examples

- **[UI primitives](https://tldraw.dev/examples/ui/ui-primitives)** — A showcase of the UI primitive components
- **[Custom menus](https://tldraw.dev/examples/ui/custom-menus)** — Add items to tldraw's menus using menu primitives
- **[Toasts and dialogs](https://tldraw.dev/examples/ui/toasts-and-dialogs)** — Show toasts and custom dialogs
- **[Custom UI](https://tldraw.dev/examples/ui/custom-ui)** — Build a completely custom interface

### User following

User following lets you track a collaborator's viewport in real time. When you follow someone, your [camera](https://tldraw.dev/sdk-features/camera) automatically moves to match their view, including page changes and zoom level. The editor handles follow chains (A follows B who follows C) and smoothly interpolates your viewport toward the target on each frame.

Following works with the [collaboration](https://tldraw.dev/docs/collaboration) presence system to track collaborators. It stops automatically when you interact with the canvas or when the followed user disconnects.

#### How it works

##### Follow mechanism

When you call `Editor#startFollowingUser`, the editor stores the target user ID in `followingUserId` on the instance state and sets up two reactive processes: one watches for page changes and switches pages when the followed user navigates, and another runs on every frame to interpolate your camera toward their viewport.

The frame handler calculates the difference between your current viewport and the target, then applies smooth interpolation using your animation speed preference. This creates an ease-out effect where the camera moves quickly at first and slows as it approaches. Once the viewport difference drops below the `followChaseViewportSnap` threshold (default 2 pixels), the editor locks onto the target and mirrors their viewport exactly instead of interpolating.

##### Viewport calculation

The editor reads the followed user's camera state and screen bounds from their presence record, constructs their viewport in page space, then resizes your viewport to contain theirs while maintaining your screen's aspect ratio. You can see everything the followed user sees, regardless of screen size.

The interpolation uses `lerp` on viewport bounds rather than camera values directly, which produces more natural movement when zoom levels differ. The interpolation factor clamps between 0.1 and 0.8 based on the animation speed preference, preventing both sluggish and aggressive following.

##### Page synchronization

Page changes happen separately from camera movement. A reactive effect watches the followed user's `currentPageId` and immediately switches [pages](https://tldraw.dev/sdk-features/pages) when it changes. The page switch uses a direct store update rather than `setCurrentPage()` to avoid triggering the automatic follow-stopping behavior that occurs on manual page changes.

When switching pages to follow a user, the editor sets an internal `_isLockedOnFollowingUser` flag to prevent camera interpolation from running until the page switch completes. This avoids jarring camera movements during page transitions.

#### API methods

##### startFollowingUser

Start following a user's viewport. Pass a user ID to set up frame handlers that track their camera and page.

```ts
editor.startFollowingUser('user-abc-123')
```

The editor automatically stops following any previously followed user before starting the new follow. If it can't find the target user's presence, the method returns early without starting follow mode.

##### stopFollowingUser

Stop following and return control to the local user. This commits the current camera position to the store and clears `followingUserId` from instance state.

```ts
editor.stopFollowingUser()
```

Following stops automatically when you pan or zoom the canvas. Manual page changes also stop following unless the follow system itself initiated the page change.

##### zoomToUser

Animate the camera to a user's cursor position without entering follow mode. Use this for one-time navigation to a collaborator's location.

```ts
editor.zoomToUser('user-abc-123')
editor.zoomToUser('user-abc-123', { animation: { duration: 200 } })
```

If you're already following someone, `zoomToUser()` stops following first. The method switches pages if necessary, centers the camera on the user's cursor, and briefly highlights their cursor for visual feedback. The highlight clears after `collaboratorIdleTimeoutMs` (default 3 seconds).

#### Follow chains

When user A follows user B who is following user C, user A follows user C directly rather than B's view of C. The editor traverses the follow chain until it finds a user who isn't following anyone else.

The traversal maintains a visited set to prevent infinite loops if users create a circular follow chain. If a cycle is detected, it returns the last valid presence before the cycle.

```ts
// A follows B follows C
// A's camera tracks C's viewport, not B's viewport
editor.startFollowingUser('user-b')
// Internally resolves to user-c's presence if user-b is following user-c
```

The follow chain resolution runs on every frame, so changes in the chain (like B stopping following C) immediately affect A's target viewport without requiring A to restart following.

#### UI integration

##### FollowingIndicator

The `DefaultFollowingIndicator` component displays a colored border around the canvas when you're following someone. It reads `followingUserId` from instance state and fetches the corresponding presence record to get the user's color.

```tsx
import { useEditor, usePresence, useValue } from '@tldraw/editor'

export function DefaultFollowingIndicator() {
	const editor = useEditor()
	const followingUserId = useValue('follow', () => editor.getInstanceState().followingUserId, [
		editor,
	])
	if (!followingUserId) return null
	return <FollowingIndicatorInner userId={followingUserId} />
}

function FollowingIndicatorInner({ userId }: { userId: string }) {
	const presence = usePresence(userId)
	if (!presence) return null
	return <div className="tlui-following-indicator" style={{ borderColor: presence.color }} />
}
```

You can override this component through the [UI customization](https://tldraw.dev/sdk-features/ui-components) system to change its appearance or add the followed user's name.

##### Follow state detection

Check `editor.getInstanceState().followingUserId` to detect when a user is following someone. Use this to show UI elements like a "Stop following" button or status indicators.

```ts
const followingUserId = editor.getInstanceState().followingUserId
if (followingUserId) {
	// Show stop following button
}
```

The presence records from `Editor#getCollaborators` include each user's `followingUserId`, so you can visualize the entire follow graph and show which users are following whom.

#### Related

- [Camera](https://tldraw.dev/sdk-features/camera) - Learn about viewport and camera control
- [Collaboration](https://tldraw.dev/docs/collaboration) - Set up multiplayer presence and sync
- [Pages](https://tldraw.dev/sdk-features/pages) - Understand page management
- [UI components](https://tldraw.dev/sdk-features/ui-components) - Customize UI elements like the following indicator

### User preferences

User preferences store per-user settings that persist across sessions and synchronize across browser tabs. Access them through `editor.user`:

```tsx
import { Tldraw, useEditor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'

function PreferencesPanel() {
	const editor = useEditor()

	// Read preferences reactively
	const isDark = useValue('isDarkMode', () => editor.user.getIsDarkMode(), [editor])

	// Update preferences
	const toggleDarkMode = () => {
		editor.user.updateUserPreferences({
			colorScheme: isDark ? 'light' : 'dark',
		})
	}

	return <button onClick={toggleDarkMode}>Toggle theme</button>
}

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw components={{ TopPanel: PreferencesPanel }} />
		</div>
	)
}
```

Preferences fall into three categories: visual settings (color scheme, animation speed), interaction settings (snap mode, edge scroll speed), and identity properties (user name, color, locale). The system stores data in localStorage and uses the BroadcastChannel API to sync changes across tabs in real time.

#### Reading preferences

The `UserPreferencesManager` exposes each preference as a computed value. These are reactive [signals](https://tldraw.dev/sdk-features/signals): read them inside `useValue` (or another reactive context) and your component re-renders when the value changes.

```tsx
// Individual preferences
const isDark = editor.user.getIsDarkMode()
const speed = editor.user.getAnimationSpeed()
const locale = editor.user.getLocale()
const userName = editor.user.getName()
const userColor = editor.user.getColor()
const isSnapMode = editor.user.getIsSnapMode()

// All preferences as an object
const allPrefs = editor.user.getUserPreferences()
```

#### Updating preferences

Use `updateUserPreferences()` to change one or more preferences at once:

```tsx
editor.user.updateUserPreferences({
	colorScheme: 'dark',
	animationSpeed: 0.5,
	isSnapMode: true,
})
```

Changes apply immediately, save to localStorage, and broadcast to other tabs.

#### Available preferences

##### Visual preferences

| Preference         | Type                            | Default                        | Description                          |
| ------------------ | ------------------------------- | ------------------------------ | ------------------------------------ |
| `colorScheme`      | `'light' \| 'dark' \| 'system'` | `'light'`                      | Theme mode                           |
| `animationSpeed`   | `number`                        | `1` (or `0` if reduced motion) | Multiplier for animation durations   |
| `enhancedA11yMode` | `boolean`                       | `false`                        | Additional UI labels and visual aids |

When `colorScheme` is `'system'`, the editor tracks the operating system's dark mode preference through a media query listener.

##### Interaction preferences

| Preference                    | Type                            | Default | Description                                     |
| ----------------------------- | ------------------------------- | ------- | ----------------------------------------------- |
| `isSnapMode`                  | `boolean`                       | `false` | Snap shapes to other shapes and guides          |
| `isWrapMode`                  | `boolean`                       | `false` | Enable text wrapping in text shapes             |
| `isDynamicSizeMode`           | `boolean`                       | `false` | Live shape updates during resize                |
| `isPasteAtCursorMode`         | `boolean`                       | `false` | Paste at cursor instead of original location    |
| `edgeScrollSpeed`             | `number`                        | `1`     | Speed multiplier for edge scrolling during drag |
| `areKeyboardShortcutsEnabled` | `boolean`                       | `true`  | Enable or disable keyboard shortcuts            |
| `inputMode`                   | `'trackpad' \| 'mouse' \| null` | `null`  | Optimize behavior for input device              |
| `isZoomDirectionInverted`     | `boolean`                       | `false` | Invert scroll-wheel zoom direction (mouse mode) |

##### Identity properties

| Preference | Type     | Default             | Description                          |
| ---------- | -------- | ------------------- | ------------------------------------ |
| `id`       | `string` | Auto-generated      | Unique user identifier               |
| `name`     | `string` | `''`                | Display name shown to collaborators  |
| `color`    | `string` | Random from palette | User color for cursor and selections |
| `locale`   | `string` | Browser locale      | Language code (e.g., `'en'`, `'fr'`) |

The user color is randomly chosen from 12 visually distinct colors designed for collaboration.

#### Dark mode

The `getIsDarkMode()` method resolves the color scheme to a boolean. When `colorScheme` is `'system'`, it tracks the operating system's preference through a media query listener:

```tsx
const isDark = editor.user.getIsDarkMode()
// true if colorScheme is 'dark', or 'system' with OS in dark mode
```

The editor-level `colorScheme` prop sets the default color scheme. When a user preference is set, it takes priority over the prop. See [Themes](https://tldraw.dev/sdk-features/themes#color-mode) for more.

#### Persistence and synchronization

Preferences persist to localStorage under the key `TLDRAW_USER_DATA_v3`. Each save includes a version number, and the system runs migrations when loading older data to keep preferences compatible across tldraw releases.

The system uses the BroadcastChannel API to sync preference changes across browser tabs. When you change a preference in one tab, all other tabs update automatically. Each tab has a unique origin ID to avoid processing its own broadcasts.

#### Accessibility defaults

The `animationSpeed` default respects the `prefers-reduced-motion` media query. Users with reduced motion enabled get `animationSpeed: 0` by default, disabling animations without manual configuration.

#### Validation

Preferences are validated using `userTypeValidator` from `@tldraw/editor`. Invalid data falls back to fresh preferences rather than causing errors.

#### Related examples

- [Toggle dark mode](https://tldraw.dev/examples/ui/dark-mode-toggle) - Toggle between light and dark mode by changing `colorScheme`.

### Validation

The `@tldraw/validate` package handles validation across tldraw's schemas, record types, and shape props. Validators enforce runtime type safety and provide structured errors when data is malformed.

#### Where validation runs

- Shape and binding props via `RecordProps`
- Record types in the Store via `createRecordType` and `StoreSchema`

#### Core validators

Use `T` validators to describe data shapes and validate unknown input:

```typescript
import { T } from '@tldraw/validate'

const userValidator = T.object({
	id: T.string,
	name: T.string.optional(),
	age: T.number.optional(),
})

const user = userValidator.validate(input)
```

Every validator has three key methods:

- `validate(value)` - validates unknown input and returns a typed result
- `isValid(value)` - returns true if valid, false otherwise (useful as a type guard)
- `validateUsingKnownGoodVersion(knownGood, newValue)` - performance-optimized validation that reuses previously validated data

#### Validator catalog

##### Primitives

- `T.unknown`, `T.any`
- `T.string`, `T.number`, `T.boolean`, `T.bigint`

##### Numbers

- `T.positiveNumber`, `T.nonZeroNumber`, `T.nonZeroFiniteNumber`
- `T.unitInterval`, `T.integer`, `T.positiveInteger`, `T.nonZeroInteger`

##### Collections and objects

- `T.array`, `T.arrayOf`, `T.object`, `T.unknownObject`
- `T.dict`, `T.jsonDict`, `T.jsonValue`

##### Unions and enums

- `T.literal`, `T.literalEnum`, `T.setEnum`
- `T.union`, `T.or`

##### URLs and identifiers

- `T.linkUrl`, `T.srcUrl`, `T.httpUrl`, `T.indexKey`

##### Modifiers and helpers

- `validator.optional()`, `validator.nullable()`
- `T.optional(...)`, `T.nullable(...)`
- `validator.refine(...)`, `validator.check(...)`
- `T.model(...)`

#### Common validator patterns

```typescript
import { T, ValidationError } from '@tldraw/validate'

const configValidator = T.object({
	id: T.string,
	mode: T.literalEnum('view', 'edit'),
	tags: T.arrayOf(T.string).optional(),
	meta: T.object({ note: T.string }).nullable(),
})

const evenNumber = T.number.check('even', (value) => {
	if (value % 2 !== 0) throw new ValidationError('Expected even number')
})
```

#### Record props validation

Shapes and bindings use `RecordProps` to validate their `props` at runtime. This keeps stored data consistent with the schema.

```typescript
import { ShapeUtil, type RecordProps, T, DefaultColorStyle } from 'tldraw'

class MyShapeUtil extends ShapeUtil<MyShape> {
	static override props: RecordProps<MyShape> = {
		color: DefaultColorStyle,
		text: T.string,
	}
}
```

#### Store validation and recovery

The Store validates records on write. You can provide `onValidationFailure` to recover or sanitize data:

```typescript
import { StoreSchema, createRecordType } from '@tldraw/store'

const Book = createRecordType<Book>('book', { scope: 'document' })

const schema = StoreSchema.create(
	{ book: Book },
	{
		onValidationFailure: (failure) => failure.record,
	}
)
```

The `failure` object contains:

| Property       | Description                                                                              |
| -------------- | ---------------------------------------------------------------------------------------- |
| `error`        | The validation error that occurred                                                       |
| `store`        | The store instance where validation failed                                               |
| `record`       | The invalid record                                                                       |
| `phase`        | When validation failed: `'initialize'`, `'createRecord'`, `'updateRecord'`, or `'tests'` |
| `recordBefore` | The previous record state (null for new records)                                         |

#### Validation lifecycle

1. A record is created or updated.
2. The record type validator runs.
3. If validation fails, `onValidationFailure` receives the failure object.
4. The handler can return a corrected record or throw to abort the write.

#### Error handling

The `ValidationError` class provides structured information about what went wrong:

```typescript
import { T, ValidationError } from '@tldraw/validate'

const userValidator = T.object({
	name: T.string,
	settings: T.object({ theme: T.literalEnum('light', 'dark') }),
})

try {
	userValidator.validate({ name: 'Alice', settings: { theme: 'invalid' } })
} catch (error) {
	if (error instanceof ValidationError) {
		console.log(error.message) // 'At settings.theme: Expected "light" or "dark", got invalid'
		console.log(error.rawMessage) // 'Expected "light" or "dark", got invalid'
		console.log(error.path) // ['settings', 'theme']
	}
}
```

The error has two useful properties:

- `rawMessage` - the error message without path information
- `path` - an array showing where in the data structure validation failed (e.g., `['settings', 'theme']` or `['items', 0, 'name']`)

The full `message` property combines these: `"At settings.theme: Expected..."`.

#### Gotchas

- Validators must be pure and must not mutate input values.
- If you use `onValidationFailure`, return a valid record or rethrow to abort the write.

#### Related examples

- **[Custom shape](https://tldraw.dev/examples/shapes/tools/custom-shape)** - Define shape props with validators using RecordProps.
- **[Shape meta (on create)](https://tldraw.dev/examples/events/meta-on-create)** - Add custom metadata to shapes when they're created.

### Visibility

The editor's visibility system determines which shapes are rendered on screen. It handles two separate concerns: **culling** (hiding off-screen shapes for performance) and **hidden shapes** (shapes your application explicitly hides).

#### Culling

Culling is a performance optimization. Shapes outside the viewport are removed from the render output by setting `display: none` on their DOM elements. The shapes remain in the store and can still be selected, updated, or queried—they just don't render.

You can get the set of culled shape IDs with `Editor#getCulledShapes`:

```ts
const culledIds = editor.getCulledShapes()
```

Two kinds of shapes are never culled, even when off-screen:

1. **Selected shapes** — the user might be dragging them back into view
2. **The editing shape** — the user is actively working on it

##### How culling works

The editor uses a spatial index (an R-tree) to quickly find which shapes are inside the viewport. Shapes outside the viewport are candidates for culling, but the final decision depends on `ShapeUtil#canCull`:

```ts
class MyShapeUtil extends ShapeUtil<MyShape> {
	override canCull(shape: MyShape): boolean {
		return true // default behavior
	}
}
```

Return `false` from `canCull` to prevent a shape from being culled. You'd do this for shapes that need to keep running while off-screen—for example, shapes that measure their DOM content to determine their size:

```ts
class DynamicSizeShapeUtil extends ShapeUtil<DynamicSizeShape> {
	override canCull() {
		return false // keep rendering so we can measure DOM
	}
}
```

##### Culling methods

| Method                                          | Description                                                                   |
| ----------------------------------------------- | ----------------------------------------------------------------------------- |
| `Editor#getCulledShapes`                     | Returns the set of shape IDs that are currently culled.                       |
| `Editor#getNotVisibleShapes`                 | Returns shape IDs outside the viewport (before selection filtering).          |
| `Editor#getCurrentPageRenderingShapesSorted` | Returns shapes that will actually render (excludes culled and hidden shapes). |

#### Hidden shapes

Hidden shapes are shapes your application explicitly hides using the `getShapeVisibility` option. Unlike culled shapes, hidden shapes are excluded from hit tests, exports, and rendering.

```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				getShapeVisibility={(shape) => {
					if (shape.meta.hidden) return 'hidden'
					return 'inherit'
				}}
			/>
		</div>
	)
}
```

The function can return:

| Return value               | Behavior                                                |
| -------------------------- | ------------------------------------------------------- |
| `'inherit'` or `undefined` | Shape is visible unless its parent is hidden (default). |
| `'hidden'`                 | Shape is always hidden.                                 |
| `'visible'`                | Shape is always visible, even if parent is hidden.      |

Hidden shapes are still in the store. They're just excluded from:

- Canvas rendering
- Hit tests (`Editor#getShapeAtPoint`, `Editor#getShapesAtPoint`)
- `Editor#getRenderingShapes` and `Editor#getCurrentPageRenderingShapesSorted`
- Image exports and printing

You can check if a shape is hidden with `Editor#isShapeHidden`:

```ts
if (editor.isShapeHidden(shapeId)) {
	// shape won't render
}
```

##### Preventing hidden shapes from being selected

Hidden shapes can still be selected via keyboard shortcuts like select-all. If you want to prevent this, filter the selection when it changes:

```tsx
import { react, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'

export default function App() {
	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				getShapeVisibility={(shape) => (shape.meta.hidden ? 'hidden' : 'inherit')}
				onMount={(editor) => {
					return react('filter hidden from selection', () => {
						const selectedIds = editor.getSelectedShapeIds()
						const visibleIds = selectedIds.filter((id) => !editor.isShapeHidden(id))
						if (selectedIds.length !== visibleIds.length) {
							editor.setSelectedShapes(visibleIds)
						}
					})
				}}
			/>
		</div>
	)
}
```

For examples using hidden shapes, see the [collaboration private content example](https://tldraw.dev/examples/collaboration/sync-private-content) and the [layer panel example](https://tldraw.dev/examples/ui/layer-panel).

#### Opacity

Each shape has an `opacity` property (0 to 1) that controls its transparency. When shapes are nested, opacity multiplies down the hierarchy—a shape at 0.5 opacity inside a parent at 0.5 opacity renders at 0.25 opacity.

The editor calculates the cumulative opacity for each shape and passes it to the rendering layer. You can access the computed opacity through `Editor#getRenderingShapes`:

```ts
const renderingShapes = editor.getRenderingShapes()
for (const { shape, opacity } of renderingShapes) {
	console.log(shape.id, opacity) // cumulative opacity
}
```

##### Erasing feedback

When shapes are being erased (the user is mid-gesture with the eraser tool), they render at 32% of their normal opacity. This gives visual feedback that the shapes will be deleted when the gesture completes. The editor handles this automatically—you don't need to implement anything for erasing feedback to work.

#### Rendering shapes

`Editor#getRenderingShapes` returns the shapes that should be rendered, along with their computed properties:

```ts
interface TLRenderingShape {
	id: TLShapeId
	shape: TLShape
	util: ShapeUtil
	index: number // z-index for the shape layer
	backgroundIndex: number // z-index for the background layer
	opacity: number // cumulative opacity
}
```

The `index` and `backgroundIndex` values control z-ordering. The editor uses CSS z-index rather than DOM ordering to position shapes visually. This keeps the DOM stable (shapes stay in ID order) and avoids expensive reflows when z-order changes.

For most use cases, you won't need to work with rendering shapes directly—the editor's canvas handles this. But if you're building custom rendering or need to understand which shapes are visible at what opacity, this is the API to use.

## Community

Community resources for the tldraw SDK.

### Translations

The tldraw [user interface](https://tldraw.dev/docs/user-interface) is translated into more than forty languages. Where a key's translation is missing in the user's current language, the default (English) translation is used instead.

We manage our translations through [Lokalise](https://lokalise.com).

To learn how the SDK detects and applies languages, see [Internationalization](https://tldraw.dev/sdk-features/internationalization).

### Contributing

tldraw's source code is available on GitHub at [github.com/tldraw/tldraw](https://github.com/tldraw/tldraw). While you can read the code, we are not accepting external contributions.

The best way to help improve tldraw is to share your feedback. To report a bug or request a feature, [open an issue](https://github.com/tldraw/tldraw/issues) on GitHub or let us know on [Discord](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink). If a code example would help the discussion, please fork the repository and link to your branch in the issue.

### License

The tldraw SDK's source code and published packages are provided under the [tldraw license](https://tldraw.dev/legal/tldraw-license).

Under its default terms, the tldraw SDK license permits use **only in development**.

To use the tldraw SDK **in production**, you need one of the following:

- a [trial license](#Trial-license) for evaluation
- a [commercial license](#Commercial-license) for commercial projects
- a [hobby license](#Hobby-license) for non-commercial projects

Trial, commercial, and hobby licenses each come with a license key. The SDK will work in production only when provided with a valid and active license key. See the [License keys](#License-keys) section for more information.

##### Trial license

You can get a free 100-day trial license by completing [this form](https://tldraw.dev/get-a-license/trial). When you submit the form, we will immediately begin your free trial and email you a [license key](#License-keys). You are allowed only one trial license per commercial unit. Please do not abuse trial licenses and tell your friends not to, either. During your trial, the SDK [collects information](#Data-collection) about where it is used.

The license key will stop working when the trial ends. If you wish to continue using tldraw in production after this date, you will need to get either a [commercial license](#Commercial-license) or a [hobby license](#Hobby-license).

##### Commercial license

You can request a commercial license by completing [this form](https://tldraw.dev/get-a-license/plans). When you submit the form, our sales team will be in touch to learn about your requirements and discuss pricing. Startup pricing may be available for small teams. Check our [pricing page](https://tldraw.dev/pricing) for the latest.

##### Hobby license

For non-commercial projects, we also provide a discretionary hobby license. You can request a hobby license by completing [this form](https://tldraw.dev/get-a-license/hobby). When you submit the form, our team may issue a license or reach out to learn about your project. When using the tldraw SDK under a hobby license, the "made with tldraw" watermark must be shown on the canvas.

#### License keys

The tldraw SDK enforces its license using **license keys**.

When you receive a trial, commercial, or hobby license, you will also receive a public license key that encodes information about your license. You can provide this license key to the SDK as shown below.

```tsx
function App() {
	return <Tldraw licenseKey="tldraw-***********************" />
}
```

License keys are validated **on the client**. You can use them offline. They can be public. The tldraw SDK will not work in production without a valid license key.

#### Data collection

When using the tldraw SDK under a commercial or hobby license, no information is sent to tldraw.

When using the tldraw SDK in production under a trial license, the tldraw SDK will ping tldraw's servers with a hash of the license key. This information is primarily for analytics purposes. No user data, canvas contents, or personally-identifiable information (PII) is sent or saved.

#### Notes on open source

While the tldraw SDK is [source available](https://github.com/tldraw/tldraw), it is not permissively licensed. Many of our examples and demos are available under the MIT license, and you should feel free to use this code as if it were your own; however, any source code or packages covered by the tldraw SDK license would not be [Open Source](https://opensource.org/osd) by any definition.

If you wish to include tldraw in an open source project, you may do so but the SDK itself must remain under its original license. This means that you and your downstream users will require their own trial, commercial, or hobby license in order to use the SDK in production. In this case, please also see our [trademark guidelines](https://tldraw.dev/legal/trademark-guidelines) that limit the use of the tldraw name and branding.

The tldraw SDK is a product of a company. See [tldraw.dev](https://tldraw.dev/company) for more information about the company and the team.
