# tldraw SDK features
--------
# 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`](/reference/tldraw/reference/tldraw/DefaultA11yAnnouncer) component, which renders a visually hidden live region that screen readers monitor for changes. The [`useA11y`](/reference/tldraw/reference/tldraw/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
}
export default function App() {
return (
)
}
```
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](/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`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/ShapeUtil#canTabTo):
```tsx
class DecorativeShapeUtil extends ShapeUtil {
// 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`](/reference/editor/reference/editor/ShapeUtil#getText) returns the shape's primary text content, while [`ShapeUtil.getAriaDescriptor`](/reference/editor/reference/editor/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
Custom shapes can provide screen reader descriptions by overriding [`ShapeUtil.getAriaDescriptor`](/reference/editor/reference/editor/ShapeUtil#getAriaDescriptor):
```tsx
class CardShapeUtil extends ShapeUtil {
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`](/reference/editor/reference/editor/ShapeUtil#getText) instead. The announcement system checks `getAriaDescriptor()` first, then falls back to `getText()`:
```tsx
class NoteShapeUtil extends ShapeUtil {
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`](/reference/tldraw/reference/tldraw/usePrefersReducedMotion) in custom shape components to check whether to show animations:
```tsx
import { usePrefersReducedMotion } from 'tldraw'
function AnimatedIndicator() {
const prefersReducedMotion = usePrefersReducedMotion()
if (prefersReducedMotion) {
return
}
return
}
```
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 under Help.
> See the [reduced motion example](/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
Some users prefer or require keyboard shortcuts to be disabled, particularly when using assistive technologies that have their own keyboard commands. The `areKeyboardShortcutsEnabled` preference controls this:
```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 Help 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`](/reference/tldraw/reference/tldraw/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](/docs/shapes).
## Debugging accessibility
Enable the `a11y` debug flag to log accessibility announcements to the console:
```tsx
import { debugFlags } from '@tldraw/editor'
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 (
)
}
```
The `@tldraw/tldraw` package includes about 80 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
}
```
| 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 (
)
}
```
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`
**Preferences**: `toggle-dark-mode`, `toggle-snap-mode`, `toggle-grid`, `toggle-focus-mode`
Each action includes appropriate guards. For example, `group` checks that multiple shapes are selected before enabling, and arrangement actions verify that shapes can be reordered.
## 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
}
```
### 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 (
)
}
```
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/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/keyboard-shortcuts) — Change keyboard shortcuts for tools and actions.
- [Custom menus](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/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 enable smooth transitions for individual shape properties. The editor tracks each animating shape independently, allowing multiple concurrent animations that can be interrupted or replaced.
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 can define their own interpolation logic by implementing the `getInterpolatedProps()` method. This allows each shape type to control how its unique properties animate. 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. This is useful for focusing on shapes, navigating between sections, or implementing 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 animations, making movements feel natural rather than mechanical. Choose an easing based on the interaction: 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 multiplies animation durations, 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/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/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`](/reference/tlschema/reference/tlschema/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`](/reference/editor/reference/editor/inlineBase64AssetStore) converts images to data URLs—quick for prototyping but doesn't persist across sessions
- **With [`persistenceKey`](/docs/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](/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 |
| `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`](/reference/tlschema/reference/tlschema/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
```
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
```
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((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 (
)
}
```
### The TLAssetContext
When resolving assets, tldraw gives you a [`TLAssetContext`](/reference/tlschema/reference/tlschema/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`](/reference/editor/reference/editor/Editor) class provides methods for managing assets:
| Method | Description |
| --------------------------- | ----------------------------------------- |
| [`Editor.createAssets`](/reference/editor/reference/editor/Editor#createAssets) | Add asset records to the store |
| [`Editor.updateAssets`](/reference/editor/reference/editor/Editor#updateAssets) | Update existing assets with partial data |
| [`Editor.deleteAssets`](/reference/editor/reference/editor/Editor#deleteAssets) | Remove assets and call the remove handler |
| [`Editor.getAsset`](/reference/editor/reference/editor/Editor#getAsset) | Get an asset by ID |
| [`Editor.getAssets`](/reference/editor/reference/editor/Editor#getAssets) | Get all assets in the store |
| [`Editor.resolveAssetUrl`](/reference/editor/reference/editor/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 (only provide changed fields)
editor.updateAssets([{ id: imageAsset.id, type: 'image', props: { name: 'new-name.jpg' } }])
// Get an asset with type safety
const asset = editor.getAsset(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`](/reference/tlschema/reference/tlschema/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
You can define custom asset types by extending [`TLBaseAsset`](/reference/tlschema/reference/tlschema/TLBaseAsset). Create a validator with [`createAssetValidator`](/reference/tlschema/reference/tlschema/createAssetValidator), then implement shapes that reference your custom assets.
Custom asset types follow the same storage lifecycle as built-in types. Your upload, resolve, and remove handlers need to support them, and your custom shapes handle the rendering.
### 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. The [`assetValidator`](/reference/tlschema/reference/tlschema/assetValidator) uses a discriminated union on the `type` field. If you're creating custom asset types, create validators following the same pattern and add migration sequences to handle schema changes.
## Related examples
- [Hosted images](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/hosted-images) - Implement a TLAssetStore that uploads images to a server
- [Local images](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/local-images) - Create image shapes from local asset records
- [Local videos](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/local-videos) - Create video shapes from local asset records
- [Asset options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/asset-props) - Control allowed asset types, max size, and dimensions
- [Static assets](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/static-assets) - Pre-load custom fonts and icons
--------
# 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, making queries efficient. 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, providing fast lookups without scanning 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 {
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 `canBind()` in their ShapeUtil:
```typescript
class MyShapeUtil extends ShapeUtil {
canBind({ fromShapeType, toShapeType, bindingType }: TLShapeUtilCanBindOpts) {
// Only allow arrow bindings where this shape is the target
return bindingType === 'arrow' && toShapeType === 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 {
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
```
## 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/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/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/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 coordinates represent the top-left corner of the viewport in page coordinates.
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, preventing content from touching 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, ensuring all bounds are visible
- `'fit-max'`: Fit the larger axis, potentially cropping content
- `'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.currentScreenPoint, { 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.
### 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, ensuring 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](/docs/user-following) for implementation details.
## Related examples
- **[Camera options](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/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/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/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/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/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/scroll)** - Use the editor inside a scrollable container with proper mousewheel event handling.
--------
# Click detection
In tldraw, the click detection system lets you respond to double, triple, and quadruple clicks. The [`ClickManager`](/reference/editor/reference/editor/ClickManager) tracks consecutive pointer down events using a state machine, dispatching click events when timing and distance thresholds are met.
This powers text editing features like word selection on double-click and paragraph selection on triple-click. You can use these events to add custom multi-click behaviors in your own shapes and tools.
## How it works
When a pointer down event occurs, the manager either starts a new sequence or advances to the next click level. Each state has a timeout that determines how long to wait for the next click before settling on the current level.
Two timeout durations control the detection speed. The first click uses `doubleClickDurationMs` (450ms by default), giving users time to initiate a double-click sequence. Subsequent clicks use the shorter `multiClickDurationMs` (200ms by default), requiring faster input for triple and quadruple clicks. This pattern matches common operating system behavior.
### State transitions
The click state machine progresses through these states:
| State | Description |
| ------------------ | ------------------------------------------ |
| `idle` | No active click sequence |
| `pendingDouble` | First click registered, waiting for second |
| `pendingTriple` | Second click registered, waiting for third |
| `pendingQuadruple` | Third click registered, waiting for fourth |
| `pendingOverflow` | Fourth click registered, waiting for fifth |
| `overflow` | More than four clicks detected |
When a pointer down event arrives, the state advances to the next pending state and sets a timeout. If the timeout expires before the next click, the manager dispatches a settle event for the current click level and returns to idle. If another pointer down arrives before the timeout, the state advances and a click event is dispatched immediately.
### Distance validation
Consecutive clicks must occur within a maximum distance of 40 pixels (screen space). If pointer down events are farther apart, the click sequence resets to idle.
### Click event phases
Click events are dispatched with a `phase` property indicating when in the sequence the event fired:
| Phase | When it fires |
| -------- | -------------------------------------------------------------- |
| `down` | Immediately when a multi-click is detected during pointer down |
| `up` | During pointer up for multi-clicks that are still pending |
| `settle` | When the timeout expires without further clicks |
The phase system lets tools and shapes respond at different points in the click sequence. For example, the hand tool waits for the `settle` phase before zooming in—this avoids triggering a zoom if the user is about to triple-click.
### Movement cancellation
If the pointer moves too far during a pending click sequence, the system cancels the sequence and returns to idle. This prevents multi-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`](/reference/editor/reference/editor/TLEventHandlers) interface. Here's a complete example of a custom tool that zooms in on double-click:
```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 !== 'settle') return
this.editor.zoomIn(info.point, { animation: { duration: 200 } })
}
override onTripleClick(info: TLClickEventInfo) {
if (info.phase !== 'settle') return
this.editor.zoomOut(info.point, { animation: { duration: 200 } })
}
}
const customTools = [ZoomTool]
export default function App() {
return (
{
editor.setCurrentTool('zoom')
}}
/>
)
}
```
Tools can handle [`StateNode.onDoubleClick`](/reference/editor/reference/editor/StateNode#onDoubleClick), [`StateNode.onTripleClick`](/reference/editor/reference/editor/StateNode#onTripleClick), and [`StateNode.onQuadrupleClick`](/reference/editor/reference/editor/StateNode#onQuadrupleClick) events. Each receives a [`TLClickEventInfo`](/reference/editor/reference/editor/TLClickEventInfo) object with details about the click.
Shape utilities handle double-clicks through the [`ShapeUtil.onDoubleClick`](/reference/editor/reference/editor/ShapeUtil#onDoubleClick) method. 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`](/reference/editor/reference/editor/TLClickEventInfo) type includes these properties:
| Property | Type | Description |
| ----------- | ------------------------------------------------------- | ------------------------------------------------------ |
| `type` | `'click'` | Event type identifier |
| `name` | `'double_click' \| 'triple_click' \| 'quadruple_click'` | Which multi-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'` | 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](/docs/options):
| Option | Default | Description |
| ----------------------- | ------- | -------------------------------------------------------- |
| `doubleClickDurationMs` | 450ms | Time window for the first click to become a double-click |
| `multiClickDurationMs` | 200ms | Time window for subsequent clicks in the sequence |
## Related examples
- [Canvas events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/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/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/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`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/Editor#getContentFromCurrentPage) to serialize them into a [`TLContent`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/Editor#putContentOntoCurrentPage) handles paste operations. It takes [`TLContent`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/TLContent) in a `
` 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`](/reference/editor/reference/editor/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.
## Content structure
The [`TLContent`](/reference/editor/reference/editor/TLContent) type defines the clipboard payload:
```ts
interface TLContent {
shapes: TLShape[]
bindings: TLBinding[] | undefined
rootShapeIds: TLShapeId[]
assets: TLAsset[]
schema: SerializedSchema
}
```
- `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, enabling migration when pasting content from a different editor version
## Position handling
[`Editor.putContentOntoCurrentPage`](/reference/editor/reference/editor/Editor#putContentOntoCurrentPage) offers flexible positioning:
- By default, shapes paste at a slight offset from their original position, making it 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. This is useful when duplicating pages where you need to maintain specific IDs.
## External content handling
For non-tldraw content (images, URLs, plain text), use [`Editor.putExternalContent`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/Editor#registerExternalContentHandler) to customize how different content types are processed. See [External content](/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/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/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 (
)
}
```
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
Connecting...
}
if (store.status === 'error') {
return
Connection error: {store.error.message}
}
return (
)
}
```
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](/docs/assets) documentation for more on implementing asset stores.
## User identity
By default, users get a random name and color. To customize this, pass `userInfo`:
```tsx
const store = useSyncDemo({
roomId: 'my-room',
userInfo: {
id: 'user-123',
name: 'Alice',
color: '#ff0000',
},
})
```
For dynamic user info that updates during the session, use an atom:
```tsx
import { atom } from 'tldraw'
const userInfo = atom('userInfo', {
id: 'user-123',
name: 'Alice',
color: '#ff0000',
})
// Later, update the user info
userInfo.set({ ...userInfo.get(), name: 'Alice (away)' })
const store = useSyncDemo({
roomId: 'my-room',
userInfo,
})
```
### Integrating with useTldrawUser
If you need to let users edit their preferences through tldraw's UI, use `useTldrawUser`:
```tsx
import { useSyncDemo } from '@tldraw/sync'
import { useState } from 'react'
import { TLUserPreferences, Tldraw, useTldrawUser } from 'tldraw'
export default function App({ roomId }: { roomId: string }) {
const [userPreferences, setUserPreferences] = useState({
id: 'user-123',
name: 'Alice',
color: 'coral',
colorScheme: 'dark',
})
const store = useSyncDemo({ roomId, userInfo: userPreferences })
const user = useTldrawUser({ userPreferences, setUserPreferences })
return (
)
}
```
## 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 (
)
}
```
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.
## 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 {
added: Record // New records
updated: Record // Changed records (before/after)
removed: Record // 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 { Tldraw, createTLStore, defaultShapeUtils, TLRecord } from 'tldraw'
function App() {
const [store] = useState(() => createTLStore({ shapeUtils: defaultShapeUtils }))
const wsRef = useRef(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
}
```
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](/examples/collaboration/sync-demo) — Basic multiplayer setup with the demo server
- [Custom user](/examples/collaboration/sync-custom-user) — Setting custom user identity for multiplayer
- [Custom presence](/examples/collaboration/sync-custom-presence) — Customizing presence data sent to collaborators
- [Custom shapes](/examples/collaboration/sync-custom-shape) — Syncing custom shapes with multiplayer
--------
# 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/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/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 (
{
// Get shapes outside the viewport (before selection filtering)
const notVisible = editor.getNotVisibleShapes()
// Get 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)
}}
/>
)
}
```
Use [`Editor.getNotVisibleShapes`](/reference/editor/reference/editor/Editor#getNotVisibleShapes) to get all shapes whose bounds don't intersect the viewport. Use [`Editor.getCulledShapes`](/reference/editor/reference/editor/Editor#getCulledShapes) to get the final set of shapes 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. This calculation iterates through every shape on the current page and performs a bounds collision check. The comparison is fast enough to run reactively.
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 {
static override type = 'my-shape' as const
static override props: RecordProps = {
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
}
indicator(shape: MyShape) {
return
}
}
```
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](/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 (
{
// Start chatting programmatically
editor.updateInstanceState({ isChatting: true })
// Or set a message directly
editor.updateInstanceState({
chatMessage: 'Hello from the canvas!',
})
}}
/>
)
}
```
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`](/reference/editor/reference/editor/Editor#getInstanceState):
```tsx
const { isChatting, chatMessage } = editor.getInstanceState()
```
Update it with [`Editor.updateInstanceState`](/reference/editor/reference/editor/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 Enter or the input loses focus, `isChatting` becomes `false`
5. The message displays for 2 seconds, then clears automatically
Messages time out after 5 seconds while typing, or 2 seconds after the user stops.
## 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 (
)
}
```
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`](/reference/tlschema/reference/tlschema/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 (
)
}
```
For customizing how remote users' chat messages appear, override the `CollaboratorCursor` component instead. See [Cursors](/sdk-features/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](/sdk-features/cursors) — Cursor types, colors, and collaborator cursor customization
- [Collaboration](/sdk-features/collaboration) — Presence synchronization and multiplayer setup
- [User preferences](/sdk-features/user-preferences) — User colors and identity
## Related examples
- [User presence](/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`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/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 through the `CollaboratorCursor` component. The default implementation ([`DefaultCursor`](/reference/editor/reference/editor/DefaultCursor)) displays:
- The cursor icon in the user's color
- The user's name as a label below the cursor
- Any active chat message in a bubble
You can customize this by providing your own `CollaboratorCursor` component through [`TLEditorComponents`](/reference/editor/reference/editor/TLEditorComponents):
```tsx
import { Tldraw, TLEditorComponents, TLCursorProps } from 'tldraw'
import 'tldraw/tldraw.css'
function CustomCursor({ point, color, name, zoom }: TLCursorProps) {
if (!point) return null
return (
)
}
```
### Cursor position in presence
Collaborator cursor positions are stored in presence records ([`TLInstancePresence`](/reference/tlschema/reference/tlschema/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](/sdk-features/cursor-chat) - Send ephemeral chat messages at the cursor position
- [Tools](/sdk-features/tools) - Learn how tools handle input and set cursors
- [Collaboration](/sdk-features/collaboration) - User presence and multiplayer features
- [User preferences](/sdk-features/user-preferences) - Manage user colors and other preferences
- [UI components](/sdk-features/ui-components) - Customize the collaborator cursor component
## Related examples
- [Custom tool](/examples/shapes/tools/custom-tool) - Build a custom tool with cursor handling
- [User presence](/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` prop:
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function App() {
return (
)
}
```
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..`
- Viewport links (`v`) encode bounding box coordinates: `v...` with optional page ID
- Page links (`p`) encode a page ID: `p`
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 (updates window.location)
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` prop 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/deep-links)** - Demonstrates how to use the `deepLinks` prop 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`](/reference/editor/reference/editor/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](/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 (`none`, `semi`, `solid`, `pattern`) |
| `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](/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](/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](/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` | Dictionary of control points with IDs, indices, and positions |
| `scale` | `number` | Scale factor applied to the shape |
Line shapes don't have configuration options. The line tool's behavior is controlled through tool configuration rather than the shape utility.
### 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. |
| `underlayOpacity` | `number` | `0.82` | Opacity of the underlay (background layer). Combined with overlay creates the highlight effect. |
| `overlayOpacity` | `number` | `0.35` | Opacity of the overlay (foreground layer). Lower values create a more subtle highlight. |
```tsx
const ConfiguredHighlightUtil = HighlightShapeUtil.configure({
maxPointsPerShape: 800,
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](/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,
})
```
### 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. Their geometry is the union of all child geometries. Groups delete themselves automatically when the last child is removed, and arrows can't bind to them. Double-click a group to enter it and edit 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` | `{ s: 28, m: 36, l: 44, xl: 66 }` | How far elbow arrows extend from target shapes, per size. |
| `minElbowLegLength` | `Record` | 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 style |
| `dash` | `solid`, `dashed`, `dotted`, `draw` | 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 (
)
}
```
You can also extend shape utilities to add custom behavior beyond configuration options. See the [custom shapes](/docs/shapes) guide for details.
## Related examples
- [Custom shape](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/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/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/shape-options): Configure shape utility behavior using the configure() 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 within 2× the stroke width of the starting point
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`](/reference/editor/reference/editor/Editor#user):
```typescript
// Check current mode
const isDynamic = editor.user.getIsDynamicResizeMode()
// Enable dynamic resize mode
editor.user.updateUserPreferences({ isDynamicResizeMode: 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` |
| `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/editor'
// 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`](/reference/editor/reference/editor/Circle2d) centered at the point with radius equal to the stroke width
- **Closed path**: Returns a [`Polygon2d`](/reference/editor/reference/editor/Polygon2d) that can be filled
- **Open path**: Returns a [`Polyline2d`](/reference/editor/reference/editor/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](/sdk-features/default-shapes#highlight)**: Uses the same point capture system but renders semi-transparently for marking up content
- **[Line](/sdk-features/default-shapes#line)**: Creates editable multi-point lines with draggable handles
## Related articles
- [Default shapes](/sdk-features/default-shapes) — Overview of all built-in shapes
- [Tools](/sdk-features/tools) — How tools handle user input
- [Styles](/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) {
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. When an edge has an inset (like a toolbar or panel), that edge's scroll zone starts at the inset boundary rather than at the physical screen edge. This prevents scrolling when dragging near UI elements.
The insets array follows CSS convention: `[top, right, bottom, left]`. A truthy value means that edge has an inset and won't trigger edge scrolling.
## 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 manager only moves the camera when all these conditions are met:
- The editor is dragging (`editor.inputs.getIsDragging()` returns true)
- The editor is not panning (`editor.inputs.getIsPanning()` returns false)
- The camera is not locked (`editor.getCameraOptions().isLocked` is false)
- The proximity factor is non-zero for at least one axis
If any condition fails, 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 (
)
}
```
### User preferences
Users can adjust edge scroll speed through `editor.user.getEdgeScrollSpeed()`, which returns a multiplier that defaults to 1 and persists across sessions. This affects all edge scrolling uniformly 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 and drag state from the editor's input system, so you only need to pass the elapsed time.
```typescript
import { StateNode, TLTickEventInfo } from '@tldraw/editor'
export class CustomDragState extends StateNode {
static override id = 'dragging'
override onTick({ elapsed }: TLTickEventInfo) {
this.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](/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](/examples/shapes/tools/tool-with-child-states) example.
--------
# Editor
The [`Editor`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/Editor#createShapes). Need to delete them? Use [`Editor.deleteShapes`](/reference/editor/reference/editor/Editor#deleteShapes). Want a sorted array of every shape on the current page? Use [`Editor.getCurrentPageShapesSorted`](/reference/editor/reference/editor/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`](/reference/tldraw/reference/tldraw/Tldraw) component's `onMount` callback:
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function App() {
return (
{
// Your editor code here
editor.createShape({ type: 'geo', x: 100, y: 100 })
}}
/>
)
}
```
2. Via the [`useEditor`](/reference/editor/reference/editor/useEditor) hook, which must be called from within the [`Tldraw`](/reference/tldraw/reference/tldraw/Tldraw) component tree:
```tsx
function InsideOfContext() {
const editor = useEditor()
// Your editor code here
return null
}
function App() {
return (
)
}
```
## 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`](/reference/editor/reference/editor/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](/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
})
```
The `track` higher-order component automatically subscribes to signals accessed during render. When those signals change, the component re-renders.
See [Signals](/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`](/reference/editor/reference/editor/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](/sdk-features/tools) for building custom tools.
### Managers
The editor delegates specialized functionality to manager classes:
| Manager | Responsibility |
| --------------------------- | --------------------------------------------- |
| [`HistoryManager`](/reference/editor/reference/editor/HistoryManager) | Undo/redo stack and history marks |
| [`SnapManager`](/reference/editor/reference/editor/SnapManager) | Shape snapping during transforms |
| FocusManager | Focus state and keyboard event handling |
| [`TextManager`](/reference/editor/reference/editor/TextManager) | Text measurement and layout |
| [`FontManager`](/reference/editor/reference/editor/FontManager) | Font loading and management |
| TickManager | Animation frame scheduling |
| [`InputsManager`](/reference/editor/reference/editor/InputsManager) | Pointer position and modifier key tracking |
| [`ClickManager`](/reference/editor/reference/editor/ClickManager) | Click, double-click, and long-press detection |
| [`ScribbleManager`](/reference/editor/reference/editor/ScribbleManager) | Brush and scribble interactions |
| [`EdgeScrollManager`](/reference/editor/reference/editor/EdgeScrollManager) | Auto-scroll at viewport edges |
| [`UserPreferencesManager`](/reference/editor/reference/editor/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](/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](/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](/sdk-features/history) for the full history API.
## Transactions
Use [`Editor.run`](/reference/editor/reference/editor/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](/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](/sdk-features/camera) and [Coordinates](/sdk-features/coordinates) for the full camera API.
## Input state
The [`Editor.inputs`](/reference/editor/reference/editor/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](/sdk-features/input-handling) for input details.
## Instance state
The editor maintains per-instance state in a [`TLInstance`](/reference/tlschema/reference/tlschema/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](/sdk-features/tools#tool-lock) for more on tool lock.
Each page also has instance state ([`TLInstancePageState`](/reference/tlschema/reference/tlschema/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](/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](/sdk-features/side-effects) for the complete API.
## Events
The editor receives events through [`Editor.dispatch`](/reference/editor/reference/editor/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](/sdk-features/events) for event types.
## Related examples
- **[Controlling the canvas](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/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/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/exploded)** — Compose tldraw from individual sublibraries for full customization.
- **[Canvas events](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/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-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.
## Supported services
| Service | Hostnames | Resizable | Aspect ratio locked |
| --------------- | ---------------------------- | --------- | ------------------- |
| tldraw | tldraw.com, beta.tldraw.com | Yes | No |
| Figma | figma.com | Yes | No |
| YouTube | youtube.com, youtu.be | Yes | Yes |
| Google Maps | google.com/maps | Yes | No |
| Google Calendar | calendar.google.com | Yes | No |
| Google Slides | docs.google.com/presentation | 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 |
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 to enter editing mode or hold Shift while clicking to interact directly.
**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 isn't recognized as embeddable, the embed shape falls back to rendering as a [bookmark](/sdk-features/default-shapes#bookmark). The shape's geometry changes to match the bookmark dimensions, and a link card replaces the iframe.
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.setEmbedDefinitions()`:
```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,
},
]
// Set before mounting the editor
EmbedShapeUtil.setEmbedDefinitions(myEmbedDefinitions)
export default function App() {
return (
)
}
```
### 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 uses `embedOnPaste: false` because nested tldraw canvases are blocked when the current page is already inside an iframe (to prevent 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](/sdk-features/default-shapes) — Overview of all built-in shape types
- [Bookmark shape](/sdk-features/default-shapes#bookmark) — URL link cards (fallback for non-embeddable URLs)
- [External content handling](/sdk-features/external-content) — Handling pasted and dropped content
- [Embedded example](/examples/misc/embedded) — Interactive example of embedding tldraw
--------
# 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 (
)
}
```
### 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 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.
## Browser quirks we handle
Here's a sample of what we use environment detection for internally.
**Safari** has the most workarounds. SVG-to-image export fails silently unless we add a 250ms delay after loading the image—a WebKit bug that's been open for years. We also draw minimap triangles four times because Safari's WebGL implementation sometimes drops draw calls. Text outlines render incorrectly, so we disable them entirely on Safari.
**iOS** coalesced pointer events are broken (they report wrong coordinates), so we skip them entirely and use individual events. We also handle the virtual keyboard differently since iOS doesn't fire standard resize events.
**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
{/* Your shapes, each with their own boundary */}
{/* Shape content */}
```
## 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.
`DefaultShapeIndicatorErrorFallback` renders a red circle where the selection outline would be. It signals that something went wrong without disrupting selection behavior.
## Customizing error components
Replace any error fallback through the `components` prop:
```tsx
import { Tldraw, TLErrorFallbackComponent, TLShapeErrorFallbackComponent } from 'tldraw'
const MyErrorFallback: TLErrorFallbackComponent = ({ error, editor }) => {
return (
}
;
```
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 (
Something went wrong
{JSON.stringify(annotations, null, 2)}
)
}
```
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 }) => (
)
}
```
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`](/reference/editor/reference/editor/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`, `triple_click`, `quadruple_click` | Multi-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 |
| `zoom` | — | Programmatic zoom events |
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`](/reference/store/reference/store/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
{
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`](/reference/tldraw/reference/tldraw/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](/docs/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/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/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/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 (
)
}
```
## 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
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, Twitter, 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 {
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/tldraw`:
- `defaultHandleExternalTextContent`
- `defaultHandleExternalFileContent`
- `defaultHandleExternalUrlContent`
- `defaultHandleExternalSvgTextContent`
- `defaultHandleExternalEmbedContent`
- `defaultHandleExternalTldrawContent`
- `defaultHandleExternalExcalidrawContent`
- `defaultHandleExternalFileAsset`
- `defaultHandleExternalUrlAsset`
- `defaultHandleExternalFileReplaceContent`
## Related examples
- [External content sources](/examples/data/assets/external-content-sources) - Handle pasted HTML by creating custom shapes.
- [Hosted images](/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 (
)
}
```
## Controlling focus
Use [`Editor.focus`](/reference/editor/reference/editor/Editor#focus) and [`Editor.blur`](/reference/editor/reference/editor/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
```
## 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([])
function handleEditorFocus(focusedEditor: Editor) {
for (const editor of editors) {
if (editor === focusedEditor) {
editor.focus()
} else {
editor.blur()
}
}
}
return (
<>
>
)
}
```
## Related examples
- [Editor focus](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/editor-focus) - Control editor focus with focus and blur methods.
- [Multiple editors](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/multiple) - Manage focus between multiple tldraw instances.
--------
# Geo shape
The geo shape is one of the default shapes in tldraw. It renders geometric primitives with optional text labels. Geo shapes support 20 different geometric forms—from basic shapes like rectangles and ellipses to specialized forms like stars, clouds, and directional arrows. They can display rich text labels with configurable alignment. These capabilities make geo shapes the foundation for flowcharts, diagrams, and annotated illustrations.
## 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`](/reference/editor/reference/editor/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, allowing you to 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.setCurrentTool`](/reference/editor/reference/editor/Editor#setCurrentTool)), 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: `none`, `semi`, `solid`, `pattern` |
| `dash` | `TLDefaultDashStyle` | Stroke pattern: `draw`, `solid`, `dashed`, `dotted` |
| `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
```
## 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({ isDynamicResizeMode: 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 has a corresponding path definition in `getGeoShapePath`, which generates the outline used for both fill rendering and stroke styles. The path system supports:
- **Fill rendering**: Solid, semi-transparent, or pattern fills
- **Stroke styles**: Solid lines, dashed patterns, dotted patterns, and hand-drawn "draw" style
- **Efficient caching**: Path calculations are cached per shape instance
When the `dash` property is set to `'draw'`, the shape renders with organic, hand-drawn strokes that vary slightly based on the shape's position and size.
## Geometry
The shape's geometry returns a [`Group2d`](/reference/editor/reference/editor/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](/sdk-features/default-shapes) — Overview of all built-in shapes
- [Rich text](/sdk-features/rich-text) — Working with formatted text content
- [Styles](/sdk-features/styles) — Working with shape styles like color and fill
- [Tools](/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`](/reference/editor/reference/editor/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](/docs/shapes).
## How geometry works
Each [`ShapeUtil`](/reference/editor/reference/editor/ShapeUtil) implements a [`ShapeUtil.getGeometry`](/reference/editor/reference/editor/ShapeUtil#getGeometry) method that returns a [`Geometry2d`](/reference/editor/reference/editor/Geometry2d) instance. The editor calls this method to calculate bounds, test hits, find intersections, and measure distances.
```typescript
class MyShapeUtil extends ShapeUtil {
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](/docs/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`](/reference/editor/reference/editor/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. Requires both `point` and `margin` parameters—the margin determines how close a click must be to register as a hit.
```typescript
new Point2d({
point: new Vec(50, 50),
margin: 10, // clicks within 10 units count as hits
})
```
### 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`](/reference/editor/reference/editor/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/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/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/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 support nesting, allowing hierarchical organization of shapes on the canvas.
## 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 and in its idle state. The operations won't run while you're in the middle of another interaction.
## Related examples
- **[Layer panel](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/layer-panel)** - Build a hierarchical layer panel that shows shape and group structure with visibility controls.
--------
# 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 (
{
// Highlight specific shapes with a visual indicator
const shapes = editor.getCurrentPageShapes()
if (shapes.length > 0) {
editor.setHintingShapes([shapes[0]])
}
}}
/>
)
}
```
## 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`](/reference/editor/reference/editor/Editor#getHoveredShapeId) or [`Editor.getHoveredShape`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/Editor#getHintingShapeIds) or [`Editor.getHintingShape`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/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 related shapes when one is selected:**
```typescript
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
const selected = next.selectedShapeIds
if (selected.length === 1) {
// Find bindings connected to the selected shape
const bindings = editor.getBindingsToShape(selected[0], 'arrow')
const connectedShapeIds = bindings.map((b) => b.fromId)
editor.setHintingShapes(connectedShapeIds)
} else {
editor.setHintingShapes([])
}
})
```
## Visual rendering
Both hover and hint indicators use the same selection color (`--tl-color-selected` CSS variable). The editor renders them using either SVG or canvas depending on the shape's indicator 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`](/reference/tlschema/reference/tlschema/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](/sdk-features/instance-state) — Session state including page state
- [Selection](/sdk-features/selection) — Working with selected shapes
- [Cursors](/sdk-features/cursors) — Cursor types and customization
## API reference
| Method | Description |
| ------------------------------ | -------------------------------------- |
| [`Editor.getHoveredShapeId`](/reference/editor/reference/editor/Editor#getHoveredShapeId) | Get the ID of the shape under pointer |
| [`Editor.getHoveredShape`](/reference/editor/reference/editor/Editor#getHoveredShape) | Get the shape object under pointer |
| [`Editor.setHoveredShape`](/reference/editor/reference/editor/Editor#setHoveredShape) | Manually set or clear hover state |
| [`Editor.getHintingShapeIds`](/reference/editor/reference/editor/Editor#getHintingShapeIds) | Get IDs of shapes with hint indicators |
| [`Editor.getHintingShape`](/reference/editor/reference/editor/Editor#getHintingShape) | Get shapes with hint indicators |
| [`Editor.setHintingShapes`](/reference/editor/reference/editor/Editor#setHintingShapes) | Set shapes to show hint indicators |
--------
# History (undo/redo)
The editor's history system tracks changes to the [store](/docs/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`](/reference/editor/reference/editor/Editor#undo) and [`Editor.redo`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/Editor#canUndo) and [`Editor.canRedo`](/reference/editor/reference/editor/Editor#canRedo) properties 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 (
)
}
```
### Running operations with history options
The [`Editor.run`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/Editor#bail) reverts to the most recent mark. [`Editor.bailToMark`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/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`](/reference/editor/reference/editor/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](/docs/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/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/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`](/reference/editor/reference/editor/ShapeUtil):
- If the shape implements [`ShapeUtil.toSvg`](/reference/editor/reference/editor/ShapeUtil#toSvg) or [`ShapeUtil.toBackgroundSvg`](/reference/editor/reference/editor/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 `` element
The temporary render step is necessary because CSS and layout aren't computed until elements are in the document. `` 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 `` 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 `