# 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/DefaultA11yAnnouncer) component, which renders a visually hidden live region that screen readers monitor for changes. The [`useA11y`](/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'` and defaults to `'assertive'`. Polite announcements wait for a pause in speech, while assertive announcements interrupt immediately. See [`TLUiA11y`](/reference/tldraw/TLUiA11y) for the message type.
> 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. With a shape selected, Tab moves selection 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, and Ctrl/Cmd+Shift+Down or Up select a group's first child or its parent. [`Editor.selectAdjacentShape`](/reference/editor/Editor#selectAdjacentShape) is the programmatic equivalent.
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/Editor#getCurrentPageShapesInReadingOrder).
A "Skip to main content" link appears when users Tab into the editor. It selects the first shape on the canvas and zooms to it.
### Excluding shapes from keyboard navigation
Exclude custom shapes from keyboard navigation by overriding [`ShapeUtil.canTabTo`](/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/ShapeUtil#getText) returns the shape's primary text content, while [`ShapeUtil.getAriaDescriptor`](/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
Give custom shapes screen reader descriptions by overriding [`ShapeUtil.getAriaDescriptor`](/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/ShapeUtil#getText) instead. The announcement system checks `getAriaDescriptor()` first, then falls back to `getText()`:
```tsx
class CardShapeUtil extends ShapeUtil {
getText(shape: CardShape) {
return shape.props.title
}
// ...
}
```
## 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/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, found under Preferences in the main menu.
> 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". This helps users who need more context than an icon provides.
Toggle this setting programmatically:
```tsx
editor.user.updateUserPreferences({
enhancedA11yMode: true,
})
```
## Disabling keyboard shortcuts
Assistive technologies often have their own keyboard commands that conflict with tldraw's shortcuts. The `areKeyboardShortcutsEnabled` preference lets users turn tldraw's shortcuts off:
```tsx
editor.user.updateUserPreferences({
areKeyboardShortcutsEnabled: false,
})
```
When disabled, tldraw's keyboard shortcuts don't interfere with assistive technology shortcuts. Basic navigation with Tab and arrow keys still works for shape selection.
## Accessibility menu
The default UI includes an accessibility submenu under Preferences in the main menu, with toggles for:
| Setting | Effect |
| --------------------------- | --------------------------------------------- |
| Reduce motion | Disables animations |
| Keyboard shortcuts | Enables or disables tldraw keyboard shortcuts |
| Enhanced accessibility mode | Shows visible labels on UI elements |
You can use these components individually to build custom accessibility controls:
```tsx
import {
ToggleReduceMotionItem,
ToggleKeyboardShortcutsItem,
ToggleEnhancedA11yModeItem,
} from 'tldraw'
```
See [`AccessibilityMenu`](/reference/tldraw/AccessibilityMenu) for the default implementation.
## Best practices for custom shapes
Override `getAriaDescriptor()` or `getText()` to give screen reader users context about what the shape contains. A shape announced as "card" is less useful than "Meeting notes: Q4 planning session".
The shape's `component()` renders inside an HTML container, so the usual web accessibility rules apply: use semantic elements rather than styled divs, make interactive elements focusable and operable with the keyboard, and check `usePrefersReducedMotion()` before showing animations.
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'
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` package includes nearly 100 default actions covering editing, arrangement, export, zoom, and preferences. You can override any of these or add your own through the `overrides` prop, typed as [`TLUiOverrides`](/reference/tldraw/TLUiOverrides).
## How actions work
Actions live in a React context inside the tldraw UI. When the UI mounts, it registers all default actions, applies any overrides you've provided, and makes them available through the [`useActions`](/reference/tldraw/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`](/reference/tldraw/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`](/reference/tldraw/TLUiActionItem) interface defines what an action contains:
| 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 bind several combinations, e.g. `'cmd+g,ctrl+g'` (see [Keyboard shortcuts](#keyboard-shortcuts)) |
| `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 [`TLUiEventSource`](/reference/tldraw/TLUiEventSource) indicating the trigger origin (`'kbd'`, `'menu'`, `'toolbar'`, etc.) |
## Accessing actions
Use the [`useActions`](/reference/tldraw/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. Some common ones, by category:
| Category | Action ids |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Editing | `undo`, `redo`, `duplicate`, `delete`, `copy`, `cut`, `paste` |
| Grouping | `group`, `ungroup` |
| Arrangement | `bring-to-front`, `bring-forward`, `send-backward`, `send-to-back`, `align-left`, `align-center-horizontal`, `align-right`, `distribute-horizontal`, `distribute-vertical` |
| Export | `export-as-svg`, `export-as-png`, `copy-as-svg`, `copy-as-png` |
| Zoom | `zoom-in`, `zoom-out`, `zoom-to-100`, `zoom-to-fit`, `zoom-to-selection`, `select-zoom-tool` |
| Preferences | `toggle-dark-mode`, `toggle-snap-mode`, `toggle-grid`, `toggle-focus-mode` |
The full list is in the [default actions source](https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/ui/context/actions.tsx). Most actions guard themselves inside their handlers. For example, `group` and the arrangement actions do nothing unless shapes are selected and the select tool is active.
## Overriding actions
Pass an `overrides` prop to customize actions. The override function receives the editor, the default actions, and helper utilities:
```typescript
import { Tldraw, TLUiOverrides } from 'tldraw'
const overrides: TLUiOverrides = {
actions(editor, actions, helpers) {
// Modify existing action
actions['duplicate'].kbd = 'cmd+shift+d,ctrl+shift+d'
// Disable an action by removing it
delete actions['print']
return actions
},
}
function App() {
return
}
```
The `copy`, `cut`, and `paste` shortcuts are handled by native clipboard events rather than the `kbd` system, so changing their `kbd` has no effect.
### 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, override the menu components; see [Actions in menus](#actions-in-menus) and the [custom menus example](/examples/ui/custom-menus).
### Using helper utilities
The override function receives a `helpers` object ([`TLUiOverrideHelpers`](/reference/tldraw/TLUiOverrideHelpers), the return value of [`useDefaultHelpers`](/reference/tldraw/useDefaultHelpers)):
```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 bind several combinations to the same action. Every combination is active on every platform; the conventional `cmd+…,ctrl+…` pair covers Mac and everything else, and only the shortcut hint shown in menus ([`TldrawUiKbd`](/reference/tldraw/TldrawUiKbd)) is platform-specific:
```typescript
kbd: 'cmd+g,ctrl+g' // Cmd+G or Ctrl+G
kbd: 'shift+1' // Shift+1
kbd: 'cmd+shift+s,ctrl+shift+s' // Cmd+Shift+S or Ctrl+Shift+S
```
Modifiers are `cmd` (alias `meta`), `ctrl`, `shift`, and `alt` (alias `option`). Special keys include `del`, `backspace`, `enter`, `escape`, `space`, and the arrow keys (`left`, `right`, `up`, `down`).
Shortcuts only fire while the editor is focused and the key event does not target a text input. They are also 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. In readonly mode, only actions with `readonlyOk` are bound. Actions marked with `isRequiredA11yAction: true` bypass the disabled check for accessibility purposes.
## Actions in menus
The default UI uses [`TldrawUiMenuActionItem`](/reference/tldraw/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, and shortcut hint. Pass `disabled` yourself if the item should be disabled. For toggle actions, use [`TldrawUiMenuActionCheckboxItem`](/reference/tldraw/TldrawUiMenuActionCheckboxItem) with a `checked` prop.
## Context-sensitive labels
Some actions show different labels depending on where they appear. The `label` property can be an object mapping menu context types ([`TLUiMenuContextType`](/reference/tldraw/TLUiMenuContextType), plus `default`) 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](/examples/ui/action-overrides) - Add custom actions and modify existing action shortcuts using the overrides prop.
- [Keyboard shortcuts](/examples/ui/keyboard-shortcuts) - Change keyboard shortcuts for tools and actions.
- [Custom menus](/examples/ui/custom-menus) - Build custom menus that use actions with proper labels and shortcuts.
--------
# Animation
The animation system drives smooth transitions for shapes and for the camera. Shape animations interpolate a shape's position, rotation, opacity, and props. Camera animations move the viewport for pans and zooms.
## How it works
Animations run on the editor's [tick system](/sdk-features/ticks). When you call [`Editor.animateShape`](/reference/editor/Editor#animateShape) or a camera method with an `animation` option, the editor subscribes to `tick` events, applies the easing function to the elapsed time, and interpolates between the start and end values until the animation completes.
Camera animations respect the user's animation speed preference; shape animations don't. See [User preferences](#user-preferences) below.
## Shape animations
Use [`Editor.animateShape`](/reference/editor/Editor#animateShape) to animate a single shape or [`Editor.animateShapes`](/reference/editor/Editor#animateShapes) to animate several at once. The editor tracks each animating shape independently, so multiple animations can run at the same time:
```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 editor linearly interpolates the properties common to every shape: `x`, `y`, `rotation` (in radians), and `opacity` (0 to 1).
For shape-specific props like width and height, the shape util implements [`ShapeUtil.getInterpolatedProps`](/reference/editor/ShapeUtil#getInterpolatedProps). If a util doesn't implement it, the props jump to their end values on the first frame. This is how [`BaseBoxShapeUtil`](/reference/editor/BaseBoxShapeUtil) interpolates its dimensions (`lerp` is exported from `tldraw`):
```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
Shape animations default to a duration of 500 ms and `linear` easing. Intermediate frames don't create history entries; when the animation finishes the editor calls `updateShapes()` with the final values, so a single undo restores the starting state.
You can interrupt an animation in two ways. Calling `updateShapes()` on an animating shape cancels its animation and applies the new values immediately. Starting a new animation for a shape cancels the existing one.
User interaction wins over ongoing animations. If you drag a shape that's animating, the animation stops and the shape follows your pointer.
## Camera animations
The camera-move methods ([`Editor.setCamera`](/reference/editor/Editor#setCamera), [`Editor.zoomToBounds`](/reference/editor/Editor#zoomToBounds), [`Editor.zoomToFit`](/reference/editor/Editor#zoomToFit), and the rest) accept an `animation` option in [`TLCameraMoveOptions`](/reference/editor/TLCameraMoveOptions). Without it, or with a `duration` of `0`, the camera jumps straight to the target. See [Camera](/sdk-features/camera) for the full set of methods.
```typescript
editor.setCamera(
{ x: 0, y: 0, z: 1 },
{ animation: { duration: 320, easing: EASINGS.easeInOutCubic } }
)
```
Camera animations default to `easeInOutCubic` easing. They stop as soon as the user pans, zooms, or pinches, and you can stop them yourself with [`Editor.stopCameraAnimation`](/reference/editor/Editor#stopCameraAnimation). If the camera is locked, camera methods do nothing unless you pass `force: true`.
### Zooming to bounds
Use `zoomToBounds()` to animate the camera so a specific area fills the viewport, for example to focus on shapes or build slideshow-style transitions. You can also cap the zoom with `targetZoom` and add screen-space padding with `inset`:
```typescript
const bounds = { x: 0, y: 0, w: 800, h: 600 }
editor.zoomToBounds(bounds, {
animation: { duration: 500 },
targetZoom: 1, // zoom to 100%
inset: 50, // padding around the bounds in pixels
})
```
`zoomToFit()` is a convenience wrapper that zooms to fit all shapes on the current page:
```typescript
editor.zoomToFit({ animation: { duration: 200 } })
```
### Camera slide
[`Editor.slideCamera`](/reference/editor/Editor#slideCamera) creates momentum-based camera movement that decelerates under friction:
```typescript
editor.slideCamera({
speed: 1,
direction: { x: 1, y: 0 },
friction: 0.1,
})
```
## Easing functions
Easing functions control the rate of change during an animation. Use an `easeOut` curve when responding to user actions (fast start, gentle settle), `easeIn` for exits (gentle start, quick finish), and `easeInOut` for autonomous moves like camera transitions.
[`EASINGS`](/reference/editor/EASINGS) provides `linear` plus the standard `easeIn`, `easeOut`, and `easeInOut` variants of `Quad`, `Cubic`, `Quart`, `Quint`, `Sine`, and `Expo`, for example `EASINGS.easeOutCubic` or `EASINGS.easeInOutSine`.
## User preferences
Camera animations check `editor.user.getAnimationSpeed()` before running. This value is a speed multiplier: the editor divides animation durations by it, so users can speed up, slow down, or disable animations entirely. It defaults to `0` when the operating system reports `prefers-reduced-motion`.
When animation speed is zero, `setCamera()`, `zoomToBounds()`, `zoomToFit()`, and the other camera-move methods jump straight to the target, and `slideCamera()` does nothing. `animateShape()` and `animateShapes()` do not check this preference. 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
- [Shape animation](/examples/editor-api/shape-animation) - Animate shapes with `animateShape` and easing functions.
- [Slideshow](/examples/use-cases/slideshow) - Transition between slides with `zoomToBounds` and animation options.
- [Reduced motion](/examples/configuration/reduced-motion) - Respect the user's animation speed preference and `prefers-reduced-motion`.
--------
# 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. Deleting a shape never deletes its asset: call [`Editor.deleteAssets`](/reference/editor/Editor#deleteAssets) yourself when you know an asset is no longer referenced.
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, file size, and an optional `pixelRatio` for @2x images. The `isAnimated` flag is true for animated GIF, WebP, AVIF, and APNG files.
```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
pixelRatio: 2, // 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/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. With an in-memory store (the default), [`inlineBase64AssetStore`](/reference/editor/inlineBase64AssetStore) converts every uploaded file to a data URL: quick for prototyping, but nothing persists across sessions. With a [`persistenceKey`](/sdk-features/persistence#The-persistenceKey-prop), assets are stored in the browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) alongside the document. With a [sync server](/docs/sync), implement `TLAssetStore` yourself to upload files to S3, Google Cloud Storage, or your own API.
The interface has three methods:
| Method | Purpose |
| --------- | --------------------------------------------------------------------------------- |
| `upload` | Store a file and return its URL |
| `resolve` | Return the URL to use when rendering an asset (optional, defaults to `props.src`) |
| `remove` | Clean up files when assets are deleted (optional) |
The **upload** method receives an asset record (with metadata already populated) and the File to store. Return an object with `src` (the URL) and optionally `meta` (custom metadata to merge into the asset record). You also get an AbortSignal for cancellation.
```typescript
async upload(asset: TLAsset, file: File, abortSignal?: AbortSignal): Promise<{ src: string; meta?: JsonObject }>
```
The **resolve** method receives an asset and a [`TLAssetContext`](/reference/tlschema/TLAssetContext) describing how the asset is being displayed. It can be sync or async. Return the URL to use for rendering, or `null` if the asset is unavailable (shapes then render a broken-asset placeholder). 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/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 up to the next 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 for copy/paste and for SVG exports without an explicit `pixelRatio`. Return full quality. |
Here's a resolve handler that serves optimized images based on 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/Editor) class provides methods for managing assets:
| Method | Description |
| --------------------------- | ----------------------------------------- |
| [`Editor.createAssets`](/reference/editor/Editor#createAssets) | Add asset records to the store |
| [`Editor.updateAssets`](/reference/editor/Editor#updateAssets) | Update existing assets |
| [`Editor.deleteAssets`](/reference/editor/Editor#deleteAssets) | Remove assets and call the remove handler |
| [`Editor.getAsset`](/reference/editor/Editor#getAsset) | Get an asset by ID |
| [`Editor.getAssets`](/reference/editor/Editor#getAssets) | Get all assets in the store |
| [`Editor.resolveAssetUrl`](/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. updateAssets shallow-merges the record, so spread the existing props
editor.updateAssets([
{
...imageAsset,
props: { ...imageAsset.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 render a broken-asset placeholder.
## Extension points
### Custom storage backends
Implement [`TLAssetStore`](/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.
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
[`AssetUtil`](/reference/editor/AssetUtil) is the asset-side counterpart to `ShapeUtil`. Each asset type has one, and it defines which MIME types the type accepts, how to derive an asset record from a dropped file, and what default props new instances start with. The built-in `ImageAssetUtil`, `VideoAssetUtil`, and `BookmarkAssetUtil` live in `defaultAssetUtils`.
To add your own type, register its props on `TLGlobalAssetPropsMap` via TypeScript module augmentation, then implement an `AssetUtil` for it. Set `static props` so the store validates and migrates your records:
```typescript
import { AssetUtil, T, TLAsset, TLAssetId } from 'tldraw'
const AUDIO_TYPE = 'audio'
declare module 'tldraw' {
export interface TLGlobalAssetPropsMap {
[AUDIO_TYPE]: {
src: string | null
mimeType: string | null
name: string
}
}
}
type TLAudioAsset = TLAsset
class AudioAssetUtil extends AssetUtil {
static override type = AUDIO_TYPE
static override props = {
src: T.string.nullable(),
mimeType: T.string.nullable(),
name: T.string,
}
override getDefaultProps(): TLAudioAsset['props'] {
return { src: null, mimeType: null, name: '' }
}
override getSupportedMimeTypes() {
return ['audio/mpeg', 'audio/wav', 'audio/ogg']
}
override async getAssetFromFile(file: File, assetId: TLAssetId): Promise {
return {
id: assetId,
typeName: 'asset',
type: AUDIO_TYPE,
props: {
src: null, // populated by the asset store after upload
mimeType: file.type,
name: file.name,
},
meta: {},
}
}
}
```
Pass the util to the `` component. The default asset utils are always included; a custom util with the same `type` replaces the built-in one:
```tsx
import { Tldraw } from 'tldraw'
const assetUtils = [AudioAssetUtil]
export default function App() {
return
}
```
When a file is dropped or pasted, the editor checks it against `maxAssetSize`, then finds the first registered util whose `acceptsMimeType()` returns true for the file's MIME type and calls its `getAssetFromFile()`. The returned asset record then flows through your `TLAssetStore.upload` handler, which assigns the final `src`. To place the asset on the canvas, a shape util must declare the asset type in `static handledAssetTypes` and implement `createShapeForAsset()`; that shape reads the resolved URL through `editor.resolveAssetUrl()` like the built-in shapes do. See the [custom asset type example](/examples/data/assets/custom-asset-type) for the full pattern.
### Configuring built-in asset utils
The simplest way to configure the built-in utils is through the `` props `maxAssetSize`, `maxImageDimension`, `acceptedImageMimeTypes`, and `acceptedVideoMimeTypes`. For anything else, use [`AssetUtil.configure`](/reference/editor/AssetUtil#configure) to tweak options on a built-in util without subclassing it. For example, lock image uploads down to PNG:
```tsx
import { ImageAssetUtil, defaultAssetUtils, Tldraw } from 'tldraw'
const PngOnlyImageAssetUtil = ImageAssetUtil.configure({
supportedMimeTypes: ['image/png'],
})
const assetUtils = defaultAssetUtils.map((util) =>
util === ImageAssetUtil ? PngOnlyImageAssetUtil : util
)
```
`ImageAssetUtil` exposes `maxDimension` and `supportedMimeTypes` options. `VideoAssetUtil` exposes `supportedMimeTypes`. If you also pass the matching `` props, those win.
### 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. Setting `static props` on your `AssetUtil` is enough for the store to validate it. If you need a standalone validator, [`createAssetValidator`](/reference/tlschema/createAssetValidator) builds one for a single asset type (id, `typeName`, `type` literal, props, and meta). Add migration sequences via `static migrations` to handle schema changes over time.
## Security
### SVG sanitization
SVG files can contain scripts, event handlers, and external resource references that execute during rendering. tldraw automatically sanitizes all SVGs on paste and file drop using an allowlist-based sanitizer that:
- Strips `