# 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 `
{
// [2]
const stopListening = react('update selection classname', () => {
const selectedShapes = editor.getSelectedShapes()
// [3]
const allAreRectangles =
selectedShapes.length > 0 &&
selectedShapes.every(
(shape) => editor.isShapeOfType(shape, 'geo') && shape.props.geo === 'rectangle'
)
// [4]
if (allAreRectangles) {
editor.getContainer().classList.add('rectangle-selection')
} else {
editor.getContainer().classList.remove('rectangle-selection')
}
})
// [5]
editor
.createShapes([
{ type: 'geo', x: 0, y: 0 },
{ type: 'geo', x: 120, y: 0 },
])
.selectAll()
.zoomToSelection()
.createShape({ type: 'geo', x: 60, y: 120, props: { geo: 'ellipse' } })
return stopListening
}}
/>
)
}
/*
Introduction:
This example shows how to change the selection color based on the types of shapes selected.
When all selected shapes are rectangles, the selection will appear red instead of the default blue.
[1]
We use the onMount prop to set up our selection listener when the editor is first mounted.
[2]
We use the react function to create a reactive effect that runs whenever the selection changes.
The first parameter is a unique name for this effect, and the second is a function that will
be called whenever the selection updates.
[3]
Here we check if all selected shapes are rectangle geo shapes. You can customize this condition
to check for any shape type or combination. For example:
- Check for circles: shape.type === 'geo' && shape.props.geo === 'ellipse'
- Check for text: shape.type === 'text'
- Check for mixed types: shape.type === 'geo' && (shape.props.geo === 'rectangle' || shape.props.geo === 'ellipse')
[4]
Based on our condition, we add or remove a CSS class from the editor's container. The CSS
file (selection-color-condition.css) defines the custom colors for the .rectangle-selection class.
[5]
We create some shapes to test our condition.
*/
```
--------
# Force mobile layout
Category: UI & theming
Keywords: force, mobile, breakpoint
Force the editor UI to render as if it were on a mobile device.
This example shows how you can force the editor to use the mobile breakpoint's UI layout.
To do this, pass the `forceMobile` prop to the editor component.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function ForceMobileExample() {
return (
)
}
/*
This example shows how you can force the editor to use the mobile breakpoint.
Simply pass the `forceMobile` prop to the editor component.
*/
```
--------
# Infer dark mode
Category: UI & theming
Keywords: props
Infer dark mode based on system preferences.
When the `Tldraw` component's `inferDarkMode` is true, the editor will infer its initial theme based on the user's system preferences, if available. You can toggle the dark mode by pressing `Command + /`.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function InferDarkModeExample() {
return (
)
}
```
--------
# Toggle dark mode
Category: UI & theming
Keywords: theme, dark mode
Toggle dark mode.
This example demonstrates how to change the user's current `colorScheme` preference back-and-forth between dark mode and light mode. Try it out by clicking the button at the top of the page.
## App.tsx
```tsx
import { Tldraw, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
function DarkModeButton() {
const editor = useEditor()
const handleClick = () => {
const isDark = editor.user.getIsDarkMode()
editor.user.updateUserPreferences({ colorScheme: isDark ? 'light' : 'dark' })
}
return (
)
}
export default function DarkModeToggleExample() {
return (
)
}
```
--------
# Layer panel
Category: UI & theming
Keywords:
Implementing a minimal layers panel for tldraw.
This example adds a simple layers panel to show shapes, groups, and frames in a tree view. It also allows showing/hiding shapes and selecting shapes.
## App.tsx
```tsx
import { TLComponents, TLEditorSnapshot, Tldraw, useEditor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
import { ShapeList } from './ShapeList'
import './layer-panel.css'
import snapshot from './snapshot.json'
// There's a guide a the bottom of this file!
const components: TLComponents = {
// [1]
InFrontOfTheCanvas: () => {
const editor = useEditor()
const shapeIds = useValue(
'shapeIds',
() => editor.getSortedChildIdsForParent(editor.getCurrentPageId()),
[editor]
)
return (
Shapes
)
},
}
export default function LayerPanelExample() {
return (
s.meta.force_show ? 'visible' : s.meta.hidden ? 'hidden' : 'inherit'
}
// this is just to provide some initial content, so visitors can see the layer panel in action
snapshot={snapshot as any as TLEditorSnapshot}
/>
)
}
/*
Guide:
1. Here we override the `InFrontOfTheCanvas` component with a custom component that renders a simple layer panel.
2. We pass the root ids of the current page to the recursive ShapeList component. (see ShapeList.tsx)
3. This is a function that determines whether a shape is hidden. We use this to hide shapes that have the `hidden` meta property set to true.
*/
```
## ShapeList.tsx
```tsx
import { capitalize } from 'lodash'
import { useRef, useState } from 'react'
import { Editor, TLShapeId, useEditor, useValue } from 'tldraw'
import { VisibilityOff, VisibilityOn } from '../../icons/icons'
const selectedBg = '#E8F4FE'
const childSelectedBg = '#F3F9FE'
const childBg = '#00000006'
function ShapeItem({
shapeId,
depth,
parentIsSelected,
parentIsHidden,
}: {
shapeId: TLShapeId
depth: number
parentIsSelected?: boolean
parentIsHidden?: boolean
}) {
const editor = useEditor()
const shape = useValue('shape', () => editor.getShape(shapeId), [editor])
const children = useValue('children', () => editor.getSortedChildIdsForParent(shapeId), [editor])
const isHidden = useValue('isHidden', () => editor.isShapeHidden(shapeId), [editor])
const isSelected = useValue('isSelected', () => editor.getSelectedShapeIds().includes(shapeId), [
editor,
])
const shapeName = useValue('shapeName', () => getShapeName(editor, shapeId), [editor])
const [isEditingName, setIsEditingName] = useState(false)
const timeSinceLastVisibilityToggle = useRef(Date.now())
if (!shape) return null
return (
<>
{!!shape && (
{
setIsEditingName(true)
}}
onPointerDown={() => {
// We synchronize the selection state of the layer panel items with the selection state of the shapes in the editor.
if (editor.inputs.getCtrlKey() || editor.inputs.getShiftKey()) {
if (isSelected) {
editor.deselect(shape)
} else {
editor.select(...editor.getSelectedShapes(), shape)
}
} else {
editor.select(shape)
}
}}
style={{
paddingLeft: 10 + depth * 20,
opacity: isHidden ? 0.5 : 1,
background: isSelected
? selectedBg
: parentIsSelected
? childSelectedBg
: depth > 0
? childBg
: undefined,
}}
>
{isEditingName ? (
setIsEditingName(false)}
onChange={(ev) => {
if (shape.type === 'frame') {
editor.updateShape({ ...shape, props: { name: ev.target.value } })
} else {
editor.updateShape({ ...shape, meta: { name: ev.target.value } })
}
}}
onKeyDown={(ev) => {
// finish editing on enter
if (ev.key === 'Enter' || ev.key === 'Escape') {
ev.currentTarget.blur()
}
}}
/>
) : (
)
}
function getShapeName(editor: Editor, shapeId: TLShapeId) {
const shape = editor.getShape(shapeId)
if (!shape) return 'Unknown shape'
return (
// meta.name is the first choice, then the shape's text, then the capitalized shape type
(shape.meta.name as string) ||
editor.getShapeUtil(shape).getText(shape) ||
capitalize(shape.type + ' shape')
)
}
```
## layer-panel.css
```css
.layer-panel {
position: absolute;
left: 0;
top: 42px;
width: 200px;
height: calc(100% - 42px);
background: white;
display: flex;
flex-direction: column;
pointer-events: all;
}
.shape-tree {
display: flex;
flex-direction: column;
}
.shape-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
}
.shape-visibility-toggle {
padding: 3px 6px;
border-radius: 3px;
outline: none;
border: none;
background: none;
flex-shrink: 0;
}
.shape-visibility-toggle:hover {
cursor: pointer;
background-color: #0000000b;
}
.layer-panel-title {
padding: 10px;
font-size: smaller;
font-weight: bolder;
}
.shape-name-input {
border: none;
background: none;
width: 0;
flex-grow: 1;
padding: 0;
}
.shape-name {
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
```
--------
# Floaty window
Category: UI & theming
Keywords:
Create an illusion of a floating window.
This example shows how to create a 'floating window' illusion that was [popular on social media](https://x.com/steveruizok/status/1727436505440981099) for a while.
## App.tsx
```tsx
import { useEffect } from 'react'
import { Tldraw, Vec, useContainer, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
export default function FloatyExample() {
return (
)
}
function SneakyFloatyHook() {
const editor = useEditor()
const container = useContainer()
useEffect(() => {
if (!window.screenLeft) {
window.screenLeft = window.screenX
window.screenTop = window.screenY
}
let x = window.screenLeft ?? window.screenX
let y = window.screenTop ?? window.screenY
function updatePositions() {
const sx = window.screenLeft ?? window.screenX
const sy = window.screenTop ?? window.screenY
if (sx !== x || sy !== y) {
x = sx
y = sy
editor.setCamera(new Vec(-x, -y))
}
}
editor.on('tick', updatePositions)
return () => {
editor.off('tick', updatePositions)
}
}, [editor, container])
return null
}
```
--------
# Custom translations and overrides
Category: UI & theming
Keywords: translation, i18n, localization, language, custom, override, useTranslation
Customize tldraw's translation strings and use them in custom components.
Apps often need to customize UI text to match their brand voice or terminology. This example demonstrates how to:
- Override existing translation strings (e.g., changing "Duplicate" to "Make a copy")
- Support multiple languages with custom terminology
- Use the `useTranslation` hook in your own components to access translations
The example creates a custom toolbar that uses translated strings via the `useTranslation` hook, and shows how to override those translations for multiple languages using the `overrides` prop on the `Tldraw` component.
## App.tsx
```tsx
import {
TLComponents,
TLUiOverrides,
Tldraw,
TldrawUiButton,
useEditor,
useTranslation,
} from 'tldraw'
import 'tldraw/tldraw.css'
import './custom-language-translations.css'
// There's a guide at the bottom of this file!
// [1]
function CustomToolbar() {
const editor = useEditor()
const msg = useTranslation()
return (
)
}
/*
This example shows how to customize tldraw's translation strings and use them in your own
components. This is useful when you need to match your app's brand voice or terminology.
[1]
We create a custom toolbar component that will use the translation system. The useTranslation
hook returns a function (commonly named 'msg') that retrieves translated strings by their key.
[2]
We call the msg function with translation keys like 'action.duplicate' and 'action.delete'.
These will return our customized strings instead of tldraw's defaults.
[3]
We define translation overrides in the overrides object. The translations property accepts an
object where each key is a language code (like 'en' or 'es') and the value is an object mapping
translation keys to custom strings. This allows you to override existing translations or add
new ones for multiple languages.
[4]
We define our component overrides outside of the React component to keep them static. This
prevents unnecessary re-renders and follows React best practices. The CustomToolbar component
is placed in the TopPanel to demonstrate how custom UI can use the translation system.
[5]
We pass both the overrides and components to the Tldraw component. The overrides provide the
custom translations, and the components prop adds our custom toolbar that uses those translations.
In addition to our custom menu, our custom translations can be found in the other menus as well.
Try creating a shape and right clicking it to see the custom translations in the context menu, or
in the main menu, keyboard shortcuts dialog, and more.
*/
```
## custom-language-translations.css
```css
.custom-language-toolbar {
pointer-events: all;
display: flex;
margin: 8px;
}
```
--------
# Custom renderer
Category: UI & theming
Keywords: html, canvas, background, context
You can _sort of_ use a custom renderer with tldraw.
This example shows how you might use a custom renderer with tldraw.
## App.tsx
```tsx
import { useLayoutEffect } from 'react'
import { DefaultCanvas, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { CustomRenderer } from './CustomRenderer'
export default function CustomRendererExample() {
useLayoutEffect(() => {
// Hide the regular shapes layer using CSS.
const script = document.createElement('style')
if (!script) return
script.innerHTML = `.tl-shapes { display: none; }`
document.body.appendChild(script)
return () => {
script.remove()
}
})
return (
)
}
```
## CustomRenderer.tsx
```tsx
import { useLayoutEffect, useRef } from 'react'
import { getColorValue, getDefaultColorTheme, getPointsFromDrawSegment, useEditor } from 'tldraw'
export function CustomRenderer() {
const editor = useEditor()
const rCanvas = useRef(null)
useLayoutEffect(() => {
const canvas = rCanvas.current
if (!canvas) return
canvas.style.width = '100%'
canvas.style.height = '100%'
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
const ctx = canvas.getContext('2d')!
let raf = -1
function render() {
if (!canvas) return
ctx.resetTransform()
ctx.clearRect(0, 0, canvas.width, canvas.height)
const camera = editor.getCamera()
ctx.scale(camera.z, camera.z)
ctx.translate(camera.x, camera.y)
const renderingShapes = editor.getRenderingShapes()
const theme = getDefaultColorTheme({ isDarkMode: editor.user.getIsDarkMode() })
const currentPageId = editor.getCurrentPageId()
for (const { shape, opacity } of renderingShapes) {
const maskedPageBounds = editor.getShapeMaskedPageBounds(shape)
if (!maskedPageBounds) continue
ctx.save()
if (shape.parentId !== currentPageId) {
ctx.beginPath()
ctx.rect(
maskedPageBounds.minX,
maskedPageBounds.minY,
maskedPageBounds.width,
maskedPageBounds.height
)
ctx.clip()
}
ctx.beginPath()
ctx.globalAlpha = opacity
const transform = editor.getShapePageTransform(shape.id)
ctx.transform(transform.a, transform.b, transform.c, transform.d, transform.e, transform.f)
if (editor.isShapeOfType(shape, 'draw')) {
// Draw a freehand shape
for (const segment of shape.props.segments) {
const points = getPointsFromDrawSegment(segment, shape.props.scaleX, shape.props.scaleY)
ctx.moveTo(points[0].x, points[0].y)
if (segment.type === 'straight') {
ctx.lineTo(points[1].x, points[1].y)
} else {
for (const point of points.slice(1)) {
ctx.lineTo(point.x, point.y)
}
}
}
ctx.strokeStyle = getColorValue(theme, shape.props.color, 'solid')
ctx.lineWidth = 4
ctx.stroke()
if (shape.props.fill !== 'none' && shape.props.isClosed) {
ctx.fillStyle = getColorValue(theme, shape.props.color, 'semi')
ctx.fill()
}
} else if (editor.isShapeOfType(shape, 'geo')) {
// Draw a geo shape
const bounds = editor.getShapeGeometry(shape).bounds
ctx.strokeStyle = getColorValue(theme, shape.props.color, 'solid')
ctx.lineWidth = 2
ctx.strokeRect(bounds.minX, bounds.minY, bounds.width, bounds.height)
} else {
// Draw any other kind of shape
const bounds = editor.getShapeGeometry(shape).bounds
ctx.strokeStyle = 'black'
ctx.lineWidth = 2
ctx.strokeRect(bounds.minX, bounds.minY, bounds.width, bounds.height)
}
ctx.restore()
}
raf = requestAnimationFrame(render)
}
render()
return () => {
cancelAnimationFrame(raf)
}
}, [editor])
return
}
```
--------
# Inset editor
Category: Page layout
Keywords: inline
Using the tldraw component in a non-fullscreen layout.
The `` component may be placed in any layout, even if it's not full-screen. In this example, the editor is inset within the screen layout. The component's interactions should still behave as you'd expect them to.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function InsetExample() {
return (
)
}
```
--------
# Inset editor (fixed sizes)
Category: Page layout
Keywords: focus, auto, focus, multiple, editors
Use the `Tldraw` component with a set height and width.
The `Tldraw` component can be used with a set height and width.
This example shows the editor's default appearance at various different sizes.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function InlineExample() {
return (
<>
>
)
}
function InlineEditor({ width, height }: { width: number; height: number }) {
const title = `${width} x ${height}`
return (
{title}
)
}
```
--------
# Inset editor (common practices)
Category: Page layout
Keywords: focus, blur, multiple
Common practices for using the `Tldraw` component as a block within a larger page.
This example demonstrates some common best practices for using tldraw as a block within a larger page. It includes:
- Making sure that only one editor has focus at a time.
- Always defaulting to the hand tool when you click into an editor.
- Deselecting everything when an editor loses focus.
- Hiding the UI when an editor is not focused.
- Disabling edge scrolling by default.
- Using a stripped down UI to make the most of the available space.
- Removing actions from the context menu to match the stripped down UI.
## App.tsx
```tsx
import { createContext, useContext, useState } from 'react'
import { Editor, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this page!
// [1]
const focusedEditorContext = createContext(
{} as {
focusedEditor: Editor | null
setFocusedEditor(id: Editor | null): void
}
)
// [2]
function blurEditor(editor: Editor) {
editor.blur({ blurContainer: false })
editor.selectNone()
editor.setCurrentTool('hand')
}
export default function InlineBehaviorExample() {
const [focusedEditor, setFocusedEditor] = useState(null)
return (
{
if (!focusedEditor) return
blurEditor(focusedEditor)
setFocusedEditor(null)
}}
>
)
}
/*
This example demonstrates some common best practices for using tldraw as an inline block within a
larger document editor.
It includes:
- Making sure that only one editor has focus at a time.
- Always defaulting to the hand tool when you click into an editor.
- Deselecting everything when an editor loses focus.
- Hiding the UI when an editor is not focused.
- Disabling edge scrolling by default.
- Using a stripped down UI to make the most of the available space.
[1]
We use a context to manage which editor is currently focused. This allows us to have multiple
editors on the same page, without them interfering with each other, or hijacking any keyboard
shortcuts. For more information about handling focus, check out the 'Multiple editors' and 'Editor
focus' examples.
[2]
We have a helper function that we call on any editor that loses focus. We deselect everything, and
switch back to the hand tool, essentially 'resetting' the user's tool state.
[3]
When the user clicks anywhere on the page outside of an editor, we blur the currently focused
editor.
[4]
When the user clicks into an editor, we focus it, and blur any other editor.
We also prevent pointer down events from passing through to the parent.
[5]
We hide the UI of any unfocused editor.
[6]
We disable pages to hide the pages menu. We disable edge scrolling, which can sometimes be too
easily triggered when the editor is in a small space.
[7]
We disable many of tldraw's default UI components to make the most of the available space.
[8]
When an editor mounts, we default to the hand tool. We also store a reference to the editor so that
we can access it later.
*/
```
--------
# Inset canvas
Category: Page layout
Keywords:
Handling events when the canvas is inset within the editor.
If for some reason you need to move the canvas around, that should still work.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import './inset-canvas.css'
export default function InsetCanvasExample() {
return (
)
}
```
## inset-canvas.css
```css
.tldraw__editor-with-inset-canvas .tl-canvas {
position: absolute;
inset: 25%;
width: 50%;
height: 50%;
}
```
--------
# Scrollable container
Category: Page layout
Keywords: focus, mouse wheel
Use the editor inside a scrollable container.
The `Tldraw` component can be used inside of any layout, including a scrollable container. Note that the editor will accept mousewheel events unless it is "unfocused".
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function ScrollExample() {
return (
)
}
/*
This example shows how you can use the Tldraw component inside a scrollable container.
The component will still accept mousewheel events while "focused". Try turning off the
autoFocus prop to see the difference.
*/
```
--------
# Multiple editors
Category: Page layout
Keywords: multiple, focus
Use multiple `` components on the same page.
This example shows how to use multiple `` components on the same page.
## App.tsx
```tsx
import { createContext, useCallback, useContext, useState } from 'react'
import { Editor, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this page!
// [1]
const focusedEditorContext = createContext(
{} as {
focusedEditor: Editor | null
setFocusedEditor(id: Editor | null): void
}
)
// [2]
export default function MultipleExample() {
const [focusedEditor, _setFocusedEditor] = useState(null)
const setFocusedEditor = useCallback(
(editor: Editor | null) => {
if (focusedEditor !== editor) {
if (focusedEditor) {
focusedEditor.blur()
}
if (editor) {
editor.focus()
}
_setFocusedEditor(editor)
}
},
[focusedEditor]
)
const focusName =
focusedEditor === (window as any).EDITOR_A
? 'A'
: focusedEditor === (window as any).EDITOR_B
? 'B'
: focusedEditor === (window as any).EDITOR_C
? 'C'
: 'none'
return (
setFocusedEditor(null)}
>
Focusing: {focusName}
These two editors share the same persistence key so they will share a (locally)
synchronized document.
setFocusedEditor((window as any).EDITOR_A)}
style={{ height: 600 }}
// Capture pointer down events that happen within the editor
onPointerDown={(e) => e.stopPropagation()}
>
{
;(window as any).EDITOR_A = editor
setFocusedEditor(editor)
}}
/>
setFocusedEditor((window as any).EDITOR_C)}
style={{ height: 600 }}
>
{
;(window as any).EDITOR_C = editor
}}
/>
)
}
// [5]
function ABunchOfText() {
return (
White Board
Chapter 1: The First Strokes
The fluorescent lights flickered overhead as John sat hunched over his desk, his fingers
tapping rhythmically on the keyboard. He was a software developer, and tonight, he had a
peculiar mission. A mission that would take him deep into the labyrinthine world of web
development. John had stumbled upon a new whiteboard library called “tldraw”, a seemingly
simple tool that promised to revolutionize collaborative drawing on the web. Little did he
know that this discovery would set off a chain of events that would challenge his skills,
test his perseverance, and blur the line between reality and imagination.
With a newfound sense of excitement, John began integrating “tldraw” into his latest
project. As lines of code danced across his screen, he imagined the possibilities that lay
ahead. The potential to create virtual spaces where ideas could be shared, concepts could be
visualized, and teams could collaborate seamlessly from different corners of the world. It
was a dream that seemed within reach, a vision of a future where creativity and technology
merged into a harmonious symphony.
As the night wore on, John’s mind became consumed with the whiteboard library. He couldn’t
help but marvel at its elegance and simplicity. With each stroke of his keyboard, he felt a
surge of inspiration, a connection to something greater than himself. It was as if the lines
of code he was writing were transforming into a digital canvas, waiting to be filled with
the strokes of imagination. In that moment, John realized that he was not just building a
tool, but breathing life into a new form of expression. The whiteboard was no longer just a
blank slate; it had become a portal to a world where ideas could flourish and dreams could
take shape.
Little did John know, this integration of “tldraw” was only the beginning. It would lead him
down a path filled with unforeseen challenges, where he would confront his own limitations
and question the very nature of creation. The journey ahead would test his resolve, pushing
him to the edge of his sanity. And as he embarked on this perilous adventure, he could not
shake the feeling that the whiteboard held secrets far beyond his understanding. Secrets
that would unfold before his eyes, one stroke at a time.
)
}
/*
This example shows how to use multiple editors on the same page. When doing this, you'll
need to make sure that only one editor is focused at a time. We can manage this using
the autofocus prop on the tldraw component, along with React's context and set state
APIs.
[1]
We first create a context that will hold the focused editor id and a setter for that id.
We'll use this to keep track of which editor is focused.
[2]
Wrap the editors in the context provider. This will make the context available to all
of the editors.
[3]
Get the focused editor id and the setter from the context. We'll use these to determine
if the editor should be focused or not. We wrap the Tldraw component in a div and use
the onFocus event to set the focused editor id.
[4]
Same again, but we're using the same persistence key for editors B and C. This means
that they will share a document.
[5]
A long story that doesn't really go anywhere, clearly written by a computer. But it's
a good way to test the scroll behavior of the page.
*/
```
--------
# External dialog
Category: Page layout
Keywords: css
Make dialogs open outside of the `Tldraw` component.
You can make dialogs open outside of the `Tldraw` component by overriding our default styles. To see this in action, open the Insert embed dialog through the menu or by pressing 'Cmd + I'.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// This CSS file overrides the default styles for dialogs
import './external-dialog.css'
export default function ExternalDialog() {
return (
)
}
```
## external-dialog.css
```css
.tlui-dialog__overlay {
position: fixed;
}
```
--------
# External UI (using context)
Category: Page layout
Keywords: outside, editor, context
This example shows how to control the tldraw editor from an external UI, using React context.
This example shows how to control the tldraw editor from an external UI, outside
of the `Tldraw` component. There are a few ways of doing this—for example, by putting the editor on the window object, passing it around via props, or using React context.
In this example, we use React context to distribute a reference to the editor to child components.
## App.tsx
```tsx
import { createContext, useContext, useState } from 'react'
import { Editor, GeoShapeGeoStyle, Tldraw, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
import './external-ui.css'
// There's a guide at the bottom of this file!
// [1]
const editorContext = createContext({} as { editor: Editor })
export default function ExternalUiExample2() {
const [editor, setEditor] = useState(null)
return (
)
}
/*
[1]
Use React context to store the editor at a higher place in the React component tree.
[2]
Use the `onMount` prop to get the editor instance and store it in state.
[3]
When we have an editor in state, render the context provider and its descendants.
[4]
You can access the editor from any of the provider's descendants.
*/
```
## external-ui.css
```css
.external-toolbar {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
gap: 8px;
}
.external-button {
pointer-events: all;
padding: 4px 12px;
background: white;
border: 1px solid black;
border-radius: 16px;
}
.external-button[data-isactive='true'] {
background-color: black;
color: white;
}
```
--------
# External UI (using state)
Category: Page layout
Keywords: outside, editor
This example shows how to control the tldraw editor from an external UI, using state.
This example shows how to control the tldraw editor from an external UI, outside
of the `Tldraw` component. There are a few ways of doing this—for example, by putting the editor on the window object, passing it around via props, or using React context.
In this example, we'll just put the editor instance in state and use it in the same component. See the [other External UI example](https://tldraw.dev/examples/external-ui-context) for an alternative (and more realistic) solution using React context.
## App.tsx
```tsx
import { useState } from 'react'
import { Editor, GeoShapeGeoStyle, Tldraw, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
import './external-ui.css'
// There's a guide at the bottom of this file!
export default function ExternalUiExample() {
// [1]
const [editor, setEditor] = useState(null)
const currentToolId = useValue('current tool id', () => editor?.getCurrentToolId(), [editor])
return (
)
}
/*
[1]
Use React state to store the editor instance.
[2]
Use the `onMount` prop to get the editor instance and store it in state.
[3]
Use data from the editor instance or use the editor's methods to control the editor.
Note that these callbacks also need to work if the editor isn't mounted yet.
[4]
The geo tool is a bit special since it controls the creation of many geo shapes (oval, rectangle, etc).
This is why we first set the type of the shape we wish to add, then we set the tool to 'geo'.
You can see all the available geo shapes in the `GeoShapeGeoStyle` enum.
*/
```
## external-ui.css
```css
.external-toolbar {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
gap: 8px;
}
.external-button {
pointer-events: all;
padding: 4px 12px;
background: white;
border: 1px solid black;
border-radius: 16px;
}
.external-button[data-isactive='true'] {
background-color: black;
color: white;
}
```
--------
# Snapshot image component
Category: Page layout
Keywords: snapshot, export
Display a tldraw snapshot as an image by using the `TldrawImage` component.
The `TldrawImage` component is a simple way to display a tldraw snapshot as an image. This example shows how to use it.
## App.tsx
```tsx
import { useState } from 'react'
import {
Box,
Editor,
StoreSnapshot,
TLPageId,
TLRecord,
TLStoreSnapshot,
Tldraw,
TldrawImage,
getSnapshot,
} from 'tldraw'
import 'tldraw/tldraw.css'
import initialSnapshot from './snapshot.json'
// There's a guide at the bottom of this file!
export default function TldrawImageExample() {
const [editor, setEditor] = useState()
const [snapshot, setSnapshot] = useState>(
initialSnapshot as TLStoreSnapshot
)
const [currentPageId, setCurrentPageId] = useState()
const [showBackground, setShowBackground] = useState(true)
const [isDarkMode, setIsDarkMode] = useState(false)
const [viewportPageBounds, setViewportPageBounds] = useState(new Box(0, 0, 600, 400))
const [isEditing, setIsEditing] = useState(false)
const [format, setFormat] = useState<'svg' | 'png'>('svg')
return (
)
}
/*
This example shows how to use the `TldrawImage` component to display a snapshot
as an image. The example also allows you to toggle between editing the snapshot
and viewing it.
[1] Pass your snapshot to the `snapshot` prop of the `TldrawImage` component.
[2] You can specify which page to display by using the `pageId` prop. By
default, the first page is shown.
[3] You can customize the appearance of the image by passing other props to the
`TldrawImage` component. For example, you can toggle the background, set
the dark mode, and specify the viewport bounds.
*/
```
--------
# Unsaved changes
Category: Events & effects
Keywords: save, unsaved, changes, document, listen, state
Track unsaved changes and enable save functionality.
This example shows how to track when the document has unsaved changes by listening to document scope events. A save button is enabled only when there are unsaved changes, and clicking it clears the unsaved state. The example uses `Editor.store.listen` with the `document` scope to monitor changes to the tldraw document.
## App.tsx
```tsx
import { useCallback, useEffect, useRef, useState } from 'react'
import {
RecordsDiff,
TLComponents,
TLEditorSnapshot,
TLEventMapHandler,
TLRecord,
Tldraw,
squashRecordDiffs,
useEditor,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
function SaveButton() {
const editor = useEditor()
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const rUnsavedChanges = useRef>({ added: {}, removed: {}, updated: {} })
useEffect(() => {
// [1]
const handleDocumentChange: TLEventMapHandler<'change'> = (diff) => {
squashRecordDiffs([rUnsavedChanges.current, diff.changes], { mutateFirstDiff: true })
setHasUnsavedChanges(
!isPlainObjectEmpty(rUnsavedChanges.current.added) ||
!isPlainObjectEmpty(rUnsavedChanges.current.removed) ||
!isPlainObjectEmpty(rUnsavedChanges.current.updated)
)
}
// [2]
return editor.store.listen(handleDocumentChange, { scope: 'document' })
}, [editor])
// [3]
const handleSave = useCallback(() => {
// The diff is the difference between the current document and the last saved document
const diff = rUnsavedChanges.current
// Maybe also get the current document / schema snapshot
const snapshot = editor.getSnapshot()
// Save everything somewhere...
saveChanges(diff, snapshot)
// Clear the unsaved changes state
setHasUnsavedChanges(false)
// Reset the diff
rUnsavedChanges.current = {
added: {},
removed: {},
updated: {},
}
}, [editor])
return (
)
}
function saveChanges(_diff: RecordsDiff, _snapshot: TLEditorSnapshot) {
// todo: do something with the diff, or save the whole document snapshot somewhere
}
function isPlainObjectEmpty(obj: object) {
for (const key in obj) return false
return true
}
// [4]
const components: TLComponents = {
TopPanel: SaveButton,
}
export default function UnsavedChangesExample() {
return (
)
}
/*
This example shows how to track unsaved changes in a tldraw document using the store's
listen method with document scope, and how to accumulate a diff of all changes since
the last save.
[1]
We create a handler that will be called whenever there are changes to the document.
The handler receives a diff of the changes that occurred. We use `squashRecordDiffs`
to accumulate all changes since the last save into a single diff object. This gives
us a complete picture of what has changed without storing redundant intermediate states.
[2]
We listen to store changes with the 'document' scope, which means we only get notified
about changes to document content (shapes, pages, etc.) and not to instance data like
camera position or selected shapes.
[3]
The save function demonstrates how you might handle saving in a real application. We
pass both the accumulated diff (showing exactly what changed since last save) and a
complete snapshot of the current document state to our save function. After saving,
we reset both the unsaved changes flag and the accumulated diff. In a real application,
you might send just the diff to minimize bandwidth, or save the full snapshot for
simpler server-side handling.
[4]
We define our component overrides outside of the React component to keep them static.
This prevents unnecessary re-renders and follows React best practices. The SaveButton
component is placed in the TopPanel to provide a prominent save interface.
*/
```
--------
# Signals
Category: Events & effects
Keywords: signia, state, store, side, effects, subscribe, track
React to changes by using signals.
tldraw uses signals to manage its state and store. You can subscribe to values in the store and run
side effects when they change. This example shows some ways of doing that.
## App.tsx
```tsx
import { TLComponents, Tldraw, track, useEditor, useReactor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
// [1]
const InfoPanel = track(() => {
const editor = useEditor()
const tool = editor.getCurrentToolId()
const zoom = editor.getZoomLevel().toFixed(2)
useReactor(
'change title',
() => {
const shapes = editor.getCurrentPageShapes()
document.title = `shapes: ${shapes.length}`
},
[editor]
)
return (
)
}
/*
tldraw uses signals to manage its state and store. You can subscribe to values in the store
and run side effects when they change.
[1]
Our InfoPanel component will display above the style panel. We want it to show the current
selected tool and zoom level of the editor. In order to make sure it displays up-to-date
information, we can wrap the component in the track function. This will track any signals
used in the component and re-render it when they change.
We also use the useReactor hook to update the document title with the number of shapes. This
side effect will run whenever the shapes on the page change. We pass the editor as a
dependency to the useReactor hook so it will always have the latest editor instance.
useQuickReactor runs immediately, whereas useReactor runs on the next animation frame.
[2]
We can also use the useValue hook to subscribe to a value in the store. You can pass it a
value or a function. Functions will be memoized and only re-run when the dependencies change.
*/
```
--------
# Canvas events
Category: Events & effects
Keywords: cursor, pointer, mouse, click, keyboard, handler, input
Listen to events from tldraw's canvas.
This example listens to events from tldraw's canvas and shows them on the right-hand-side. Try moving your cursor, dragging, using modifier keys, etc.
## App.tsx
```tsx
import { useCallback, useState } from 'react'
import { TLEventInfo, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
type TimedEvent = TLEventInfo & { lastUpdated: number }
export default function CanvasEventsExample() {
const [events, setEvents] = useState>({})
const handleEvent = useCallback((data: TLEventInfo) => {
// Update the event entry for this event type with new data
// This replaces previous event data of this type completely, keeping one per type
setEvents((prevEvents) => ({
...prevEvents,
[data.type]: {
...data,
lastUpdated: Date.now(),
},
}))
}, [])
// Convert events to array and sort by lastUpdated ascending (newest at bottom)
const eventsArray = Object.values(events).sort((a, b) => a.lastUpdated - b.lastUpdated)
return (
)
}
/*
This example shows how to listen to canvas events. This includes things like pointer and
keyboard events, but not things such as undo/redo, create/delete shapes, etc. Those are store events.
To listen to changes to the store, check out the store events example.
*/
```
--------
# Store events
Category: Events & effects
Keywords: listen, changes
Listen to changes from tldraw's store.
This example listens to changes from tldraw's store and shows them on the right-hand-side. Try creating & deleting shapes, or switching pages. The changes will be logged next to the canvas.
## App.tsx
```tsx
import _ from 'lodash'
import { useCallback, useEffect, useState } from 'react'
import { Editor, TLEventMapHandler, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
export default function StoreEventsExample() {
const [editor, setEditor] = useState()
const setAppToState = useCallback((editor: Editor) => {
setEditor(editor)
}, [])
const [storeEvents, setStoreEvents] = useState([])
useEffect(() => {
if (!editor) return
function logChangeEvent(eventName: string) {
setStoreEvents((events) => [...events, eventName])
}
//[1]
const handleChangeEvent: TLEventMapHandler<'change'> = (change) => {
// Added
for (const record of Object.values(change.changes.added)) {
if (record.typeName === 'shape') {
logChangeEvent(`created shape (${record.type})\n`)
}
}
// Updated
for (const [from, to] of Object.values(change.changes.updated)) {
if (
from.typeName === 'instance' &&
to.typeName === 'instance' &&
from.currentPageId !== to.currentPageId
) {
logChangeEvent(`changed page (${from.currentPageId}, ${to.currentPageId})`)
} else if (from.id.startsWith('shape') && to.id.startsWith('shape')) {
let diff = _.reduce(
from,
(result: any[], value, key: string) =>
_.isEqual(value, (to as any)[key]) ? result : result.concat([key, (to as any)[key]]),
[]
)
if (diff?.[0] === 'props') {
diff = _.reduce(
(from as any).props,
(result: any[], value, key) =>
_.isEqual(value, (to as any).props[key])
? result
: result.concat([key, (to as any).props[key]]),
[]
)
}
logChangeEvent(`updated shape (${JSON.stringify(diff)})\n`)
}
}
// Removed
for (const record of Object.values(change.changes.removed)) {
if (record.typeName === 'shape') {
logChangeEvent(`deleted shape (${record.type})\n`)
}
}
}
// [2]
const cleanupFunction = editor.store.listen(handleChangeEvent, { source: 'user', scope: 'all' })
return () => {
cleanupFunction()
}
}, [editor])
return (
event.stopPropagation()}
>
{storeEvents}
)
}
/*
This example shows how to listen to store events. This includes things creating/deleting shapes,
or moving between pages, but not things such as pointer and keyboard events. Those are canvas events.
To listen to changes to the canvas, check out the canvas events example.
[1]
This is the fire hose, it will be called at the end of every transaction. We're checking to see what
kind of changes were made and logging a more readable message to the to our panel.
[2]
This is the function that subscribes to changes to the store. You pass in the callback function that
you want to execute along with a handy filter object. In this case, we're only listening to changes
that were made by the user. It also returns a cleanup function that you can shove into the return of
a useeffect hook.
*/
```
--------
# UI events
Category: Events & effects
Keywords: ui, events, api, x-ray
Listen to UI events.
This example listens to UI events and shows them on the right-hand-side. Try creating & deleting shapes, or switching pages. The events will be logged next to the canvas.
Try selecting tools, using keyboard shortcuts, undo/redo, etc. Events will be logged next to the canvas.
## App.tsx
```tsx
import { Fragment, useCallback, useState } from 'react'
import { TLUiEventHandler, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { getCodeSnippet } from './codeSnippets'
// There's a guide at the bottom of this file!
export default function UiEventsExample() {
const [uiEvents, setUiEvents] = useState([])
const handleUiEvent = useCallback((name, data: any) => {
const codeSnippet = getCodeSnippet(name, data)
setUiEvents((events) => [
...events,
`event: ${name} ${JSON.stringify(data)}${codeSnippet && `\ncode: ${codeSnippet}`}`,
])
}, [])
return (
)
}
/*
This example shows how to listen to UI events. This includes includes things like selecting a tool,
grouping shapes, zooming etc. Events are included even if they are triggered by a keyboard shortcut.
However, interactions with the style panel are not included. For a full list of events and sources,
check out the TLUiEventSource and TLUiEventMap types.
It also shows the relevant code snippet for each event. This is useful for debugging and learning
the tldraw SDK.
We can pass a handler function to the onUiEvent prop of the Tldraw component. This handler function
will be called with the name of the event and the data associated with the event. We're going to
display these events in a list on the right side of the screen.
To listen to canvas events or changes to the store, check out the canvas events and store events
examples.
*/
```
## codeSnippets.ts
```ts
const STYLE_EVENT = {
'tldraw:color': 'DefaultColorStyle',
'tldraw:dash': 'DefaultDashStyle',
'tldraw:fill': 'DefaultFillStyle',
'tldraw:font': 'DefaultFontStyle',
'tldraw:horizontalAlign': 'DefaultHorizontalAlignStyle',
'tldraw:size': 'DefaultSizeStyle',
'tldraw:verticalAlign': 'DefaultVerticalAlignStyle',
'tldraw:geo': 'GeoShapeGeoStyle',
}
const REORDER_EVENT = {
toFront: 'bringToFront',
forward: 'bringForward',
backward: 'sendBackward',
toBack: 'sendToBack',
}
const SHAPES_META_EVENT = {
'group-shapes': 'groupShapes',
'ungroup-shapes': 'ungroupShapes',
'delete-shapes': 'deleteShapes',
}
const SHAPES_EVENT = {
'distribute-shapes': 'distributeShapes',
'align-shapes': 'alignShapes',
'stretch-shapes': 'stretchShapes',
'flip-shapes': 'flipShapes',
}
const USER_PREFS_EVENT = {
'toggle-snap-mode': 'isSnapMode',
'toggle-dark-mode': 'isDarkMode',
'toggle-reduce-motion': 'animationSpeed',
'toggle-edge-scrolling': 'edgeScrollSpeed',
}
const PREFS_EVENT = {
'toggle-transparent': 'exportBackground',
'toggle-tool-lock': 'isToolLocked',
'toggle-focus-mode': 'isFocusMode',
'toggle-grid-mode': 'isGridMode',
'toggle-debug-mode': 'isDebugMode',
}
const ZOOM_EVENT = {
'zoom-in': 'zoomIn',
'zoom-out': 'zoomOut',
'reset-zoom': 'resetZoom',
'zoom-to-fit': 'zoomToFit',
'zoom-to-selection': 'zoomToSelection',
}
export function getCodeSnippet(name: string, data: any) {
let codeSnippet = ''
if (name === 'set-style') {
if (data.id === 'opacity') {
codeSnippet = `editor.setOpacityForNextShapes(${data.value});`
} else {
codeSnippet = `editor.setStyleForNextShapes(${
STYLE_EVENT[data.id as keyof typeof STYLE_EVENT] ?? '?'
}, '${data.value}');`
}
} else if (['rotate-ccw', 'rotate-cw'].includes(name)) {
codeSnippet = 'editor.rotateShapesBy(editor.getSelectedShapeIds(), )'
} else if (name === 'edit-link') {
codeSnippet =
'editor.updateShapes([{ id: editor.getOnlySelectedShape().id, type: editor.getOnlySelectedShape().type, props: { url: }, }, ])'
} else if (name.startsWith('export-as')) {
codeSnippet = `exportAs(editor.getSelectedShapeIds(), '${data.format}')`
} else if (name.startsWith('copy-as')) {
codeSnippet = `copyAs(editor.getSelectedShapeIds(), '${data.format}')`
} else if (name === 'select-all-shapes') {
codeSnippet = `editor.selectAll()`
} else if (name === 'select-none-shapes') {
codeSnippet = `editor.selectNone()`
} else if (name === 'reorder-shapes') {
codeSnippet = `editor.${
REORDER_EVENT[data.operation as keyof typeof REORDER_EVENT] ?? '?'
}(editor.getSelectedShapeIds())`
} else if (['group-shapes', 'ungroup-shapes', 'delete-shapes'].includes(name)) {
codeSnippet = `editor.${
SHAPES_META_EVENT[name as keyof typeof SHAPES_META_EVENT] ?? '?'
}(editor.getSelectedShapeIds())`
} else if (name === 'stack-shapes') {
codeSnippet = `editor.stackShapes(editor.getSelectedShapeIds(), '${data.operation}', 16)`
} else if (name === 'pack-shapes') {
codeSnippet = `editor.packShapes(editor.getSelectedShapeIds(), 16)`
} else if (name === 'duplicate-shapes') {
codeSnippet = `editor.duplicateShapes(editor.getSelectedShapeIds(), {x: , y: })`
} else if (name.endsWith('-shapes')) {
codeSnippet = `editor.${
SHAPES_EVENT[name as keyof typeof SHAPES_EVENT] ?? '?'
}(editor.getSelectedShapeIds(), '${data.operation}')`
} else if (name === 'select-tool') {
if (data.id === 'media') {
codeSnippet = 'insertMedia()'
} else if (data.id.startsWith('geo-')) {
codeSnippet = `\n editor.updateInstanceState({
stylesForNextShape: {
...editor.getInstanceState().stylesForNextShape,
[GeoShapeGeoStyle.id]: '${data.id.replace('geo-', '')}',
},
}, { ephemeral: true });
editor.setCurrentTool('${data.id}')`
} else {
codeSnippet = `editor.setCurrentTool('${data.id}')`
}
} else if (name === 'print') {
codeSnippet = 'printSelectionOrPages()'
} else if (name === 'unlock-all') {
codeSnippet = `\n const updates = [] as TLShapePartial[]
for (const shape of editor.getCurrentPageShapes()) {
if (shape.isLocked) {
updates.push({ id: shape.id, type: shape.type, isLocked: false })
}
}
if (updates.length > 0) {
editor.updateShapes(updates)
}`
} else if (['undo', 'redo'].includes(name)) {
codeSnippet = `editor.${name}()`
} else if (['cut', 'copy'].includes(name)) {
codeSnippet = `\n const { ${name} } = useMenuClipboardEvents();\n ${name}()`
} else if (name === 'paste') {
codeSnippet = `\n const { paste } = useMenuClipboardEvents();\n navigator.clipboard?.read().then((clipboardItems) => {\n paste(clipboardItems)\n })`
} else if (name === 'stop-following') {
codeSnippet = `editor.stopFollowingUser()`
} else if (name === 'exit-pen-mode') {
codeSnippet = `editor.updateInstanceState({ isPenMode: false })`
} else if (name === 'remove-frame') {
codeSnippet = `removeFrame(editor, editor.getSelectedShapes().map((shape) => shape.id))`
} else if (name === 'fit-frame-to-content') {
codeSnippet = `fitFrameToContent(editor, editor.getOnlySelectedShape().id)`
} else if (name.startsWith('zoom-') || name === 'reset-zoom') {
codeSnippet = `editor.${ZOOM_EVENT[name as keyof typeof ZOOM_EVENT]}(${
name !== 'zoom-to-fit' && name !== 'zoom-to-selection'
? 'editor.getViewportScreenCenter(), '
: ''
}{ duration: 320 })`
} else if (name.startsWith('toggle-')) {
if (name === 'toggle-lock') {
codeSnippet = `editor.toggleLock(editor.getSelectedShapeIds())`
} else {
const userPrefName = USER_PREFS_EVENT[name as keyof typeof USER_PREFS_EVENT]
const prefName = PREFS_EVENT[name as keyof typeof PREFS_EVENT]
codeSnippet = userPrefName
? `editor.user.updateUserPreferences({ ${userPrefName}: })`
: `editor.updateInstanceState({ ${prefName}: !editor.getInstanceState().${prefName} })`
}
}
return codeSnippet
}
```
--------
# Block events
Category: Events & effects
Keywords: event, block, propagation, stop, no, select, user-select
Stop events from reaching the canvas.
If you don't want the user's interactions to reach the canvas, you can call `stopPropagation` on the user's pointer events.
## App.tsx
```tsx
import { TLComponents, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
function WelcomeScreen() {
return (
Notice that if you click on this box or start a drag from in here, you will not be
interacting with the canvas. However, you can still interact with the canvas by clicking
anywhere else!
)
}
/*
[1]
This div will overlay the whole canvas. We want the user's pointer events to
pass through this div rather than getting blocked by it div, so we turn
pointer events off.
[2]
This is the container that's centered on the screen. For this div, we want to
block pointer events so that the user can't interact with the canvas behind it,
so we turn pointer events on.
[3]
As a side note, we also turn off user-select for anything inside of the canvas.
If you want the user to be able to select text, you can set this style to 'all'.
*/
```
--------
# Prevent instance changes
Category: Events & effects
Keywords: side, effect, instance, grid, mode, prevent
Prevent a change to the "instance" record that would turn off grid mode.
You can use Editor's side effects API to prevent certain changes from occurring in the instance state. In this example, we prevent the user from changing the instance's `isGridMode` property.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this page!
export default function PreventInstanceChangeExample() {
return (
)
}
/*
In this example, we want to prevent the user from changing the isGridMode property.
[1]
Here we register a handler that will run whenever a change is about to be made to
to an "instance" type record.
The logic we want is that: if the new instance has `isGridMode` set to `false`, then
we want to reject the change; otherwise, we want to allow it.
To reject the change, we return the previous record. To allow the change, we
return the next record.
*/
```
--------
# Prevent shape changes
Category: Events & effects
Keywords: side, effect, move, prevent
Prevent changes to a shape's properties.
You can use Editor's side effects API to prevent certain changes from occurring in a shape. In this example, we prevent any changes to the shape's position, rotation, or size.
## App.tsx
```tsx
import { Tldraw, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this page!
export default function PreventMoveExample() {
return (
)
}
/*
[1]
Here we register a handler that will run whenever a change is about to be made to
a shape's record.
The logic we want is that: if the shape is a geo shape and a rectangle, and then
if the x, y, or rotation properties would be different in the next version of
the shape record, or if the props.w, or props.h properties would change, then
we want to reject the change; otherwise, we want to allow it.
To reject the change, we return the previous record. To allow the change, we
return the next record.
*/
```
--------
# Prevent multi-shape selection
Category: Events & effects
Keywords: selection, editor, instance, state
This example demonstrates how to prevent users from selecting multiple shapes at once in tldraw.
You can prevent multiple shape selection by registering a before-change handler for the `instance_page_state` type. This handler intercepts selection changes and ensures only one shape can be selected at a time.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function PreventMultiShapeSelectionExample() {
return (
)
}
/*
In this example, we want to prevent the user from selecting multiple shapes at once.
[1]
Here we register a handler that will run whenever a change is about to be made to
an "instance_page_state" type record, which contains the current selection state.
[2]
We check if this is a selection change and if it would result in multiple shapes being selected.
If both conditions are true, we modify the change to only select the most recently
selected shape by returning a new record with a single selected shape id.
*/
```
--------
# Before create/update shape
Category: Events & effects
Keywords: handler, register, side effects, records
Register a handler to run before shapes are created or updated.
You can intercept the creation or update of any record in the store and return a new record to be
used in it place. In this example, we lock shapes to a circle in the center of the screen.
## App.tsx
```tsx
import { Box, Editor, SVGContainer, TLShape, Tldraw, Vec, isShapeId } from 'tldraw'
import 'tldraw/tldraw.css'
// This function takes a shape and returns a new shape where the x/y origin is within `radius`
// distance of the center of the page. If the shape is already within `radius` (or isn't parented to
// the page) it returns the same shape.
function constrainShapeToRadius(editor: Editor, shape: TLShape, radius: number) {
// if the shape is parented to another shape (instead of the page) leave it as-is
if (isShapeId(shape.parentId)) return shape
// get the position of the shape
const shapePoint = Vec.From(shape)
const distanceFromCenter = shapePoint.len()
// if the shape is outside the radius, move it to the edge of the radius:
if (distanceFromCenter > radius) {
const newPoint = shapePoint.uni().mul(radius)
return {
...shape,
x: newPoint.x,
y: newPoint.y,
}
}
// otherwise, leave the shape as-is
return shape
}
export default function BeforeCreateUpdateShapeExample() {
return (
{
// we can run our `constrainShapeToRadius` function before any shape is created
// or changed. These `sideEffects` handlers let us take modify the shape that
// will be created or updated by returning a new one to be used in its place.
editor.sideEffects.registerBeforeCreateHandler('shape', (shape) => {
return constrainShapeToRadius(editor, shape, 500)
})
editor.sideEffects.registerBeforeChangeHandler('shape', (prevShape, nextShape) => {
return constrainShapeToRadius(editor, nextShape, 500)
})
// center the camera on the area we're constraining shapes to
editor.zoomToBounds(new Box(-500, -500, 1000, 1000))
// lock the camera on that area
editor.setCameraOptions({ isLocked: true })
}}
components={{
// to make it a little clearer what's going on in this example, we'll draw a
// circle on the canvas showing where shapes are being constrained to.
OnTheCanvas: () => (
),
}}
/>
)
}
```
--------
# Before delete shape
Category: Events & effects
Keywords: handler, register, side effects, records
Register a handler to run before shapes are deleted.
You can intercept the creation of any record in the store. This example intercepts arrow creation to
make sure each arrow has a label. You can do the same thing to change the props of any newly created
shape.
## App.tsx
```tsx
import { Editor, Tldraw, createShapeId, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
export default function BeforeDeleteShapeExample() {
return (
{
// register a handler to run before any shape is deleted:
editor.sideEffects.registerBeforeDeleteHandler('shape', (shape) => {
// if the shape is red, prevent the deletion:
if ('color' in shape.props && shape.props.color === 'red') {
return false
}
return
})
createDemoShapes(editor)
}}
/>
)
}
// create some shapes to demonstrate the side-effect we added
function createDemoShapes(editor: Editor) {
editor
.createShapes([
{
id: createShapeId(),
type: 'text',
props: {
richText: toRichText("Red shapes can't be deleted"),
color: 'red',
},
},
{
id: createShapeId(),
type: 'text',
y: 30,
props: {
richText: toRichText('but other shapes can'),
color: 'black',
},
},
])
.zoomToFit({ animation: { duration: 0 } })
}
```
--------
# Custom double-click behavior
Category: Events & effects
Keywords: double click, runtime override, state node, select tool, custom behavior
Override the default double-click behavior by replacing the SelectTool's Idle state method at runtime.
This example shows how to customize the double-click behavior on canvas by overriding the SelectTool's Idle state's `handleDoubleClickOnCanvas` method from the `onMount` callback.
The example demonstrates runtime method replacement, which is a powerful technique for customizing built-in tool behavior without creating entirely new tools. In this simplified version, double-clicking on the canvas shows an alert instead of creating a text shape, demonstrating the basic pattern for method override.
This pattern is useful when you want to extend existing tool behavior, add conditional logic, or customize built-in interactions without forking the entire tool.
## App.tsx
```tsx
import { StateNode, TLClickEventInfo, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
export default function CustomDoubleClickBehaviorExample() {
return (
{
// [2]
type IdleStateNode = StateNode & {
handleDoubleClickOnCanvas(info: TLClickEventInfo): void
}
// [3]
const selectIdleState = editor.getStateDescendant('select.idle')
if (!selectIdleState) throw Error('SelectTool Idle state not found')
// [4]
function customDoubleClickOnCanvasHandler(_info: TLClickEventInfo) {
// Your custom behavior goes here...
window.alert('double clicked on the canvas')
}
// [5]
selectIdleState.handleDoubleClickOnCanvas =
customDoubleClickOnCanvasHandler.bind(selectIdleState)
}}
/>
)
}
/*
This example demonstrates how to customize the double-click behavior on canvas
by overriding the SelectTool's Idle state's handleDoubleClickOnCanvas method.
Key concepts:
[1] onMount callback:
The onMount callback gives us access to the editor instance after it's
fully initialized. This is where we can access and modify built-in tools.
[2] Type definition for IdleStateNode:
We create a type that extends StateNode and includes the handleDoubleClickOnCanvas
method. This gives us proper TypeScript support when accessing the method.
[3] Getting the SelectTool's Idle state:
We use `editor.getStateDescendant('select.idle')` to get a
reference to the Idle state of the SelectTool. The path 'select.idle'
refers to the SelectTool's 'idle' child state.
[4] Custom handler function:
We define our custom behavior in a separate function. This keeps the code
clean and makes it easy to test or reuse the handler logic.
[5] Method replacement with binding:
We replace the original handleDoubleClickOnCanvas method with our custom
implementation, binding it to the selectIdleState context so that `this`
refers to the correct state node when the function is called. This
completely overrides the default behavior.
The handleDoubleClickOnCanvas method is called when the user double-clicks on
the canvas (not on a shape). By overriding this method, we can customize what
happens when the user double-clicks on empty space.
Note: This approach completely replaces the original method. If you want to
preserve the original behavior and add to it, you should store a reference
to the original method before replacing it, then call it from your custom
implementation when appropriate.
*/
```
--------
# After create/update shape
Category: Events & effects
Keywords: handler, register, side effects, records
Register a handler to run after shapes are created or updated.
You can register handlers to run after any record is created or updated. This is most useful for
updating _other_ records in response to a particular record changing. In this example, we make sure
there's only ever one red shape on a page.
## App.tsx
```tsx
import { Editor, TLShape, TLShapeId, Tldraw, createShapeId, toRichText } from 'tldraw'
type ShapeWithColor = Extract
// this function takes a shape ID, and if that shape is red, sets all other red shapes on the same
// page to black.
function ensureOnlyOneRedShape(editor: Editor, shapeId: TLShapeId) {
// grab the shape and check it's red:
const shape = editor.getShape(shapeId)!
if (!isRedShape(shape)) return
// get the ID of the page that shape belongs to:
const pageId = editor.getAncestorPageId(shape.id)!
// find any other red shapes on the same page:
const otherRedShapesOnPage = Array.from(editor.getPageShapeIds(pageId))
.map((id) => editor.getShape(id)!)
.filter(
(otherShape): otherShape is ShapeWithColor =>
otherShape.id !== shape.id && isRedShape(otherShape)
)
// set the color of all those shapes to black:
editor.updateShapes(
otherRedShapesOnPage.map((shape) => ({
id: shape.id,
type: shape.type,
props: {
color: 'black',
},
}))
)
}
function isRedShape(shape: TLShape) {
return 'color' in shape.props && shape.props.color === 'red'
}
export default function AfterCreateUpdateShapeExample() {
return (
{
// we can run our `ensureOnlyOneRedShape` function after any shape is created or
// changed. this means we can enforce our "only one red shape at a time" rule,
// while making sure that the shape most recently set to red is the one that
// stays red.
editor.sideEffects.registerAfterCreateHandler('shape', (shape) => {
ensureOnlyOneRedShape(editor, shape.id)
})
editor.sideEffects.registerAfterChangeHandler('shape', (prevShape, nextShape) => {
ensureOnlyOneRedShape(editor, nextShape.id)
})
createDemoShapes(editor)
}}
/>
)
}
// create some shapes to demonstrate the side-effects we added
function createDemoShapes(editor: Editor) {
editor
.createShapes(
'there can only be one red shape'.split(' ').map((word, i) => ({
id: createShapeId(),
type: 'text',
y: i * 30,
props: {
color: i === 5 ? 'red' : 'black',
richText: toRichText(word),
},
}))
)
.zoomToFit({ animation: { duration: 0 } })
}
```
--------
# After delete shape
Category: Events & effects
Keywords: handler, register, side effects, records
Register a handler to run after shapes are deleted.
You can register handlers to run after any record is deleted. In this example, we delete frames
after the last shape inside them is deleted.
## App.tsx
```tsx
import { Editor, Tldraw, createShapeId, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
export default function AfterDeleteShapeExample() {
return (
{
// register a handler to run after any shape is deleted:
editor.sideEffects.registerAfterDeleteHandler('shape', (shape) => {
// grab the parent of the shape and check if it's a frame:
const parentShape = editor.getShape(shape.parentId)
if (parentShape && parentShape.type === 'frame') {
// if it is, get the IDs of all its remaining children:
const siblings = editor.getSortedChildIdsForParent(parentShape.id)
// if there are none (so the frame is empty), delete the frame:
if (siblings.length === 0) {
editor.deleteShape(parentShape.id)
}
}
})
createDemoShapes(editor)
}}
/>
)
}
// crate some demo shapes to show off the new side-effect we added
function createDemoShapes(editor: Editor) {
const frameId = createShapeId()
editor.createShapes([
{
id: frameId,
type: 'frame',
props: { w: 400, h: 200 },
},
{
id: createShapeId(),
type: 'text',
parentId: frameId,
x: 50,
y: 40,
props: {
richText: toRichText('Frames will be deleted when their last child is.'),
w: 300,
autoSize: false,
},
},
...[50, 180, 310].map((x) => ({
id: createShapeId(),
type: 'geo' as const,
parentId: frameId,
x,
y: 120,
props: { w: 40, h: 40 },
})),
])
editor.zoomToFit({ animation: { duration: 0 } })
}
```
--------
# Permissions
Category: Events & effects
Keywords: constraints, bounds, side effects, permissions, clamping
Use side effect APIs to constrain shape movement within a bounding box.
This example demonstrates how to use tldraw's side effect APIs to enforce permissions or
constraints on shapes. Try dragging the rectangle around - its movement is constrained to
stay within the dashed container using the `registerBeforeChangeHandler` side effect.
This pattern is useful for implementing permission systems, bounded regions, or any scenario
where you need to restrict where shapes can be positioned.
## App.tsx
```tsx
import { Box, Editor, SVGContainer, TLGeoShape, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// [1]
const CONTAINER_BOUNDS = new Box(100, 100, 400, 300)
// [2]
function constrainShapeToBounds(editor: Editor, shape: TLGeoShape) {
const shapeGeometry = editor.getShapeGeometry(shape)
const shapeBounds = shapeGeometry.bounds
// Calculate the shape's world-space bounds
const shapeWorldBounds = Box.From({
x: shape.x + shapeBounds.x,
y: shape.y + shapeBounds.y,
w: shapeBounds.w,
h: shapeBounds.h,
})
// Check if the shape is completely within the container
if (CONTAINER_BOUNDS.contains(shapeWorldBounds)) {
return shape
}
// [3]
// Clamp the shape's position so it stays within bounds
const clampedX = Math.max(
CONTAINER_BOUNDS.x - shapeBounds.x,
Math.min(shape.x, CONTAINER_BOUNDS.maxX - shapeBounds.x - shapeBounds.w)
)
const clampedY = Math.max(
CONTAINER_BOUNDS.y - shapeBounds.y,
Math.min(shape.y, CONTAINER_BOUNDS.maxY - shapeBounds.y - shapeBounds.h)
)
return {
...shape,
x: clampedX,
y: clampedY,
}
}
export default function PermissionsExample() {
return (
)
}
/*
[1]
Define the invisible container bounds that our shape will be constrained to. This is a box
with top-left at (100, 100) and dimensions of 400x300.
[2]
This function checks if a shape's bounds extend outside the container and returns a modified
shape with clamped position if needed. We use editor.getShapeGeometry() to get the shape's
actual geometry including any padding or offsets.
[3]
Clamp the shape's x and y coordinates so that the shape's bounds stay completely within
the container bounds. We account for the shape's geometry offset and dimensions.
[4]
Register a beforeChange handler that runs whenever any shape is about to be modified. We only
apply the constraint to geo shapes (rectangles). This handler intercepts changes and returns
a modified version of the shape with position clamped to the container.
[5]
Create a rectangle shape positioned inside our container bounds. Users can drag this shape,
but it will be prevented from leaving the container area.
[6]
Draw a dashed rectangle on the canvas to visualize the container bounds. This uses SVGContainer
to render directly on the canvas in world coordinates.
*/
```
--------
# Permissions 2
Category: Events & effects
Keywords: constraints, bounds, side effects, permissions, clamping
A second example of how to use side effect APIs to constrain shape movement within a bounding box.
This example demonstrates how to use tldraw's side effect APIs to enforce permissions or
constraints on shapes. We create a rectangle that can be dragged around, but its movement
is constrained to stay within an invisible container using the `registerBeforeChangeHandler`
side effect. This pattern is useful for implementing permission systems, bounded regions,
or any scenario where you need to restrict where shapes can be positioned.
## App.tsx
```tsx
import { Box, Editor, SVGContainer, TLGeoShape, Tldraw, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
// [1]
const CONTAINER_BOUNDS = new Box(100, 100, 400, 300)
// [2]
function constrainShapeToBounds(editor: Editor, shape: TLGeoShape) {
const shapeGeometry = editor.getShapeGeometry(shape)
const shapeBounds = shapeGeometry.bounds
// Calculate the shape's world-space bounds
const shapeWorldBounds = editor.getShapePageBounds(shape)
if (!shapeWorldBounds) return shape
// Check if the shape is completely within the container
if (CONTAINER_BOUNDS.contains(shapeWorldBounds)) {
return shape
}
// [3]
// Calculate maximum allowed dimensions based on container size
const maxWidth = CONTAINER_BOUNDS.w - shapeBounds.x * 2
const maxHeight = CONTAINER_BOUNDS.h - shapeBounds.y * 2
// Clamp the shape's size if it would exceed the container
const clampedW = Math.min(shape.props.w, maxWidth)
const clampedH = Math.min(shape.props.h, maxHeight)
// Recalculate bounds with the clamped size
const clampedShapeBounds = Box.From({
x: shape.x + shapeBounds.x,
y: shape.y + shapeBounds.y,
w: (clampedW / shape.props.w) * shapeBounds.w,
h: (clampedH / shape.props.h) * shapeBounds.h,
})
// Clamp the shape's position so it stays within bounds
let clampedX = Math.max(
CONTAINER_BOUNDS.x - shapeBounds.x,
Math.min(shape.x, CONTAINER_BOUNDS.maxX - shapeBounds.x - clampedShapeBounds.w)
)
let clampedY = Math.max(
CONTAINER_BOUNDS.y - shapeBounds.y,
Math.min(shape.y, CONTAINER_BOUNDS.maxY - shapeBounds.y - clampedShapeBounds.h)
)
if (shapeBounds.w >= CONTAINER_BOUNDS.w) {
clampedX = CONTAINER_BOUNDS.x
}
if (shapeBounds.h >= CONTAINER_BOUNDS.h) {
clampedY = CONTAINER_BOUNDS.y
}
return {
...shape,
x: clampedX,
y: clampedY,
props: {
...shape.props,
w: clampedW,
h: clampedH,
},
}
}
export default function PermissionsExample() {
return (
{
// [4]
editor.sideEffects.registerBeforeChangeHandler('shape', (prevShape, nextShape) => {
// Only constrain geo shapes (our rectangle)
if (nextShape.type === 'geo') {
return constrainShapeToBounds(editor, nextShape as TLGeoShape)
}
return nextShape
})
// [5]
// Create the constrained rectangle
editor.createShape({
type: 'geo',
x: 250,
y: 200,
props: {
geo: 'rectangle',
w: 150,
h: 100,
richText: toRichText('Try to drag me around'),
},
})
// Zoom to show the container area
editor.zoomToBounds(new Box(0, 0, 600, 500))
}}
components={{
// [6]
OnTheCanvas: () => (
),
}}
/>
)
}
/*
[1]
Define the invisible container bounds that our shape will be constrained to. This is a box
with top-left at (100, 100) and dimensions of 400x300.
[2]
This function checks if a shape's bounds extend outside the container and returns a modified
shape with clamped position if needed. We use editor.getShapeGeometry() to get the shape's
actual geometry including any padding or offsets.
[3]
First, calculate the maximum allowed dimensions and clamp the shape's size if needed. Then
clamp the position to keep the shape within bounds. This ensures the shape can neither be
moved nor resized outside the container.
[4]
Register a beforeChange handler that runs whenever any shape is about to be modified. We only
apply the constraint to geo shapes (rectangles). This handler intercepts changes and returns
a modified version of the shape with position clamped to the container.
[5]
Create a rectangle shape with text positioned inside our container bounds. Users can drag and
resize this shape, but it will be prevented from leaving or exceeding the container area.
[6]
Draw a dashed rectangle on the canvas to visualize the container bounds. This uses SVGContainer
to render directly on the canvas in world coordinates.
*/
```
--------
# Derived view
Category: Events & effects
Keywords: basic, intro, simple, quick, start
Derive data from the editor's document in an efficient way.
You can use incremental derivations to get a specific view of data.
## App.tsx
```tsx
import { isUninitialized, RESET_VALUE } from '@tldraw/state'
import { useMemo, useRef } from 'react'
import { computed, Editor, isShape, Tldraw, TLShapeId, useEditor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
export default function DerivedViewExample() {
return (
)
}
function ShowNumberOfDrawShapesOnPage() {
const editor = useEditor()
const rRenders = useRef(0)
// Create a computed value that tracks the number of draw shapes in the document, returning a set of ids
const computed = useMemo(() => deriveNumberOfDrawShapesInDocument(editor), [editor])
// Get the size of the computed value whenever the computed value changes
const value = useValue('computed value', () => computed.get().size, [computed])
return (
{value} draw shapes in project
{/* Will go up by two in dev, NAB */}
{rRenders.current++} renders
)
}
export const deriveNumberOfDrawShapesInDocument = (editor: Editor) => {
const { store } = editor
const shapesIndex = store.query.ids('shape')
// Create an index of all the shape ids of all the draw shapes
function fromScratch() {
return new Set([...shapesIndex.get()].filter((id) => editor.getShape(id)!.type === 'draw'))
}
return computed>('_shapeIdsInCurrentPage', (prevValue, lastComputedEpoch) => {
// On first load, return the initial value
if (isUninitialized(prevValue)) {
return fromScratch()
}
// Get the changes since the last computed value
const diff = store.history.getDiffSince(lastComputedEpoch)
// Something caused the store to reset, compute a new value from scratch
if (diff === RESET_VALUE) {
return fromScratch()
}
// This will be the new set that includes the changes, if we find any
let nextValue: Set | undefined
for (const changes of diff) {
// Check all of the added records for new draw shapes
for (const record of Object.values(changes.added)) {
if (isShape(record) && record.type === 'draw') {
// If we haven't created the new set yet, do it now
if (!nextValue) {
nextValue = new Set(prevValue)
}
// mutate the new set
nextValue.add(record.id)
}
}
for (const record of Object.values(changes.removed)) {
// Check all of the removed records for deleted draw shapes
if (isShape(record) && record.type === 'draw') {
// If we haven't created the new set yet, do it now
if (!nextValue) {
nextValue = new Set(prevValue)
}
// mutate the new set
nextValue.delete(record.id)
}
}
}
// if something changed, return the new value
if (nextValue) return nextValue
// if nothing changed, return the previous value
return prevValue
})
}
```
--------
# Shape meta (on change)
Category: Events & effects
Keywords: side, effects, register, change, initial
Add custom metadata to shapes when they're changed.
We can update a shape's metadata whenever it changes. A UI displays the current selected shape's metadata. Create a shape, select it, and move it around. The metadata will updated any time the shape changes.
## App.tsx
```tsx
import { TLShape, Tldraw, track, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
export default function OnChangeShapeMetaExample() {
return (
{onlySelectedShape
? JSON.stringify(onlySelectedShape.meta, null, '\t')
: 'Select one shape to see its meta data.'}
)
})
/*
This example shows how to add meta data to shapes when they are created and
updated. In this case we are adding `updatedBy` and `updatedAt` fields.
[1]
getInitialMetaForShape is a method you can replace at runtime. Here we use
a callback on the onMount prop to replace the default implementation with
our own.
[2]
Here we're using the side effects API to add meta data to shapes when they are
updated. You can use the side effects API to do something on create, update or
delete, and you can target many things including: shapes, pages, the camera,
the pointer etc.
[3]
All tldraw shapes have a meta property with a type of unknown. To type your
meta data you can use a union like this.
[4]
A minimal ui component that displays the meta data of the selected shape. We
use track to make sure that the component is re-rendered when the signals it's
tracking change. Check out the signals example for more info:
https://tldraw.dev/examples/signals
*/
```
--------
# Shape meta (on create)
Category: Events & effects
Keywords: side, effects, initial, meta, register
Add custom metadata to shapes when they're created.
We can update a shape's metadata whenever it is created. A UI displays the current selected shape's metadata. Create a shape and select it. The metadata will display its created at / created by data.
## App.tsx
```tsx
import { TLShape, Tldraw, track, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
export default function OnCreateShapeMetaExample() {
return (
{onlySelectedShape
? JSON.stringify(onlySelectedShape.meta, null, '\t')
: 'Select one shape to see its meta data.'}
)
})
/*
This example demonstrates how to add your own data to shapes using the meta property as they're
created. Check out the docs for a more detailed explanation of the meta property:
https://tldraw.dev/docs/shapes#Meta-information
[1]
getInitialMetaForShape is a method you can replace at runtime. Here we use a callback on the onMount
prop to replace the default implementation with our own.
[2]
All tldraw shapes have a meta property with a type of unknown. To type your meta data you can use
a union like this.
[3]
A minimal ui component that displays the meta data of the selected shape. We use track to make sure
that the component is re-rendered when the signals it's tracking change. Check out the signals example
for more info: https://tldraw.dev/examples/signals
*/
```
--------
# Custom shape wrapper
Category: Shapes & tools
Keywords:
Customize the wrapper used for each shape in the DOM.
Use a custom shape wrapper to apply a special class names to shapes.
## App.tsx
```tsx
import { forwardRef } from 'react'
import {
atom,
DefaultShapeWrapper,
Editor,
TLComponents,
Tldraw,
TLShapeId,
TLShapeWrapperProps,
useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
// [1]
const specialShapeId = atom('special shape id', null)
// [2]
const CustomShapeWrapper = forwardRef(function CustomShapeWrapper(
{ children, shape, isBackground }: TLShapeWrapperProps,
ref: React.Ref
) {
// [a]
const isSpecial = useValue('is special', () => specialShapeId.get() === shape.id, [shape.id])
// [b]
return (
{children}
)
})
// [3]
const components: TLComponents = {
ShapeWrapper: CustomShapeWrapper,
}
export default function BasicExample() {
return (
)
}
function createSomeRandomShapes(editor: Editor) {
const bounds = editor.getViewportPageBounds()
for (let i = 0; i < 10; i++) {
editor.createShape({
type: 'geo',
x: bounds.x + Math.random() * bounds.width,
y: bounds.y + Math.random() * bounds.height,
})
}
}
/*
Introduction:
You can customize how shapes are wrapped in tldraw by creating a custom shape wrapper component and
passing it to the Tldraw component. In this example, we'll create a custom shape wrapper that adds a
red drop shadow to a randomly selected shape every second.
[1] We create an atom to store the ID of the currently "special" shape. Atoms are part of tldraw's
reactive state system and allow us to create reactive values that can be observed and updated. This
atom will hold the ID of the shape that should have the special styling applied to it.
[2] This is our custom shape wrapper component. Shape wrappers are React components that wrap around
every shape in the editor, allowing you to add custom styling, behavior, or data attributes to
shapes. We use forwardRef to properly forward the ref that tldraw passes to us.
[a]
We use the useValue hook to create a reactive value that checks if the current shape is the "special" shape.
This will automatically update whenever the specialShapeId atom changes. We also check if the shape is filled
by looking at its props.
[b]
We re-use the DefaultShapeWrapper component, but add a custom class to the shape when it's the "special" shape.
[3] We create a components object that tells tldraw to use our custom shape wrapper. The
TLComponents type allows us to override various parts of the tldraw UI, including the ShapeWrapper
component.
[4] In the main component, we add CSS that applies a red drop shadow to any element with the
'custom-special-shape' class. We also set up a timer that randomly selects a shape every second and
makes it the "special" shape by updating the specialShapeId atom.
The shape wrapper approach is useful when you want to:
- Add custom styling to all shapes or specific shapes
- Add data attributes for CSS targeting
- Implement custom behavior that affects how shapes are rendered
- Create visual effects that apply to the shape container rather than the shape content
This is different from custom shapes (like in the CustomShapeExample) because it doesn't change what
the shape is, only how it's wrapped and styled in the editor.
*/
```
--------
# Globs
Category: Shapes & tools
Keywords:
A globs based vector editor, based on the https://jcgt.org/published/0004/03/01/paper-lowres.pdf paper.
Some useful shortcuts:
- Place a node by pressing 'n'
- Create a glob by selecting a node and pressing 'c' and either place a new node or connect to an existing one
- Hold 'cmd' whilst dragging "d" handles to drag that handle and its opposite
- Hold 'cmd' whilst dragging tension handles to drag its opposite handle
- Hold 'cmd + shift' to drag all four tension handles on a glob together
## App.tsx
```tsx
import { useState } from 'react'
import {
DefaultToolbar,
DefaultToolbarContent,
StateNode,
TLComponents,
Tldraw,
TldrawUiButtonIcon,
TldrawUiMenuItem,
TldrawUiPopover,
TldrawUiPopoverContent,
TldrawUiPopoverTrigger,
TldrawUiToolbar,
TldrawUiToolbarButton,
TLKeyboardEventInfo,
tlmenus,
TLPointerEventInfo,
TLShape,
TLShapeId,
TLUiAssetUrlOverrides,
TLUiOverrides,
track,
useEditor,
useTools,
useValue,
} from 'tldraw'
import { CustomHandles } from './CustomHandles'
import { GlobBinding, GlobBindingUtil } from './GlobBindingUtil'
import { GlobShape, GlobShapeUtil } from './GlobShapeUtil'
import { GlobTool } from './GlobTool/GlobTool'
import { NodeShape, NodeShapeUtil } from './NodeShapeUtil'
const customAssetUrls: TLUiAssetUrlOverrides = {
icons: {
'glob-icon': '/glob-icon.svg',
'node-icon': '/node-icon.svg',
'connect-node-icon': '/connect-node.svg',
},
}
const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
tools['glob.node'] = {
id: 'glob.node',
icon: 'node-icon',
label: 'Node',
kbd: 'n',
meta: { variant: 'node' },
onSelect: () => {
editor.setCurrentTool('glob.node')
},
}
tools['glob.connect'] = {
id: 'glob.connect',
icon: 'connect-node-icon',
label: 'Connect Nodes',
kbd: 'c',
meta: { variant: 'connect' },
onSelect: () => {
// Only allow connecting if nodes are selected
const selectedShapes = editor.getSelectedShapes()
const hasNodesSelected =
selectedShapes.length > 0 &&
selectedShapes.every((shape) => editor.isShapeOfType(shape, 'node'))
if (hasNodesSelected) {
editor.setCurrentTool('glob.connect')
}
},
}
return tools
},
}
const GlobToolWithPopover = track(() => {
const tools = useTools()
const editor = useEditor()
const [isOpen, setIsOpen] = useState(false)
const currentGlobTool = useValue(
'current glob tool',
() => {
const tool = editor.getPath()
if (tool === 'glob.connect') return 'glob.connect'
return 'glob.node'
},
[editor]
)
// Check if any nodes are selected
const hasNodesSelected = useValue(
'has nodes selected',
() => {
const selectedShapes = editor.getSelectedShapes()
return (
selectedShapes.length > 0 &&
selectedShapes.every((shape) => editor.isShapeOfType(shape, 'node'))
)
},
[editor]
)
const isSelected = editor.getPath() === currentGlobTool
const popoverId = 'glob-tool-popover'
const handleToolSelect = (id: string) => {
if (id === 'glob.connect' && !hasNodesSelected) return
editor.setCurrentTool(id)
tlmenus.deleteOpenMenu(popoverId, editor.contextId)
setIsOpen(false)
}
return (
<>
handleToolSelect('glob.node')}
>
handleToolSelect('glob.connect')}
disabled={!hasNodesSelected}
>
>
)
})
const components: TLComponents = {
Toolbar: (props) => {
return (
)
},
Handles: CustomHandles,
}
const shapes = [NodeShapeUtil, GlobShapeUtil]
const tools = [GlobTool]
const bindings = [GlobBindingUtil]
export default function GlobsExample() {
return (
{
editor.updateInstanceState({ isDebugMode: true })
// Override dragging_handle state to prevent space from interrupting handle dragging
const draggingHandleState = editor.getStateDescendant('select.dragging_handle')
if (draggingHandleState) {
const originalOnKeyDown = draggingHandleState.onKeyDown?.bind(draggingHandleState)
draggingHandleState.onKeyDown = (info: TLKeyboardEventInfo) => {
originalOnKeyDown?.(info)
}
}
// Override pointing_handle state to allow handle dragging with modifier keys, otherwise
// it starts brushing instead
const pointingHandleState = editor.getStateDescendant('select.pointing_handle')
if (!pointingHandleState) {
throw new Error('SelectTool pointing_handle state not found')
}
// Store original handlers with proper binding
const originalOnPointerMove = pointingHandleState.onPointerMove?.bind(pointingHandleState)
// Return to idle state after dragging a handle
pointingHandleState.onPointerMove = (info: TLPointerEventInfo) => {
if (!info.shape) return
if (editor.isShapeOfType(info.shape, 'glob')) {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('select.dragging_handle', {
...info,
})
return
}
originalOnPointerMove?.(info)
}
// if we have a just a glob selected, expand the selection to include the nodes it's connected to
const originalGetContent = editor.getContentFromCurrentPage.bind(editor)
editor.getContentFromCurrentPage = (shapes) => {
// Extract shape IDs
const ids =
typeof shapes[0] === 'string'
? (shapes as TLShapeId[])
: (shapes as TLShape[]).map((s) => s.id)
// Expand selection to include bound nodes for any globs
const expandedIds = new Set(ids)
for (const id of ids) {
const shape = editor.getShape(id)
if (shape && editor.isShapeOfType(shape, 'glob')) {
const bindings = editor.getBindingsFromShape(id, 'glob')
for (const binding of bindings) {
expandedIds.add(binding.toId)
}
}
}
// Call original with expanded selection
return originalGetContent(Array.from(expandedIds))
}
}}
shapeUtils={shapes}
tools={tools}
bindingUtils={bindings}
overrides={uiOverrides}
assetUrls={customAssetUrls}
components={components}
/>
)
}
```
## CustomHandles.tsx
```tsx
import {
DefaultHandle,
Mat,
TLHandle,
TLShapeId,
toDomPrecision,
useEditor,
useValue,
} from 'tldraw'
import { ControlLine, getGlobInfo, getNeighborGlobs, GlobShape } from './GlobShapeUtil'
export function CustomHandles({ children }: { children: React.ReactNode }) {
const editor = useEditor()
const shouldDisplayHandles = useValue(
'shouldDisplayHandles',
() => {
if (
editor.isInAny(
'select.idle',
'select.pointing_handle',
'select.pointing_shape',
'select.dragging_handle'
)
) {
return true
}
if (editor.isInAny('select.editing_shape')) {
const onlySelectedShape = editor.getOnlySelectedShape()
return onlySelectedShape && editor.isShapeOfType(onlySelectedShape, 'note')
}
return false
},
[editor]
)
const shouldDisplayGlobHandles = useValue(
'shouldDisplayGlobHandles',
() => {
const selectedGlobs = editor
.getSelectedShapeIds()
.filter((shape) => editor.isShapeOfType(shape, 'glob'))
.map((shape) => editor.getShape(shape))
return selectedGlobs && selectedGlobs.length > 0
},
[editor]
)
if (!shouldDisplayHandles) return null
const selectedGlobs = editor
.getSelectedShapeIds()
.filter((shape) => editor.isShapeOfType(shape, 'glob'))
.map((shape) => editor.getShape(shape))
const neighborGlobs: Set = new Set()
for (const selectedGlob of selectedGlobs) {
if (!selectedGlob) continue
getNeighborGlobs(editor, selectedGlob).forEach((neighbor) => neighborGlobs.add(neighbor))
}
return (
)
}
function GlobHandlesWithControlLines({ glob }: { glob: GlobShape }) {
const editor = useEditor()
const handles = editor.getShapeHandles(glob)
const transform = editor.getShapePageTransform(glob.id)
const zoomLevel = editor.getZoomLevel()
const isCoarse = editor.getInstanceState().isCoarsePointer
const globPoints = getGlobInfo(editor, glob)
if (!handles || !transform || !globPoints) return null
const dxA = toDomPrecision(glob.props.edges.edgeA.d.x)
const dyA = toDomPrecision(glob.props.edges.edgeA.d.y)
const dxB = toDomPrecision(glob.props.edges.edgeB.d.x)
const dyB = toDomPrecision(glob.props.edges.edgeB.d.y)
const txAA = toDomPrecision(globPoints.edgeA.tangentA.x)
const tyAA = toDomPrecision(globPoints.edgeA.tangentA.y)
const txAB = toDomPrecision(globPoints.edgeA.tangentB.x)
const tyAB = toDomPrecision(globPoints.edgeA.tangentB.y)
const txBA = toDomPrecision(globPoints.edgeB.tangentA.x)
const tyBA = toDomPrecision(globPoints.edgeB.tangentA.y)
const txBB = toDomPrecision(globPoints.edgeB.tangentB.x)
const tyBB = toDomPrecision(globPoints.edgeB.tangentB.y)
return (
{handles.map((handle) => (
))}
)
}
function HandleWrapper({
shapeId,
handle,
zoom,
isCoarse,
}: {
shapeId: TLShapeId
handle: TLHandle
zoom: number
isCoarse: boolean
}) {
return (
)
}
```
## GlobBindingUtil.tsx
```tsx
import {
BindingOnShapeChangeOptions,
BindingOnShapeDeleteOptions,
BindingUtil,
Editor,
TLBinding,
TLParentId,
TLShapeId,
Vec,
} from 'tldraw'
import { GlobShape } from './GlobShapeUtil'
import { NodeShape } from './NodeShapeUtil'
import { getGlobBindings, getGlobTangentUpdate } from './shared'
const GLOB_BINDING_TYPE = 'glob'
declare module 'tldraw' {
export interface TLGlobalBindingPropsMap {
[GLOB_BINDING_TYPE]: GlobBindingProps
}
}
export type GlobBinding = TLBinding<'glob'>
interface GlobBindingProps {
terminal: 'start' | 'end'
}
export class GlobBindingUtil extends BindingUtil {
static override type = 'glob' as const
override getDefaultProps(): Partial {
return {
terminal: 'start',
}
}
override onAfterChangeFromShape({
binding,
shapeBefore,
shapeAfter,
}: BindingOnShapeChangeOptions): void {
const glob = this.editor.getShape(binding.fromId)
const node = this.editor.getShape(binding.toId)
if (!glob || !node) return
if (glob.props.isGhosting) return
const selectedIds = this.editor.getSelectedShapeIds()
if (selectedIds.includes(node.id)) return
if (!selectedIds.includes(glob.id)) return
if (
glob.parentId === node.parentId &&
glob.parentId !== this.editor.getCurrentPageId() &&
!selectedIds.includes(glob.id)
) {
return
}
// if a glob has been reparented (moved into/out of a frame), we need to move the nodes along with it
if (shapeBefore.parentId !== shapeAfter.parentId) {
const bindings = getGlobBindings(this.editor, glob)
const boundNodes: NodeShape[] = []
if (bindings.start) {
const node = this.editor.getShape(bindings.start.toId)
if (node) boundNodes.push(node)
}
if (bindings.end) {
const node = this.editor.getShape(bindings.end.toId)
if (node) boundNodes.push(node)
}
// Move each node to maintain its page position while reparenting to match the glob
for (const node of boundNodes) {
const nodePagePos = this.editor.getShapePageTransform(node).point()
const newParentTransform = this.editor.getShapeParentTransform(shapeAfter)
const newLocalPos = newParentTransform.clone().invert().applyToPoint(nodePagePos)
this.editor.reparentShapes([node.id], shapeAfter.parentId)
this.editor.updateShape({
id: node.id,
type: 'node',
x: newLocalPos.x,
y: newLocalPos.y,
})
}
// reparent and update
reparentGlob(this.editor, glob.id)
updateGlobGeometry(this.editor, glob.id)
return
}
const beforePageTransform = this.editor.getShapeParentTransform(shapeBefore)
const afterPageTransform = this.editor.getShapeParentTransform(shapeAfter)
const beforePagePos = beforePageTransform.applyToPoint(shapeBefore)
const afterPagePos = afterPageTransform.applyToPoint(shapeAfter)
const deltaInPageSpace = Vec.Sub(afterPagePos, beforePagePos)
const nodePagePos = this.editor.getShapePageTransform(node).point()
const newNodePagePos = Vec.Add(nodePagePos, deltaInPageSpace)
const nodeParentTransform = this.editor.getShapeParentTransform(node)
const newNodeLocalPos = nodeParentTransform.clone().invert().applyToPoint(newNodePagePos)
this.editor.run(
() => {
this.editor.updateShape({
id: node.id,
type: 'node',
x: newNodeLocalPos.x,
y: newNodeLocalPos.y,
})
},
{ history: 'ignore' }
)
}
override onAfterChangeToShape({
binding,
shapeBefore,
shapeAfter,
reason,
}: BindingOnShapeChangeOptions): void {
const glob = this.editor.getShape(binding.fromId)
if (!glob) return
if (glob.props.isGhosting) return
const selectedIds = this.editor.getSelectedShapeIds()
const node = this.editor.getShape(binding.toId)
if (!node) return
if (!selectedIds.includes(node.id) && selectedIds.includes(glob.id)) {
return
}
// If both glob and node share a frame parent and neither is selected,
// they're moving together (e.g., frame is being moved), don't update
if (
glob.parentId === node.parentId &&
glob.parentId !== this.editor.getCurrentPageId() &&
!selectedIds.includes(glob.id) &&
!selectedIds.includes(node.id) &&
reason !== 'ancestry'
) {
return
}
if (
reason !== 'ancestry' &&
shapeBefore.parentId === shapeAfter.parentId &&
shapeBefore.index === shapeAfter.index
) {
return
}
reparentGlob(this.editor, binding.fromId)
updateGlobGeometry(this.editor, binding.fromId)
}
override onBeforeDeleteToShape({ binding }: BindingOnShapeDeleteOptions): void {
this.editor.deleteShape(binding.fromId)
}
}
function reparentGlob(editor: Editor, globId: TLShapeId) {
const glob = editor.getShape(globId)
if (!glob) return
const bindings = getGlobBindings(editor, glob)
const { start, end } = bindings
const startShape = start ? editor.getShape(start.toId) : undefined
const endShape = end ? editor.getShape(end.toId) : undefined
const parentPageId = editor.getAncestorPageId(glob)
if (!parentPageId) return
let nextParentId: TLParentId
if (startShape && endShape) {
nextParentId = editor.findCommonAncestor([startShape, endShape]) ?? parentPageId
} else if (startShape || endShape) {
const bindingParentId = (startShape || endShape)?.parentId
if (bindingParentId && bindingParentId === glob.parentId) {
nextParentId = glob.parentId
} else {
nextParentId = parentPageId
}
} else {
return
}
if (nextParentId && nextParentId !== glob.parentId) {
editor.reparentShapes([globId], nextParentId)
}
}
function updateGlobGeometry(editor: Editor, globId: TLShapeId) {
const glob = editor.getShape(globId)
if (!glob) return
if (glob.props.isGhosting) return
const bindings = getGlobBindings(editor, glob)
const { start, end } = bindings
const startShape = start ? editor.getShape(start.toId) : undefined
const endShape = end ? editor.getShape(end.toId) : undefined
if (!startShape || !endShape) return
const startNodePagePos = editor.getShapePageTransform(startShape.id).point()
const endNodePagePos = editor.getShapePageTransform(endShape.id).point()
const update = getGlobTangentUpdate(
editor,
globId,
startNodePagePos,
startShape.props.radius,
endNodePagePos,
endShape.props.radius
)
editor.updateShape(update)
}
```
## GlobShapeUtil.tsx
```tsx
import { round } from 'lodash'
import {
createComputedCache,
Editor,
getIndicesAbove,
HandleSnapGeometry,
Mat,
PathBuilder,
PointsSnapIndicator,
RecordProps,
Rectangle2d,
ShapeUtil,
SVGContainer,
T,
TLHandle,
TLHandleDragInfo,
TLResizeInfo,
TLShape,
TLShapePartial,
toDomPrecision,
track,
uniqueId,
useEditor,
useValue,
Vec,
VecModel,
vecModelValidator,
ZERO_INDEX_KEY,
} from 'tldraw'
import { GlobBinding } from './GlobBindingUtil'
import { getStartAndEndNodes } from './shared'
import {
getArcFlag,
getClosestPointOnCircle,
getGlobEndPoint,
getOuterTangentPoints,
projectTensionPoint,
} from './utils'
export interface NodeGeometry {
position: VecModel
radius: number
}
export interface EdgeGeometry {
tangentA: VecModel
tangentB: VecModel
tensionA: VecModel
tensionB: VecModel
}
export interface GlobGeometry {
startNode: NodeGeometry
endNode: NodeGeometry
edgeA: EdgeGeometry
edgeB: EdgeGeometry
}
export interface EdgeProps {
d: VecModel
tensionRatioA: number
tensionRatioB: number
}
export type EdgeCurveType = 'edgeA' | 'edgeB'
export interface GlobProps {
edges: {
edgeA: EdgeProps
edgeB: EdgeProps
}
opacity: number
isGhosting: boolean
}
const GLOB_TYPE = 'glob'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[GLOB_TYPE]: GlobProps
}
}
export type GlobShape = TLShape<'glob'>
interface SnapData {
nudge: VecModel
indicators: PointsSnapIndicator[]
}
const edgeCurveValidator = T.object({
d: vecModelValidator,
tensionRatioA: T.number,
tensionRatioB: T.number,
})
const globInfoCache = createComputedCache(
'glob info',
(editor: Editor, shape: GlobShape): GlobGeometry | null => {
const nodes = getStartAndEndNodes(editor, shape.id)
if (!nodes) return null
const { startNodeShape, endNodeShape } = nodes
const startNodePagePos = editor.getShapePageTransform(startNodeShape.id).point()
const endNodePagePos = editor.getShapePageTransform(endNodeShape.id).point()
const globPagePos = editor.getShapePageTransform(shape.id).point()
const localStartNode = Vec.Sub(startNodePagePos, globPagePos)
const localEndNode = Vec.Sub(endNodePagePos, globPagePos)
let tangentA_A = getGlobEndPoint(
localStartNode,
shape.props.edges.edgeA.d,
startNodeShape.props.radius,
0
)
let tangentB_A = getGlobEndPoint(
localEndNode,
shape.props.edges.edgeA.d,
endNodeShape.props.radius,
1
)
let tangentA_B = getGlobEndPoint(
localStartNode,
shape.props.edges.edgeB.d,
startNodeShape.props.radius,
1
)
let tangentB_B = getGlobEndPoint(
localEndNode,
shape.props.edges.edgeB.d,
endNodeShape.props.radius,
0
)
// if we drag a node over an existing d handle, the solution does not exist so collapse the points
//
if (!tangentA_A && tangentA_B) {
tangentA_A = tangentA_B
} else if (!tangentA_B && tangentA_A) {
tangentA_B = tangentA_A
}
if (!tangentB_A && tangentB_B) {
tangentB_A = tangentB_B
} else if (!tangentB_B && tangentB_A) {
tangentB_B = tangentB_A
}
if (!tangentA_A || !tangentB_A || !tangentA_B || !tangentB_B) return null
return {
startNode: {
position: localStartNode,
radius: startNodeShape.props.radius,
},
endNode: {
position: localEndNode,
radius: endNodeShape.props.radius,
},
edgeA: {
tangentA: tangentA_A,
tangentB: tangentB_A,
tensionA: Vec.Lrp(
tangentA_A,
shape.props.edges.edgeA.d,
shape.props.edges.edgeA.tensionRatioA
),
tensionB: Vec.Lrp(
shape.props.edges.edgeA.d,
tangentB_A,
shape.props.edges.edgeA.tensionRatioB
),
},
edgeB: {
tangentA: tangentA_B,
tangentB: tangentB_B,
tensionA: Vec.Lrp(
tangentA_B,
shape.props.edges.edgeB.d,
shape.props.edges.edgeB.tensionRatioA
),
tensionB: Vec.Lrp(
shape.props.edges.edgeB.d,
tangentB_B,
shape.props.edges.edgeB.tensionRatioB
),
},
}
}
)
export function getGlobInfo(editor: Editor, shape: GlobShape): GlobGeometry | null {
return globInfoCache.get(editor, shape.id) ?? null
}
export class GlobShapeUtil extends ShapeUtil {
static override type = 'glob' as const
static override props: RecordProps = {
edges: T.object({
edgeA: edgeCurveValidator,
edgeB: edgeCurveValidator,
}),
opacity: T.number,
isGhosting: T.boolean,
}
override getDefaultProps(): GlobShape['props'] {
return {
edges: {
edgeA: {
d: { x: 0, y: 0 },
tensionRatioA: 0.5,
tensionRatioB: 0.5,
},
edgeB: {
d: { x: 0, y: 0 },
tensionRatioA: 0.5,
tensionRatioB: 0.5,
},
},
opacity: 1,
isGhosting: false,
}
}
override hideSelectionBoundsBg(_shape: GlobShape): boolean {
return true
}
override hideResizeHandles(_shape: GlobShape): boolean {
return true
}
override hideRotateHandle(_shape: GlobShape): boolean {
return true
}
override hideSelectionBoundsFg(_shape: GlobShape): boolean {
return true
}
override onResize(_shape: GlobShape, info: TLResizeInfo) {
const { scaleX, scaleY, initialShape } = info
const scaledEdgeA_d = {
x: initialShape.props.edges.edgeA.d.x * scaleX,
y: initialShape.props.edges.edgeA.d.y * scaleY,
}
const scaledEdgeB_d = {
x: initialShape.props.edges.edgeB.d.x * scaleX,
y: initialShape.props.edges.edgeB.d.y * scaleY,
}
const didFlipX = scaleX < 0
const didFlipY = scaleY < 0
const shouldSwap = didFlipX !== didFlipY
const finalEdgeA = shouldSwap
? {
...initialShape.props.edges.edgeB,
d: scaledEdgeB_d,
}
: {
...initialShape.props.edges.edgeA,
d: scaledEdgeA_d,
}
const finalEdgeB = shouldSwap
? {
...initialShape.props.edges.edgeA,
d: scaledEdgeA_d,
}
: {
...initialShape.props.edges.edgeB,
d: scaledEdgeB_d,
}
return {
props: {
edges: {
edgeA: finalEdgeA,
edgeB: finalEdgeB,
},
},
}
}
override onRotate(initial: GlobShape, current: GlobShape): TLShapePartial {
const delta = current.rotation - initial.rotation
const rotatedDA = Vec.Rot(initial.props.edges.edgeA.d, delta).toJson()
const rotatedDB = Vec.Rot(initial.props.edges.edgeB.d, delta).toJson()
// rotating nodes will handle rotating the glob, but we still need to update the d handles
return {
id: current.id,
type: current.type,
rotation: 0,
props: {
edges: {
edgeA: {
...initial.props.edges.edgeA,
d: rotatedDA,
},
edgeB: {
...initial.props.edges.edgeB,
d: rotatedDB,
},
},
},
}
}
override getGeometry(shape: GlobShape) {
const globPoints = getGlobInfo(this.editor, shape)
if (!globPoints) return new Rectangle2d({ width: 1, height: 1, isFilled: false })
const pathBuilder = buildGlobPath(globPoints, true)
if (!pathBuilder) return new Rectangle2d({ width: 1, height: 1, isFilled: false })
return pathBuilder.toGeometry()
}
override getHandles(shape: GlobShape) {
const globPoints = getGlobInfo(this.editor, shape)
if (!globPoints) return []
const NUM_D_POINTS = 2
const NUM_TENSION_POINTS = 4
const indices = getIndicesAbove(ZERO_INDEX_KEY, NUM_D_POINTS + NUM_TENSION_POINTS)
let idx = 0
const handles: TLHandle[] = []
handles.push({
id: 'edgeA.d',
type: 'vertex',
x: shape.props.edges.edgeA.d.x,
y: shape.props.edges.edgeA.d.y,
index: indices[idx++],
snapType: 'point',
})
handles.push({
id: 'edgeB.d',
type: 'vertex',
x: shape.props.edges.edgeB.d.x,
y: shape.props.edges.edgeB.d.y,
index: indices[idx++],
snapType: 'point',
})
const tensions = [
'tensionA',
'tensionB',
] as const satisfies readonly (keyof GlobGeometry['edgeA'])[]
for (const edge of ['edgeA', 'edgeB'] as const satisfies readonly (keyof GlobGeometry)[]) {
for (const tension of tensions) {
handles.push({
id: `${edge}.${tension}`,
type: 'vertex',
x: globPoints[edge][tension].x,
y: globPoints[edge][tension].y,
index: indices[idx++],
snapType: 'point',
snapReferenceHandleId: `${edge}.${tension}`, // this is a hack to make these handles not have angle snapping, when we shift drag which moves all tension handles at the same time
})
}
}
return handles
}
override getHandleSnapGeometry(shape: GlobShape): HandleSnapGeometry {
const editor = this.editor
const globPoints = getGlobInfo(editor, shape)
if (!globPoints) return {}
return {
getSelfSnapPoints(handle) {
const { edgeType, point } = getHandleData(handle.id)
const d = shape.props.edges[edgeType].d
switch (point) {
case 'tensionA': {
const mid = Vec.Lrp(globPoints[edgeType].tangentA, d, 0.5)
return [mid]
}
case 'tensionB': {
const mid = Vec.Lrp(globPoints[edgeType].tangentB, d, 0.5)
return [mid]
}
}
return []
},
}
}
override onHandleDrag(shape: GlobShape, info: TLHandleDragInfo) {
const { handle, initial } = info
if (!initial) return shape
const globPoints = getGlobInfo(this.editor, shape)
if (!globPoints) return shape
const { edgeType, point } = getHandleData(handle.id)
const edge = shape.props.edges[edgeType]
const oppositeEdgeType = edgeType === 'edgeA' ? 'edgeB' : 'edgeA'
const initialEdge = initial.props.edges[edgeType]
const initialOppositeEdge = initial.props.edges[oppositeEdgeType]
switch (point) {
case 'd': {
let d = { x: handle.x, y: handle.y }
this.editor.snaps.clearIndicators()
const snapPoint = this.getSnaps(shape, handle)
const isSnapMode = this.editor.user.getIsSnapMode()
if (
snapPoint &&
!this.editor.inputs.getMetaKey() &&
(isSnapMode ? !this.editor.inputs.getCtrlKey() : this.editor.inputs.getCtrlKey())
) {
this.editor.snaps.setIndicators(snapPoint.indicators)
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[edgeType]: {
...edge,
d: Vec.Add(d, snapPoint.nudge).toJson(),
},
},
},
}
}
// constrain the d handle around the node if we try drag it inside a node
const distStart = Vec.Dist(handle, globPoints.startNode.position)
if (distStart <= globPoints.startNode.radius) {
d = getClosestPointOnCircle(
globPoints.startNode.position,
globPoints.startNode.radius,
handle
)
}
// constrain the d handle around the node if we try drag it inside a node
const distEnd = Vec.Dist(handle, globPoints.endNode.position)
if (distEnd <= globPoints.endNode.radius) {
d = getClosestPointOnCircle(
globPoints.endNode.position,
globPoints.endNode.radius,
handle
)
}
// drag both d handles at the same time
if (this.editor.inputs.getMetaKey()) {
const delta = Vec.Sub(handle, initialEdge.d)
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[oppositeEdgeType]: {
...initialOppositeEdge,
d: Vec.Add(initialOppositeEdge.d, delta).toJson(),
},
[edgeType]: {
...edge,
d: d,
},
},
},
}
}
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[edgeType]: {
...edge,
d: d,
},
[oppositeEdgeType]: {
...initialOppositeEdge,
},
},
},
}
}
case 'tensionA': {
const lineStart = globPoints[edgeType].tangentA
const lineEnd = shape.props.edges[edgeType].d
const projectedPoint = projectTensionPoint(lineStart, lineEnd, handle)
// drag ALL the tension handles
if (this.editor.inputs.getMetaKey() && this.editor.inputs.getShiftKey()) {
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[edgeType]: {
...initialEdge,
tensionRatioA: projectedPoint,
tensionRatioB: 1 - projectedPoint,
},
[oppositeEdgeType]: {
...initialOppositeEdge,
tensionRatioA: projectedPoint,
tensionRatioB: 1 - projectedPoint,
},
},
},
}
}
// drag opposite tension handles at the same time
if (this.editor.inputs.getMetaKey()) {
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[edgeType]: {
...initialEdge,
tensionRatioA: projectedPoint,
tensionRatioB: 1 - projectedPoint,
},
},
},
}
}
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[edgeType]: {
...initialEdge,
tensionRatioA: projectedPoint,
},
[oppositeEdgeType]: {
...initialOppositeEdge,
},
},
},
}
}
case 'tensionB': {
const lineStart = shape.props.edges[edgeType].d
const lineEnd = globPoints[edgeType].tangentB
const projectedPoint = projectTensionPoint(lineStart, lineEnd, handle)
// drag ALL the tension handles
if (this.editor.inputs.getMetaKey() && this.editor.inputs.getShiftKey()) {
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[edgeType]: {
...initialEdge,
tensionRatioA: 1 - projectedPoint,
tensionRatioB: projectedPoint,
},
[oppositeEdgeType]: {
...initialOppositeEdge,
tensionRatioA: 1 - projectedPoint,
tensionRatioB: projectedPoint,
},
},
},
}
}
// drag opposite tension handles at the same time
if (this.editor.inputs.getMetaKey()) {
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[edgeType]: {
...initialEdge,
tensionRatioA: 1 - projectedPoint,
tensionRatioB: projectedPoint,
},
},
},
}
}
return {
...shape,
props: {
...shape.props,
edges: {
...shape.props.edges,
[edgeType]: {
...initialEdge,
tensionRatioB: projectedPoint,
},
[oppositeEdgeType]: {
...initialOppositeEdge,
},
},
},
}
}
}
return shape
}
override component(shape: GlobShape) {
const showControlLines =
this.editor.isInAny(
'select.idle',
'select.pointing_handle',
'select.pointing_shape',
'select.dragging_handle'
) && this.editor.getOnlySelectedShape() === shape
return
}
override indicator(shape: GlobShape) {
const zoomLevel = this.editor.getZoomLevel()
const globPoints = getGlobInfo(this.editor, shape)
if (!globPoints) return null
const pathBuilder = buildGlobPath(globPoints)
if (!pathBuilder) return null
return (
)
}
override toSvg(shape: GlobShape) {
const globPoints = getGlobInfo(this.editor, shape)
if (!globPoints) return null
const pathBuilder = buildGlobPath(globPoints)
if (!pathBuilder) return null
return pathBuilder.toSvg({
style: 'solid',
strokeWidth: 2,
forceSolid: false,
props: { stroke: 'black', fill: 'black' },
})
}
private getSnaps(shape: GlobShape, handle: TLHandle): SnapData | null {
const INFINITE_LENGTH = 100000
const pageToCurrentShape = this.editor.getShapePageTransform(shape.id).clone().invert()
const neighborGlobs: Set = getNeighborGlobs(this.editor, shape)
// infinite lines going from direction of d handles to tangent points
// for neighboring globs of the selected glob
const snapEdges: { start: VecModel; end: VecModel; d: VecModel }[] = []
for (const neighborGlob of neighborGlobs) {
const transform = Mat.Compose(
pageToCurrentShape,
this.editor.getShapePageTransform(neighborGlob.id)
)
const neighborGeo = getGlobInfo(this.editor, neighborGlob)
if (!neighborGeo) continue
for (const edge of ['edgeA', 'edgeB'] as const) {
for (const tangent of ['tangentA', 'tangentB'] as const) {
const dLocal = neighborGlob.props.edges[edge].d
const tangentLocal = neighborGeo[edge][tangent]
const direction = Vec.Sub(tangentLocal, dLocal).uni()
const infiniteEndPoint = Vec.Add(dLocal, Vec.Mul(direction, INFINITE_LENGTH))
const p1Transformed = transform.applyToPoint(dLocal)
const p2Transformed = transform.applyToPoint(infiniteEndPoint)
snapEdges.push({
start: p1Transformed,
end: p2Transformed,
d: p1Transformed,
})
}
}
}
let nearestPoint: Vec | null = null
const endPoints: VecModel[] = []
let minDistance = this.editor.snaps.getSnapThreshold()
for (const { start, end, d } of snapEdges) {
const snapPoint = Vec.NearestPointOnLineSegment(start, end, handle, true)
const distance = Vec.Dist(handle, snapPoint)
if (round(distance) <= round(minDistance)) {
if (round(distance) < round(minDistance)) {
endPoints.length = 0
minDistance = distance
nearestPoint = snapPoint
}
endPoints.push(d)
}
}
const globInfo = getGlobInfo(this.editor, shape)
if (!globInfo) return null
const { edgeType } = getHandleData(handle.id)
const outerTangentPoints = getOuterTangentPoints(
globInfo.startNode.position,
globInfo.startNode.radius,
globInfo.endNode.position,
globInfo.endNode.radius,
edgeType
)
// mid point, outer tangent edge, perp to that edge
const midD = Vec.Lrp(outerTangentPoints[0], outerTangentPoints[1], 0.5)
const outerLine = Vec.Sub(outerTangentPoints[0], outerTangentPoints[1]).uni()
// infinite line of the perpendicular outer line
const perpendicularOuterLine = Vec.Per(outerLine).uni()
const normalDStart = Vec.Add(midD, Vec.Mul(perpendicularOuterLine, -INFINITE_LENGTH))
const normalDEnd = Vec.Add(midD, Vec.Mul(perpendicularOuterLine, INFINITE_LENGTH))
// check snap to the outer line
let snappedToOuterLine = false
const outerLineSnapPoint = Vec.NearestPointOnLineSegment(
outerTangentPoints[0],
outerTangentPoints[1],
handle,
false
)
const outerLineDistance = Vec.Dist(handle, outerLineSnapPoint)
if (round(outerLineDistance) <= round(minDistance)) {
if (round(outerLineDistance) < round(minDistance)) {
minDistance = outerLineDistance
nearestPoint = outerLineSnapPoint
}
snappedToOuterLine = true
}
// check snap to the perpendicular line
let snappedToPerpLine = false
const perpLineSnapPoint = Vec.NearestPointOnLineSegment(normalDStart, normalDEnd, handle, true)
const perpLineDistance = Vec.Dist(handle, perpLineSnapPoint)
if (round(perpLineDistance) <= round(minDistance)) {
if (round(perpLineDistance) < round(minDistance)) {
minDistance = perpLineDistance
nearestPoint = perpLineSnapPoint
}
snappedToPerpLine = true
}
// if both outer line and perp line are snapped, snap to their intersection (midD)
if (snappedToOuterLine && snappedToPerpLine) {
nearestPoint = midD
}
// if no snap found, return null
if (!nearestPoint) return null
// transform to page space and calculate final snapped handle position
const getShapePageTransform = this.editor.getShapePageTransform(shape.id)
const handleInPageSpace = getShapePageTransform.applyToPoint(handle)
const nearestPointInPageSpace = getShapePageTransform.applyToPoint(nearestPoint)
const snappedHandle = Vec.Add(
handleInPageSpace,
Vec.Sub(nearestPointInPageSpace, handleInPageSpace)
)
const indicators: PointsSnapIndicator[] = endPoints.map((endPoint) => ({
id: uniqueId(),
type: 'points',
points: [getShapePageTransform.applyToPoint(endPoint), snappedHandle],
}))
if (snappedToOuterLine) {
indicators.push({
id: uniqueId(),
type: 'points',
points: [
getShapePageTransform.applyToPoint(outerTangentPoints[0]),
getShapePageTransform.applyToPoint(outerTangentPoints[1]),
],
})
}
if (snappedToPerpLine) {
indicators.push({
id: uniqueId(),
type: 'points',
points: [snappedHandle, getShapePageTransform.applyToPoint(midD)],
})
}
return {
nudge: Vec.Sub(nearestPoint, handle),
indicators,
}
}
}
function buildGlobPath(globPoints: GlobGeometry, geometry: boolean = false) {
const pathBuilder = new PathBuilder()
pathBuilder.moveTo(globPoints.edgeA.tangentA.x, globPoints.edgeA.tangentA.y, {
geometry: { isFilled: true },
})
const arcFlagA = getArcFlag(
globPoints.startNode.position,
globPoints.edgeA.tangentA,
globPoints.edgeB.tangentA
)
const arcFlagB = getArcFlag(
globPoints.endNode.position,
globPoints.edgeB.tangentB,
globPoints.edgeA.tangentB
)
pathBuilder.circularArcTo(
globPoints.startNode.radius,
geometry ? !arcFlagA : arcFlagA,
geometry ? false : true,
globPoints.edgeB.tangentA.x,
globPoints.edgeB.tangentA.y
)
pathBuilder.cubicBezierTo(
globPoints.edgeB.tangentB.x,
globPoints.edgeB.tangentB.y,
globPoints.edgeB.tensionA.x,
globPoints.edgeB.tensionA.y,
globPoints.edgeB.tensionB.x,
globPoints.edgeB.tensionB.y
)
pathBuilder.circularArcTo(
globPoints.endNode.radius,
geometry ? !arcFlagB : arcFlagB,
geometry ? false : true,
globPoints.edgeA.tangentB.x,
globPoints.edgeA.tangentB.y
)
pathBuilder.cubicBezierTo(
globPoints.edgeA.tangentA.x,
globPoints.edgeA.tangentA.y,
globPoints.edgeA.tensionB.x,
globPoints.edgeA.tensionB.y,
globPoints.edgeA.tensionA.x,
globPoints.edgeA.tensionA.y
)
pathBuilder.close()
return pathBuilder
}
export const GlobShape = track(function GlobShape({
shape,
showControlLines,
}: {
shape: GlobShape
showControlLines: boolean
}) {
const editor = useEditor()
const zoomLevel = editor.getZoomLevel()
// Use reactive inputs to track if space key is pressed
const fillGlob = useValue('space key pressed', () => editor.inputs.keys.has('Space'), [editor])
const globPoints = getGlobInfo(editor, shape)
if (!globPoints) return null
const pathBuilder = buildGlobPath(globPoints)
if (!pathBuilder) return null
const dxA = toDomPrecision(shape.props.edges.edgeA.d.x)
const dyA = toDomPrecision(shape.props.edges.edgeA.d.y)
const dxB = toDomPrecision(shape.props.edges.edgeB.d.x)
const dyB = toDomPrecision(shape.props.edges.edgeB.d.y)
const txAA = toDomPrecision(globPoints.edgeA.tangentA.x)
const tyAA = toDomPrecision(globPoints.edgeA.tangentA.y)
const txAB = toDomPrecision(globPoints.edgeA.tangentB.x)
const tyAB = toDomPrecision(globPoints.edgeA.tangentB.y)
const txBA = toDomPrecision(globPoints.edgeB.tangentA.x)
const tyBA = toDomPrecision(globPoints.edgeB.tangentA.y)
const txBB = toDomPrecision(globPoints.edgeB.tangentB.x)
const tyBB = toDomPrecision(globPoints.edgeB.tangentB.y)
return (
{showControlLines && (
<>
>
)}
)
})
export function ControlLine({
x1,
y1,
x2,
y2,
}: {
x1: number
y1: number
x2: number
y2: number
}) {
const editor = useEditor()
const zoomLevel = editor.getZoomLevel()
const dashArray = `${3 / zoomLevel} ${3 / zoomLevel}`
return (
)
}
const getHandleData = (id: string) => {
const [edgeType, point] = id.split('.') as [EdgeCurveType, keyof EdgeGeometry | keyof EdgeProps]
return { edgeType, point }
}
export const getNeighborGlobs = (editor: Editor, shape: GlobShape) => {
const currentGlobBindings = editor.getBindingsFromShape(shape.id, 'glob')
const neighborGlobs: Set = new Set()
// try find nodes that attach to other globs
for (const binding of currentGlobBindings) {
const nodeBindings = editor.getBindingsToShape(binding.toId, 'glob')
for (const nodeBinding of nodeBindings) {
const neighborGlob = editor.getShape(nodeBinding.fromId)
// if this is the glob we selecting or a glob we've already added, skip
if (!neighborGlob || neighborGlob.id === shape.id) continue
if (neighborGlobs.has(neighborGlob)) continue
neighborGlobs.add(neighborGlob)
}
}
return neighborGlobs
}
```
## NodeShapeUtil.tsx
```tsx
import {
Circle2d,
Editor,
getColorValue,
getIndicesAbove,
RecordProps,
ShapeUtil,
SVGContainer,
T,
TLHandle,
TLHandleDragInfo,
TLResizeInfo,
TLShape,
useDefaultColorTheme,
useValue,
Vec,
ZERO_INDEX_KEY,
} from 'tldraw'
export interface NodeProps {
opacity: number
radius: number
}
const NODE_TYPE = 'node'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[NODE_TYPE]: NodeProps
}
}
export type NodeShape = TLShape<'node'>
export class NodeShapeUtil extends ShapeUtil {
static override type = 'node' as const
static override props: RecordProps = {
opacity: T.number,
radius: T.number,
}
override getDefaultProps(): NodeShape['props'] {
return {
opacity: 1,
radius: 50,
}
}
override hideResizeHandles(_shape: NodeShape): boolean {
return true
}
override hideRotateHandle(_shape: NodeShape): boolean {
return true
}
override hideSelectionBoundsBg(_shape: NodeShape): boolean {
return true
}
override hideSelectionBoundsFg(_shape: NodeShape): boolean {
return true
}
override getGeometry(shape: NodeShape) {
return new Circle2d({
x: -shape.props.radius,
y: -shape.props.radius,
radius: shape.props.radius,
isFilled: true,
})
}
override onResize(_shape: NodeShape, info: TLResizeInfo) {
const { scaleX, scaleY, initialShape } = info
const avgScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2
return {
props: {
radius: initialShape.props.radius * avgScale,
},
}
}
override getHandles(shape: NodeShape): TLHandle[] {
const east = Vec.Uni({ x: -1, y: 0 }).mul(shape.props.radius)
const west = Vec.Uni({ x: 1, y: 0 }).mul(shape.props.radius)
const indices = getIndicesAbove(ZERO_INDEX_KEY, 2)
return [
{
id: 'east',
type: 'vertex',
index: indices[0],
x: east.x,
y: east.y,
},
{
id: 'west',
type: 'vertex',
index: indices[1],
x: west.x,
y: west.y,
},
]
}
override onHandleDrag(shape: NodeShape, info: TLHandleDragInfo) {
const { handle, initial } = info
if (!initial) return shape
const mag = Vec.Len(handle) - initial.props.radius
return {
...shape,
props: {
...shape.props,
radius: initial.props.radius + mag,
},
}
}
override indicator(shape: NodeShape) {
const zoom = this.editor.getZoomLevel()
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
const blue = getColorValue(theme, 'blue', 'solid')
return
}
override component(shape: NodeShape) {
const isSingleNode = this.editor.getBindingsToShape(shape.id, 'glob').length === 0
return
}
}
function NodeComponent({
shape,
editor,
isSingleNode,
}: {
shape: NodeShape
editor: Editor
isSingleNode: boolean
}) {
const zoom = useValue('zoom', () => editor.getZoomLevel(), [editor])
const { radius } = shape.props
const dashArray = `${3 / zoom} ${3 / zoom}`
// Use reactive inputs to track if space key is pressed
const isSpacePressed = useValue('space key pressed', () => editor.inputs.keys.has('Space'), [
editor,
])
const fillNode = isSingleNode && isSpacePressed
if (!isSingleNode && isSpacePressed) return null
return (
)
}
```
## shared.ts
```ts
import { Editor, TLShapeId, Vec, VecLike } from 'tldraw'
import { GlobBinding } from './GlobBindingUtil'
import { GlobShape } from './GlobShapeUtil'
import { NodeShape } from './NodeShapeUtil'
import { getOuterTangentPoints } from './utils'
export const getStartAndEndNodes = (editor: Editor, glob: TLShapeId) => {
const bindings = editor.getBindingsFromShape(glob, 'glob')
if (!bindings.length) return null
const startNode = bindings.find((b) => b.props.terminal === 'start')
if (!startNode) return null
const startNodeShape = editor.getShape(startNode.toId)
if (!startNodeShape) return null
const endNode = bindings.find((b) => b.props.terminal === 'end')
if (!endNode) return null
const endNodeShape = editor.getShape(endNode.toId)
if (!endNodeShape) return null
return { startNodeShape, endNodeShape }
}
export interface GlobBindings {
start?: GlobBinding
end?: GlobBinding
}
export function getGlobBindings(editor: Editor, shape: GlobShape): GlobBindings {
const bindings = editor.getBindingsFromShape(shape.id, 'glob')
const start = bindings.find((b) => b.props.terminal === 'start')
const end = bindings.find((b) => b.props.terminal === 'end')
return { start, end }
}
export const getGlobTangentUpdate = (
editor: Editor,
globId: TLShapeId,
startNodePagePos: VecLike,
startRadius: number,
endNodePagePos: VecLike,
endRadius: number
) => {
// Calculate midpoint in page space
const midPagePos = Vec.Average([startNodePagePos, endNodePagePos])
// Get the glob shape to determine its parent
const glob = editor.getShape(globId)
if (!glob) return null
// Convert midpoint to glob's parent space
const globParentTransform = editor.getShapeParentTransform(glob)
const midInGlobParentSpace = globParentTransform.clone().invert().applyToPoint(midPagePos)
// Calculate local node positions relative to midpoint in page space
const localStartNode = Vec.Sub(startNodePagePos, midPagePos)
const localEndNode = Vec.Sub(endNodePagePos, midPagePos)
const tangentPoints = getOuterTangentPoints(localStartNode, startRadius, localEndNode, endRadius)
const d0 = Vec.Lrp(tangentPoints[0], tangentPoints[1], 0.5)
const d1 = Vec.Lrp(tangentPoints[2], tangentPoints[3], 0.5)
return {
id: globId,
type: 'glob' as const,
x: midInGlobParentSpace.x,
y: midInGlobParentSpace.y,
props: {
edges: {
edgeA: { d: { x: d0.x, y: d0.y }, tensionRatioA: 0.5, tensionRatioB: 0.5 },
edgeB: { d: { x: d1.x, y: d1.y }, tensionRatioA: 0.5, tensionRatioB: 0.5 },
},
},
}
}
```
## utils.ts
```ts
import { Vec, VecLike, VecModel } from 'tldraw'
export const getOuterTangentPoints = (
c0: VecLike,
r0: number,
c1: VecLike,
r1: number,
side?: 'edgeA' | 'edgeB'
): VecModel[] => {
const offsetAngle = Vec.Angle(c0, c1)
const d = Vec.Dist(c0, c1)
const theta = Math.acos((r0 - r1) / d)
const angle0 = offsetAngle + theta
const angle1 = offsetAngle - theta
const t00 = Vec.Add(c0, Vec.FromAngle(angle0).mul(r0))
const t01 = Vec.Add(c1, Vec.FromAngle(angle0).mul(r1))
const t10 = Vec.Add(c0, Vec.FromAngle(angle1).mul(r0))
const t11 = Vec.Add(c1, Vec.FromAngle(angle1).mul(r1))
if (side) {
if (side === 'edgeA') {
return [
{ x: t00.x, y: t00.y },
{ x: t01.x, y: t01.y },
]
}
return [
{ x: t10.x, y: t10.y },
{ x: t11.x, y: t11.y },
]
}
return [
{ x: t00.x, y: t00.y },
{ x: t01.x, y: t01.y },
{ x: t10.x, y: t10.y },
{ x: t11.x, y: t11.y },
]
}
export const getGlobEndPoint = (c: VecModel, d: VecModel, r: number, side: number = 0) => {
const angle = Vec.Angle(c, d)
const displacement = Vec.Sub(c, d)
const dist = Vec.Len(displacement)
// we are inside the circle no solutions, so return null
if (dist <= r) {
return
}
const theta = Math.acos(r / dist)
const sideTheta = side === 0 ? theta : -theta
const p = Vec.Add(c, Vec.FromAngle(angle + sideTheta).mul(r))
return p
}
export const getArcFlag = (c: VecModel, e0: VecModel, e1: VecModel) => {
const d0 = Vec.Angle(c, e0)
const d1 = Vec.Angle(c, e1)
const diff = d1 - d0
const theta = Math.atan2(Math.sin(diff), Math.cos(diff))
return theta > 0 ? false : true
}
export const projectTensionPoint = (lineStart: VecModel, lineEnd: VecModel, handle: VecModel) => {
const lineDir = Vec.Sub(lineEnd, lineStart)
const lineLength = lineDir.len()
const toHandle = Vec.Sub(handle, lineStart)
const projection = Vec.Dpr(toHandle, Vec.Uni(lineDir))
const clampedProjection = Math.max(0, Math.min(lineLength, projection))
return clampedProjection / lineLength
}
export const circleCentresOverlap = (c0: VecModel, r0: number, c1: VecModel) => {
const d = Vec.Dist(c0, c1)
return d <= r0
}
export const getClosestPointOnCircle = (c: VecModel, r: number, p: VecModel) => {
const pDirection = Vec.Sub(p, c).uni()
return Vec.Add(c, Vec.Mul(pDirection, r + 1)).toJson()
}
```
## GlobTool.tsx
```tsx
import {
createShapeId,
snapAngle,
StateNode,
TLBindingUpdate,
TLParentId,
TLPointerEventInfo,
TLShapeId,
Vec,
} from 'tldraw'
import { GlobBinding } from '../GlobBindingUtil'
import { GlobShape } from '../GlobShapeUtil'
import { NodeShape } from '../NodeShapeUtil'
import { getGlobTangentUpdate, getStartAndEndNodes } from '../shared'
export class GlobTool extends StateNode {
static override id = 'glob'
static override initial = 'idle'
static override children() {
return [IdleState, NodeState, ConnectState]
}
}
export class IdleState extends StateNode {
static override id = 'idle'
override onCancel() {
this.editor.setCurrentTool('select')
}
}
export class NodeState extends StateNode {
static override id = 'node'
private ghostShapeId: TLShapeId | null = null
override onEnter() {
this.editor.setCursor({ type: 'cross' })
}
override onPointerMove(_info: TLPointerEventInfo) {
const pagePoint = this.editor.inputs.getCurrentPagePoint()
if (!this.ghostShapeId) {
const id = createShapeId()
this.editor.createShape({
id: id,
type: 'node',
x: pagePoint.x,
y: pagePoint.y,
props: {
radius: 50,
opacity: 0.25,
},
})
this.ghostShapeId = id
return
}
const shape = this.editor.getShape(this.ghostShapeId)
if (!shape) return
// could be inside a frame, so we need to get the point in the parent space
const parentPoint = this.editor.getPointInParentSpace(shape, pagePoint)
this.editor.updateShape({
...shape,
x: parentPoint.x,
y: parentPoint.y,
})
}
override onPointerDown(_info: TLPointerEventInfo) {
if (!this.ghostShapeId) return
const node = this.editor.getShape(this.ghostShapeId)!
if (!node) return
// if we try place another node such that it overlaps with an existing node radii, don't allow it
const pagePoint = this.editor.inputs.getCurrentPagePoint()
const shapes = this.editor
.getShapesAtPoint(pagePoint, {
hitInside: true,
})
.filter(
(shape) =>
shape.id !== this.ghostShapeId && this.editor.isShapeOfType(shape, 'node')
)
if (shapes.length) {
return
}
const parentPoint = this.editor.getPointInParentSpace(node, pagePoint)
this.editor.updateShape({
...node,
x: parentPoint.x,
y: parentPoint.y,
props: {
...node.props,
opacity: 1,
},
})
this.ghostShapeId = null
this.complete()
}
override onCancel() {
this.complete()
}
private complete() {
if (this.ghostShapeId) {
this.editor.deleteShapes([this.ghostShapeId])
this.ghostShapeId = null
}
this.editor.setCurrentTool('select')
}
}
export class ConnectState extends StateNode {
static override id = 'connect'
selectedNodeIds: TLShapeId[] = []
ghostGlobIds: TLShapeId[] = []
ghostNodeId: TLShapeId | null = null
override onEnter() {
this.selectedNodeIds = this.editor.getSelectedShapeIds()
this.editor.setCursor({ type: 'cross' })
}
override onPointerMove(info: TLPointerEventInfo) {
// selected nodes go to the ghost node
const selectedNode = this.editor.getShape(this.selectedNodeIds[0])
if (!selectedNode) return
// Apply shift snapping for angle constraints
let pagePoint = this.editor.inputs.getCurrentPagePoint()
if (info.shiftKey) {
const selectedNodeCenter = new Vec(selectedNode.x, selectedNode.y)
const angle = Vec.Angle(selectedNodeCenter, pagePoint)
const snappedAngle = snapAngle(angle, 24)
const distance = Vec.Dist(selectedNodeCenter, pagePoint)
pagePoint = Vec.FromAngle(snappedAngle, distance).add(selectedNodeCenter)
}
// if there is no ghost node, create one
if (!this.ghostNodeId) {
const id = createShapeId()
this.editor.createShape({
id: id,
type: 'node',
x: pagePoint.x,
y: pagePoint.y,
props: {
radius: selectedNode.props.radius,
},
})
this.ghostNodeId = id
}
const parentPoint = this.editor.getPointInParentSpace(this.ghostNodeId, pagePoint)
this.editor.updateShape({
id: this.ghostNodeId,
type: 'node',
x: parentPoint.x,
y: parentPoint.y,
})
// if there are no ghost globs, create them, we could be ghosting a whole selection of nodes
if (!this.ghostGlobIds.length) {
for (let i = 0; i < this.selectedNodeIds.length; i++) {
const id = createShapeId()
// Determine the proper parent for the glob based on the bound nodes
const startNode = this.editor.getShape(this.selectedNodeIds[i])
const endNode = this.editor.getShape(this.ghostNodeId)
let globParentId: TLParentId = this.editor.getCurrentPageId()
if (startNode && endNode) {
// Find common ancestor of the two nodes
const commonAncestor = this.editor.findCommonAncestor([startNode, endNode])
if (commonAncestor) {
globParentId = commonAncestor
}
}
this.editor.createShape({
id: id,
type: 'glob',
parentId: globParentId,
x: 0,
y: 0,
props: {
opacity: 0.25,
isGhosting: true,
},
})
const glob = this.editor.getShape(id)
if (!glob) continue
this.ghostGlobIds.push(id)
// bind each node to the glob
this.editor.createBindings([
{
fromId: id,
toId: this.selectedNodeIds[i],
type: 'glob',
props: {
terminal: 'start',
},
},
{
fromId: id,
toId: this.ghostNodeId,
type: 'glob',
props: {
terminal: 'end',
},
},
])
}
}
// update each ghost globs as we drag the ghost node around
for (let i = 0; i < this.ghostGlobIds.length; i++) {
const selectedNode = this.editor.getShape(this.selectedNodeIds[i])
if (!selectedNode) continue
const pageNode = this.editor.getShapePageTransform(this.selectedNodeIds[i]).point()
const update = getGlobTangentUpdate(
this.editor,
this.ghostGlobIds[i],
pageNode,
selectedNode.props.radius,
pagePoint,
selectedNode.props.radius
)
this.editor.updateShape(update)
}
}
override onPointerDown(info: TLPointerEventInfo) {
if (!this.ghostNodeId) return
const pagePoint = this.editor.screenToPage(info.point)
const shapes = this.editor.getShapesAtPoint(pagePoint, {
hitInside: true,
})
// find any existing node we may be trying to connect to
const nodes = shapes.filter(
(shape) =>
this.editor.isShapeOfType(shape, 'node') && shape.id !== this.ghostNodeId
)
// if we don't find a node, just place the ghost node and complete
if (!nodes.length) {
this.complete()
return
}
// we found a node, we need to update the bindings to connect to the existing node,
// recalculate the tangent points for the glob, with different radii as well as the bindings
const updates: TLBindingUpdate[] = []
const bindings = this.editor.getBindingsToShape(this.ghostNodeId, 'glob')
if (!bindings.length) {
this.complete()
return
}
// update all current bindings to connect to the existing node
for (const binding of bindings) {
updates.push({
...binding,
toId: nodes[0].id,
})
}
this.editor.updateBindings(updates)
// update the outer tangents because the radii may have changed
for (const glob of this.ghostGlobIds) {
const globShape = this.editor.getShape(glob)
if (!globShape) continue
const nodes = getStartAndEndNodes(this.editor, glob)
if (!nodes) continue
const startNodePagePos = this.editor.getShapePageTransform(nodes.startNodeShape.id).point()
const endNodePagePos = this.editor.getShapePageTransform(nodes.endNodeShape.id).point()
const update = getGlobTangentUpdate(
this.editor,
glob,
startNodePagePos,
nodes.startNodeShape.props.radius,
endNodePagePos,
nodes.endNodeShape.props.radius
)
this.editor.updateShape(update)
}
if (this.ghostNodeId) {
this.editor.deleteShape(this.ghostNodeId)
}
this.complete()
}
override onCancel() {
if (this.ghostGlobIds.length) {
this.editor.deleteShapes(this.ghostGlobIds)
this.ghostGlobIds = []
}
if (this.ghostNodeId) {
this.editor.deleteShapes([this.ghostNodeId])
this.ghostNodeId = null
}
this.editor.setCurrentTool('select')
}
override onComplete() {
this.complete()
}
private complete() {
// remove the ghosting from the globs
for (let i = 0; i < this.ghostGlobIds.length; i++) {
this.editor.updateShape({
id: this.ghostGlobIds[i],
type: 'glob',
props: { isGhosting: false },
})
}
this.ghostNodeId = null
this.editor.setSelectedShapes(this.ghostGlobIds)
this.selectedNodeIds = []
this.ghostGlobIds = []
this.editor.setCurrentTool('select')
}
}
```
--------
# Custom shape
Category: Shapes & tools
Keywords: util
A simple custom shape.
You can create custom shapes in tldraw by creating a shape util and passing it to the Tldraw component.
In this example, we'll create a custom shape that is a simple rectangle with some text inside of it.
## App.tsx
```tsx
import {
Geometry2d,
HTMLContainer,
RecordProps,
Rectangle2d,
ShapeUtil,
T,
TLResizeInfo,
TLShape,
Tldraw,
resizeBox,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
const MY_CUSTOM_SHAPE_TYPE = 'my-custom-shape'
// [1]
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_CUSTOM_SHAPE_TYPE]: { w: number; h: number; text: string }
}
}
// [2]
type ICustomShape = TLShape
// [3]
export class MyShapeUtil extends ShapeUtil {
// [a]
static override type = MY_CUSTOM_SHAPE_TYPE
static override props: RecordProps = {
w: T.number,
h: T.number,
text: T.string,
}
// [b]
getDefaultProps(): ICustomShape['props'] {
return {
w: 200,
h: 200,
text: "I'm a shape!",
}
}
// [c]
override canEdit() {
return false
}
override canResize() {
return true
}
override isAspectRatioLocked() {
return false
}
// [d]
getGeometry(shape: ICustomShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
// [e]
override onResize(shape: any, info: TLResizeInfo) {
return resizeBox(shape, info)
}
// [f]
component(shape: ICustomShape) {
return {shape.props.text}
}
// [g]
indicator(shape: ICustomShape) {
return
}
}
// [4]
const customShape = [MyShapeUtil]
export default function CustomShapeExample() {
return (
)
}
/*
Introduction:
You can create custom shapes in tldraw by creating a shape util and passing it to the Tldraw component.
In this example, we'll create a custom shape that is a simple rectangle with some text inside of it.
[1]
First, we need to extend TLGlobalShapePropsMap to add our shape's props to the global type system.
This tells TypeScript about the shape's properties. For this shape, we define width (w), height (h),
and text as the shape's properties.
[2]
Define the shape type using TLShape with the shape's type as a type argument.
[3]
This is our shape util. In tldraw shape utils are classes that define how a shape behaves and renders.
We can extend the ShapeUtil class and provide the shape type as a generic. If we extended the
BaseBoxShapeUtil class instead, we wouldn't have define methods such as `getGeometry` and `onResize`.
[a]
This is where we define out shape's props and type for the editor. It's important to use the same
string for the type as we did in [2]. We need to define the shape's props using tldraw's validator
library. The validator will help make sure the store always has shape data we can trust.
[b]
This is a method that returns the default props for our shape.
[c]
Some handy methods for controlling different shape behaviour. You don't have to define these, and
they're only shown here so you know they exist. Check out the editable shape example to learn more
about creating an editable shape.
[d]
The getGeometry method is what the editor uses for hit-testing, binding etc. We're using the
Rectangle2d class from tldraw's geometry library to create a rectangle shape. If we extended the
BaseBoxShapeUtil class, we wouldn't have to define this method.
[e]
We're using the resizeBox utility method to handle resizing our shape. If we extended the
BaseBoxShapeUtil class, we wouldn't have to define this method.
[f]
The component method defines how our shape renders. We're returning an HTMLContainer here, which
is a handy component that tldraw exports. It's essentially a div with some special css. There's a
lot of flexibility here, and you can use any React hooks you want and return any valid JSX.
[g]
The indicator is the blue outline around a selected shape. We're just returning a rectangle with the
same width and height as the shape here. You can return any valid JSX here.
[4]
This is where we render the Tldraw component with our custom shape. We're passing in our custom shape
util as an array to the shapeUtils prop. We're also using the onMount callback to create a shape on
the canvas. If you want to learn how to add a tool for your shape, check out the custom config example.
If you want to learn how to programmatically control the canvas, check out the Editor API examples.
*/
```
--------
# Custom tool (sticker)
Category: Shapes & tools
Keywords: state, machine, chart, node, sticker
A simple custom tool.
Tools are nodes in tldraw's state chart. They are responsible for handling user input.
You can create custom tools by extending the `StateNode` class and overriding its methods. In this example we make a very simple sticker tool that adds a heart emoji to the canvas when you click.
## App.tsx
```tsx
import { StateNode, Tldraw, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
const OFFSET = 12
// [1]
class StickerTool extends StateNode {
static override id = 'sticker'
// [a]
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
// [b]
override onPointerDown() {
const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
this.editor.createShape({
type: 'text',
x: currentPagePoint.x - OFFSET,
y: currentPagePoint.y - OFFSET,
props: { richText: toRichText('❤️') },
})
}
}
// [2]
const customTools = [StickerTool]
export default function CustomToolExample() {
return (
)
}
/*
Introduction:
Tools are nodes in tldraw's state machine. They are responsible for handling user input.
You can create custom tools by extending the `StateNode` class and overriding its methods.
In this example we make a very simple sticker tool that adds a heart emoji to the canvas
when you click.
[1]
We extend the `StateNode` class to create a new tool called `StickerTool`. We set its id
to "sticker". We are not implementing any child states in this example, so we don't need
to set an initial state or define any children states. To see an example of a custom tool
with child states, check out the screenshot tool or minimal examples.
[a] The onEnter method is called when the tool is activated. We use it to set the cursor
to a crosshair.
[b] The onPointerDown method is called when the user clicks on the canvas. We use it to
create a new shape at the click location. We can get the click location from the
editor's inputs.
[2]
We pass our custom tool to the Tldraw component using the `tools` prop. We also set the
initial state to our custom tool. We hide the ui and add some helpful text to the canvas
using the `onMount` prop. This is not necessary for the tool to work but it helps make the
example more visually clear.
*/
```
--------
# Custom tool (screenshot)
Category: Shapes & tools
Keywords: state chart, state machine, child states
A custom tool that takes a screenshot of the canvas.
Tools are the parts of tldraw's state chart. Most interactions in tldraw are tools.
This example shows how to create a custom tool that takes a screenshot of a specific area of the canvas.
## App.tsx
```tsx
import {
Box,
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiAssetUrlOverrides,
TLUiOverrides,
Tldraw,
TldrawUiMenuItem,
useEditor,
useIsToolSelected,
useTools,
useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'
import { ScreenshotTool } from './ScreenshotTool/ScreenshotTool'
import { ScreenshotDragging } from './ScreenshotTool/childStates/Dragging'
// There's a guide at the bottom of this file!
// [1]
const customTools = [ScreenshotTool]
// [2]
const customUiOverrides: TLUiOverrides = {
tools: (editor, tools) => {
return {
...tools,
screenshot: {
id: 'screenshot',
label: 'Screenshot',
icon: 'tool-screenshot',
kbd: 'j',
onSelect() {
editor.setCurrentTool('screenshot')
},
},
}
},
}
function CustomToolbar() {
const tools = useTools()
const isScreenshotSelected = useIsToolSelected(tools['screenshot'])
return (
)
}
// [3]
const customAssetUrls: TLUiAssetUrlOverrides = {
icons: {
'tool-screenshot': '/tool-screenshot.svg',
},
}
// [4]
function ScreenshotBox() {
const editor = useEditor()
const screenshotBrush = useValue(
'screenshot brush',
() => {
// Check whether the screenshot tool (and its dragging state) is active
if (editor.getPath() !== 'screenshot.dragging') return null
// Get screenshot.dragging state node
const draggingState = editor.getStateDescendant('screenshot.dragging')!
// Get the box from the screenshot.dragging state node
const box = draggingState.screenshotBox.get()
// The box is in "page space", i.e. panned and zoomed with the canvas, but we
// want to show it in front of the canvas, so we'll need to convert it to
// "page space", i.e. uneffected by scale, and relative to the tldraw
// page's top left corner.
const zoomLevel = editor.getZoomLevel()
const { x, y } = editor.pageToViewport({ x: box.x, y: box.y })
return new Box(x, y, box.w * zoomLevel, box.h * zoomLevel)
},
[editor]
)
if (!screenshotBrush) return null
return (
)
}
const customComponents: TLComponents = {
InFrontOfTheCanvas: ScreenshotBox,
Toolbar: CustomToolbar,
}
// [5]
export default function ScreenshotToolExample() {
return (
)
}
/*
Introduction:
This example shows how to create a custom tool. In tldraw, tools are parts of the
tldraw state chart. While the most common use for tools is creating shapes, you can
use tools to create other types of interactions too! In this example, we create a
"screenshot tool" that lets the user draw a box on the canvas. When the user finishes
drawing their box, we'll export (or copy) a screenshot of that area.
[1]
Our custom tool is a class that extends the StateNode class. See the ScreenshotTool
files for more about the too. We define an array (outside of any React component)
to hold the custom tools. We'll pass this into the Tldraw component's `tools` prop.
[2]
Here we make sure the UI knows about our new tool. We do this by adding it to the
`tools` object, which tells other parts of the UI a tool's label, icon, what should
happen when it's selected, etc. We'll pass our customUiOverrides object into the
Tldraw component's `overrides` prop.
[3]
Our toolbar item is using a custom icon, so we need to provide the asset url for it.
We do this by providing a custom assetUrls object to the Tldraw component.
This object is a map of icon ids to their urls. The icon ids are the same as the
icon prop on the toolbar item. We'll pass our assetUrls object into the Tldraw
component's `assetUrls` prop.
[4]
We want to show a box on the canvas when the screenshot tool is active. We do this
by providing an override to the InFrontOfTheCanvas component. This component will be shown
in front of the canvas but behind any other UI elements, such as menus and the toolbar.
We'll pass our components object into the Tldraw component's `components` prop.
[5]
Finally we pass all of our customizations into the Tldraw component. It's important
that the customizations are defined outside of the React component, otherwise they
will cause the Tldraw component to see them as new values on every render, which may
produce unexpected results.
*/
```
## ScreenshotTool.ts
```ts
import { StateNode } from 'tldraw'
import { ScreenshotDragging } from './childStates/Dragging'
import { ScreenshotIdle } from './childStates/Idle'
import { ScreenshotPointing } from './childStates/Pointing'
// There's a guide at the bottom of this file!
export class ScreenshotTool extends StateNode {
// [1]
static override id = 'screenshot'
static override initial = 'idle'
static override children() {
return [ScreenshotIdle, ScreenshotPointing, ScreenshotDragging]
}
// [2]
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onExit() {
this.editor.setCursor({ type: 'default', rotation: 0 })
}
// [3]
override onInterrupt() {
this.complete()
}
override onCancel() {
this.complete()
}
private complete() {
this.parent.transition('select', {})
}
}
/*
This file contains our screenshot tool. The tool is a StateNode with the `id` "screenshot".
[1]
It has three child state nodes, ScreenshotIdle, ScreenshotPointing, and ScreenshotDragging.
Its initial state is `idle`.
[2]
When the screenshot tool is entered, we set the cursor to a crosshair. When it is exited, we
set the cursor back to the default cursor.
[3]
When the screenshot tool is interrupted or cancelled, we transition back to the select tool.
*/
```
## Dragging.ts
```ts
import { Box, StateNode, atom, copyAs, exportAs } from 'tldraw'
// There's a guide at the bottom of this file!
export class ScreenshotDragging extends StateNode {
static override id = 'dragging'
// [1]
screenshotBox = atom('screenshot brush', new Box())
// [2]
override onEnter() {
this.update()
}
override onPointerMove() {
this.update()
}
override onKeyDown() {
this.update()
}
override onKeyUp() {
this.update()
}
private update() {
const inputs = this.editor.inputs
const shiftKey = inputs.getShiftKey()
const altKey = inputs.getAltKey()
const originPagePoint = inputs.getOriginPagePoint()
const currentPagePoint = inputs.getCurrentPagePoint()
const box = Box.FromPoints([originPagePoint, currentPagePoint])
if (shiftKey) {
if (box.w > box.h * (16 / 9)) {
box.h = box.w * (9 / 16)
} else {
box.w = box.h * (16 / 9)
}
if (currentPagePoint.x < originPagePoint.x) {
box.x = originPagePoint.x - box.w
}
if (currentPagePoint.y < originPagePoint.y) {
box.y = originPagePoint.y - box.h
}
}
if (altKey) {
box.w *= 2
box.h *= 2
box.x = originPagePoint.x - box.w / 2
box.y = originPagePoint.y - box.h / 2
}
this.screenshotBox.set(box)
}
// [3]
override onPointerUp() {
const { editor } = this
const box = this.screenshotBox.get()
// get all shapes contained by or intersecting the box
const shapes = editor.getCurrentPageShapes().filter((s) => {
const pageBounds = editor.getShapeMaskedPageBounds(s)
if (!pageBounds) return false
return box.includes(pageBounds)
})
if (shapes.length) {
if (editor.inputs.getCtrlKey()) {
// Copy the shapes to the clipboard
copyAs(
editor,
shapes.map((s) => s.id),
{ format: 'png', bounds: box }
)
} else {
// Export the shapes as a png
exportAs(
editor,
shapes.map((s) => s.id),
{
format: 'png',
name: 'Screenshot',
bounds: box,
}
)
}
}
this.editor.setCurrentTool('select')
}
// [4]
override onCancel() {
this.editor.setCurrentTool('select')
}
}
/*
[1]
This state has a reactive property (an Atom) called "screenshotBox". This is the box
that the user is drawing on the screen as they drag their pointer. We use an Atom here
so that our UI can subscribe to this property using `useValue` (see the ScreenshotBox
component in ScreenshotToolExample).
[2]
When the user enters this state, or when they move their pointer, we update the
screenshotBox property to be drawn between the place where the user started pointing
and the place where their pointer is now. If the user is holding Shift, then we modify
the dimensions of this box so that it is in a 16:9 aspect ratio.
[3]
When the user makes a pointer up and stops dragging, we export the shapes contained by
the screenshot box as a png. If the user is holding the ctrl key, we copy the shapes
to the clipboard instead.
[4]
When the user cancels (esc key) or makes a pointer up event, we transition back to the
select tool.
*/
```
## Idle.ts
```ts
import { StateNode } from 'tldraw'
// There's a guide at the bottom of this file!
export class ScreenshotIdle extends StateNode {
static override id = 'idle'
// [1]
override onPointerDown() {
this.parent.transition('pointing')
}
}
/*
[1]
When we the user makes a pointer down event, we transition to the pointing state.
*/
```
## Pointing.ts
```ts
import { StateNode } from 'tldraw'
// There's a guide at the bottom of this file!
export class ScreenshotPointing extends StateNode {
static override id = 'pointing'
// [1]
override onPointerMove() {
if (this.editor.inputs.getIsDragging()) {
this.parent.transition('dragging')
}
}
// [2]
override onPointerUp() {
this.complete()
}
override onCancel() {
this.complete()
}
private complete() {
this.parent.transition('idle')
}
}
/*
[1]
When the user makes a pointer move event, we check if they are dragging. If they are,
we transition to the dragging state. If they are not yet dragging, we stay in this state.
[2]
When the user cancelles or makes a pointer up event (while this state is still active,
so after the user has started pointing but before they've moved their pointer far enough
to start dragging), then we transition back to the idle state.
*/
```
--------
# Cubic bezier curve shape
Category: Shapes & tools
Keywords: shape, util, handles, geometry, pen
A custom shape with interactive bezier curve editing.
This example demonstrates how to create a cubic bezier curve shape with draggable control handles. It includes a custom pen tool for entering edit mode and shows how to customize handle behavior and snapping.
The shape features four handles (start, end, and two control points) that can be dragged to adjust the curve. Control points snap to the start and end positions, and moving the endpoints automatically shifts their associated control points to maintain smooth editing.
## App.tsx
```tsx
import {
createShapeId,
StateNode,
TLAnyShapeUtilConstructor,
Tldraw,
TLPointerEventInfo,
} from 'tldraw'
import { BezierCurveShapeUtil } from './CubicBezierShape'
import { CustomHandles } from './CustomHandles'
import { SneakyUndoRedoWhileEditing } from './SneakyUndoRedoWhileEditing'
const customShapes: TLAnyShapeUtilConstructor[] = [BezierCurveShapeUtil]
export default function BezierCurveShapeExample() {
return (
{
editor.user.updateUserPreferences({ isSnapMode: true })
const viewportPageBounds = editor.getViewportPageBounds()
const centerX = viewportPageBounds.center.x
const centerY = viewportPageBounds.center.y
const id = createShapeId()
editor.createShape({
id,
type: 'bezier-curve',
x: centerX - 200,
y: centerY - 150,
})
// Select and edit the shape on appear
editor.select(id)
editor.setEditingShape(id)
// [10]
// Get state nodes with proper type safety
const pointingHandleState = editor.getStateDescendant('select.pointing_handle')
const editingShapeState = editor.getStateDescendant('select.editing_shape')
if (!pointingHandleState) {
throw new Error('SelectTool pointing_handle state not found')
}
if (!editingShapeState) {
throw new Error('SelectTool editing_shape state not found')
}
// store original handlers with proper binding
const originalHandlers = {
pointingHandle: {
onPointerMove: pointingHandleState.onPointerMove?.bind(pointingHandleState),
onPointerUp: pointingHandleState.onPointerUp?.bind(pointingHandleState),
},
editingShape: {
onPointerDown: editingShapeState.onPointerDown?.bind(editingShapeState),
onPointerMove: editingShapeState.onPointerMove?.bind(editingShapeState),
},
}
// clicking on start or end point should not go to select.idle
pointingHandleState.onPointerUp = (info: TLPointerEventInfo & { target: 'handle' }) => {
if (!info.shape) return
if (
info.accelKey &&
editor.isShapeOfType(info.shape, 'bezier-curve') &&
info.target === 'handle'
) {
switch (info.handle.id) {
case 'cp1': {
editor.updateShape({
id: info.shape.id,
type: 'bezier-curve',
props: {
cp1: { x: info.shape.props.start.x, y: info.shape.props.start.y },
},
})
editor.setEditingShape(info.shape.id)
return
}
case 'cp2': {
editor.updateShape({
id: info.shape.id,
type: 'bezier-curve',
props: {
cp2: { x: info.shape.props.end.x, y: info.shape.props.end.y },
},
})
editor.setEditingShape(info.shape.id)
return
}
}
}
if (editor.isShapeOfType(info.shape, 'bezier-curve') && info.target === 'handle') {
editor.setEditingShape(info.shape.id)
return
}
originalHandlers.pointingHandle.onPointerUp?.(info)
}
// return to editing state after dragging a handle
pointingHandleState.onPointerMove = (info: TLPointerEventInfo) => {
if (!info.shape) return
if (editor.isShapeOfType(info.shape, 'bezier-curve')) {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('select.dragging_handle', {
...info,
onInteractionEnd: () => {
editor.setEditingShape(info.shape.id)
},
})
return
}
originalHandlers.pointingHandle.onPointerMove?.(info)
}
// allow translating in editing state
editingShapeState.onPointerMove = (info: TLPointerEventInfo) => {
if (editor.inputs.getIsDragging()) {
const editingShape = editor.getEditingShape()
if (editingShape && editor.isShapeOfType(editingShape, 'bezier-curve')) {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('select.translating', {
...info,
target: 'shape',
shape: editingShape,
onInteractionEnd: () => {
editor.setEditingShape(editingShape.id)
},
})
return
}
}
originalHandlers.editingShape.onPointerMove?.(info)
}
}}
>
{/* 11 */}
)
}
/*
Introduction:
This example demonstrates how to create a cubic bezier curve shape with interactive handles.
[9]
Use custom ControlHandles component to show handles for bezier curves when editing, translating, or
dragging handles.
[10]
Override state node methods to enable three custom interactions:
1. Meta + click on cp1/cp2 handles collapses them to their associated start/end points
2. After dragging any handle, stay in editing mode (instead of returning to select.idle)
3. Allow translating the curve while in editing mode by detecting drag and transitioning to select.translating
[11]
Add a sneaky undo/redo while editing. This is a hack to allow undo/redo while editing a shape.
It's not a perfect solution, but it's a workaround for the fact that tldraw doesn't support
undo/redo while editing a shape. Sometimes you gotta hack it.
These overrides maintain the editing context, allowing fluid adjustments without losing handle visibility.
*/
```
## CubicBezierShape.tsx
```tsx
import {
BoundsSnapGeometry,
CubicBezier2d,
Geometry2d,
getIndicesAbove,
HandleSnapGeometry,
HTMLContainer,
RecordProps,
ShapeUtil,
TLHandle,
TLHandleDragInfo,
TLResizeInfo,
TLShape,
Vec,
VecLike,
vecModelValidator,
ZERO_INDEX_KEY,
} from 'tldraw'
const BEZIER_CURVE_TYPE = 'bezier-curve'
// [1]
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[BEZIER_CURVE_TYPE]: { start: VecLike; cp1: VecLike; cp2: VecLike; end: VecLike }
}
}
// [2]
export type MyBezierCurveShape = TLShape
// [3]
export class BezierCurveShapeUtil extends ShapeUtil {
static override type = BEZIER_CURVE_TYPE
static override props: RecordProps = {
start: vecModelValidator,
cp1: vecModelValidator,
cp2: vecModelValidator,
end: vecModelValidator,
}
private isMetaKeyOnTranslateStart = false
private didHitCurveOnTranslateStart = false
override getDefaultProps(): MyBezierCurveShape['props'] {
return {
start: { x: 0, y: 0 },
cp1: { x: 0, y: 140 },
cp2: { x: 350, y: 300 },
end: { x: 400, y: 110 },
}
}
override canEdit(): boolean {
return true
}
// [4]
getGeometry(shape: MyBezierCurveShape): Geometry2d {
return new CubicBezier2d({
start: new Vec(shape.props.start.x, shape.props.start.y),
cp1: new Vec(shape.props.cp1.x, shape.props.cp1.y),
cp2: new Vec(shape.props.cp2.x, shape.props.cp2.y),
end: new Vec(shape.props.end.x, shape.props.end.y),
})
}
override hideSelectionBoundsBg(shape: MyBezierCurveShape): boolean {
return this.editor.getEditingShapeId() === shape.id
}
override hideSelectionBoundsFg(shape: MyBezierCurveShape): boolean {
return this.editor.getEditingShapeId() === shape.id
}
override hideResizeHandles(shape: MyBezierCurveShape): boolean {
return this.editor.getEditingShapeId() === shape.id
}
override onResize(shape: MyBezierCurveShape, info: TLResizeInfo) {
const { scaleX, scaleY } = info
return {
props: {
start: { x: shape.props.start.x * scaleX, y: shape.props.start.y * scaleY },
cp1: { x: shape.props.cp1.x * scaleX, y: shape.props.cp1.y * scaleY },
cp2: { x: shape.props.cp2.x * scaleX, y: shape.props.cp2.y * scaleY },
end: { x: shape.props.end.x * scaleX, y: shape.props.end.y * scaleY },
},
}
}
override getBoundsSnapGeometry(shape: MyBezierCurveShape): BoundsSnapGeometry {
return {
points: [shape.props.start, shape.props.end],
}
}
override toSvg(shape: MyBezierCurveShape) {
const path = this.getGeometry(shape).getSvgPathData(true)
return
}
// [5]
override getHandles(shape: MyBezierCurveShape): TLHandle[] {
const indices = [ZERO_INDEX_KEY, ...getIndicesAbove(ZERO_INDEX_KEY, 3)]
let handles: TLHandle[] = [
{
id: 'start',
type: 'vertex',
x: shape.props.start.x,
y: shape.props.start.y,
index: indices[0],
snapType: 'align',
},
{
id: 'cp1',
type: 'vertex',
x: shape.props.cp1.x,
y: shape.props.cp1.y,
index: indices[1],
snapType: 'align',
snapReferenceHandleId: 'start',
},
{
id: 'cp2',
type: 'vertex',
x: shape.props.cp2.x,
y: shape.props.cp2.y,
index: indices[2],
snapType: 'align',
},
{
id: 'end',
type: 'vertex',
x: shape.props.end.x,
y: shape.props.end.y,
index: indices[3],
snapType: 'align',
},
]
if (Vec.Equals(shape.props.cp1, shape.props.start)) {
handles = handles.filter((handle) => handle.id !== 'cp1')
}
if (Vec.Equals(shape.props.cp2, shape.props.end)) {
handles = handles.filter((handle) => handle.id !== 'cp2')
}
return handles
}
// [6]
override getHandleSnapGeometry(shape: MyBezierCurveShape): HandleSnapGeometry {
return {
points: [shape.props.start, shape.props.end],
getSelfSnapPoints: (handle) => {
if (handle.id === 'cp1' || handle.id === 'cp2') {
return [shape.props.start, shape.props.end]
}
return handle.id === 'end' ? [shape.props.start] : [shape.props.end]
},
}
}
// [7]
override onHandleDrag(shape: MyBezierCurveShape, info: TLHandleDragInfo) {
const { handle } = info
const { id, x, y } = handle
let props = {}
let newProps: any = {}
// if you hold command or control key whilst dragging over a start or end handle,
// move the associated control point to the new positions
if (this.editor.inputs.getMetaKey()) {
switch (id) {
case 'start': {
return {
...shape,
props: {
...shape.props,
cp1: { x, y },
},
}
}
case 'end': {
return {
...shape,
props: {
...shape.props,
cp2: { x, y },
},
}
}
}
}
// move the handles
switch (id) {
case 'start': {
const delta = Vec.Sub(handle, shape.props.start)
newProps = {
start: { x, y },
cp1: { x: shape.props.cp1.x + delta.x, y: shape.props.cp1.y + delta.y },
}
break
}
case 'end': {
const delta = Vec.Sub(handle, shape.props.end)
newProps = {
end: { x, y },
cp2: { x: shape.props.cp2.x + delta.x, y: shape.props.cp2.y + delta.y },
}
break
}
default: {
newProps = {
[id]: { x, y },
}
break
}
}
props = {
...shape.props,
...newProps,
}
return {
...shape,
props,
}
}
// [8]
override onTranslateStart(shape: MyBezierCurveShape) {
// only bend if we start translating with the command or control key pressed
// this avoids bending the curve midway through a translation where the user accidentally
// holds the command or control key
this.isMetaKeyOnTranslateStart = this.editor.inputs.getMetaKey()
// we should bend the curve if we hit the curve but not the start or end handles,
const handles = this.getHandles(shape)
const startAndEndHandles = handles.filter(
(handle) => handle.id === 'start' || handle.id === 'end'
)
if (!startAndEndHandles.length) return
const hitStartOrEndHandle = startAndEndHandles.some((handle) => {
const threshold = 8 / this.editor.getZoomLevel()
const pageTransform = this.editor.getShapePageTransform(shape)
const handleInPageSpace = pageTransform.applyToPoint(handle)
if (Vec.Dist(handleInPageSpace, this.editor.inputs.getCurrentPagePoint()) < threshold) {
return true
}
return false
})
const hitCurve = this.editor.isPointInShape(shape, this.editor.inputs.getCurrentPagePoint(), {
margin: 10 / this.editor.getZoomLevel(),
})
this.didHitCurveOnTranslateStart = hitCurve && !hitStartOrEndHandle
}
override onTranslate(initial: MyBezierCurveShape, current: MyBezierCurveShape) {
// bend the curve
if (this.isMetaKeyOnTranslateStart && this.didHitCurveOnTranslateStart) {
const delta = Vec.Sub(current, initial)
const offsetX = Math.round(delta.x)
const offsetY = Math.round(delta.y)
return {
...initial,
props: {
...initial.props,
cp1: { x: initial.props.cp1.x + offsetX, y: initial.props.cp1.y + offsetY },
cp2: { x: initial.props.cp2.x + offsetX, y: initial.props.cp2.y + offsetY },
},
}
}
return
}
// [9]
component(shape: MyBezierCurveShape) {
const path = this.getGeometry(shape).getSvgPathData(true)
const { start, end, cp1, cp2 } = shape.props
const zoomLevel = this.editor.getZoomLevel()
return (
)
}
indicator(shape: MyBezierCurveShape) {
const path = this.getGeometry(shape).getSvgPathData(true)
return
}
private shouldShowControlLines(shape: MyBezierCurveShape) {
const selectedShape = this.editor.getOnlySelectedShape() === shape
if (!selectedShape) return false
return this.editor.isInAny(
'select.editing_shape',
'select.pointing_handle',
'select.dragging_handle'
)
}
}
/*
This is our custom cubic bezier curve shape. A cubic bezier curve is defined by four points: start, end, and two control points (cp1, cp2).
[1]
First, we need to extend TLGlobalShapePropsMap to add our shape's props to the global type system.
This tells TypeScript about the shape's properties. For this shape, we define four points (start, cp1, cp2, end)
that define the curve.
[2]
Define the shape type using TLShape with the shape's type as a type argument.
[3]
The BezierCurveShapeUtil extends ShapeUtil to define all behavior for our custom shape. We specify
the static 'type' and 'props' with validators.
[4]
The getGeometry method returns a CubicBezier2d geometry used for hit-testing, bounds calculations,
and rendering.
[5]
Define four interactive handles: start, end, cp1, and cp2. Each has an id, type, position, and index.
Control point handles are hidden when they're at the same position as their associated endpoints (collapsed).
[6]
Custom handle snapping via getHandleSnapGeometry: control points (cp1, cp2) can snap to start/end points.
The snap system automatically handles screen-space thresholds (consistent across zoom levels) and visual
snap indicators. When a control point is snapped to an endpoint, it effectively "collapses" the curve at
that end, creating a sharp corner.
[7]
Handle drag behaviors:
- Meta key + drag start/end handles repositions the associated control point (cp1 or cp2)
- Dragging start/end handles moves the associated control point to maintain curve shape
- Dragging cp1/cp2 directly moves only that control point
[8]
Translation with curve bending: Hold meta key while dragging the curve (not handles) to bend it
by moving both control points together. This is detected on translate start to avoid accidental bending.
[9]
Visual feedback: Display dashed lines from start→cp1 and end→cp2 when the shape is selected
and actively being edited or translated.
*/
```
## CustomHandles.tsx
```tsx
import { TLHandlesProps, useEditor, useValue } from 'tldraw'
export function CustomHandles({ children }: TLHandlesProps) {
const editor = useEditor()
const shouldDisplayDefaultHandles = useValue(
'shouldDisplayDefaultHandles',
() => {
// bezier curve handles
const onlySelectedShape = editor.getOnlySelectedShape()
if (onlySelectedShape && editor.isShapeOfType(onlySelectedShape, 'bezier-curve')) {
return editor.isInAny(
'select.editing_shape',
'select.pointing_handle',
'select.dragging_handle'
)
}
// default handle behavior
if (editor.isInAny('select.idle', 'select.pointing_handle', 'select.pointing_shape')) {
return true
}
if (editor.isInAny('select.editing_shape')) {
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape) return false
return onlySelectedShape && editor.isShapeOfType(onlySelectedShape, 'note')
}
return false
},
[editor]
)
if (!shouldDisplayDefaultHandles) return null
return (
)
}
```
## SneakyUndoRedoWhileEditing.tsx
```tsx
import { useEffect } from 'react'
import { useEditor } from 'tldraw'
export function SneakyUndoRedoWhileEditing() {
const editor = useEditor()
useEffect(() => {
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
const editingShape = editor.getEditingShape()
if (!editingShape) return
if (e.shiftKey) {
editor.redo()
editor.setEditingShape(editingShape)
} else {
editor.undo()
editor.setEditingShape(editingShape)
}
}
}
window.addEventListener('keydown', handleKeydown)
return () => {
window.removeEventListener('keydown', handleKeydown)
}
}, [editor])
return null
}
```
--------
# Custom shape and tool
Category: Shapes & tools
Keywords: toolbar, migrations, icon, util, ui overrides, card shape
A custom shape and tool.
This example shows how to define a custom shape, as well as a custom tool that can be used to create that shape.
In this case, the card tool (select ⚫️ in the toolbar) can be used to create a card shape.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { CardShapeTool } from './CardShape/CardShapeTool'
import { CardShapeUtil } from './CardShape/CardShapeUtil'
import { components, uiOverrides } from './ui-overrides'
// There's a guide at the bottom of this file!
// [1]
const customShapeUtils = [CardShapeUtil]
const customTools = [CardShapeTool]
// [2]
export default function CustomConfigExample() {
return (
)
}
/*
Introduction:
This example shows how to create a custom shape, and add your own icon for it to the toolbar.
Check out CardShapeUtil.tsx and CardShapeTool.tsx to see how we define the shape util and tool.
Check out ui-overrides.ts for more info on how to add your icon to the toolbar.
[1]
We define an array to hold the custom shape util and custom tool. It's important to do this outside of
any React component so that this array doesn't get redefined on every render.
[2]
Now we'll pass these arrays into the Tldraw component's props, along with our ui overrides.
*/
```
## ui-overrides.tsx
```tsx
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
useIsToolSelected,
useTools,
} from 'tldraw'
// There's a guide at the bottom of this file!
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.card = {
id: 'card',
icon: 'color',
label: 'Card',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('card')
},
}
return tools
},
}
export const components: TLComponents = {
Toolbar: (props) => {
const tools = useTools()
const isCardSelected = useIsToolSelected(tools['card'])
return (
)
},
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
)
},
}
/*
This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools to
the toolbar and the keyboard shortcuts menu.
First we have to add our new tool to the tools object in the tools override. This is where we define
all the basic information about our new tool - its icon, label, keyboard shortcut, what happens when
we select it, etc.
Then, we replace the UI components for the toolbar and keyboard shortcut dialog with our own, that
add our new tool to the existing default content. Ideally, we'd interleave our new tool into the
ideal place among the default tools, but for now we're just adding it at the start to keep things
simple.
*/
```
## CardShapeTool.tsx
```tsx
import { BaseBoxShapeTool, TLClickEventInfo } from 'tldraw'
export class CardShapeTool extends BaseBoxShapeTool {
static override id = 'card'
static override initial = 'idle'
override shapeType = 'card' as const
override onDoubleClick(_info: TLClickEventInfo) {
// you can handle events in handlers like this one;
// check the BaseBoxShapeTool source as an example
}
}
/*
This file contains our custom tool. The tool is a StateNode with the `id` "card".
We get a lot of functionality for free by extending the BaseBoxShapeTool. but we can
handle events in out own way by overriding methods like onDoubleClick. For an example
of a tool with more custom functionality, check out the screenshot-tool example.
*/
```
## CardShapeUtil.tsx
```tsx
import { useState } from 'react'
import {
HTMLContainer,
Rectangle2d,
ShapeUtil,
TLDefaultColorStyle,
TLResizeInfo,
TLShape,
getColorValue,
getDefaultColorTheme,
resizeBox,
} from 'tldraw'
import { cardShapeMigrations } from './card-shape-migrations'
import { cardShapeProps } from './card-shape-props'
const CARD_TYPE = 'card'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[CARD_TYPE]: { w: number; h: number; color: TLDefaultColorStyle }
}
}
// A type for our custom card shape
export type ICardShape = TLShape
// There's a guide at the bottom of this file!
export class CardShapeUtil extends ShapeUtil {
static override type = CARD_TYPE
// [1]
static override props = cardShapeProps
// [2]
static override migrations = cardShapeMigrations
// [3]
override isAspectRatioLocked(_shape: ICardShape) {
return false
}
override canResize(_shape: ICardShape) {
return true
}
// [4]
getDefaultProps(): ICardShape['props'] {
return {
w: 300,
h: 300,
color: 'black',
}
}
// [5]
getGeometry(shape: ICardShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
// [6]
component(shape: ICardShape) {
const bounds = this.editor.getShapeGeometry(shape).bounds
const theme = getDefaultColorTheme({ isDarkMode: this.editor.user.getIsDarkMode() })
//[a]
// eslint-disable-next-line react-hooks/rules-of-hooks
const [count, setCount] = useState(0)
return (
Clicks: {count}
)
}
// [7]
indicator(shape: ICardShape) {
return
}
// [8]
override onResize(shape: ICardShape, info: TLResizeInfo) {
return resizeBox(shape, info)
}
}
/*
A utility class for the card shape. This is where you define the shape's behavior,
how it renders (its component and indicator), and how it handles different events.
[1]
A validation schema for the shape's props (optional)
Check out card-shape-props.ts for more info.
[2]
Migrations for upgrading shapes (optional)
Check out card-shape-migrations.ts for more info.
[3]
Letting the editor know if the shape's aspect ratio is locked, and whether it
can be resized or bound to other shapes.
[4]
The default props the shape will be rendered with when click-creating one.
[5]
We use this to calculate the shape's geometry for hit-testing, bindings and
doing other geometric calculations.
[6]
Render method — the React component that will be rendered for the shape. It takes the
shape as an argument. HTMLContainer is just a div that's being used to wrap our text
and button. We can get the shape's bounds using our own getGeometry method.
- [a] Check it out! We can do normal React stuff here like using setState.
Annoying: eslint sometimes thinks this is a class component, but it's not.
- [b] You need to stop the pointer down event on buttons, otherwise the editor will
think you're trying to select drag the shape.
[7]
Indicator — used when hovering over a shape or when it's selected; must return only SVG elements here
[8]
Resize handler — called when the shape is resized. Sometimes you'll want to do some
custom logic here, but for our purposes, this is fine.
*/
```
## card-shape-migrations.ts
```ts
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from 'tldraw'
const versions = createShapePropsMigrationIds(
// this must match the shape type in the shape definition
'card',
{
AddSomeProperty: 1,
}
)
// Migrations for the custom card shape (optional but very helpful)
export const cardShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: versions.AddSomeProperty,
up(props) {
// it is safe to mutate the props object here
props.someProperty = 'some value'
},
down(props) {
delete props.someProperty
},
},
],
})
```
## card-shape-props.ts
```ts
import { DefaultColorStyle, RecordProps, T } from 'tldraw'
import { ICardShape } from './CardShapeUtil'
// Validation for our custom card shape's props, using one of tldraw's default styles
export const cardShapeProps: RecordProps = {
w: T.number,
h: T.number,
color: DefaultColorStyle,
}
// To generate your own custom styles, check out the custom styles example.
```
--------
# Custom shape with custom styles
Category: Shapes & tools
Keywords: style panel, rating
Use the custom styles API with your custom shapes.
This example shows how to create your own styles and use them in your own shapes.
To use tldraw's existing styles with your shapes, check the [tldraw styles example](https://tldraw.dev/examples/shape-with-tldraw-styles).
## App.tsx
```tsx
import {
BaseBoxShapeUtil,
DefaultStylePanel,
DefaultStylePanelContent,
HTMLContainer,
StyleProp,
T,
Tldraw,
TLShape,
useEditor,
useRelevantStyles,
} from 'tldraw'
import 'tldraw/tldraw.css'
const MY_SHAPE_WITH_CUSTOM_STYLES_TYPE = 'myshapewithcustomstyles'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_SHAPE_WITH_CUSTOM_STYLES_TYPE]: {
w: number
h: number
rating: MyRatingStyle
}
}
}
// [1]
const myRatingStyle = StyleProp.defineEnum('example:rating', {
defaultValue: 1,
values: [1, 2, 3, 4, 5],
})
// [2]
type MyRatingStyle = T.TypeOf
type IMyShape = TLShape
class MyShapeUtil extends BaseBoxShapeUtil {
static override type = MY_SHAPE_WITH_CUSTOM_STYLES_TYPE
// [3]
static override props = {
w: T.number,
h: T.number,
rating: myRatingStyle,
}
getDefaultProps(): IMyShape['props'] {
return {
w: 300,
h: 300,
rating: 4, // [4]
}
}
component(shape: IMyShape) {
// [5]
const stars = ['☆', '☆', '☆', '☆', '☆']
for (let i = 0; i < shape.props.rating; i++) {
stars[i] = '★'
}
return (
{stars}
)
}
indicator(shape: IMyShape) {
return
}
}
// [6]
function CustomStylePanel() {
const editor = useEditor()
const styles = useRelevantStyles()
if (!styles) return null
const rating = styles.get(myRatingStyle)
return (
{rating !== undefined && (
)}
)
}
export default function ShapeWithTldrawStylesExample() {
return (
)
}
/*
This file shows a custom shape that uses a user-created styles
For more on custom shapes, see our Custom Shape example.
[1]
In this example, our custom shape will use a new style called "rating".
We'll need to create the style so that we can pass it to the shape's props.
[2]
Here's we extract the type of the style's values. We use it below when
we define the shape's props.
[3]
We pass the style to the shape's props.
[4]
Since this property uses one a style, whatever value we put here in the
shape's default props will be overwritten by the editor's current value
for that style, which will either be the default value or the most
recent value the user has set. This is special behavior just for styles.
[5]
We can use the styles in the component just like any other prop.
[6]
Here we create a custom style panel that includes the default style panel
and also a dropdown for the rating style. We use the useRelevantStyles hook
to get the styles of the user's selected shapes, and the useEditor hook to
set the style for the selected shapes. For more on customizing the style
panel, see our custom style panel example.
[7]
We pass the custom shape util and custom components in as props.
[8]
And for this example, we create two shapes: the first does not specify a
rating, so it will use the editor's current style value (in this example,
this will be the style's default value of 4). The second specifies a
rating of 5, so it will use that value.
*/
```
--------
# Custom shape with tldraw styles
Category: Shapes & tools
Keywords: default styles, style panel
Use the tldraw style panel with your custom shapes.
The default tldraw UI will display UI for the styles of your selection or your current tool. For example, when you have two shapes selected that both have the tldraw's "size" style, the size selector will be displayed. If all of your selected shapes have the same value for this style, that value will be shown as selected in the panel. If they have different values, the panel will show the value as "mixed".
You can use tldraw's default styles in your own shapes. This example shows how to do that.
To create your own custom styles, check the [custom styles example](https://tldraw.dev/examples/shape-with-custom-styles).
## App.tsx
```tsx
import {
BaseBoxShapeUtil,
DefaultColorStyle,
DefaultSizeStyle,
getColorValue,
HTMLContainer,
T,
TLDefaultColorStyle,
TLDefaultSizeStyle,
Tldraw,
TLShape,
useDefaultColorTheme,
} from 'tldraw'
import 'tldraw/tldraw.css'
const MY_SHAPE_WITH_TLDRAW_STYLES_TYPE = 'myshapewithtldrawstyles' as const
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_SHAPE_WITH_TLDRAW_STYLES_TYPE]: {
w: number
h: number
size: TLDefaultSizeStyle
color: TLDefaultColorStyle
}
}
}
// There's a guide at the bottom of this file!
const FONT_SIZES: Record = {
s: 14,
m: 25,
l: 38,
xl: 48,
}
type IMyShape = TLShape
class MyShapeUtil extends BaseBoxShapeUtil {
static override type = MY_SHAPE_WITH_TLDRAW_STYLES_TYPE
// [2]
static override props = {
w: T.number,
h: T.number,
size: DefaultSizeStyle,
color: DefaultColorStyle,
}
getDefaultProps(): IMyShape['props'] {
return {
w: 300,
h: 300,
size: 'm',
color: 'black',
}
}
component(shape: IMyShape) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
return (
Select the shape and use the style panel to change the font size and color
)
}
/*
This file shows a custom shape that uses tldraw's default styles.
For more on custom shapes, see our Custom Shape example.
[1]
In this example, our custom shape will use the size and color styles from the
default styles. When typing a custom shape, you can use our types for
these styles.
[2]
For the shape's props, we'll pass the DefaultSizeStyle and DefaultColorStyle
styles for the two properties, size and color. There's nothing special about
these styles except that the editor will notice when two shapes are selected
that share the same style. (You can use the useRelevantStyles hook to get the
styles of the user's selected shapes.)
[3]
Here in the component, we'll use the styles to change the way that our shape
appears. The style values themselves are just strings, like 'xl' or 'black',
so it's up to you to decide how to use them. In this example, we're using the
size to set the text's font-size property, and also using the default theme
(via the useDefaultColorTheme hook) to get the color for the text.
*/
```
--------
# Clickable custom shape
Category: Shapes & tools
Keywords: interaction, pointer events, stop propagation, click, input
A custom shape that has its own onClick interactions.
By default the editor handles pointer events, but sometimes you want to handle interactions on your shape in your own ways, for example via a button. You can do this by using the css property `pointer events: all` and stopping event propagation. In this example we want our todo shape to have a checkbox so the user can mark them as done.
Check out my-interactive-shape-util.tsx to see how we create the shape.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { myInteractiveShape } from './my-interactive-shape-util'
// There's a guide at the bottom of this file!
// [1]
const customShapeUtils = [myInteractiveShape]
// [2]
export default function InteractiveShapeExample() {
return (
)
}
/*
[1]
By default the editor handles pointer events, but sometimes you want to handle
interactions on your shape in your own ways, for example via a button. You can do this
by using the css property `pointer events: all` and stopping event propagation. In
this example we want our todo shape to have a checkbox so the user can mark them as
done.
[2]
Check out my-interactive-shape-util.tsx to see how we create the shape.
*/
```
## my-interactive-shape-util.tsx
```tsx
import { BaseBoxShapeUtil, HTMLContainer, RecordProps, T, TLShape } from 'tldraw'
// There's a guide at the bottom of this file!
const MY_INTERACTIVE_SHAPE_TYPE = 'my-interactive-shape'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_INTERACTIVE_SHAPE_TYPE]: { w: number; h: number; checked: boolean; text: string }
}
}
export type IMyInteractiveShape = TLShape
export class myInteractiveShape extends BaseBoxShapeUtil {
static override type = MY_INTERACTIVE_SHAPE_TYPE
static override props: RecordProps = {
w: T.number,
h: T.number,
checked: T.boolean,
text: T.string,
}
getDefaultProps(): IMyInteractiveShape['props'] {
return {
w: 230,
h: 230,
checked: false,
text: '',
}
}
// [1]
component(shape: IMyInteractiveShape) {
return (
this.editor.updateShape({
id: shape.id,
type: MY_INTERACTIVE_SHAPE_TYPE,
props: { checked: !shape.props.checked },
})
}
// [b] This is where we stop event propagation
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
/>
this.editor.updateShape({
id: shape.id,
type: MY_INTERACTIVE_SHAPE_TYPE,
props: { text: e.currentTarget.value },
})
}
// [c]
onPointerDown={(e) => {
if (!shape.props.checked) {
e.stopPropagation()
}
}}
onTouchStart={(e) => {
if (!shape.props.checked) {
e.stopPropagation()
}
}}
onTouchEnd={(e) => {
if (!shape.props.checked) {
e.stopPropagation()
}
}}
/>
)
}
// [5]
indicator(shape: IMyInteractiveShape) {
return
}
}
/*
This is a custom shape, for a more in-depth look at how to create a custom shape,
see our custom shape example.
[1]
This is where we describe how our shape will render
[a] We need to set pointer-events to all so that we can interact with our shape. This CSS property is
set to "none" off by default. We need to manually opt-in to accepting pointer events by setting it to
'all' or 'auto'.
[b] We need to stop event propagation so that the editor doesn't select the shape
when we click on the checkbox. The 'canvas container' forwards events that it receives
on to the editor, so stopping propagation here prevents the event from reaching the canvas.
[c] If the shape is not checked, we stop event propagation so that the editor doesn't
select the shape when we click on the input. If the shape is checked then we allow that event to
propagate to the canvas and then get sent to the editor, triggering clicks or drags as usual.
*/
```
--------
# Custom handle snap reference
Category: Shapes & tools
Keywords: handles, snapping, snap reference
An example demonstrating `snapReferenceHandleId` for control point angle snapping.
This example shows how to use the `snapReferenceHandleId` property to control which handle serves as the reference point for shift-modifier angle snapping.
## App.tsx
```tsx
import {
Edge2d,
Geometry2d,
Group2d,
HTMLContainer,
RecordProps,
ShapeUtil,
TLHandle,
TLHandleDragInfo,
TLShape,
Tldraw,
Vec,
VecLike,
ZERO_INDEX_KEY,
getIndicesAbove,
vecModelValidator,
} from 'tldraw'
import 'tldraw/tldraw.css'
const Y_SHAPE_TYPE = 'y-shape'
// [1]
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[Y_SHAPE_TYPE]: {
center: VecLike
armTop: VecLike
armLeft: VecLike
armRight: VecLike
}
}
}
// [2]
type YShape = TLShape
// [3]
class YShapeUtil extends ShapeUtil {
static override type = Y_SHAPE_TYPE
static override props: RecordProps = {
center: vecModelValidator,
armTop: vecModelValidator,
armLeft: vecModelValidator,
armRight: vecModelValidator,
}
override getDefaultProps(): YShape['props'] {
return {
center: { x: 100, y: 100 },
armTop: { x: 100, y: 180 },
armLeft: { x: 30, y: 20 },
armRight: { x: 170, y: 20 },
}
}
override canEdit(): boolean {
return true
}
override hideSelectionBoundsBg(): boolean {
return true
}
override hideSelectionBoundsFg(): boolean {
return true
}
override hideResizeHandles(): boolean {
return true
}
override hideRotateHandle(): boolean {
return true
}
// [4]
getGeometry(shape: YShape): Geometry2d {
const { center, armTop, armLeft, armRight } = shape.props
const c = Vec.From(center)
const t = Vec.From(armTop)
const l = Vec.From(armLeft)
const r = Vec.From(armRight)
return new Group2d({
children: [
new Edge2d({ start: c, end: t }),
new Edge2d({ start: c, end: l }),
new Edge2d({ start: c, end: r }),
],
})
}
// [5]
override getHandles(shape: YShape): TLHandle[] {
const indices = [ZERO_INDEX_KEY, ...getIndicesAbove(ZERO_INDEX_KEY, 3)]
return [
{
id: 'center',
type: 'vertex',
x: shape.props.center.x,
y: shape.props.center.y,
index: indices[0],
},
{
id: 'armTop',
type: 'vertex',
x: shape.props.armTop.x,
y: shape.props.armTop.y,
index: indices[1],
// [6]
snapReferenceHandleId: 'center',
},
{
id: 'armLeft',
type: 'vertex',
x: shape.props.armLeft.x,
y: shape.props.armLeft.y,
index: indices[2],
// [7]
snapReferenceHandleId: 'center',
},
{
id: 'armRight',
type: 'vertex',
x: shape.props.armRight.x,
y: shape.props.armRight.y,
index: indices[3],
// [8]
snapReferenceHandleId: 'center',
},
]
}
override onHandleDrag(shape: YShape, info: TLHandleDragInfo) {
const { handle } = info
return {
...shape,
props: {
...shape.props,
[handle.id]: { x: handle.x, y: handle.y },
},
}
}
// [9]
component(shape: YShape) {
const { center, armTop, armLeft, armRight } = shape.props
return (
)
}
indicator(shape: YShape) {
const { center, armTop, armLeft, armRight } = shape.props
return (
<>
>
)
}
}
const customShapes = [YShapeUtil]
export default function CustomRelativeSnappingYShapeExample() {
return (
)
}
/*
This example demonstrates the `snapReferenceHandleId` property using a Y-shaped connector.
The shape has three arms radiating from a center junction point:
- center (junction point)
- armTop (top arm endpoint)
- armLeft (bottom-left arm endpoint)
- armRight (bottom-right arm endpoint)
[1]
First, we need to extend TLGlobalShapePropsMap to add our shape's props to the global type system.
This tells TypeScript about the shape type with four points representing a Y-shaped connector.
[2]
Define the shape type using TLShape with the shape's type as a type argument.
[3]
The shape util with validators for each point.
[4]
Use Group2d geometry containing three line segments from center to each arm.
[5]
Four handles in array order: [center, armTop, armLeft, armRight]
[6]
With `snapReferenceHandleId: 'center'`, when you shift+drag armTop, it will snap to the center point.
[7]
Similarly, armLeft would snap relative to the center point.
[8]
And armRight would snap to the center point.
[9]
The component method defines how our shape renders.
*/
```
--------
# Custom shape with handles
Category: Shapes & tools
Keywords: handles, handle, geometry, interaction, text label
A speech bubble shape with custom handles.
This example shows how to create a custom shape with custom handles.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { SpeechBubbleTool } from './SpeechBubble/SpeechBubbleTool'
import { SpeechBubbleUtil } from './SpeechBubble/SpeechBubbleUtil'
import { components, customAssetUrls, uiOverrides } from './SpeechBubble/ui-overrides'
import './customhandles.css'
// There's a guide at the bottom of this file!
// [1]
const shapeUtils = [SpeechBubbleUtil]
const tools = [SpeechBubbleTool]
// [2]
export default function CustomShapeWithHandles() {
return (
)
}
/*
Introduction:
This example shows how to create a custom shape using handles. You can use handles when you want
user interaction to alter the geometry of a shape. In this example, we create a speech bubble shape
with a handle on the tail so the user can alter its position and length. Most of the interesting stuff
is in SpeechBubbleUtil.tsx and helpers.tsx.
[1]
We define an array to hold the custom shape util and cusom tool. It's important to do this outside of
any React component so that this array doesn't get redefined on every render. We'll pass this into the
Tldraw component's `shapeUtils` and `tools` props.
Check out SpeechBubbleUtil.tsx and SpeechBubbleTool.tsx to see how we define the shape util and tool.
[2]
We pass the custom shape util and tool into the Tldraw component's `shapeUtils` and `tools` props.
We also pass in the custom ui overrides and asset urls to make sure our icons render where we want them to.
Check out ui-overrides.ts for more details.
*/
```
## customhandles.css
```css
/* Resize handles are normally on top, but We're going to give shape handles priority */
.tl-user-handles {
z-index: 101;
}
/* The text label doesn't normally deal with text that goes sideways,
* so this accounts for that */
.tl-shape[data-shape-type='speech-bubble'] .tl-text-label {
justify-content: flex-start !important;
}
```
## SpeechBubbleTool.tsx
```tsx
import { BaseBoxShapeTool } from 'tldraw'
export class SpeechBubbleTool extends BaseBoxShapeTool {
static override id = 'speech-bubble'
static override initial = 'idle'
override shapeType = 'speech-bubble' as const
}
/*
This file contains our speech bubble tool. The tool is a StateNode with the `id` "speech-bubble".
We get a lot of functionality for free by extending the BaseBoxShapeTool. For an example of a tool
with more custom functionality, check out the screenshot-tool example.
*/
```
## SpeechBubbleUtil.tsx
```tsx
import {
DefaultColorStyle,
DefaultFontStyle,
DefaultHorizontalAlignStyle,
DefaultSizeStyle,
DefaultVerticalAlignStyle,
FONT_FAMILIES,
Geometry2d,
LABEL_FONT_SIZES,
PlainTextLabel,
Polygon2d,
RecordPropsType,
ShapeUtil,
T,
TEXT_PROPS,
TLHandle,
TLHandleDragInfo,
TLResizeInfo,
TLShape,
Vec,
ZERO_INDEX_KEY,
getColorValue,
resizeBox,
structuredClone,
useDefaultColorTheme,
vecModelValidator,
} from 'tldraw'
import { getSpeechBubbleVertices, getTailIntersectionPoint } from './helpers'
const SPEECH_BUBBLE_TYPE = 'speech-bubble'
// [1]
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[SPEECH_BUBBLE_TYPE]: SpeechBubbleShapeProps
}
}
// Copied from tldraw/tldraw
export const STROKE_SIZES = {
s: 2,
m: 3.5,
l: 5,
xl: 10,
}
// There's a guide at the bottom of this file!
export const speechBubbleShapeProps = {
w: T.number,
h: T.number,
size: DefaultSizeStyle,
color: DefaultColorStyle,
font: DefaultFontStyle,
align: DefaultHorizontalAlignStyle,
verticalAlign: DefaultVerticalAlignStyle,
growY: T.positiveNumber,
text: T.string,
tail: vecModelValidator,
}
export type SpeechBubbleShapeProps = RecordPropsType
// [2]
export type SpeechBubbleShape = TLShape
export class SpeechBubbleUtil extends ShapeUtil {
static override type = SPEECH_BUBBLE_TYPE
// [3]
static override props = speechBubbleShapeProps
override isAspectRatioLocked(_shape: SpeechBubbleShape) {
return false
}
override canResize(_shape: SpeechBubbleShape) {
return true
}
override canEdit() {
return true
}
// [4]
getDefaultProps(): SpeechBubbleShapeProps {
return {
w: 200,
h: 130,
color: 'black',
size: 'm',
font: 'draw',
align: 'middle',
verticalAlign: 'start',
growY: 0,
text: '',
tail: { x: 0.5, y: 1.5 },
}
}
getHeight(shape: SpeechBubbleShape) {
return shape.props.h + shape.props.growY
}
getGeometry(shape: SpeechBubbleShape): Geometry2d {
const speechBubbleGeometry = getSpeechBubbleVertices(shape)
const body = new Polygon2d({
points: speechBubbleGeometry,
isFilled: true,
})
return body
}
// [5]
override getHandles(shape: SpeechBubbleShape): TLHandle[] {
const { tail, w } = shape.props
return [
{
id: 'tail',
type: 'vertex',
label: 'Move tail',
index: ZERO_INDEX_KEY,
// props.tail coordinates are normalized
// but here we need them in shape space
x: tail.x * w,
y: tail.y * this.getHeight(shape),
},
]
}
override onHandleDrag(shape: SpeechBubbleShape, { handle }: TLHandleDragInfo) {
return {
...shape,
props: {
tail: {
x: handle.x / shape.props.w,
y: handle.y / this.getHeight(shape),
},
},
}
}
override onBeforeCreate(next: SpeechBubbleShape) {
return this.getGrowY(next, next.props.growY)
}
// [6]
override onBeforeUpdate(prev: SpeechBubbleShape, shape: SpeechBubbleShape) {
const { w, tail } = shape.props
const fullHeight = this.getHeight(shape)
const { segmentsIntersection, insideShape } = getTailIntersectionPoint(shape)
const slantedLength = Math.hypot(w, fullHeight)
const MIN_DISTANCE = slantedLength / 5
const MAX_DISTANCE = slantedLength / 1.5
const tailInShapeSpace = new Vec(tail.x * w, tail.y * fullHeight)
const distanceToIntersection = tailInShapeSpace.dist(segmentsIntersection)
const center = new Vec(w / 2, fullHeight / 2)
const tailDirection = Vec.Sub(tailInShapeSpace, center).uni()
let newPoint = tailInShapeSpace
if (insideShape) {
newPoint = Vec.Add(segmentsIntersection, tailDirection.mul(MIN_DISTANCE))
} else {
if (distanceToIntersection <= MIN_DISTANCE) {
newPoint = Vec.Add(segmentsIntersection, tailDirection.mul(MIN_DISTANCE))
} else if (distanceToIntersection >= MAX_DISTANCE) {
newPoint = Vec.Add(segmentsIntersection, tailDirection.mul(MAX_DISTANCE))
}
}
const next = structuredClone(shape)
next.props.tail.x = newPoint.x / w
next.props.tail.y = newPoint.y / fullHeight
return this.getGrowY(next, prev.props.growY)
}
component(shape: SpeechBubbleShape) {
const {
id,
type,
props: { color, font, size, align, text },
} = shape
const vertices = getSpeechBubbleVertices(shape)
const pathData = 'M' + vertices[0] + 'L' + vertices.slice(1) + 'Z'
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
return (
<>
>
)
}
indicator(shape: SpeechBubbleShape) {
const vertices = getSpeechBubbleVertices(shape)
const pathData = 'M' + vertices[0] + 'L' + vertices.slice(1) + 'Z'
return
}
override onResize(shape: SpeechBubbleShape, info: TLResizeInfo) {
const resized = resizeBox(shape, info)
const next = structuredClone(info.initialShape)
next.x = resized.x
next.y = resized.y
next.props.w = resized.props.w
next.props.h = resized.props.h
return next
}
getGrowY(shape: SpeechBubbleShape, prevGrowY = 0) {
const PADDING = 17
const nextTextSize = this.editor.textMeasure.measureText(shape.props.text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size],
maxWidth: shape.props.w - PADDING * 2,
})
const nextHeight = nextTextSize.h + PADDING * 2
let growY = 0
if (nextHeight > shape.props.h) {
growY = nextHeight - shape.props.h
} else {
if (prevGrowY) {
growY = 0
}
}
return {
...shape,
props: {
...shape.props,
growY,
},
}
}
}
/*
Introduction: This file contains our custom shape util. The shape util is a class that defines how
our shape behaves. Most of the logic for how the speech bubble shape works is in the onBeforeUpdate
handler [6]. Since this shape has a handle, we need to do some special stuff to make sure it updates
the way we want it to.
[1]
First, we need to extend TLGlobalShapePropsMap to add our shape's props to the global type system.
This tells TypeScript about the shape's properties. For this shape, we define width (w), height (h),
size, color, font, alignment, text, and tail (using VecModel) as the shape's properties.
[2]
Define the shape type using TLShape with the shape's type as a type argument.
[3]
This is where we define the shape's props and a type validator for each key. tldraw exports a
bunch of handy validators for us to use. Props you define here will determine which style options
show up in the style menu, e.g. we define 'size' and 'color' props, but we could add 'dash', 'fill'
or any other of the default props.
[4]
Here is where we set the default props for our shape, this will determine how the shape looks
when we click-create it. You'll notice we don't store the tail's absolute position though, instead
we record its relative position. This is because we can also drag-create shapes. If we store the
tail's position absolutely it won't scale properly when drag-creating. Throughout the rest of the
util we'll need to convert the tail's relative position to an absolute position and vice versa.
[5]
`getHandles` tells tldraw how to turn our shape into a list of handles that'll show up when it's
selected. We only have one handle, the tail, which simplifies things for us a bit. In
`onHandleDrag`, we tell tldraw how our shape should be updated when the handle is dragged.
[6]
This is the last method that fires after a shape has been changed, we can use it to make sure
the tail stays the right length and position. Check out helpers.tsx to get into some of the more
specific geometry stuff.
*/
```
## helpers.tsx
```tsx
import { Vec, VecLike, lerp, pointInPolygon } from 'tldraw'
import { SpeechBubbleShape } from './SpeechBubbleUtil'
export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
const { w, tail } = shape.props
const fullHeight = shape.props.h + shape.props.growY
const tailInShapeSpace = new Vec(tail.x * w, tail.y * fullHeight)
const [tl, tr, br, bl] = [
new Vec(0, 0),
new Vec(w, 0),
new Vec(w, fullHeight),
new Vec(0, fullHeight),
]
const offsetH = w / 10
const offsetV = fullHeight / 10
const { adjustedIntersection, intersectionSegmentIndex } = getTailIntersectionPoint(shape)
let vertices: Vec[]
// Inject the tail segments into the geometry of the shape
switch (intersectionSegmentIndex) {
case 0:
// top
vertices = [
tl,
new Vec(adjustedIntersection.x - offsetH, adjustedIntersection.y),
new Vec(tailInShapeSpace.x, tailInShapeSpace.y),
new Vec(adjustedIntersection.x + offsetH, adjustedIntersection.y),
tr,
br,
bl,
]
break
case 1:
// right
vertices = [
tl,
tr,
new Vec(adjustedIntersection.x, adjustedIntersection.y - offsetV),
new Vec(tailInShapeSpace.x, tailInShapeSpace.y),
new Vec(adjustedIntersection.x, adjustedIntersection.y + offsetV),
br,
bl,
]
break
case 2:
// bottom
vertices = [
tl,
tr,
br,
new Vec(adjustedIntersection.x + offsetH, adjustedIntersection.y),
new Vec(tailInShapeSpace.x, tailInShapeSpace.y),
new Vec(adjustedIntersection.x - offsetH, adjustedIntersection.y),
bl,
]
break
case 3:
// left
vertices = [
tl,
tr,
br,
bl,
new Vec(adjustedIntersection.x, adjustedIntersection.y + offsetV),
new Vec(tailInShapeSpace.x, tailInShapeSpace.y),
new Vec(adjustedIntersection.x, adjustedIntersection.y - offsetV),
]
break
default:
throw Error("no intersection found, this shouldn't happen")
}
return vertices
}
export function getTailIntersectionPoint(shape: SpeechBubbleShape) {
const { w, tail } = shape.props
const fullHeight = shape.props.h + shape.props.growY
const tailInShapeSpace = new Vec(tail.x * w, tail.y * fullHeight)
const center = new Vec(w / 2, fullHeight / 2)
const corners = [new Vec(0, 0), new Vec(w, 0), new Vec(w, fullHeight), new Vec(0, fullHeight)]
const segments = [
[corners[0], corners[1]],
[corners[1], corners[2]],
[corners[2], corners[3]],
[corners[3], corners[0]],
]
let segmentsIntersection: Vec | null = null
let intersectionSegment: Vec[] | null = null
// If the point inside of the box's corners?
const insideShape = pointInPolygon(tailInShapeSpace, corners)
// We want to be sure we get an intersection, so if the point is
// inside the shape, push it away from the center by a big distance
const pointToCheck = insideShape
? Vec.Add(tailInShapeSpace, Vec.Sub(tailInShapeSpace, center).uni().mul(1000000))
: tailInShapeSpace
// Test each segment for an intersection
for (const segment of segments) {
segmentsIntersection = intersectLineSegmentLineSegment(
segment[0],
segment[1],
center,
pointToCheck
)
if (segmentsIntersection) {
intersectionSegment = segment
break
}
}
if (!(segmentsIntersection && intersectionSegment)) {
throw Error("no intersection found, this shouldn't happen")
}
const [start, end] = intersectionSegment
const intersectionSegmentIndex = segments.indexOf(intersectionSegment)
// a normalised vector from start to end, so this can work in any direction
const unit = Vec.Sub(end, start).uni()
// Where is the intersection relative to the start?
const totalDistance = Vec.Dist(start, end)
const distance = Vec.Dist(segmentsIntersection, start)
// make it stick to the middle
const middleRelative = mapRange(0, totalDistance, -1, 1, distance) // absolute -> -1 to 1
const squaredRelative = Math.abs(middleRelative) ** 2 * Math.sign(middleRelative) // square it and keep the sign
const squared = mapRange(-1, 1, 0, totalDistance, squaredRelative) // -1 to 1 -> absolute
//keep it away from the edges
const offset = (segments.indexOf(intersectionSegment) % 2 === 0 ? w / 10 : fullHeight / 10) * 3
const constrained = mapRange(0, totalDistance, offset, totalDistance - offset, distance)
// combine the two
const interpolated = lerp(constrained, squared, 0.5)
const adjustedIntersection = unit.mul(interpolated).add(start)
// We need the adjusted intersection to draw the tail, but the original intersection
// for the onBeforeUpdate handler
return {
segmentsIntersection,
adjustedIntersection,
intersectionSegmentIndex,
insideShape,
}
}
// This function is copied from the tldraw codebase
function intersectLineSegmentLineSegment(a1: VecLike, a2: VecLike, b1: VecLike, b2: VecLike) {
const ABx = a1.x - b1.x
const ABy = a1.y - b1.y
const BVx = b2.x - b1.x
const BVy = b2.y - b1.y
const AVx = a2.x - a1.x
const AVy = a2.y - a1.y
const ua_t = BVx * ABy - BVy * ABx
const ub_t = AVx * ABy - AVy * ABx
const u_b = BVy * AVx - BVx * AVy
if (ua_t === 0 || ub_t === 0) return null // coincident
if (u_b === 0) return null // parallel
if (u_b !== 0) {
const ua = ua_t / u_b
const ub = ub_t / u_b
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
return Vec.AddXY(a1, ua * AVx, ua * AVy)
}
}
return null // no intersection
}
/**
* Inverse linear interpolation
*/
function invLerp(a: number, b: number, v: number) {
return (v - a) / (b - a)
}
/**
* Maps a value from one range to another.
* e.g. mapRange(10, 20, 50, 100, 15) => 75
*/
function mapRange(a1: number, b1: number, a2: number, b2: number, s: number) {
return lerp(a2, b2, invLerp(a1, b1, s))
}
```
## ui-overrides.tsx
```tsx
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiAssetUrlOverrides,
TLUiOverrides,
TldrawUiMenuItem,
useIsToolSelected,
useTools,
} from 'tldraw'
// There's a guide at the bottom of this file!
// [1]
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
tools.speech = {
id: 'speech-bubble',
icon: 'speech-bubble',
label: 'Speech Bubble',
kbd: 's',
onSelect: () => {
editor.setCurrentTool('speech-bubble')
},
}
return tools
},
}
// [2]
export const customAssetUrls: TLUiAssetUrlOverrides = {
icons: {
'speech-bubble': '/speech-bubble.svg',
},
}
export const components: TLComponents = {
Toolbar: (props) => {
const tools = useTools()
const isSpeechBubbleSelected = useIsToolSelected(tools['speech'])
return (
)
},
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
)
},
}
/*
This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools
to the toolbar and the keyboard shortcuts menu.
[1]
Here we add our new tool to the UI's tools object in the tools override. This is where we define
all the basic information about our new tool - its icon, label, keyboard shortcut, what happens when
we select it, etc.
[2]
Our toolbar item is using a custom icon, so we need to provide the asset url for it.
We do this by providing a custom assetUrls object to the Tldraw component.
This object is a map of icon ids to their urls. The icon ids are the same as the
icon prop on the toolbar item. We'll pass our assetUrls object into the Tldraw
component's `assetUrls` prop.
[3]
We replace the UI components for the toolbar and keyboard shortcut dialog with our own, that
add our new tool to the existing default content. Ideally, we'd interleave our new tool into the
ideal place among the default tools, but for now we're just adding it at the start to keep things
simple.
*/
```
--------
# Custom tool with child states
Category: Shapes & tools
Keywords: state machine, custom tool, state node, interactions
You can implement more complex behaviour in a custom tool by using child states
Tools are nodes in tldraw's state machine. They are responsible for handling user input. You can create custom tools by extending the StateNode class and overriding its methods. In this example we expand on the sticker tool from the [custom tool example](https://tldraw.dev/examples/custom-tool) to show how to create a tool that can handle more complex interactions by using child states.
## App.tsx
```tsx
import {
StateNode,
TLClickEventInfo,
TLPointerEventInfo,
TLShapePartial,
TLTextShape,
Tldraw,
createShapeId,
toRichText,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
const OFFSET = -12
// [1]
class StickerTool extends StateNode {
static override id = 'sticker'
static override initial = 'idle'
static override children() {
return [Idle, Pointing, Dragging]
}
}
// [2]
class Idle extends StateNode {
static override id = 'idle'
//[a]
override onEnter() {
this.editor.setCursor({ type: 'cross' })
}
//[b]
override onPointerDown(info: TLPointerEventInfo) {
const { editor } = this
switch (info.target) {
case 'canvas': {
const hitShape = editor.getShapeAtPoint(editor.inputs.getCurrentPagePoint())
if (hitShape) {
this.onPointerDown({
...info,
shape: hitShape,
target: 'shape',
})
return
}
this.parent.transition('pointing', { shape: null })
break
}
case 'shape': {
if (editor.inputs.getShiftKey()) {
editor.updateShape({
id: info.shape.id,
type: 'text',
props: { richText: toRichText('👻 boo!') },
})
} else {
this.parent.transition('pointing', { shape: info.shape })
}
break
}
}
}
//[c]
override onDoubleClick(info: TLClickEventInfo) {
const { editor } = this
if (info.phase !== 'up') return
switch (info.target) {
case 'canvas': {
const hitShape = editor.getShapeAtPoint(editor.inputs.getCurrentPagePoint())
if (hitShape) {
this.onDoubleClick({
...info,
shape: hitShape,
target: 'shape',
})
return
}
const currentPagePoint = editor.inputs.getCurrentPagePoint()
editor.createShape({
type: 'text',
x: currentPagePoint.x + OFFSET,
y: currentPagePoint.y + OFFSET,
props: { richText: toRichText('❤️') },
})
break
}
case 'shape': {
editor.deleteShapes([info.shape.id])
break
}
}
}
}
// [3]
class Pointing extends StateNode {
static override id = 'pointing'
private shape: TLTextShape | null = null
override onEnter(info: { shape: TLTextShape | null }) {
this.shape = info.shape
}
override onPointerUp() {
this.parent.transition('idle')
}
override onPointerMove() {
if (this.editor.inputs.getIsDragging()) {
this.parent.transition('dragging', { shape: this.shape })
}
}
}
// [4]
class Dragging extends StateNode {
static override id = 'dragging'
// [a]
private shape: TLShapePartial | null = null
private emojiArray = ['❤️', '🔥', '👍', '👎', '😭', '🤣']
// [b]
override onEnter(info: { shape: TLShapePartial }) {
const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
const newShape: TLShapePartial = {
id: createShapeId(),
type: 'text',
x: currentPagePoint.x + OFFSET,
y: currentPagePoint.y + OFFSET,
props: { richText: toRichText('❤️') },
}
if (info.shape) {
this.shape = info.shape
} else {
this.editor.createShape(newShape)
this.shape = { ...newShape }
}
}
//[c]
override onPointerUp() {
this.parent.transition('idle')
}
//[d]
override onPointerMove() {
const { shape } = this
const originPagePoint = this.editor.inputs.getOriginPagePoint()
const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
const distance = originPagePoint.dist(currentPagePoint)
if (shape) {
this.editor.updateShape({
id: shape.id,
type: 'text',
props: {
richText: toRichText(this.emojiArray[Math.floor(distance / 20) % this.emojiArray.length]),
},
})
}
}
}
// [5]
const customTools = [StickerTool]
export default function ToolWithChildStatesExample() {
return (
{
editor.createShape({
type: 'text',
x: 50,
y: 50,
props: {
richText: toRichText(
'-Double click the canvas to add a sticker\n-Double click a sticker to delete it\n-Click and drag on a sticker to change it\n-Click and drag on the canvas to create a sticker\n-Shift click a sticker for a surprise!'
),
size: 's',
textAlign: 'start',
},
})
}}
/>
)
}
/*
Introduction:
Tools are nodes in tldraw's state machine. They are responsible for handling user input.
You can create custom tools by extending the `StateNode` class and overriding its
methods. In this example we expand on the sticker tool from the custom tool example to
show how to create a tool that can handle more complex interactions by using child states.
[1]
This is our custom tool. It has three child states: `Idle`, `Pointing`, and `Dragging`.
We need to define the `id` and `initial` properties, the id is a unique string that
identifies the tool to the editor, and the initial property is the initial state of the
tool. We also need to define a `children` method that returns an array of the tool's
child states.
[2]
This is our Idle state. It is the initial state of the tool. It's job is to figure out
what the user is trying to do and transition to the appropriate state. When transitioning
between states we can use the second argument to pass data to the new state. It has three
methods:
[a] `onEnter`
When entering any state, the `onEnter` method is called. In this case, we set the cursor to
a crosshair.
[b] `onPointerDown`
This method is called when the user presses the mouse button. The target parameter is always
the canvas, so we can use an editor method to check if we're over a shape, and call the
method again with the shape as the target. If we are over a shape, we transition to the
`pointing` state with the shape in the info object. If we're over a shape and holding the
shift key, we update the shape's text. If we're over the canvas, we transition to the
`pointing` state with a null shape in the info object.
[c] `onDoubleClick`
This method is called when the user double clicks the mouse button. We're using some similar
logic here to check if we're over a shape, and if we are, we delete it. If we're over the canvas,
we create a new shape.
[3]
This is our `Pointing` state. It's a transitionary state, we use it to store the shape we're pointing
at, and transition to the dragging state if the user starts dragging. It has three methods:
[a] `onEnter`
When entering this state, we store the shape we're pointing at by getting it from the info object.
[b] `onPointerUp`
This method is called when the user releases the mouse button. We transition to the `idle` state.
[c] `onPointerMove`
This method is called when the user moves the mouse. If the user starts dragging, we transition to
the `dragging` state and pass the shape we're pointing at.
[4]
This is our `Dragging` state. It's responsible for creating and updating the shape that the user is
dragging.
[a] `onEnter`
When entering this state, we create a new shape if we're not dragging an existing one. If we are,
we store the shape we're dragging.
[b] `onPointerUp`
This method is called when the user releases the mouse button. We transition to the `idle` state.
[c] `onPointerMove`
This method is called when the user moves the mouse. We use the distance between the origin and
current mouse position to cycle through an array of emojis and update the shape's text.
[5]
We pass our custom tool to the `Tldraw` component as an array. We also set the initial state to our
custom tool. For the purposes of this demo, we're also hiding the UI and adding some helpful text to
the canvas.
*/
```
--------
# Editable custom shape
Category: Shapes & tools
Keywords: custom
A custom shape that you can edit by double-clicking it.
In tldraw, the Editor can have one editing shape at a time. When in its editing state, the editor will ignore events until the user exits the editing state by pressing Escape or clicking on the canvas.
Only shapes with a `canEdit` flag that returns true may become editable. A user may begin editing a shape by double clicking on the editable shape, or selecting the editable shape and pressing enter.
Many of our shapes use editing to allow for interactions inside of the shape. For example, a text shape behaves like a text graphic until the user begins editing it—and only then can the user use their keyboard to edit the text. Note that a shape can be interactive regardless of whether it's the editor's editing shape—the "editing" mechanic is just a way of managing a common pattern in canvas applications.
In this example we'll create a shape that renders an emoji and allows the user to change the emoji when the shape is in the editing state.
Most of the relevant code for this is in the EditableShapeUtil.tsx file. If you want a more in-depth explanation of the shape util, check out the custom shape example.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { EditableShapeUtil } from './EditableShapeUtil'
const customShapeUtils = [EditableShapeUtil]
export default function EditableShapeExample() {
return (
)
}
/*
Introduction:
In tldraw, shapes can exist in an editing state. When shapes are in the editing state
they are focused and can't be dragged, resized or rotated. Shapes enter this state
when they are double-clicked. In our default shapes we mostly use this for editing text.
In this example we'll create a shape that renders an emoji and allows the user to change
the emoji when the shape is in the editing state.
Most of the relevant code for this is in the EditableShapeUtil.tsx file. If you want a more
in-depth explanation of the shape util, check out the custom shape example.
*/
```
## EditableShapeUtil.tsx
```tsx
import { BaseBoxShapeUtil, HTMLContainer, RecordProps, T, TLShape } from 'tldraw'
// There's a guide at the bottom of this file!
const MY_EDITABLE_SHAPE_TYPE = 'my-editable-shape'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_EDITABLE_SHAPE_TYPE]: {
w: number
h: number
animal: number
}
}
}
const ANIMAL_EMOJIS = ['🐶', '🐱', '🐨', '🐮', '🐴']
export type IMyEditableShape = TLShape
export class EditableShapeUtil extends BaseBoxShapeUtil {
static override type = MY_EDITABLE_SHAPE_TYPE
static override props: RecordProps = {
w: T.number,
h: T.number,
animal: T.number,
}
// [1]
override canEdit() {
return true
}
// [1b]
override canEditWhileLocked() {
return true
}
getDefaultProps(): IMyEditableShape['props'] {
return {
w: 200,
h: 200,
animal: 0,
}
}
// [2]
component(shape: IMyEditableShape) {
// [a]
const isEditing = this.editor.getEditingShapeId() === shape.id
return (
{ANIMAL_EMOJIS[shape.props.animal]}
{/* [c] */}
{isEditing ? (
) : (
// [d] when not editing...
Double Click to Edit
)}
)
}
indicator(shape: IMyEditableShape) {
return
}
// [3]
override onEditEnd(shape: IMyEditableShape) {
this.editor.animateShape(
{ ...shape, rotation: shape.rotation + Math.PI * 2 },
{ animation: { duration: 250 } }
)
}
}
/*
This is our shape util, which defines how our shape renders and behaves. For
more information on the shape util, check out the custom shape example.
[1]
We override the canEdit method to allow the shape to enter the editing state.
[1b] We override canEditWhileLocked to allow the shape to be edited even
when it is locked. This is useful for shapes that need to remain
interactive despite being locked in place, preventing accidental
movement while still allowing content changes.
[2]
We want to conditionally render the component based on whether it is being
edited or not.
[a] We can check whether our shape is being edited by comparing the
editing shape id to the shape's id.
[b] We want to allow pointer events when the shape is being edited,
and stop event propagation on pointer down. Check out the interactive
shape example for more information on this.
[c] We render a button to change the animal emoji when the shape is being
edited.
[e] We also render a message when the shape is not being edited.
[3]
The onEditEnd method is called when the shape exits the editing state. In this
case we rotate the shape 360 degrees.
*/
```
--------
# Arrow binding options
Category: Shapes & tools
Keywords: arrow, binding, precise, exact, anchor, snap
Demonstrate arrow binding options for precise positioning.
This example shows the different arrow binding options that control how arrows connect to shapes:
- **isPrecise: false** - Arrow always targets the center of the shape (default safe behavior)
- **isPrecise: true** - Arrow respects the `normalizedAnchor` and targets the specified position
- **isExact: false** - Arrow stops at the shape's edge (default)
- **isExact: true** - Arrow passes through the shape to reach the exact target point
The `normalizedAnchor` property specifies where on the shape the arrow connects using normalized coordinates (0-1 on each axis). For example, `{x: 0.5, y: 0.5}` is the center, `{x: 0, y: 0}` is top-left, and `{x: 1, y: 1}` is bottom-right.
These options provide fine-grained control over arrow positioning for technical diagrams, architectural drawings, and other use cases requiring precise arrow placement.
## App.tsx
```tsx
import { Tldraw, createShapeId, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
export default function ArrowBindingOptionsExample() {
return (
)
}
/*
[1]
Create the first shape demonstrating isPrecise: false. When isPrecise is false, the arrow
always targets the center of the shape, regardless of the normalizedAnchor value provided.
[2]
Create an arrow with isPrecise: false. Even though we specify normalizedAnchor at (0.25, 0.5)
(left side of the shape), the arrow will point to the center because isPrecise is false.
The arrow stops at the shape's edge.
[3]
Create the second shape demonstrating isPrecise: true. When isPrecise is true, the arrow
respects the normalizedAnchor and targets the specified position within the shape.
[4]
Create an arrow with isPrecise: true and the same normalizedAnchor at (0.25, 0.5). This time,
the arrow will actually target the left side of the shape as specified by the anchor. The arrow
still stops at the shape's edge (isExact: false).
[5]
Create the third shape demonstrating isExact: true. When isExact is true, the arrow passes
through the shape to reach its exact target point instead of stopping at the edge.
[6]
Create an arrow with both isPrecise: true and isExact: true. The normalizedAnchor at (0.5, 0.5)
targets the center of the shape, and isExact: true makes the arrow pass through the shape to
reach that exact point. This is useful for diagrams where arrows need to cross through shapes.
[7]
Create the fourth shape demonstrating the combination of precise and exact targeting. The arrow
targets the bottom-right quadrant (0.75, 0.75) and passes through the shape to that exact point.
This shows fine-grained control over arrow positioning.
Key takeaways:
- isPrecise: false = arrow always points to center (default safe behavior)
- isPrecise: true = arrow respects normalizedAnchor position
- isExact: false = arrow stops at shape edge (default)
- isExact: true = arrow passes through to exact target point
*/
```
--------
# Custom shape geometry
Category: Shapes & tools
Keywords: svg, path, house, door
A shape with custom geometry.
This example demonstrates how to create a shape with custom geometry in tldraw. The
shape we're creating is a simple house shape with a door.
## App.tsx
```tsx
import {
Group2d,
Polygon2d,
RecordPropsType,
Rectangle2d,
ShapeUtil,
T,
TLResizeInfo,
TLShape,
Tldraw,
Vec,
resizeBox,
structuredClone,
} from 'tldraw'
import 'tldraw/tldraw.css'
const HOUSE_TYPE = 'house'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[HOUSE_TYPE]: HouseShapeProps
}
}
const houseShapeProps = {
w: T.number,
h: T.number,
}
type HouseShapeProps = RecordPropsType
type HouseShape = TLShape
class HouseShapeUtil extends ShapeUtil {
static override type = HOUSE_TYPE
static override props = houseShapeProps
override canResize() {
return true
}
override getDefaultProps() {
return {
w: 100,
h: 100,
}
}
//[1]
override getGeometry(shape: HouseShape) {
const { house: houseGeometry } = getHouseVertices(shape)
const house = new Polygon2d({
points: houseGeometry,
isFilled: true,
})
const door = new Rectangle2d({
x: shape.props.w / 2 - shape.props.w / 10,
y: shape.props.h - shape.props.h / 4,
width: shape.props.w / 5,
height: shape.props.h / 4,
isFilled: true,
})
const geometry = new Group2d({
children: [house, door],
})
return geometry
}
// [2]
override component(shape: HouseShape) {
const { house: houseVertices, door: doorVertices } = getHouseVertices(shape)
const housePathData = 'M' + houseVertices[0] + 'L' + houseVertices.slice(1) + 'Z'
const doorPathData = 'M' + doorVertices[0] + 'L' + doorVertices.slice(1) + 'Z'
return (
)
}
// [3]
override indicator(shape: HouseShape) {
const { house: houseVertices, door: doorVertices } = getHouseVertices(shape)
const housePathData = 'M' + houseVertices[0] + 'L' + houseVertices.slice(1) + 'Z'
const doorPathData = 'M' + doorVertices[0] + 'L' + doorVertices.slice(1) + 'Z'
return
}
override onResize(shape: HouseShape, info: TLResizeInfo) {
const resized = resizeBox(shape, info)
const next = structuredClone(info.initialShape)
next.x = resized.x
next.y = resized.y
next.props.w = resized.props.w
next.props.h = resized.props.h
return next
}
}
// [4]
function getHouseVertices(shape: HouseShape): { house: Vec[]; door: Vec[] } {
const { w, h } = shape.props
const halfW = w / 2
const roofStart = h / 2.5
const house = [
new Vec(0, roofStart), // Roof start (left)
new Vec(w, roofStart), // Roof start (right)
new Vec(w, h), // Bottom-right corner
new Vec(0, h), // Bottom-left corner
new Vec(0, roofStart), // Roof start (left)
new Vec(halfW, 0), // Roof peak
new Vec(w, roofStart), // Roof start (right)
]
const door = [
new Vec(halfW - w / 10, h), // Bottom-left corner
new Vec(halfW + w / 10, h), // Bottom-right corner
new Vec(halfW + w / 10, h - h / 4), // Top-right corner
new Vec(halfW - w / 10, h - h / 4), // Top-left corner
new Vec(halfW - w / 10, h), // Bottom-left corner
]
return { house, door }
}
const shapeUtils = [HouseShapeUtil]
export default function ShapeWithGeometryExample() {
return (
)
}
/*
Introduction:
This file demonstrates how to create a shape with custom geometry in tldraw. The
shape we're creating is a simple house shape with a door. The HouseShapeUtil class
defines the behavior and appearance of our custom house shape.
[1]
The getGeometry method defines the geometric representation of our shape. This geometry
is used for hit-testing, intersection checking and other geometric calculations. We use
Polygon2d for the house body and Rectangle2d for the door. These are combined into a
Group2d to form the complete house geometry.
[2]
The component method determines how our shape is rendered. We create SVG paths for
both the house body and the door, combining them into a single path element. This
method is called when the shape needs to be drawn on the canvas. The tl-svg-container
class contains some helpful styles for rendering the svg correctly.
[3]
The indicator method renders the same path as a thin blue line when the shape is selected.
[4]
The getHouseVertices function calculates the vertices for both the house body and the door
based on the shape's dimensions. This is used by both the geometry and rendering methods
to ensure consistency in the shape's appearance.
*/
```
--------
# Custom shape migrations
Category: Shapes & tools
Keywords: version, update
Migrate your shapes and their data between versions
Sometimes you'll want to update the way a shape works in your application. When this happens there can be a risk of errors and bugs. For example, users with an old version of a shape in their documents might encounter errors when the editor tries to access a property that doesn't exist. This example shows how you can use our migrations system to preserve your users' data between versions. It uses a snapshot to load a document with a shape that is missing a "color" prop, and uses the migrations method of the shape util to update it.
## App.tsx
```tsx
import {
BaseBoxShapeUtil,
HTMLContainer,
T,
TLResizeInfo,
TLShape,
TLStoreSnapshot,
Tldraw,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
resizeBox,
} from 'tldraw'
import 'tldraw/tldraw.css'
import snapshot from './snapshot.json'
const MY_SHAPE_WITH_MIGRATIONS_TYPE = 'myshapewithmigrations' as const
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_SHAPE_WITH_MIGRATIONS_TYPE]: { w: number; h: number; color: string }
}
}
// There's a guide at the bottom of this file!
export type IMyShape = TLShape
// [1]
const versions = createShapePropsMigrationIds(
// this must match the shape type in the shape definition
MY_SHAPE_WITH_MIGRATIONS_TYPE,
{
AddColor: 1,
}
)
// [2]
export const cardShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: versions.AddColor,
up(props) {
// it is safe to mutate the props object here
props.color = 'lightblue'
},
down(props) {
delete props.color
},
},
],
})
export class MigratedShapeUtil extends BaseBoxShapeUtil {
static override type = MY_SHAPE_WITH_MIGRATIONS_TYPE
static override props = {
w: T.number,
h: T.number,
color: T.string,
}
// [3]
static override migrations = cardShapeMigrations
getDefaultProps(): IMyShape['props'] {
return {
w: 300,
h: 300,
color: 'lightblue',
}
}
component(shape: IMyShape) {
return (
)
}
indicator(shape: IMyShape) {
return
}
override onResize(shape: IMyShape, info: TLResizeInfo) {
return resizeBox(shape, info)
}
}
const customShapeUtils = [MigratedShapeUtil]
export default function ShapeWithMigrationsExample() {
return (
)
}
/*
Introduction:
Sometimes you'll want to update the way a shape works in your application without breaking older
versions of the shape that a user may have stored or persisted in memory.
This example shows how you can use our migrations system to upgrade (or downgrade) user's data
between different versions. Most of the code above is general "custom shape" code—see our custom
shape example for more details.
[1] First, we need IDs for each migration. List each change with its version number. Once you've
added a migration, it should not change again.
[2] Next, we create a migration sequence. This is where we actually write our migration logic. Each
migration had three parts: an `id` (created in [1]), an `up` migration and `down` migration. In this
case, the `up` migration adds the `color` prop to the shape, and the `down` migration removes it.
In some cases (mainly in multiplayer sessions) a peer or server may need to take a later version of
a shape and migrate it down to an older version—in this case, it would run the down migrations in
order to get it to the needed version.
[3] Finally, we add our migrations to the ShapeUtil. This tells tldraw about the migrations so they
can be used with your shapes.
How it works:
Each time the editor's store creates a snapshot (`editor.store.createSnapshot`), it serializes all
of the records (the snapshot's `store`) as well as versions of each record that it contains (the
snapshot's `schema`). When the editor loads a snapshot, it compares its current schema with the
snapshot's schema to determine which migrations to apply to each record.
In this example, we have a snapshot (snapshot.json) that we created in version 0, however our shape
now has a 'color' prop that was added in version 1.
The snapshot looks something like this:
```json{
{
"store": {
"shape:BqG5uIAa9ig2-ukfnxwBX": {
...,
"props": {
"w": 300,
"h": 300
},
},
},
"schema": {
...,
"sequences": {
...,
"com.tldraw.shape.arrow": 4,
"com.tldraw.shape.myshape": 0
}
}
}
```
Note that the shape in the snapshot doesn't have a 'color' prop.
Note also that the schema's version for this shape is 0.
When the editor loads the snapshot, it will compare the serialized schema's version with its current
schema's version for the shape, which is 1 as defined in our shape's migrations. Since the
serialized version is older than its current version, it will use our migration to bring it up to
date: it will run the migration's `up` function, which will add the 'color' prop to the shape.
*/
```
--------
# Custom shape SVG export
Category: Shapes & tools
Keywords: basic, svg, custom, export, copy
Determine how your custom shapes look when copied/exported as an image.
The "export as SVG/PNG" and "copy as SVG/PNG" actions use the `toSvg` or `toBackgroundSvg` methods of a shape util. If a shape does not have a `toSvg` or `toBackgroundSvg` method defined, it will default to placing the shape's component inside a `` element.
## App.tsx
```tsx
import { ReactElement } from 'react'
import {
Geometry2d,
HTMLContainer,
RecordProps,
Rectangle2d,
ShapeUtil,
SvgExportContext,
T,
Tldraw,
TLShape,
} from 'tldraw'
import 'tldraw/tldraw.css'
const MY_CUSTOM_SHAPE_TO_SVG_TYPE = 'my-custom-shape-to-svg'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_CUSTOM_SHAPE_TO_SVG_TYPE]: { w: number; h: number }
}
}
// There's a guide at the bottom of this file!
type ICustomShape = TLShape
const LIGHT_FILL = '#ff8888'
const DARK_FILL = '#ffcccc'
export class MyShapeUtil extends ShapeUtil {
static override type = MY_CUSTOM_SHAPE_TO_SVG_TYPE
static override props: RecordProps = {
w: T.number,
h: T.number,
}
getDefaultProps(): ICustomShape['props'] {
return {
w: 200,
h: 200,
}
}
override canEdit() {
return false
}
override canResize() {
return false
}
override isAspectRatioLocked() {
return false
}
getGeometry(shape: ICustomShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
component(_shape: ICustomShape) {
const isDarkmode = this.editor.user.getIsDarkMode()
return
}
indicator(shape: ICustomShape) {
return this.getSvgRect(shape)
}
// [1]
override toSvg(
shape: ICustomShape,
ctx: SvgExportContext
): ReactElement | null | Promise {
// ctx.addExportDef(getFontDef(shape))
const isDarkmode = ctx.isDarkMode
const fill = isDarkmode ? DARK_FILL : LIGHT_FILL
return this.getSvgRect(shape, { fill })
}
getSvgRect(shape: ICustomShape, props?: { fill: string }) {
return
}
// [2]
// override toBackgroundSvg(
// shape: ICustomShape,
// ctx: SvgExportContext
// ): ReactElement | null | Promise {
// const isDarkmode = ctx.isDarkMode
// const fill = isDarkmode ? '#333' : '#efefef'
// return
// }
}
// [3]
// function getFontDef(shape: ICustomShape): SvgExportDef {
// //
// return {
// some unique key,
// key: 'my-custom-shape-font',
// getElement: async () => {
// return element
// check out the defaultStyleDefs.tsx file for an example of how
// we do this for tldraw fonts
// },
// }
// }
const customShape = [MyShapeUtil]
export default function CustomShapeToSvgExample() {
return (
)
}
/*
The "export as SVG/PNG" and "copy as SVG/PNG" actions use the `toSvg` or `toBackgroundSvg`
methods of a shape util. If a shape does not have a `toSvg` or `toBackgroundSvg` method
defined, it will default to an empty box.
For more information on creating a custom shape, check out the custom shape example.
[1]
This method should return a React element that represents the shape as an SVG element.
If your shape is HTML, then you will need to convert it to an SVG representation. In this
example we've used a `rect` element to represent the shape. Other shapes may require more
complex work to render them as SVGs, especially if they contain text. Check out [3] for more
info.
[2]
The `toBackgroundSvg` method is used to render a layer behind the shape when exporting as SVG.
We use this in the tldraw codebase to make the highlighter shape. It's commented out here as
we don't need it for this example.
[3]
If your shape contains text, you may need to add a font definition to the SVG. This is done
using the `addExportDef` method of the `SvgExportContext`. Your font def must contain a unique
key and a function that returns a React element. Check out the `` function
in the `defaultStyleDefs.tsx` file for an example of how this is done for tldraw fonts.
*/
```
--------
# Custom snapping
Category: Shapes & tools
Keywords: geometry, custom
Custom shapes with special bounds snapping behaviour.
This example shows how to create a shape with custom snapping geometry. When shapes are moved around in snap mode, they will snap to the bounds of other shapes by default. However, a shape can return custom snapping geometry to snap to instead.
In this case, we've created a custom playing card shape. The cards are designed to snap together so that the top-left icon remains visible when stacked, similar to a hand of cards in a game.
## App.tsx
```tsx
import { Editor, TLStoreSnapshot, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { PlayingCardTool } from './PlayingCardShape/playing-card-tool'
import { PlayingCardUtil } from './PlayingCardShape/playing-card-util'
import snapshot from './snapshot.json'
import { components, uiOverrides } from './ui-overrides'
// There's a guide at the bottom of this file!
// [1]
const customShapes = [PlayingCardUtil]
const customTools = [PlayingCardTool]
export default function BoundsSnappingShapeExample() {
// [2]
const handleMount = (editor: Editor) => {
editor.user.updateUserPreferences({ isSnapMode: true })
}
// [3]
return (
)
}
/*
Introduction:
This example shows how to create a shape with custom snapping geometry. When shapes are moved around
in snap mode, they will snap to the bounds of other shapes by default. A shape can return custom
snapping geometry to snap to instead. This example creates a playing card shape. The cards are
designed to snap together so that the top-left icon remains visible when stacked, similar to a hand
of cards in a game. The most relevant code for this customisation is in playing-card-util.tsx.
[1]
We define the custom shape and util arrays we'll pass to the Tldraw component. It's important to do
this outside of the component so that the arrays don't change on every render.
This is where we define the Tldraw component and pass in all our customisations.
[2]
We define a handleMount function that will be called when the editor mounts. We're using it to set
the snap mode to true in the user preferences. This is just to help demonstrate the custom snapping
geometry feature. Without snap mode being set in this way the user can still enter it by holding
cmd/ctrl while dragging.
[3]
This is where we're passing in all our customisations to the Tldraw component. Check out the
associated files for more information on what's being passed in.
[a] Firstly, our custom shape (playing-card-util.tsx) and tool (playing-card-tool.tsx)
This tells the editor about our custom shape and tool.
[b] Then our the uiOverrides and custom keyboard shortcuts component (ui-overrides.tsx),
this makes sure that an icon for our tool appears in the toolbar and the shortcut
for it appears in the dialog.
[c] We pass in our handleMount function so that it's called when the editor mounts.
[d] Finally we pass in a snapshot so that the editor starts with some shapes in it.
This isn't necessary, it just makes the example clearer on first glance.
*/
```
## ui-overrides.tsx
```tsx
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
useIsToolSelected,
useTools,
} from 'tldraw'
// There's a guide at the bottom of this file!
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.PlayingCard = {
id: 'PlayingCard',
icon: 🃏,
label: 'Playing Card',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('PlayingCard')
},
}
return tools
},
}
export const components: TLComponents = {
Toolbar: (props) => {
const tools = useTools()
const isCardSelected = useIsToolSelected(tools['PlayingCard'])
return (
)
},
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
)
},
}
/*
This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools to
the toolbar and the keyboard shortcuts menu.
First we have to add our new tool to the tools object in the tools override. This is where we define
all the basic information about our new tool - its icon, label, keyboard shortcut, what happens when
we select it, etc.
Then, we replace the UI components for the toolbar and keyboard shortcut dialog with our own, that
add our new tool to the existing default content. Ideally, we'd interleave our new tool into the
ideal place among the default tools, but for now we're just adding it at the start to keep things
simple.
*/
```
## playing-card-tool.tsx
```tsx
import { BaseBoxShapeTool } from 'tldraw'
export class PlayingCardTool extends BaseBoxShapeTool {
static override id = 'PlayingCard'
static override initial = 'idle'
override shapeType = 'PlayingCard' as const
}
/*
This file contains our custom tool. The tool is a StateNode with the `id` "PlayingCard".
We get a lot of functionality for free by extending the BaseBoxShapeTool. but we can
handle events in our own way by overriding methods like onDoubleClick. For an example
of a tool with more custom functionality, check out the screenshot-tool example.
*/
```
## playing-card-util.tsx
```tsx
import {
BaseBoxShapeUtil,
BoundsSnapGeometry,
HTMLContainer,
RecordProps,
Rectangle2d,
T,
TLShape,
} from 'tldraw'
// There's a guide at the bottom of this file!
const PLAYING_CARD_TYPE = 'PlayingCard'
// [1]
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[PLAYING_CARD_TYPE]: {
w: number
h: number
suit: string
}
}
}
// [2]
export type IPlayingCard = TLShape
export class PlayingCardUtil extends BaseBoxShapeUtil {
// [3]
static override type = PLAYING_CARD_TYPE
static override props: RecordProps = {
w: T.number,
h: T.number,
suit: T.string,
}
// [4]
override isAspectRatioLocked(_shape: IPlayingCard) {
return true
}
// [5]
getDefaultProps(): IPlayingCard['props'] {
const cardSuitsArray: string[] = ['♠️', '♣️', '♥️', '♦️']
const randomSuit = cardSuitsArray[Math.floor(Math.random() * cardSuitsArray.length)]
return {
w: 270,
h: 370,
suit: randomSuit,
}
}
// [6]
override getBoundsSnapGeometry(shape: IPlayingCard): BoundsSnapGeometry {
return {
points: new Rectangle2d({
width: shape.props.h / 4.5,
height: shape.props.h / 4.5,
isFilled: true,
}).bounds.cornersAndCenter,
}
}
// [7]
component(shape: IPlayingCard) {
return (
{shape.props.suit}
{shape.props.suit}
)
}
// [8]
indicator(shape: IPlayingCard) {
return
}
}
/*
This is a utility class for the PlayingCard shape. This is where you define the shape's behavior,
how it renders (its component and indicator), and how it handles different events. The most relevant
part of the code to custom snapping can be found in [7].
[1]
First, we need to extend TLGlobalShapePropsMap to add our shape's props to the global type system.
This tells TypeScript about the shape's properties. For this shape, we define width (w), height (h),
and suit as the shape's properties.
[2]
Define the shape type using TLShape with the shape's type as a type argument.
[3]
We define the shape's type and props for the editor. We can use tldraw's validator library to
make sure that the store always has shape data we can trust. In this case, we define the width
and height properties as numbers and assign a validator from tldraw's library to them.
[4]
We're going to lock the aspect ratio of this shape.
[5]
getDefaultProps determines what our shape looks like when click-creating one. In this case, we
want the shape to be 270x370 pixels and generate a suit for the card at random.
[6]
This is the important part for custom snapping. We define the getBoundsSnapGeometry method. This
method returns the geometry that the shape will snap to. In this case, we want the shape to snap
to a rectangle in the top left that contains the suit of the card. We can use the Rectangle2d helper
again here and set it to the same width and height as the span containing the suit which is defined
in [7].
[7]
We define the component method. This controls what the shape looks like and it returns JSX. It
generates a random suit for the card and returns a div with the suit in the center and a span with
the suit in the top left. The HTMLContainer component is a helpful wrapper that the tldraw library
exports, it's a div that comes with a css class.
[8]
The indicator is the blue box that appears around the shape when it's selected. We're just returning
a rectangle with the same width and height as the shape here.
*/
```
--------
# Custom validators for shape props
Category: Shapes & tools
Keywords: validation, validators, check, refine, constraints, props, custom, shape
Demonstrates using custom validators with `.check()` and `.refine()` methods to add validation constraints to shape props.
This example shows how to create custom validators for shape properties using `@tldraw/validate`. It demonstrates:
- Chaining `.check()` calls to add validation constraints without transforming values
- Using `.refine()` to validate and transform values
The example creates a custom shape with two validated properties:
1. **Percentage** - Chains multiple `.check()` calls to validate that the value is between 0 and 100. Invalid values throw an error.
2. **Rating** - Uses `.refine()` to clamp values to the 1-5 range. Invalid values are transformed rather than rejected.
When the example loads, it demonstrates both behaviors: attempting to create a shape with percentage=150 throws an error, while creating a shape with rating=10 succeeds but the value is clamped to 5.
## App.tsx
```tsx
import { HTMLContainer, RecordProps, Rectangle2d, ShapeUtil, T, TLShape, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// [1]
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
'validated-shape': {
w: number
h: number
percentage: number
rating: number
}
}
}
type ValidatedShape = TLShape<'validated-shape'>
// [2]
const validatedShapeProps: RecordProps = {
w: T.positiveNumber,
h: T.positiveNumber,
// [3]
percentage: T.nonZeroFiniteNumber.check('max-value', (value) => {
if (value > 100) throw new Error('Percentage cannot exceed 100')
}),
// [4]
rating: T.integer.refine((value) => {
return Math.max(1, Math.min(5, value))
}),
}
class ValidatedShapeUtil extends ShapeUtil {
static override type = 'validated-shape' as const
static override props = validatedShapeProps
getDefaultProps(): ValidatedShape['props'] {
return { w: 300, h: 150, percentage: 50, rating: 3 }
}
getGeometry(shape: ValidatedShape) {
return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true })
}
component(shape: ValidatedShape) {
return (
)
}
/*
This example demonstrates custom validators using .check() and .refine() methods.
[1]
Extend TLGlobalShapePropsMap to register your custom shape's props with the type system.
[2]
Define validators for each prop. Each validator adds constraints beyond basic type checking.
[3]
Use .check() to add validation constraints. Each check validates without
transforming the value. The name (e.g. 'max-value') appears in error messages for debugging.
[4]
Use .refine() to transform values. Unlike .check(), refine() returns a (possibly modified)
value rather than just validating. Here it clamps the rating to 1-5 instead of throwing.
[5]
Create the shape utils array outside the component to prevent recreation on each render.
[6]
Create a valid shape on mount to show the default values.
[7]
Demonstrate .check() validation by attempting to create a shape with an invalid percentage.
Open your browser console to see the validation error.
[8]
Demonstrate .refine() transformation - this shape is created successfully with rating=10,
but the stored value is clamped to 5.
*/
```
--------
# Programmatic text shape creation
Category: Shapes & tools
Keywords: text, create, programmatic, autoSize, font, textAlign, richText, toRichText, bold, marks
Create and configure text shapes programmatically.
This example demonstrates how to create text shapes programmatically with various configuration options:
- **Auto-sized text** - Text shapes that automatically adjust width to fit content (`autoSize: true`)
- **Fixed-width text** - Text shapes with specified width that wrap content (`autoSize: false`, `w: number`)
- **Text alignment** - Horizontal alignment: `start` (left), `middle` (center), `end` (right)
- **Font styles** - Different fonts: `draw` (handdrawn), `sans`, `serif`, `mono`
- **Sizes** - Font sizes: `s`, `m`, `l`, `xl`
- **Rich text formatting** - Bold, italic, and other formatting using marks
Text shapes use rich text format internally. Use `toRichText('your text')` to convert plain text strings. For formatting like bold or italic, construct the rich text document with marks as shown in the example.
## App.tsx
```tsx
import { Tldraw, createShapeId, toRichText } from 'tldraw'
import 'tldraw/tldraw.css'
export default function ProgrammaticTextShapeCreationAndConfigurationExample() {
return (
)
}
/*
[1]
Create an auto-sized text shape. When autoSize is true, the text shape automatically
adjusts its width to fit the content. This is the default behavior for text shapes.
Use toRichText() to convert plain text strings to the required rich text format.
[2]
Create a fixed-width text shape by setting autoSize to false and specifying a width.
The text will wrap when it reaches the specified width. This is useful for creating
text blocks with consistent formatting.
[3]
Create a text shape with center alignment. The textAlign property controls horizontal
text alignment within the shape: 'start' (left), 'middle' (center), or 'end' (right).
[4]
Create a text shape with bold formatting. To apply formatting like bold, italic, or
code, you need to construct the rich text document manually with marks. The marks array
on a text node specifies which formatting to apply. Available marks include 'bold',
'italic', 'code', 'link', and 'highlight'.
[5]
Create a text shape with a specific font. The font property supports 'draw' (handdrawn),
'sans' (sans-serif), 'serif' (serif), and 'mono' (monospace). The size property controls
the font size: 's', 'm', 'l', or 'xl'.
Note: The richText property requires a TLRichText object. Use toRichText('your text')
to convert plain text strings. For rich formatting with marks, construct the document
structure directly as shown in example [4].
*/
```
--------
# Data grid shape
Category: Shapes & tools
Keywords: data grid ag grid
A custom shape that renders AG Grid.
This example shows how to create a custom shape that renders AG Grid.
## App.tsx
```tsx
/* eslint-disable react-hooks/rules-of-hooks */
import { AgGridReact } from 'ag-grid-react'
import { BaseBoxShapeUtil, TLShape, Tldraw, createShapeId, useDelaySvgExport } from 'tldraw'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-quartz.css'
import 'tldraw/tldraw.css'
const AG_GRID_TYPE = 'ag-grid'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[AG_GRID_TYPE]: { w: number; h: number; rowData: any[]; columnDefs: any[] }
}
}
type AgGridShape = TLShape
class AgGridShapeUtil extends BaseBoxShapeUtil {
static override type = AG_GRID_TYPE
override canScroll(): boolean {
return true
}
override canEdit(): boolean {
return true
}
override getDefaultProps() {
return {
w: 300,
h: 200,
rowData: [],
columnDefs: [],
}
}
override component(shape: AgGridShape) {
const isEditing = this.editor.getEditingShapeId() === shape.id
const isReady = useDelaySvgExport()
return (
)
}
/*
[1]
First, we need to extend TLGlobalShapePropsMap to add our shape's props to the global type system.
This tells TypeScript about the shape's properties. Here we use Record since our shapes
don't need any custom properties. These are very basic custom shapes: see the custom shape examples for
more complex examples.
[2]
Define the shape types using TLShape with each shape's type as a type argument.
[3]
Create a ShapeUtil for the counter shape. This defines how the shape behaves and renders. We disable resizing
and use Circle2d geometry for collision detection. The component renders as a red circle using HTMLContainer.
[4]
Create a ShapeUtil for the grid shape. This creates a rectangular grid that can accept dropped shapes. We use
Rectangle2d geometry and render it with CSS grid lines using background gradients.
[5]
Override onDragShapesIn to handle when shapes are dragged into the grid. We filter for counter shapes that
aren't already children of this grid, then reparent them to become children. This makes them move with the grid.
[6]
Override onDragShapesOut to handle when shapes are dragged out of the grid. If they're not being dragged to
another shape, we reparent them back to the page level, making them independent again.
*/
```
--------
# Attach shapes together (bindings)
Category: Shapes & tools
Keywords: attach
A sticker shape, using bindings to attach shapes to one and other
This example shows how to use bindings to attach shapes together. In this case, we've created a sticker that can be stuck onto other shapes.
## App.tsx
```tsx
import {
BindingOnShapeChangeOptions,
BindingOnShapeDeleteOptions,
BindingUtil,
Box,
DefaultToolbar,
DefaultToolbarContent,
RecordProps,
Rectangle2d,
ShapeUtil,
StateNode,
TLBinding,
TLPointerEventInfo,
TLShape,
TLUiComponents,
TLUiOverrides,
Tldraw,
TldrawUiMenuItem,
VecModel,
createShapeId,
invLerp,
lerp,
useIsToolSelected,
useTools,
} from 'tldraw'
import 'tldraw/tldraw.css'
const STICKER_TYPE = 'sticker'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[STICKER_TYPE]: Record
}
}
type StickerShape = TLShape
const offsetX = -16
const offsetY = -26
class StickerShapeUtil extends ShapeUtil {
static override type = STICKER_TYPE
static override props: RecordProps = {}
override getDefaultProps() {
return {}
}
override canBind() {
// stickers can bind to anything
return true
}
override canEdit() {
return false
}
override canResize() {
return false
}
override canSnap() {
return false
}
override hideRotateHandle() {
return true
}
override isAspectRatioLocked() {
return true
}
override getGeometry() {
return new Rectangle2d({
width: 32,
height: 32,
x: offsetX,
y: offsetY,
isFilled: true,
})
}
override component() {
return (
)
}
/*
Introduction:
This example demonstrates how to create a shape whose size is determined by its DOM content rather than
shape props. It showcases two potentially reusable utilities: ShapeSizes and useDynamicShapeSize, which
can be adapted for other shapes that need DOM-driven sizing.
[1]
First, we need to extend TLGlobalShapePropsMap to add our shape's props to the global type system.
This tells TypeScript about the shape's properties.
[2]
Define the shape type. This shape only stores content data - its size is determined dynamically by
measuring the DOM element that renders the content.
[3]
ShapeSizes is a global EditorAtom that stores size information for shapes by their ID. This is the key
piece that makes DOM-driven sizing work:
[a] We register a cleanup handler to remove size data when shapes are deleted, preventing memory leaks.
[4]
useDynamicShapeSize is a reusable hook that measures DOM elements and updates the shape size data:
[a] We measure the actual DOM dimensions using offsetWidth/offsetHeight
[b] We store these dimensions in our global ShapeSizes atom. The atom will trigger re-renders of
components that depend on this data when the size changes.
[c] We measure immediately on every render to ensure we have current size data
[d] We use ResizeObserver to watch for size changes and update accordingly. This is what makes
the shape truly dynamic - it will update whenever the DOM content changes size.
[5]
The shape util defines how our dynamic-size shape behaves:
[a] Standard shape type and props definition. Note we only store content, not size.
[b] Default props with some sample content
[c] Prevent the shape from being culled when it's outside the viewport, which would break our measurements
[d] Shape behavior: not editable, not resizable (since size comes from DOM), aspect ratio locked
[e] getGeometry uses the size from our ShapeSizes atom. This is where the DOM-measured size gets
used by the editor for hit-testing, selection bounds, etc.
[f] The component renders the content and uses our hook to measure it:
[i] We animate the text content to demonstrate the dynamic sizing in action
[ii] The ref from useDynamicShapeSize is attached to the DOM element we want to measure
[g] Standard indicator for selection outline
[6]
Standard setup - pass our custom shape util to Tldraw and create an instance on mount.
Reusability:
The ShapeSizes atom and useDynamicShapeSize hook are designed to be reusable. To use them with other
shapes, you just need to:
1. Call useDynamicShapeSize(shape) in your component and attach the returned ref
2. Use ShapeSizes.get(editor).get(shapeId) in your getGeometry method
3. Ensure your shape doesn't have conflicting size props (or handle the conflict appropriately)
*/
```
## contents.ts
```ts
export const contents = `
Have five minutes? Let's try out the tldraw SDK in a React project. If you're new to React, we recommend using a Vite template as a starter. We'll assume your project is already running locally.
Prefer to jump straight to some code? Try this sandbox.
First, install the tldraw package from NPM:
npm install tldraw
Next, in your React project, import the Tldraw component and tldraw's CSS styles. Then render the Tldraw component inside a full screen container:
That's pretty much it! At this point, you should have a complete working single-user canvas. You can draw and write on the canvas, add images and video, zoom and pan, copy and paste, undo and redo, and do just about everything else you'd expect to do on a canvas.
You'll be starting from our default shapes, tools, and user interface, but you can customize all of these things for your project if you wish. For now, let's show off a few more features.
`
.replace(/\n/g, '')
.replace(/\s+/g, ' ')
.split(' ')
```
--------
# Layout constraints (bindings)
Category: Shapes & tools
Keywords: constraints, group, shape, custom, bindings, drag, drop, position
How to constrain shapes to a layout using bindings.
You can use bindings to make shapes respond to changes to other shapes. This is useful for enforcing layout constraints
## App.tsx
```tsx
import {
BindingOnChangeOptions,
BindingOnCreateOptions,
BindingOnDeleteOptions,
BindingOnShapeChangeOptions,
BindingUtil,
HTMLContainer,
IndexKey,
RecordProps,
Rectangle2d,
ShapeUtil,
T,
TLBinding,
TLShape,
Tldraw,
Vec,
clamp,
createBindingId,
getIndexBetween,
} from 'tldraw'
import 'tldraw/tldraw.css'
import snapShot from './snapshot.json'
const CONTAINER_TYPE = 'container'
const ELEMENT_TYPE = 'element'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[CONTAINER_TYPE]: { height: number; width: number }
[ELEMENT_TYPE]: { color: string }
}
}
// The container shapes that can contain element shapes
const CONTAINER_PADDING = 24
type ContainerShape = TLShape
class ContainerShapeUtil extends ShapeUtil {
static override type = CONTAINER_TYPE
static override props: RecordProps = { height: T.number, width: T.number }
override getDefaultProps() {
return {
width: 100 + CONTAINER_PADDING * 2,
height: 100 + CONTAINER_PADDING * 2,
}
}
override canBind({
fromShapeType,
toShapeType,
bindingType,
}: {
fromShapeType: string
toShapeType: string
bindingType: string
}) {
return fromShapeType === 'container' && toShapeType === 'element' && bindingType === LAYOUT_TYPE
}
override canEdit() {
return false
}
override canResize() {
return false
}
override hideRotateHandle() {
return true
}
override isAspectRatioLocked() {
return true
}
override getGeometry(shape: ContainerShape) {
return new Rectangle2d({
width: shape.props.width,
height: shape.props.height,
isFilled: true,
})
}
override component(shape: ContainerShape) {
return (
)
}
override indicator(shape: ContainerShape) {
return
}
}
// The element shapes that can be placed inside the container shapes
type ElementShape = TLShape
class ElementShapeUtil extends ShapeUtil {
static override type = ELEMENT_TYPE
static override props: RecordProps = {
color: T.string,
}
override getDefaultProps() {
return {
color: '#AEC6CF',
}
}
override canBind({
fromShapeType,
toShapeType,
bindingType,
}: {
fromShapeType: string
toShapeType: string
bindingType: string
}) {
return fromShapeType === 'container' && toShapeType === 'element' && bindingType === LAYOUT_TYPE
}
override canEdit() {
return false
}
override canResize() {
return false
}
override hideRotateHandle() {
return true
}
override isAspectRatioLocked() {
return true
}
override getGeometry() {
return new Rectangle2d({
width: 100,
height: 100,
isFilled: true,
})
}
override component(shape: ElementShape) {
return
}
override indicator() {
return
}
private getTargetContainer(shape: ElementShape, pageAnchor: Vec) {
// Find the container shape that the element is being dropped on
return this.editor.getShapeAtPoint(pageAnchor, {
hitInside: true,
filter: (otherShape) =>
this.editor.canBindShapes({ fromShape: otherShape, toShape: shape, binding: LAYOUT_TYPE }),
}) as ContainerShape | undefined
}
getBindingIndexForPosition(shape: ElementShape, container: ContainerShape, pageAnchor: Vec) {
// All the layout bindings from the container
const allBindings = this.editor
.getBindingsFromShape(container, LAYOUT_TYPE)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
// Those bindings that don't involve the element
const siblings = allBindings.filter((b) => b.toId !== shape.id)
// Get the relative x position of the element center in the container
// Where should the element be placed? min index at left, max index + 1
const order = clamp(
Math.round((pageAnchor.x - container.x - CONTAINER_PADDING) / (100 + CONTAINER_PADDING)),
0,
siblings.length + 1
)
// Get a fractional index between the two siblings
const belowSib = allBindings[order - 1]
const aboveSib = allBindings[order]
let index: IndexKey
if (belowSib?.toId === shape.id) {
index = belowSib.props.index
} else if (aboveSib?.toId === shape.id) {
index = aboveSib.props.index
} else {
index = getIndexBetween(belowSib?.props.index, aboveSib?.props.index)
}
return index
}
override onTranslateStart(shape: ElementShape) {
// Update all the layout bindings for this shape to be placeholders
this.editor.updateBindings(
this.editor.getBindingsToShape(shape, LAYOUT_TYPE).map((binding) => ({
...binding,
props: { ...binding.props, placeholder: true },
}))
)
}
override onTranslate(_: ElementShape, shape: ElementShape) {
// Find the center of the element shape
const pageAnchor = this.editor.getShapePageTransform(shape).applyToPoint({ x: 50, y: 50 })
// Find the container shape that the element is being dropped on
const targetContainer = this.getTargetContainer(shape, pageAnchor)
if (!targetContainer) {
// Delete all the bindings to the element
const bindings = this.editor.getBindingsToShape(shape, LAYOUT_TYPE)
this.editor.deleteBindings(bindings)
return
}
// Get the index for the new binding
const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)
// Is there an existing binding already between the container and the shape?
const existingBinding = this.editor
.getBindingsFromShape(targetContainer, LAYOUT_TYPE)
.find((b) => b.toId === shape.id)
if (existingBinding) {
// If a binding already exists, update it
if (existingBinding.props.index === index) return
this.editor.updateBinding({
...existingBinding,
props: {
...existingBinding.props,
placeholder: true,
index,
},
})
} else {
// ...otherwise, create a new one
this.editor.createBinding({
id: createBindingId(),
type: LAYOUT_TYPE,
fromId: targetContainer.id,
toId: shape.id,
props: {
index,
placeholder: true,
},
})
}
}
override onTranslateEnd(_: ElementShape, shape: ElementShape) {
// Find the center of the element shape
const pageAnchor = this.editor.getShapePageTransform(shape).applyToPoint({ x: 50, y: 50 })
// Find the container shape that the element is being dropped on
const targetContainer = this.getTargetContainer(shape, pageAnchor)
// No target container? no problem
if (!targetContainer) return
// get the index for the new binding
const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)
// delete all the previous bindings for this shape
this.editor.deleteBindings(this.editor.getBindingsToShape(shape, LAYOUT_TYPE))
// ...and then create a new one
this.editor.createBinding({
id: createBindingId(),
type: LAYOUT_TYPE,
fromId: targetContainer.id,
toId: shape.id,
props: {
index,
placeholder: false,
},
})
}
}
// The binding between the element shapes and the container shapes
const LAYOUT_TYPE = 'layout'
declare module 'tldraw' {
export interface TLGlobalBindingPropsMap {
[LAYOUT_TYPE]: {
index: IndexKey
placeholder: boolean
}
}
}
type LayoutBinding = TLBinding
class LayoutBindingUtil extends BindingUtil {
static override type = LAYOUT_TYPE
override getDefaultProps() {
return {
index: 'a1' as IndexKey,
placeholder: true,
}
}
override onAfterCreate({ binding }: BindingOnCreateOptions): void {
this.updateElementsForContainer(binding)
}
override onAfterChange({ bindingAfter }: BindingOnChangeOptions): void {
this.updateElementsForContainer(bindingAfter)
}
override onAfterChangeFromShape({ binding }: BindingOnShapeChangeOptions): void {
this.updateElementsForContainer(binding)
}
override onAfterDelete({ binding }: BindingOnDeleteOptions): void {
this.updateElementsForContainer(binding)
}
private updateElementsForContainer({
props: { placeholder },
fromId: containerId,
toId,
}: LayoutBinding) {
// Get all of the bindings from the layout container
const container = this.editor.getShape(containerId)
if (!container) return
const bindings = this.editor
.getBindingsFromShape(container, LAYOUT_TYPE)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
if (bindings.length === 0) return
for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i]
if (toId === binding.toId && placeholder) continue
const offset = new Vec(CONTAINER_PADDING + i * (100 + CONTAINER_PADDING), CONTAINER_PADDING)
const shape = this.editor.getShape(binding.toId)
if (!shape) continue
const point = this.editor.getPointInParentSpace(
shape,
this.editor.getShapePageTransform(container)!.applyToPoint(offset)
)
if (shape.x !== point.x || shape.y !== point.y) {
this.editor.updateShape({
id: binding.toId,
type: 'element',
x: point.x,
y: point.y,
})
}
}
const width =
CONTAINER_PADDING +
(bindings.length * 100 + (bindings.length - 1) * CONTAINER_PADDING) +
CONTAINER_PADDING
const height = CONTAINER_PADDING + 100 + CONTAINER_PADDING
if (width !== container.props.width || height !== container.props.height) {
this.editor.updateShape({
id: container.id,
type: 'container',
props: { width, height },
})
}
}
}
export default function LayoutExample() {
return (
)
}
```
--------
# Rich text with custom extension and toolbar
Category: Shapes & tools
Keywords: text, tip, tap, extension, toolbar
Extend the TipTap text editor by adding a custom extension and toolbar.
This example shows how to add a custom extension and toolbar to the TipTap text editor by setting the `textOptions` prop.
## App.tsx
```tsx
import { Mark, mergeAttributes } from '@tiptap/core'
import { StarterKit } from '@tiptap/starter-kit'
import {
DefaultRichTextToolbar,
TLComponents,
Tldraw,
TldrawUiButton,
preventDefault,
useEditor,
useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'
import './RichTextCustomExtension.css'
interface WavyExtensionOptions {
HTMLAttributes: object
}
declare module '@tiptap/core' {
interface Commands {
wavy: {
setWavy(): ReturnType
toggleWavy(): ReturnType
unsetWavy(): ReturnType
}
}
}
const Wavy = Mark.create({
name: 'wavy',
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [
{
tag: 'span.wavy',
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(this.options.HTMLAttributes, { class: 'wavy' }, HTMLAttributes),
0,
]
},
addCommands() {
return {
setWavy:
() =>
({ commands }) =>
commands.setMark(this.name),
toggleWavy:
() =>
({ commands }: any) =>
commands.toggleMark(this.name),
unsetWavy:
() =>
({ commands }) =>
commands.unsetMark(this.name),
}
},
})
const components: TLComponents = {
RichTextToolbar: () => {
const editor = useEditor()
const textEditor = useValue('textEditor', () => editor.getRichTextEditor(), [editor])
return (
{
textEditor?.chain().focus().toggleWavy().run()
}}
isActive={textEditor?.isActive('wavy')}
onPointerDown={preventDefault}
>
〰️
{/* Add the DefaultRichTextToolbarContent if you want to add more items. */}
{/* {}} /> */}
)
},
}
const textOptions = {
tipTapConfig: {
extensions: [StarterKit, Wavy],
},
}
export default function RichTextCustomExtensionExample() {
return (
)
}
/*
This example shows how to set custom properties on the TipTap editor.
*/
```
## RichTextCustomExtension.css
```css
.wavy {
text-decoration: wavy underline hotpink;
}
```
--------
# Rich text with font options
Category: Shapes & tools
Keywords: text, tip, tap, extension, toolbar, font
Extend the TipTap text editor by adding font-family and font-size options.
This example shows how to add font-family and font-size options to the TipTap text editor by setting the `textOptions` prop.
## App.tsx
```tsx
import { EditorEvents as TextEditorEvents } from '@tiptap/core'
import { FontFamily } from '@tiptap/extension-font-family'
import { TextStyleKit } from '@tiptap/extension-text-style'
import { EditorState as TextEditorState } from '@tiptap/pm/state'
import { useEffect, useState } from 'react'
import {
DefaultRichTextToolbar,
DefaultRichTextToolbarContent,
Editor,
TLComponents,
TLTextOptions,
Tldraw,
defaultAddFontsFromNode,
tipTapDefaultExtensions,
useEditor,
useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'
import { FontSize } from './FontSizeExtension'
import './RichTextFontExtension.css'
import { extensionFontFamilies } from './fonts'
const fontOptions = [
{ label: 'Default', value: 'DEFAULT' },
{ label: 'Inter', value: 'Inter' },
{ label: 'Comic Sans MS', value: 'Comic Sans MS' },
{ label: 'serif', value: 'serif' },
{ label: 'monospace', value: 'monospace' },
{ label: 'cursive', value: 'cursive' },
{ label: 'Exo 2 (Google Font)', value: "'Exo 2'" },
]
const fontSizeOptions = [
{ label: 'Small', value: '12px' },
{ label: 'Normal', value: '16px' },
{ label: 'Large', value: '20px' },
{ label: 'X-Large', value: '24px' },
{ label: 'XX-Large', value: '28px' },
{ label: 'Huge', value: '32px' },
]
const components: TLComponents = {
RichTextToolbar: () => {
const editor = useEditor()
const textEditor = useValue('textEditor', () => editor.getRichTextEditor(), [editor])
const [_, setTextEditorState] = useState(textEditor?.state ?? null)
// Set up text editor transaction listener.
useEffect(() => {
if (!textEditor) {
setTextEditorState(null)
return
}
const handleTransaction = ({ editor: textEditor }: TextEditorEvents['transaction']) => {
setTextEditorState(textEditor.state)
}
textEditor.on('transaction', handleTransaction)
return () => {
textEditor.off('transaction', handleTransaction)
setTextEditorState(null)
}
}, [textEditor])
if (!textEditor) return null
const currentFontFamily = textEditor?.getAttributes('textStyle').fontFamily ?? 'DEFAULT'
const currentFontSize = textEditor?.getAttributes('textStyle').fontSize
return (
{/* Add the DefaultRichTextToolbarContent if you want to add more items. */}
)
},
}
const textOptions: Partial = {
tipTapConfig: {
extensions: [...tipTapDefaultExtensions, FontFamily, FontSize, TextStyleKit],
},
addFontsFromNode(node, state, addFont) {
state = defaultAddFontsFromNode(node, state, addFont)
// if we have a font-family attribute, keep track of that in the state so it applies to children
for (const mark of node.marks) {
if (
mark.type.name === 'textStyle' &&
mark.attrs.fontFamily &&
mark.attrs.fontFamily !== 'DEFAULT' &&
mark.attrs.fontFamily !== state.family
) {
state = { ...state, family: mark.attrs.fontFamily }
}
}
// if one of our extension font families matches the current state, add that font to the document.
const font = extensionFontFamilies[state.family]?.[state.style]?.[state.weight]
if (font) addFont(font)
return state
},
}
export default function RichTextFontExtensionExample() {
const fontFaces = Object.values(extensionFontFamilies)
.map((fontFamily) => Object.values(fontFamily))
.flat()
.map((fontStyle) => Object.values(fontStyle))
.flat()
// We need to preload the fonts so that they are available when
// making font changes. This is to avoid any FOUC as you change the
// font families.
const onMount = (editor: Editor) => {
editor.fonts.requestFonts(fontFaces)
}
const exoFont = extensionFontFamilies["'Exo 2'"].normal.normal.src.url
return (
)
}
/*
This example shows how to set font family and font size properties on the TipTap editor.
*/
```
## FontSizeExtension.ts
```ts
import '@tiptap/extension-text-style'
import { Extension } from '@tiptap/core'
export interface FontSizeOptions {
/**
* A list of node names where the font size can be applied.
* @default ['textStyle']
* @example ['heading', 'paragraph']
*/
types: string[]
}
declare module '@tiptap/core' {
interface Commands {
fontSize: {
/**
* Set the font size
* @param fontSize The font size
* @example editor.commands.setFontSize('32px')
*/
setFontSize(fontSize: string): ReturnType
/**
* Unset the font size
* @example editor.commands.unsetFontSize()
*/
unsetFontSize(): ReturnType
}
}
}
/**
* This extension allows you to set a font size for text.
*/
export const FontSize = Extension.create({
name: 'fontSize',
addOptions() {
return {
types: ['textStyle'],
}
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element) => element.style.fontSize,
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return {}
}
return {
style: `font-size: ${attributes.fontSize}`,
}
},
},
},
},
]
},
addCommands() {
return {
setFontSize:
(fontSize) =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize }).run()
},
unsetFontSize:
() =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run()
},
}
},
})
```
## RichTextFontExtension.css
```css
.rich-text-font-extension-select {
border: 0;
background: transparent;
margin: 0 8px;
}
```
## fonts.ts
```ts
import { TLDefaultFont, TLFontFace } from 'tldraw'
// NOTE: these fonts only support the latin character set. To support other languages, you'll add
// each one as a new font-family, similar to how you would with @font-face.
export const extensionFontFamilies: {
[key: string]: { [key: string]: { [key: string]: TLFontFace } }
} = {
Inter: {
normal: {
normal: {
family: 'Inter',
src: {
url: 'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fAZ9hiJ-Ek-_EeA.woff2',
format: 'woff2',
},
weight: '500',
style: 'normal',
},
bold: {
family: 'Inter',
src: {
url: 'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hiJ-Ek-_EeA.woff2',
format: 'woff2',
},
weight: '700',
style: 'normal',
},
},
italic: {
normal: {
family: 'Inter',
src: {
url: 'https://fonts.gstatic.com/s/inter/v18/UcCM3FwrK3iLTcvneQg7Ca725JhhKnNqk4j1ebLhAm8SrXTc69tRipWFsevceSGM.woff2',
format: 'woff2',
},
weight: '500',
style: 'normal',
},
bold: {
family: 'Inter',
src: {
url: 'https://fonts.gstatic.com/s/inter/v18/UcCM3FwrK3iLTcvneQg7Ca725JhhKnNqk4j1ebLhAm8SrXTcPtxRipWFsevceSGM.woff2',
format: 'woff2',
},
weight: '700',
style: 'normal',
},
},
},
"'Exo 2'": {
normal: {
normal: {
family: 'Exo 2',
src: {
url: 'https://fonts.gstatic.com/s/exo2/v24/7cH1v4okm5zmbvwkAx_sfcEuiD8jjPKsOdC_jJ7bpAhL.woff2',
format: 'woff2',
},
weight: '500',
style: 'normal',
},
bold: {
family: 'Exo 2',
src: {
url: 'https://fonts.gstatic.com/s/exo2/v24/7cH1v4okm5zmbvwkAx_sfcEuiD8jWfWsOdC_jJ7bpAhL.woff2',
format: 'woff2',
},
weight: '700',
style: 'normal',
},
},
italic: {
normal: {
family: 'Exo 2',
src: {
url: 'https://fonts.gstatic.com/s/exo2/v24/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drFGfeC9hp_5oBBKRrs.woff2',
format: 'woff2',
},
weight: '500',
style: 'normal',
},
bold: {
family: 'Exo 2',
src: {
url: 'https://fonts.gstatic.com/s/exo2/v24/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drGTeuC9hp_5oBBKRrs.woff2',
format: 'woff2',
},
weight: '700',
style: 'normal',
},
},
},
} satisfies Record
```
--------
# Outlined text example
Category: Shapes & tools
Keywords: text, outline, stroke, extension, toolbar, styling
Add outlined text styling to the TipTap text editor with a custom extension.
This example shows how to add a text outline effect by creating a custom TipTap extension that applies CSS text-stroke styling to selected text. The example includes a custom toolbar button to toggle the outline effect on and off.
## App.tsx
```tsx
import { Mark, mergeAttributes } from '@tiptap/core'
import { StarterKit } from '@tiptap/starter-kit'
import {
DefaultRichTextToolbar,
TLComponents,
Tldraw,
TldrawUiButton,
preventDefault,
useEditor,
useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'
import './OutlinedTextExample.css'
interface OutlineExtensionOptions {
HTMLAttributes: object
}
declare module '@tiptap/core' {
interface Commands {
outline: {
setOutline(): ReturnType
toggleOutline(): ReturnType
unsetOutline(): ReturnType
}
}
}
const Outline = Mark.create({
name: 'outline',
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [
{
tag: 'span.outlined',
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(this.options.HTMLAttributes, { class: 'outlined filled' }, HTMLAttributes),
0,
]
},
addCommands() {
return {
setOutline:
() =>
({ commands }) =>
commands.setMark(this.name),
toggleOutline:
() =>
({ commands }: any) =>
commands.toggleMark(this.name),
unsetOutline:
() =>
({ commands }) =>
commands.unsetMark(this.name),
}
},
onCreate() {
this.editor.commands.toggleMark('outline')
},
})
const components: TLComponents = {
RichTextToolbar: () => {
const editor = useEditor()
const textEditor = useValue('textEditor', () => editor.getRichTextEditor(), [editor])
return (
{
textEditor?.chain().focus().toggleOutline().run()
}}
isActive={textEditor?.isActive('outline')}
onPointerDown={preventDefault}
title="Toggle text outline"
>
⬜
)
},
}
const textOptions = {
tipTapConfig: {
extensions: [StarterKit, Outline],
},
}
export default function OutlinedTextExample() {
return (
)
}
/*
This example shows how to add outlined text styling using a custom TipTap extension.
The outline effect is created using CSS text-stroke properties.
*/
```
## OutlinedTextExample.css
```css
.outlined {
-webkit-text-stroke: 2px #000000;
text-stroke: 2px #000000;
color: transparent;
font-weight: bold;
}
.outlined.filled {
color: #ffffff;
}
```
--------
# Popup shape
Category: Shapes & tools
Keywords: dynamic shadows, css
Create a 3D illusion of depth with dynamic shadows and CSS transforms.
Create a 3D illusion of depth with dynamic shadows and CSS transforms.
## App.tsx
```tsx
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { PopupShapeUtil } from './PopupShapeUtil'
const customShapeUtils = [PopupShapeUtil]
export default function PopupShapeExample() {
return (
{
for (let i = 0; i < 9; i++) {
editor.createShape({
type: 'my-popup-shape',
x: (i % 3) * 220,
y: Math.floor(i / 3) * 220,
})
}
editor.zoomToBounds(editor.getCurrentPageBounds()!, { animation: { duration: 0 } })
}}
/>
)
}
indicator(shape: IMyPopupShape) {
return
}
}
```
--------
# Multiplayer sync
Category: Collaboration
Keywords: basic, intro, simple, quick, start, multiplayer, sync, collaboration
Use tldraw sync for multiplayer collaboration.
The `useSyncDemo` hook can be used to quickly prototype multiplayer experiences in tldraw using a demo backend that we host. Data is wiped after one day.
## App.tsx
```tsx
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function SyncDemoExample({ roomId }: { roomId: string }) {
const store = useSyncDemo({ roomId })
return (
)
}
```
--------
# Multiplayer sync with a custom shape
Category: Collaboration
Keywords: basic, intro, simple, quick, start, multiplayer, sync, collaboration, custom shape
Use a custom shape in combination with tldraw sync.
This example shows a custom shape in combination with tldraw sync.
## App.tsx
```tsx
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { CounterShapeTool, CounterShapeUtil } from './CounterShape'
import { components, uiOverrides } from './ui'
const customShapes = [CounterShapeUtil]
const customTools = [CounterShapeTool]
export default function SyncDemoShapeExample({ roomId }: { roomId: string }) {
const store = useSyncDemo({ roomId, shapeUtils: customShapes })
return (
)
}
```
## CounterShape.tsx
```tsx
import { MouseEvent } from 'react'
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLShape } from 'tldraw'
const COUNTER_TYPE = 'counter'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[COUNTER_TYPE]: { w: number; h: number; count: number }
}
}
export type CounterShape = TLShape
export class CounterShapeUtil extends BaseBoxShapeUtil {
static override type = COUNTER_TYPE
static override props = {
w: T.positiveNumber,
h: T.positiveNumber,
count: T.number,
}
override getDefaultProps() {
return {
w: 200,
h: 200,
count: 0,
}
}
override component(shape: CounterShape) {
const onClick = (event: MouseEvent, change: number) => {
event.stopPropagation()
this.editor.updateShape({
id: shape.id,
type: COUNTER_TYPE,
props: { count: shape.props.count + change },
})
}
return (
{shape.props.count}
)
}
override indicator(shape: CounterShape) {
return
}
}
export class CounterShapeTool extends BaseBoxShapeTool {
static override id = 'counter'
override shapeType = 'counter' as const
}
```
## ui.tsx
```tsx
import {
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
useIsToolSelected,
useTools,
} from 'tldraw'
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.counter = {
id: 'counter',
icon: 'color',
label: 'counter',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('counter')
},
}
return tools
},
}
export const components: TLComponents = {
Toolbar: (props) => {
const tools = useTools()
const isCounterSelected = useIsToolSelected(tools['counter'])
return (
)
},
}
```
--------
# Multiplayer sync with custom people menu
Category: Collaboration
Keywords: multiplayer, sync, collaboration, custom shape, presence, people, ui, facepile
A custom multiplayer people menu / facepile that displays connected collaborators.
This example demonstrates how to build a custom people menu (or facepile) that shows information about all users in a multiplayer session.
This is useful when you want to create a more detailed or custom-styled presence indicator than the default tldraw provides. This example shows user's names, ids, colors, and cursor position, all information provided in the `TLInstancePresence` returned by `editor.getCollaborators()`.
## App.tsx
```tsx
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw, useEditor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
import './sync-custom-people-menu.css'
// [1]
const components = {
SharePanel: () => (
)
}
/*
[1]
We define custom components to override tldraw's default UI. Here we're replacing the SharePanel with our own CustomPeopleMenu component.
[2]
This is the main component that sets up a synced tldraw editor. It uses the useSyncDemo hook to create a multiplayer store and passes our custom components to replace the default UI elements.
[3]
The CustomPeopleMenu component displays information about all connected users. It uses tldraw's collaboration hooks to access real-time presence data. You can do whatever you like in here, see the TLInstancePresence interface to see what informatino you have access to.
[a]
We use the useValue hook to reactively get the current user's information (color, name, and ID). These values will automatically update if the user changes their name or the system assigns a new color (note: the examlpe doesn't allow for name changing).
[b]
We get the live presence of all other users information using the editor's getCollaborators() method. We need to call getCollaborators() in a useValue hook in order for the presence info to be reactive.
[c]
Display the current user's information with their color indicator and name. We show both the display name and the internal user ID for debugging purposes.
[d]
For each connected collaborator, we display their name (or ID if no name is set), their color indicator, and their current cursor position.
*/
```
## sync-custom-people-menu.css
```css
/* Custom People Menu Styles */
.custom-people-menu {
background: #f5f5f5;
padding: 4px;
display: flex;
flex-direction: column;
gap: 12px;
width: 320px;
}
.user-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-title {
margin: 0;
}
.user-info {
display: flex;
align-items: center;
padding: 8px 12px;
gap: 8px;
}
.user-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 8px;
}
.user-name {
word-break: break-word;
}
.other-users-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.other-users-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.other-user-item {
display: flex;
align-items: start;
padding: 6px 12px;
}
.other-user-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 8px;
}
.other-user-name {
word-break: break-word;
width: 170px;
}
.cursor-info {
width: 80px;
color: #aaa;
font-size: 12px;
margin-left: 8px;
}
```
--------
# Multiplayer sync with custom presence
Category: Collaboration
Keywords: basic, intro, simple, quick, start, multiplayer, sync, collaboration, presence
Customize the presence data synced between different tldraw instances.
This example shows how to customize the presence data synced between different tldraw instances.
## App.tsx
```tsx
import { useSyncDemo } from '@tldraw/sync'
import { useEffect } from 'react'
import { Tldraw, getDefaultUserPresence, useAtom } from 'tldraw'
import 'tldraw/tldraw.css'
export default function SyncCustomUserExample({ roomId }: { roomId: string }) {
// [1]
const timer = useAtom('timer', Date.now())
useEffect(() => {
const tick = () => {
timer.set(Date.now())
frame = requestAnimationFrame(tick)
}
let frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [timer])
// [2]
const store = useSyncDemo({
roomId,
getUserPresence(store, user) {
// [2.1]
const defaults = getDefaultUserPresence(store, user)
if (!defaults) return null
return {
...defaults,
// [2.2]
camera: undefined,
// [2.3]
cursor: {
...defaults.cursor,
x: (defaults.cursor.x ?? 0) + 20 * Math.sin(timer.get() / 200),
y: (defaults.cursor.y ?? 0) + 20 * Math.cos(timer.get() / 200),
},
}
},
})
// [3]
return (
)
}
/**
* # Sync Custom User
*
* This example demonstrates how to use the sync demo server with custom presence state. The
* presence state is synchronized to all other clients and used for multiplayer features like
* cursors and viewport following. You can use custom presence state to change the data that's
* synced to other clients, or remove parts you don't need for your app.
*
* 1. We create a timer that updates every frame. You don't need to do this in your app, it's just
* to power an animation. We store it in an `atom` so that changes to it will cause the presence
* info to update.
*
* 2. We create a multiplayer store using the userSyncDemo hook, and pass in a custom
* `getUserPresence` function to change the presence state that gets sent.
*
* 2.1. We get the default presence state using the `getDefaultUserPresence` function. If you wanted
* to send a very minimal set of presence data, you could avoid this part.
*
* 2.2. We remove the camera from the presence state. This means that the camera position won't be
* sent to other clients. Attempting to follow this users viewport will not work.
*
* 2.3. We update the cursor position and rotation based on the current time. This will make the
* cursor spin around in a circle.
*
* 3. We create a Tldraw component and pass in the multiplayer store. This will render the editor.
*/
```
--------
# Multiplayer sync with custom user data
Category: Collaboration
Keywords: basic, intro, simple, quick, start, multiplayer, sync, collaboration, custom shape
Integrate your own user data into tldraw sync.
This example shows how to integrate your own user data into tldraw sync.
## App.tsx
```tsx
import { useSyncDemo } from '@tldraw/sync'
import { useState } from 'react'
import { TLUserPreferences, Tldraw, useTldrawUser } from 'tldraw'
import 'tldraw/tldraw.css'
export default function SyncCustomUserExample({ roomId }: { roomId: string }) {
// [1]
const [userPreferences, setUserPreferences] = useState({
id: 'user-' + Math.random(),
name: 'Jimmothy',
color: 'palevioletred',
colorScheme: 'dark',
})
// [2]
const store = useSyncDemo({ roomId, userInfo: userPreferences })
// [3]
const user = useTldrawUser({ userPreferences, setUserPreferences })
// [4]
return (
)
}
/**
* # Sync Custom User
*
* This example demonstrates how to use the sync demo server with a custom user.
*
* You need access to two things to do this integration:
*
* - The user info
* - A function to set the user info
*
* In this example we create an in-memory state for the user info, but in your system it's probably synchronized with a backend database somehow.
*
* 1. We get our user info and a function to set it from a `useState` hook. In your app this might come from a context provider or you might hook it up manually to your backend.
* 2. We use the `useSyncDemo` hook to create the multiplayer store, and pass in the current user state as `userInfo`, which is a subset of the `userPreferences` type.
* 3. We use the `useTLUser` hook to create a TLUser object, which allows the Editor to both read and update the user info and preferences.
* 4. We render the `Tldraw` component with the multiplayer store and the user object.
*
* You can pass the same `user` object into the `useSync` hook if you're using your own server.
*/
```
--------
# Multiplayer sync with private content
Category: Collaboration
Keywords: basic, intro, simple, quick, start, multiplayer, sync, collaboration
Show and hide private content in a multiplayer session.
This is a simple example of how to show and hide private content in a multiplayer session based on a simple
data ownership model. Try it out by opening the example in two different tabs, toggling the private mode, and
drawing some shapes.
## App.tsx
```tsx
import { useSyncDemo } from '@tldraw/sync'
import React from 'react'
import {
Atom,
TLComponents,
Tldraw,
react,
useAtom,
useEditor,
useIsToolSelected,
useTools,
useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'
import { VisibilityOff, VisibilityOn } from '../../icons/icons'
import { Toggle } from './Toggle'
import './style.css'
// There's a guide at the bottom of this file!
// [1]
const PrivateModeContext = React.createContext>(null)
const components: TLComponents = {
// [2]
InFrontOfTheCanvas: () => {
const editor = useEditor()
const isInSelectTool = useIsToolSelected(useTools().select)
const userId = useValue('userId', () => editor.user.getId(), [])
const myPrivateSelectedShapes = useValue(
'private shapes',
() =>
editor
.getSelectedShapes()
.filter((shape) => !!shape.meta.private && shape.meta.ownerId === userId),
[editor, userId]
)
// [3]
const isPrivateMode$ = React.useContext(PrivateModeContext)!
const isPrivateMode = useValue(isPrivateMode$)
return (
<>
{isInSelectTool && myPrivateSelectedShapes.length > 0 ? (
)
}
export default function SyncPrivateContentExample({ roomId }: { roomId: string }) {
return (
)
}
/**
* This example demonstrates how to create a 'private' drawing mode where any shapes created by one person cannot be seen by another.
* It sets up a simple ownership system where each shape created is tagged with the id of the user who created it.
* It also adds a boolean flag to each shape called 'private' which is set to true if the shape is created in private mode.
* If the user selects one or more private shapes, they will be given the option to make them public.
*
* 1. We create a context to store the atom that will hold the state of the private drawing mode. We are using signals here but you can use any state management library you like.
* 2. We override the `InFrontOfTheCanvas` component to add a tool panel at the top of the screen that allows the user to toggle private drawing mode on and off, and to make private shapes public.
* 3. We use the context to get the atom that holds the state of the private drawing mode. We then have to call 'useValue' on the atom to get the current value in a reactive way.
* 4. We override the `getShapeVisibility` function to hide shapes that are private and not owned by the current user.
* 5. We register a side effect that adds the 'private' and 'ownerId' meta fields to each shape created. We set the 'private' field to the current value of the private drawing mode atom.
* 6. We register a side effect that cleans up the selection by removing any hidden shapes from the selection. This re-runs whenever the selection or the hidden state of a selected shape changes.
* 7. Child shapes (e.g inside groups and frames) do not inherit the 'private' meta property from their parent. So when making a shape public, we decide to also make all descendant shapes public since this is most likely what the user intended.
*/
```
## Toggle.tsx
```tsx
export function Toggle({ isChecked }: { isChecked: boolean }) {
const height = 20
const width = 40
const pillSize = height - 4
return (
{isChecked && (
ON
)}
{!isChecked && (
OFF
)}
)
}
```
## style.css
```css
.toggle-panel {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
border-radius: 8px;
background: #efefef;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.1),
0 4px 8px rgba(0, 0, 0, 0.1);
padding: 9px 10px 9px 15px;
display: flex;
align-items: center;
gap: 10px;
}
.toggle-panel button {
cursor: pointer;
}
.toggle-panel.pointer {
cursor: pointer;
}
```
--------
# Manually update user presence
Category: Collaboration
Keywords: cursor
Manually show other users editing the same document.
Here, we add fake `InstancePresence` records to the store to simulate other users. If you have your own presence system, you could add real records to the store in the same way.
## App.tsx
```tsx
import { useRef } from 'react'
import { InstancePresenceRecordType, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
// [1]
const USER_NAME = 'huppy da arrow'
const MOVING_CURSOR_SPEED = 0.25 // 0 is stopped, 1 is full send
const MOVING_CURSOR_RADIUS = 100
const CURSOR_CHAT_MESSAGE = 'Hey, I think this is just great.'
// [2]
export default function UserPresenceExample() {
const rRaf = useRef(-1)
return (
)
}
/*
This example shows how to add instance presence records to the store to show other users' cursors.
It is not an example of how to implement user presence, check out the collaboration examples for that:
https://tldraw.dev/examples/sync-demo
[1]
We're going to a fake a user's cursor and chat message, these are the values we'll use.
[2]
This is where we'll render the Tldraw component. We'll use the onMount callback to access the editor
instance.
[a] For every connected peer we need to add an instance presence record to the store. We can do
this using the InstancePresenceRecordType.create function and add it to the store using the
store.put method.
[b] We'll use the requestAnimationFrame function to update the cursor position and chat message.
This is just for demonstration purposes.
*/
```
--------
# Persist to storage
Category: Data & assets
Keywords: store, snapshot, debounce
Manually persist the contents of the editor to storage.
In this example, we load the contents of the editor from your browser's `localStorage`, and save it there when you make changes.
## App.tsx
```tsx
import { throttle } from 'lodash'
import { useLayoutEffect, useMemo, useState } from 'react'
import { DefaultSpinner, Tldraw, createTLStore, getSnapshot, loadSnapshot } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
const PERSISTENCE_KEY = 'example-3'
export default function PersistenceExample() {
//[1]
const store = useMemo(() => createTLStore(), [])
//[2]
const [loadingState, setLoadingState] = useState<
{ status: 'loading' } | { status: 'ready' } | { status: 'error'; error: string }
>({
status: 'loading',
})
//[3]
useLayoutEffect(() => {
setLoadingState({ status: 'loading' })
// Get persisted data from local storage
const persistedSnapshot = localStorage.getItem(PERSISTENCE_KEY)
if (persistedSnapshot) {
try {
const snapshot = JSON.parse(persistedSnapshot)
loadSnapshot(store, snapshot)
setLoadingState({ status: 'ready' })
} catch (error: any) {
setLoadingState({ status: 'error', error: error.message }) // Something went wrong
}
} else {
setLoadingState({ status: 'ready' }) // Nothing persisted, continue with the empty store
}
// Each time the store changes, run the (debounced) persist function
const cleanupFn = store.listen(
throttle(() => {
const snapshot = getSnapshot(store)
localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(snapshot))
}, 500)
)
return () => {
cleanupFn()
}
}, [store])
// [4]
if (loadingState.status === 'loading') {
return (
)
}
if (loadingState.status === 'error') {
return (
Error!
{loadingState.error}
)
}
return (
)
}
/*
This example shows how to implement persistence in the Tldraw component. We do
this by saving the editor's state to local storage each time it changes. You
should replace this in your app with some sort of backend storage solution. If
you just want to save to local storage, you can use the `persistenceKey` prop
instead. Simply pass any string to this prop and the editor will automatically
save to local storage.
[1]
We create a new store using the `createTLStore` helper function. We pass in the
default shape utils so that the store knows how to handle the built-in shapes.
[2]
This is a cool pattern that uses Typescript to help keep track of our app's
loading state.
[3]
We use the `useLayoutEffect` hook to run our persistence code after the first
render. First we grab the persisted snapshot from local storage. If there is
one, we load it into the store and set the loading state to ready. If there
isn't one, we just set the loading state to ready.
Then we setup a listener on the store that will run our persistence code each
time the store changes. We use the `throttle` helper function to debounce the
listener so that it doesn't run too often. We also return a cleanup function
that will remove the listener when the component unmounts.
[4]
This is where we render our application depending on the loading state. If the
loading state is `loading`, we render a loading message. If the loading state
is `error`, we render an error message. If the loading state is `ready`, we
render the Tldraw component.
*/
```
--------
# Static assets
Category: Data & assets
Keywords: icons, fonts, pre-load
Use custom fonts, icons, and preload them using the `Tldraw` component.
The `Tldraw` component relies on static assets for fonts and icons. These must be pre-loaded in order that the component runs properly.
## App.tsx
```tsx
import { Tldraw, TldrawProps } from 'tldraw'
import 'tldraw/tldraw.css'
// [1]
const assetUrls: TldrawProps['assetUrls'] = {
fonts: {
tldraw_draw: '/ComicMono.woff',
},
icons: {
'tool-arrow': '/custom-arrow-icon.svg',
},
}
export default function StaticAssetsExample() {
return (
)
}
/**
[1]
By default, the Tldraw component will pull in assets from tldraw's asset CDN.
You can override this behavior by providing your own asset URLs. These URLs can
be relative or absolute URLs.
In this case, we are using a relative URL to a custom arrow icon and a custom font.
Because this is a Vite project, these files are stored in this project's public folder.
Check your framework documentation for how to serve static assets.
Important! This object needs to be created outside of a React component, or else
memoized using a useMemo hook, otherwise it will cause the Tldraw component to
receive a new `asserUrls` object every time the component re-renders.
```tsx
export default function StaticAssetsExample() {
const assetUrls = useMemo(() => ({
fonts: {
tldraw_draw: '/ComicMono.woff',
},
icons: {
'tool-arrow': '/custom-arrow-icon.svg',
},
}, [])
return (
)
}
```
*/
```
--------
# Export canvas as image
Category: Data & assets
Keywords: basic, assets, svg, png, blob
Export the entire canvas as an image file.
This example shows how you can use the `Editor.toImage()` function to create an image of all shapes on the canvas and then download it. The easiest way to download an image is to use the download attribute of a link element.
## App.tsx
```tsx
import { Tldraw, TLUiComponents, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
function ExportCanvasButton() {
const editor = useEditor()
return (
)
}
const components: TLUiComponents = {
SharePanel: ExportCanvasButton,
}
export default function ExportCanvasImageExample() {
return (
)
}
/*
This example shows how you can use the `Editor.toImage()` function to create an image with all the shapes
on the canvas in it and then download it. The easiest way to download an image is to use the download
attribute of a link element.
To learn more about overriding UI you can check out our various custom menu examples. For more on handling
assets, check out our Local/Hosted images examples.
*/
```
--------
# Export canvas as image (with settings)
Category: Data & assets
Keywords: basic, assets, svg, png, blob, image, settings
Export the entire canvas as an image file, with configurable settings.
This example shows how you can use the `Editor.toImage()` function to create an image of all shapes on the canvas and then download it. It also shows options for the different settings you can pass to the `toImage` function.
## App.tsx
```tsx
import { useState } from 'react'
import { Box, Tldraw, TLImageExportOptions, TLUiComponents, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
const components: TLUiComponents = {
SharePanel: ExportCanvasButton,
}
export default function ExportCanvasImageExample() {
return (
)
}
const Control = ({
name,
type,
value,
checked,
onChange,
}: {
name: string
type?: React.HTMLInputTypeAttribute
value?: string | number | readonly string[]
checked?: boolean
onChange?: React.ChangeEventHandler
}) => {
return (
<>
>
)
}
/*
This example shows how you can use the image export settings in tldraw when generating an image.
1.
These are our defaults, though the rest of export / copy features use the user preferences,
e.g. editor.user.getIsDarkMode() for whether the user has enabled dark mode or not. But if
you're calling the image functions yourself, you can provide whatever options you wish.
2.
The bounding box is an optional argument that you can use to export a specific part of the canvas
or selection.
*/
```
--------
# Hosted images
Category: Data & assets
Keywords: assets, video, image, png, jpg, file
How to handle images uploaded by the user.
This example shows how to handle images uploaded by the user. To do this we'll need to
create a `TLAssetStore`, which tells the editor how to handle uploaded assets.
## App.tsx
```tsx
import { TLAssetStore, Tldraw, uniqueId } from 'tldraw'
import 'tldraw/tldraw.css'
// [1]
const UPLOAD_URL = '/SOME_ENDPOINT'
// [2]
const myAssetStore: TLAssetStore = {
// [a]
async upload(asset, file) {
const id = uniqueId()
const objectName = `${id}-${file.name}`.replaceAll(/\W/g, '-')
const url = `${UPLOAD_URL}/${objectName}`
await fetch(url, {
method: 'POST',
body: file,
})
return { src: url }
},
// [b]
resolve(asset) {
return asset.props.src
},
}
// [3]
export default function HostedImagesExample() {
return (
)
}
/*
Introduction: This example shows how to handle images uploaded by the user. to do this we'll need to
create a TLAssetStore, which tells the editor how to handle uploaded assets.
[1] You'll want to have an endpoint on your server that accepts file uploads, and returns URLs.
[2] We define our asset store, which has two methods: upload for saving assets, and resolve for
retrieving them.
[a] The upload method is called when the user creates a file. It should take a `File` object,
and return a URL that can be used to reference the file in the future.
[b] After the file has been uploaded, whenever we want to refer to it again the editor will
call the `resolve` method with the asset. Here, we just do the default and return the `src`
prop. If you wanted to, you could return a different URL - for example, to serve optimized
images, or to add an authentication token. The implementation here is the default, and could
have been omitted.
[3] Finally, we have our actual instance. We pass our asset store to the `assets` prop of the
`Tldraw` component so it becomes part of the store.
*/
```
--------
# Custom paste behavior
Category: Data & assets
Keywords:
Change how pasting works by registering an external content handler.
This example adds a special rule for pasting single frame shapes, so they'll try to find an empty space instead of always pasting in the location they were copied from.
## App.tsx
```tsx
import {
defaultHandleExternalTldrawContent,
Editor,
Tldraw,
TLFrameShape,
TLTldrawExternalContent,
} from 'tldraw'
// this example adds special behavior when pasting a single frame shape, matching the behavior of figma
export default function CustomPasteExample() {
return (
{
// on mount, override the default tldraw handler
editor.registerExternalContentHandler('tldraw', (content) =>
handleCustomTldrawPaste(editor, content)
)
}}
/>
)
}
const SPACING_BETWEEN_FRAMES = 50
function handleCustomTldrawPaste(editor: Editor, { content, point }: TLTldrawExternalContent) {
// find the only shape in the pasted content
const onlyCopiedShape =
content.rootShapeIds.length === 1
? content.shapes.find((shape) => shape.id === content.rootShapeIds[0])
: null
// make sure that the shape is a frame. if it is, retrieve the current version of that frame
// from the document.
const onlyCopiedFrame =
onlyCopiedShape?.type === 'frame' ? (onlyCopiedShape as TLFrameShape) : null
// we only want to use our special behavior if the frame (current & pasted) will be a direct
// descendant of the current page.
const willPasteOnCurrentPage = onlyCopiedFrame
? !editor.getShape(onlyCopiedFrame.parentId)
: false
// if the paste is happening at a specific point, or we didn't copy a single frame that belongs
// to this page, fall back to the default paste behavior
if (point || !onlyCopiedFrame || !willPasteOnCurrentPage) {
defaultHandleExternalTldrawContent(editor, { content, point })
return
}
// if we are pasting a single frame, and that frame is still in the document, we want to find a
// free space to the right of the frame to put this one.
editor.putContentOntoCurrentPage(content, { select: true })
const newlyPastedFrame = editor.getOnlySelectedShape()
if (!newlyPastedFrame || !editor.isShapeOfType(newlyPastedFrame, 'frame')) return
const siblingIds = editor.getSortedChildIdsForParent(newlyPastedFrame.parentId)
const pastedBounds = editor.getShapePageBounds(newlyPastedFrame.id)!
let targetPosition = pastedBounds.minX
const siblingBounds = siblingIds
.map((id) => ({ id, bounds: editor.getShapePageBounds(id)! }))
.sort((a, b) => a.bounds.minX - b.bounds.minX)
for (const sibling of siblingBounds) {
if (sibling.id === newlyPastedFrame.id) continue
// if this sibling is above or below the copied frame, we don't need to take it into account
if (sibling.bounds.minY > pastedBounds.maxY || sibling.bounds.maxY < pastedBounds.minY) continue
// if the sibling is to the left of the copied frame, we don't need to take it into account
if (sibling.bounds.maxX < targetPosition) continue
// if the sibling is to the right of where the pasted frame would end up, we don't care about it
if (sibling.bounds.minX > targetPosition + pastedBounds.w) continue
// otherwise, we need to shift our target right edge to the right of this sibling
targetPosition = sibling.bounds.maxX + SPACING_BETWEEN_FRAMES
}
// translate the pasted frame into position:
editor.nudgeShapes([newlyPastedFrame.id], {
x: targetPosition - pastedBounds.minX,
y: 0,
})
}
```
--------
# External content sources
Category: Data & assets
Keywords: copy, paste, html
Handle different types of content when pasting into tldraw.
In this example, we register a special handler for when the user pastes in `'text/html'` content. We add it to a special shape type that renders the HTML content directly. Try copying and pasting some code from a VSCode file.
## App.tsx
```tsx
import { useCallback } from 'react'
import {
BaseBoxShapeUtil,
defaultHandleExternalTextContent,
Editor,
HTMLContainer,
Tldraw,
TLShape,
} from 'tldraw'
import 'tldraw/tldraw.css'
const DANGEROUS_HTML_TYPE = 'dangerous-html'
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[DANGEROUS_HTML_TYPE]: { w: number; h: number; html: string }
}
}
// There's a guide at the bottom of this page!
// [1]
export type IDangerousHtmlShape = TLShape
// [2]
class DangerousHtmlExample extends BaseBoxShapeUtil {
static override type = DANGEROUS_HTML_TYPE
override getDefaultProps() {
return {
w: 500,
h: 300,
html: '
hello
',
}
}
override component(shape: IDangerousHtmlShape) {
return (
)
}
override indicator(shape: IDangerousHtmlShape) {
return
}
}
// [3]
export default function ExternalContentSourcesExample() {
const handleMount = useCallback((editor: Editor) => {
// We will register a new handler for text content. When a user pastes `text/html` content into the editor,
// we will create a new shape with that html content.
// To test this copy some html content from VS Code or some other text editor.
editor.registerExternalContentHandler('text', async (content) => {
const htmlSource = content.sources?.find((s) => s.type === 'text' && s.subtype === 'html')
if (htmlSource) {
const center = content.point ?? editor.getViewportPageBounds().center
editor.createShape({
type: 'dangerous-html',
x: center.x - 250,
y: center.y - 150,
props: {
html: htmlSource.data,
},
})
} else {
// otherwise, we'll fall back to the default handler
await defaultHandleExternalTextContent(editor, content)
}
})
}, [])
return (
)
}
/*
Introduction:
This example shows how to handle content pasted from external sources, this could be
embeds, files, svgs, text, images, or urls. In this case we will handle text/html content.
[1]
We want to render our html on the canvas, the best way to do that is to create a new shape util.
Here's where we define the type for our shape.
[2]
This is our shape util. It's a class that extends BaseBoxShapeUtil. For a more detailed
example of how to create a custom shape, see the custom config example.
[3]
We use the onMount prop to get access to the editor instance via
the handleMount callback (check out the API example for a more detailed look at this). Then we
call the registerExternalContentHandler method, we could choose to handle embeds, files, svgs,
text, images, or urls. For this example we will handle text/html content. The handler is called
with the point where the user pasted the content and an array of sources. We will find and
return the html source, then create a new shape with that html content.
*/
```
--------
# Meta migrations
Category: Data & assets
Keywords: records, snapshot, sequence
Create custom migrations for `meta` properties.
You can add arbitrary data migrations for tldraw snapshot data. This is mainly useful for updating the `meta` property of a record as your data types evolve.
## App.tsx
```tsx
import { Tldraw, createMigrationIds, createMigrationSequence } from 'tldraw'
import 'tldraw/tldraw.css'
import { snapshot } from './snapshot'
import { components } from './ui-overrides'
/**
* This example demonstrates how to add custom migrations for `meta` properties, or any other
* data in your store snapshots.
*
* If you have a custom shape type and you want to add migrations for its `props` object,
* there is a simpler dedicated API for that. Check out [the docs](https://tldraw.dev/docs/persistence#Shape-props-migrations) for more info.
*/
/**
* Let's say you added some page metadata, e.g. to allow setting the background color of a page independently.
*/
interface _PageMetaV1 {
backgroundTheme?: 'red' | 'blue' | 'green' | 'purple'
}
/**
* And then perhaps later on you decided to remove support for 'purple' because it's an ugly color.
* So all purple pages will become blue.
*/
export interface PageMetaV2 {
backgroundTheme?: 'red' | 'blue' | 'green'
}
/**
* You would then create a migration to update the metadata from v1 to v2.
*/
// First pick a 'sequence id' that is unique to your app
const sequenceId = 'com.example.my-app'
// Then create a 'migration id' for each version of your metadata
const versions = createMigrationIds(sequenceId, {
// the numbers must start at 1 and increment by 1
RemovePurple: 1,
})
const migrations = createMigrationSequence({
sequenceId,
sequence: [
{
id: versions.RemovePurple,
// `scope: 'record` tells the schema to call this migration on individual records.
// `scope: 'storage'` would pass a storage object with get/set/delete methods, to allow for actions like deleting/creating records.
scope: 'record',
// When `scope` is 'record', you can specify a filter function to only apply the migration to records that match the filter.
filter: (record) => record.typeName === 'page',
// This up function will be called on all records that match the filter
up(page: any) {
if (page.meta.backgroundTheme === 'purple') {
page.meta.backgroundTheme = 'blue'
page.name += ' (was purple)'
}
},
},
],
})
export default function MetaMigrationsExample() {
return (
)
}
```
## snapshot.ts
```ts
import { TLStoreSnapshot } from 'tldraw'
export const snapshot = {
store: {
'document:document': {
gridSize: 10,
name: '',
meta: {},
id: 'document:document',
typeName: 'document',
},
'page:red': {
meta: {
backgroundTheme: 'red',
},
id: 'page:red',
name: 'Red',
index: 'a1',
typeName: 'page',
},
'page:green': {
meta: {
backgroundTheme: 'green',
},
id: 'page:green',
name: 'Green',
index: 'a2',
typeName: 'page',
},
'page:blue': {
meta: {
backgroundTheme: 'blue',
},
id: 'page:blue',
name: 'Blue',
index: 'a3',
typeName: 'page',
},
'page:purple': {
meta: {
backgroundTheme: 'purple',
},
id: 'page:purple',
name: 'Purple',
index: 'a0',
typeName: 'page',
},
},
schema: {
schemaVersion: 2,
sequences: {
'com.tldraw.store': 4,
'com.tldraw.document': 2,
'com.tldraw.page': 1,
},
},
} as TLStoreSnapshot
```
## ui-overrides.tsx
```tsx
import { useLayoutEffect } from 'react'
import { TLComponents, track, useEditor } from 'tldraw'
import { PageMetaV2 } from './MetaMigrations'
export const components: TLComponents = {
TopPanel: track(() => {
const editor = useEditor()
const currentPage = editor.getCurrentPage()
const meta: PageMetaV2 = currentPage.meta
useLayoutEffect(() => {
const elem = document.querySelector('.tl-background') as HTMLElement
if (!elem) return
elem.style.backgroundColor = meta.backgroundTheme ?? 'unset'
}, [meta.backgroundTheme])
return (
bg:
)
}),
}
```
--------
# Slideshow (fixed camera)
Category: Use cases
Keywords: annotation, camera options, constraints, zoom, pan, camera bounds, pan speed, zoom speed, scroll, slides, presentation
A simple slideshow app with a fixed camera.
This example shows one way of making a simple slideshow app with a fixed camera, using camera options.
## App.tsx
```tsx
import { useEffect, useState } from 'react'
import { Editor, TLFrameShape, Tldraw, createShapeId, transact, useEditor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
import { SLIDE_MARGIN, SLIDE_SIZE, SlidesProvider, useSlides } from './SlidesManager'
export default function SlideShowExample() {
return (
)
})
```
## slides.css
```css
.slides-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: calc(100% - 110px);
margin: 50px 0px;
padding: 4px;
background-color: var(--tl-color-low);
pointer-events: all;
border-top-right-radius: var(--tl-radius-4);
border-bottom-right-radius: var(--tl-radius-4);
overflow: auto;
border-right: 2px solid var(--tl-color-background);
border-bottom: 2px solid var(--tl-color-background);
border-top: 2px solid var(--tl-color-background);
}
.slides-panel-button {
border-radius: var(--tl-radius-4);
outline-offset: -1px;
}
.slide-shape-label {
pointer-events: all;
position: absolute;
background: var(--tl-color-low);
padding: calc(12px * var(--tl-scale));
border-bottom-right-radius: calc(var(--tl-radius-4) * var(--tl-scale));
font-size: calc(12px * var(--tl-scale));
color: var(--tl-color-text);
white-space: nowrap;
}
```
## useSlides.tsx
```tsx
import { EASINGS, Editor, atom, useEditor, useValue } from 'tldraw'
import { SlideShape } from './SlideShapeUtil'
export const $currentSlide = atom('current slide', null)
export function moveToSlide(editor: Editor, slide: SlideShape) {
const bounds = editor.getShapePageBounds(slide.id)
if (!bounds) return
$currentSlide.set(slide)
editor.selectNone()
editor.zoomToBounds(bounds, {
inset: 0,
animation: { duration: 500, easing: EASINGS.easeInOutCubic },
})
}
export function useSlides() {
const editor = useEditor()
return useValue('slide shapes', () => getSlides(editor), [editor])
}
export function useCurrentSlide() {
return useValue($currentSlide)
}
export function getSlides(editor: Editor) {
return editor
.getSortedChildIdsForParent(editor.getCurrentPageId())
.map((id) => editor.getShape(id))
.filter((s) => s?.type === 'slide') as SlideShape[]
}
```
--------
# Education canvas
Category: Use cases
Keywords: education, math, geometry, GCSE, teaching, learning, canvas, fixed camera
An educational application template with a math question on the left and a drawing canvas on the right.
This example demonstrates how to create an educational application using tldraw. It features:
- **Split Layout**: Question panel on the left, drawing canvas on the right
- **Fixed Camera**: Canvas has constrained bounds to keep students focused on the drawing area
- **GCSE Math Question**: A geometry problem suitable for GCSE-level mathematics
- **Grid Background**: Coordinate grid to help with plotting points and shapes
- **Educational Styling**: Clean, professional styling suitable for educational environments
Perfect for creating interactive math worksheets, geometry exercises, or any educational content that requires visual problem-solving.
## App.tsx
```tsx
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Box, Editor, TLCameraOptions, TLComponents, Tldraw, track, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
import './education-canvas.css'
// Fixed camera options to prevent zooming/panning
const CAMERA_OPTIONS: Partial = {
isLocked: false,
constraints: {
initialZoom: 'fit-max',
baseZoom: 'fit-max',
bounds: {
x: 0,
y: 0,
w: 600,
h: 600,
},
behavior: { x: 'contain', y: 'contain' },
padding: { x: 100, y: 100 },
origin: { x: 0.5, y: 0.5 },
},
}
const CameraSetup = track(() => {
const editor = useEditor()
useEffect(() => {
if (!editor) return
editor.run(() => {
editor.zoomToBounds(new Box(0, 0, 600, 600), {
inset: 150,
})
editor.setCameraOptions(CAMERA_OPTIONS)
editor.setCamera(editor.getCamera(), {
immediate: true,
})
// editor.updateInstanceState({
// isGridMode: true,
// })
})
}, [editor])
return null
})
const TICKS = 8
const CartesianGrid = memo(function CartesianGrid() {
return (
)
})
const components: TLComponents = {
OnTheCanvas: CartesianGrid,
}
export default function EducationCanvasExample() {
const [answers, setAnswers] = useState({
partB: '',
partC: '',
})
const handleAnswerChange = (part: keyof typeof answers, value: string) => {
setAnswers((prev) => ({ ...prev, [part]: value }))
}
const rEditor = useRef(null)
const handleMount = useCallback((editor: Editor) => {
rEditor.current = editor
}, [])
const handleSubmit = useCallback(async () => {
// Normalize answers for comparison
const normalizeAnswer = (answer: string) => {
return answer.toLowerCase().replace(/[^a-z0-9(),.-]/g, '')
}
// Check Part B - Area (accept 8, 8 square units, 8 units², etc.)
const normalizedB = normalizeAnswer(answers.partB)
const isPartBCorrect =
normalizedB.includes('8') &&
(normalizedB.includes('square') || normalizedB.includes('unit') || normalizedB === '8')
// Check Part C - Coordinates (accept (0,7), (0, 7), 0,7, etc.)
const normalizedC = normalizeAnswer(answers.partC)
const isPartCCorrect =
normalizedC.includes('0') &&
normalizedC.includes('7') &&
(normalizedC.includes('(0,7)') ||
normalizedC.includes('0,7') ||
normalizedC.match(/0.*7/) ||
normalizedC.match(/7.*0/))
if (isPartBCorrect && isPartCCorrect) {
alert('Good job! Both answers are correct!')
} else if (isPartBCorrect || isPartCCorrect) {
let message = 'Good progress! '
if (isPartBCorrect) message += 'Part B is correct. '
if (isPartCCorrect) message += 'Part C is correct. '
if (!isPartBCorrect) message += 'Check your area calculation for Part B.'
if (!isPartCCorrect) message += 'Check your coordinates for Part C.'
alert(message)
} else {
alert('Please check your answers and try again.')
}
// Do something with the answers
const editor = rEditor.current
if (editor) {
// For example, get the canvas content and the answers and send it to the server.
// const result = {
// answers: {
// partA: await editor.toImage(editor.getCurrentPageShapes()),
// partB: answers.partB,
// partC: answers.partC,
// },
// }
// console.log(result)
}
}, [answers])
return (
{/* Question Panel - Left Half */}
Mathematics - Geometry
Question 1
A triangle ABC has vertices at A(2, 3), B(6, 3), and C(4, 7).
Part A: Draw triangle ABC on the coordinate grid.
Part B: Calculate the area of triangle ABC.
handleAnswerChange('partB', e.target.value)}
/>
Part C: Find the coordinates of point D such that ABCD forms a
parallelogram.
handleAnswerChange('partC', e.target.value)}
/>
Instructions:
Use the drawing canvas on the right to sketch your solution
You can use the draw tool D to draw points and the line tool L{' '}
to draw lines
Use the text tool T to label points and write calculations
Show all your working clearly
Enter your final answers in the answer boxes above
{/* Canvas Panel - Right Half */}
{
// These are the tool ids that are allowed to be used in the education canvas...
const allowedTools = ['select', 'hand', 'draw', 'eraser', 'line', 'text']
// Tools are keyed by their id, so we can delete off all the tools that are not in the allowedTools array
for (const key in tools) {
if (!allowedTools.includes(key)) {
delete tools[key]
}
}
// Return the mutated tools
return tools
},
}}
>