# tldraw SDK Examples -------- # Tldraw component Category: Getting started Keywords: basic, intro, simple, quick, start, hello world, installation The simplest way to use the `Tldraw` component. The `Tldraw` component provides the tldraw editor as a regular React component. You can put this component anywhere in your React project. In this example, we make the component take up the height and width of the container. By default, the component does not persist between refreshes or sync locally between tabs. To keep your work after a refresh, check the [persistence key example](https://tldraw.dev/examples/persistence-key). ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function BasicExample() { return (
) } ``` ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function BasicExample() { return (
{ editor.selectAll() }} />
) } ``` -------- # Editor options Category: Configuration Keywords: Override default editor options like max number of pages and animation speed. Use the `options` property to override tldraw's options. In this example, we limit the maximum number of pages to 3, and slow down camera animations like zoom in and zoom out. ## App.tsx ```tsx import { Tldraw, TldrawOptions } from 'tldraw' import 'tldraw/tldraw.css' const options: Partial = { maxPages: 3, animationMediumMs: 5000, } export default function CustomOptionsExample() { return (
) } ``` -------- # Shape options Category: Configuration Keywords: config, configure, shape, util, frame Change the behavior of built-in shapes by setting their options. Some of the builtin tldraw shapes can be customized to behave differently based on your needs. This is done via the `ShapeUtil.configure` function which returns a new version of the shape's util class with custom options specified. You can see a shape's options by looking at the `options` property of its `ShapeUtil`. For example, the note shape's options are listed at [`NoteShapeOptions`](https://tldraw.dev/reference/tldraw/NoteShapeOptions). ## App.tsx ```tsx import { FrameShapeUtil, NoteShapeUtil, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' const shapeUtils = [ // Enable colors for frame shapes FrameShapeUtil.configure({ showColors: true }), // Enable resizing for note shapes NoteShapeUtil.configure({ resizeMode: 'scale' }), ] export default function ConfigureShapeUtilExample() { return (
) } ``` -------- # Camera options Category: Configuration Keywords: api, fixed, constraints, camera bounds, pan speed, zoom speed Configure the camera's options and constraints. The `Tldraw` component provides a prop, `cameraOptions`, that can be used to set the camera's constraints, zoom behavior, and other options. This example lets you modify those options to see how they affect the camera's behavior. ## App.tsx ```tsx import { useEffect } from 'react' import { BoxModel, TLCameraOptions, Tldraw, Vec, clamp, track, useEditor, useLocalStorageState, } from 'tldraw' import 'tldraw/tldraw.css' const CAMERA_OPTIONS: TLCameraOptions = { isLocked: false, wheelBehavior: 'pan', panSpeed: 1, zoomSpeed: 1, zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8], constraints: { initialZoom: 'fit-max', baseZoom: 'fit-max', bounds: { x: 0, y: 0, w: 1600, h: 900, }, behavior: { x: 'contain', y: 'contain' }, padding: { x: 100, y: 100 }, origin: { x: 0.5, y: 0.5 }, }, } const BOUNDS_SIZES: Record = { a4: { x: 0, y: 0, w: 1050, h: 1485 }, landscape: { x: 0, y: 0, w: 1600, h: 900 }, portrait: { x: 0, y: 0, w: 900, h: 1600 }, square: { x: 0, y: 0, w: 900, h: 900 }, } export default function CameraOptionsExample() { return (
) } const PaddingDisplay = track(() => { const editor = useEditor() const cameraOptions = editor.getCameraOptions() if (!cameraOptions.constraints) return null const { constraints: { padding: { x: px, y: py }, }, } = cameraOptions return (
) }) const BoundsDisplay = track(() => { const editor = useEditor() const cameraOptions = editor.getCameraOptions() if (!cameraOptions.constraints) return null const { constraints: { bounds: { x, y, w, h }, }, } = cameraOptions const d = Vec.ToAngle({ x: w, y: h }) * (180 / Math.PI) const colB = '#00000002' const colA = '#0000001F' return ( <>
) }) const components = { // These components are just included for debugging / visualization! OnTheCanvas: BoundsDisplay, InFrontOfTheCanvas: PaddingDisplay, } const CameraOptionsControlPanel = track(() => { const editor = useEditor() const [cameraOptions, setCameraOptions] = useLocalStorageState('camera ex1', CAMERA_OPTIONS) useEffect(() => { if (!editor) return editor.run(() => { editor.setCameraOptions(cameraOptions) editor.setCamera(editor.getCamera(), { immediate: true, }) }) }, [editor, cameraOptions]) const { constraints } = cameraOptions const updateOptions = ( options: Partial< Omit & { constraints: Partial } > ) => { const { constraints } = options const cameraOptions = editor.getCameraOptions() setCameraOptions({ ...cameraOptions, ...options, constraints: constraints === undefined ? cameraOptions.constraints : { ...(cameraOptions.constraints! ?? CAMERA_OPTIONS.constraints), ...constraints, }, }) } return (
{ const val = clamp(Number(e.target.value), 0, 2) updateOptions({ panSpeed: val }) }} /> { const val = clamp(Number(e.target.value), 0, 2) updateOptions({ zoomSpeed: val }) }} /> { try { const val = e.target.value.split(', ').map((v) => Number(v)) if (val.every((v) => typeof v === 'number' && Number.isFinite(v))) { updateOptions({ zoomSteps: val }) } } catch { // ignore } }} /> {constraints ? ( <> { const val = clamp(Number(e.target.value), 0, 1) updateOptions({ constraints: { origin: { ...constraints.origin, x: val, }, }, }) }} /> { const val = clamp(Number(e.target.value), 0, 1) updateOptions({ constraints: { ...constraints, origin: { ...constraints.origin, y: val, }, }, }) }} /> { const val = clamp(Number(e.target.value), 0) updateOptions({ constraints: { ...constraints, padding: { ...constraints.padding, x: val, }, }, }) }} /> { const val = clamp(Number(e.target.value), 0) updateOptions({ constraints: { padding: { ...constraints.padding, y: val, }, }, }) }} /> ) : null}
) }) ``` -------- # Asset options Category: Configuration Keywords: images, videos, gif, dimensions, accepted image types Control the assets (images, videos, etc.) that can be added to the canvas. This example demonstrates the `` component's props that give you control over assets: which types are allowed, the maximum size, and maximum dimensions. ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function AssetPropsExample() { return (
) } /* This example shows how to use props on the Tldraw component to control what types of assets can be uploaded. */ ``` -------- # Custom text outline Category: Configuration Keywords: Disable text outlines on text and arrow labels. This example shows how to configure the `ArrowShapeUtil` and `TextShapeUtil` to disable text outlines. By default, tldraw adds a text outline (using the canvas background color) to help text stand out when overlapping with other shapes. You can disable this feature by configuring the shape utilities. This is particularly useful for: - Performance optimization on certain browsers (we already skip on Safari) - Different visual styling preferences ## App.tsx ```tsx import { ArrowShapeUtil, GeoShapeUtil, TextShapeUtil, Tldraw, toRichText } from 'tldraw' import 'tldraw/tldraw.css' // Configure the geo shape to disable outline const CustomGeoShapeUtil = GeoShapeUtil.configure({ showTextOutline: false, }) // Configure the arrow shape to disable text outline const CustomArrowShapeUtil = ArrowShapeUtil.configure({ showTextOutline: false, }) // Configure the text shape to disable outline const CustomTextShapeUtil = TextShapeUtil.configure({ showTextOutline: false, }) // Use the configured shape utilities const customShapeUtils = [CustomArrowShapeUtil, CustomTextShapeUtil, CustomGeoShapeUtil] export default function CustomTextOutlineExample() { return (
{ if (editor.getCurrentPageShapeIds().size > 0) return const message = toRichText('very good whiteboard') // Lots of overlapping text shapes. These would normally be differentiated a bit using the text outline! editor.createShapes([ { type: 'text', x: 100, y: 100, props: { richText: message }, }, { type: 'text', x: 110, y: 110, props: { richText: message }, }, { type: 'text', x: 120, y: 120, props: { richText: message }, }, { type: 'arrow', x: 0, y: 0, props: { richText: toRichText('hello world'), start: { x: 0, y: 0 }, end: { x: 200, y: 200 }, }, }, ]) }} />
) } ``` -------- # Persistence key Category: Configuration Keywords: local storage Persist the editor's content between sessions by using a persistence key. If the `persistenceKey` prop is provided to the `` component, the editor will persist its data locally under that key. ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function PersistenceKeyExample() { return (
) } ``` -------- # Read-only Category: Configuration Keywords: read, only mode Use the editor in read-only mode. This example shows how to make the editor read-only. This will disable all editing functionality and show only the select tool, hand tool and laser pointer on the toolbar. ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function ReadOnlyExample() { return (
{ editor.updateInstanceState({ isReadonly: true }) }} />
) } /* This example shows how to make the editor read-only. We use the `onMount` prop to set the editor's `isReadonly` state to `true`. This will disable all editing functionality and show only the select tool, hand tool and laser pointer on the toolbar. */ ``` -------- # Reduced motion preferences Category: Configuration Keywords: accessibility, a11y, reduced motion, animation, prefers-reduced-motion, custom shape Respecting user motion preferences in custom shapes. This example demonstrates how to build custom shapes that respect the user's reduced motion preferences using the `usePrefersReducedMotion()` hook. The hook checks both the user's tldraw preference (`animationSpeed: 0`) and the OS-level `prefers-reduced-motion` setting. When reduced motion is preferred, animations are replaced with static alternatives. The example includes: - A custom shape with animated and static variants - A toggle button to switch between animation modes - CSS animations controlled by the motion preference This is particularly important for accessibility, ensuring users with vestibular disorders or motion sensitivities can use your application comfortably. ## App.tsx ```tsx import { Geometry2d, HTMLContainer, RecordProps, Rectangle2d, ShapeUtil, T, TLComponents, TLShape, Tldraw, TldrawUiButton, track, useEditor, usePrefersReducedMotion, } from 'tldraw' import 'tldraw/tldraw.css' import './reduced-motion.css' const PULSE_SHAPE_TYPE = 'pulse-shape' // [1] declare module 'tldraw' { export interface TLGlobalShapePropsMap { [PULSE_SHAPE_TYPE]: { w: number; h: number } } } type PulseShape = TLShape // [2] function PulseShapeComponent({ shape: _shape }: { shape: PulseShape }) { const prefersReducedMotion = usePrefersReducedMotion() return (
{/* [3] */}
{prefersReducedMotion ? 'Static mode' : 'Animated mode'}
) } // [4] export class PulseShapeUtil extends ShapeUtil { static override type = PULSE_SHAPE_TYPE static override props: RecordProps = { w: T.number, h: T.number, } getDefaultProps(): PulseShape['props'] { return { w: 200, h: 200 } } override canEdit() { return false } getGeometry(shape: PulseShape): Geometry2d { return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true, }) } component(shape: PulseShape) { return } indicator(shape: PulseShape) { return } } // [5] const MotionToggle = track(function MotionToggle() { const editor = useEditor() const prefersReducedMotion = usePrefersReducedMotion() const toggleMotion = () => { const currentSpeed = editor.user.getAnimationSpeed() editor.user.updateUserPreferences({ animationSpeed: currentSpeed === 0 ? 1 : 0, }) } return (
Motion: {prefersReducedMotion ? 'Reduced' : 'Normal'} Toggle
) }) // [6] const components: TLComponents = { InFrontOfTheCanvas: MotionToggle, } export default function ReducedMotionExample() { return (
{ // [7] editor.createShape({ type: PULSE_SHAPE_TYPE, x: 200, y: 200 }) editor.createShape({ type: PULSE_SHAPE_TYPE, x: 450, y: 200 }) editor.createShape({ type: PULSE_SHAPE_TYPE, x: 325, y: 450 }) }} />
) } /* [1] Extend TLGlobalShapePropsMap to register our custom shape type with TypeScript. This shape has width (w) and height (h) properties. [2] PulseShapeComponent is a React component that renders the shape's content. It uses usePrefersReducedMotion() to check if the user prefers reduced motion. This hook returns true when either: - The user has set animationSpeed to 0 in tldraw preferences - The OS has prefers-reduced-motion enabled [3] The visual indicator changes based on motion preference. When reduced motion is preferred, it shows a static gray circle. Otherwise, it shows an animated blue circle with a pulsing effect defined in the CSS file. [4] PulseShapeUtil class controls the shape's behavior. The component method returns the PulseShapeComponent, which allows React hooks to be used for checking motion preferences. [5] MotionToggle is a custom component that displays the current motion state and provides a button to toggle between animated and static modes. It uses track() to reactively update when preferences change, and updateUserPreferences() to modify the animation speed setting. [6] Pass the toggle as the SharePanel component. This places it in the top-right corner of the editor using tldraw's built-in layout system, avoiding custom positioning CSS. [7] On mount, we create three pulse shapes to demonstrate the effect. All shapes respond simultaneously to the motion preference change. */ ``` ## reduced-motion.css ```css /* Pulse shape container */ .pulse-shape { display: flex; align-items: center; justify-content: center; pointer-events: all; background-color: var(--tl-color-low); border: 1px solid var(--tl-color-low-border); border-radius: var(--tl-radius-3); } .pulse-shape__content { text-align: center; } .pulse-shape__label { font-size: 14px; color: var(--tl-color-text-1); } /* Shared pulse indicator styles */ .pulse-indicator, .pulse-indicator--static { width: 40px; height: 40px; border-radius: 50%; margin: 0 auto 16px; } /* Animated indicator */ .pulse-indicator { background-color: var(--tl-color-primary); animation: pulse 2s ease-in-out infinite; } /* Static indicator - no animation */ .pulse-indicator--static { background-color: var(--tl-color-text-3); } @keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.2); opacity: 0.7; } } /* Motion toggle */ .motion-toggle { position: absolute; top: 100px; left: 20px; display: flex; gap: 8px; align-items: center; pointer-events: all; } .motion-toggle__label { font-size: 14px; } ``` -------- # Custom embeds Category: Configuration Keywords: embed, custom Allow custom embeds. tldraw comes with support for many types of embeds out of the box, but it also comes with the ability to create custom embeds. In this example we'll remove some of the built in embeds and also create a JSFiddle embed. ## App.tsx ```tsx import { CustomEmbedDefinition, DEFAULT_EMBED_DEFINITIONS, DefaultEmbedDefinitionType, Tldraw, } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! // [1] const defaultEmbedTypesToKeep: DefaultEmbedDefinitionType[] = ['tldraw', 'youtube'] const defaultEmbedsToKeep = DEFAULT_EMBED_DEFINITIONS.filter((embed) => defaultEmbedTypesToKeep.includes(embed.type) ) // [2] const customEmbed: CustomEmbedDefinition = { type: 'jsfiddle', title: 'JSFiddle', hostnames: ['jsfiddle.net'], minWidth: 300, minHeight: 300, width: 720, height: 500, doesResize: true, toEmbedUrl: (url) => { const urlObj = new URL(url) const matches = urlObj.pathname.match(/\/([^/]+)\/([^/]+)\/(\d+)\/embedded/) if (matches) { return `https://jsfiddle.net/${matches[1]}/${matches[2]}/embedded/` } return }, fromEmbedUrl: (url) => { const urlObj = new URL(url) const matches = urlObj.pathname.match(/\/([^/]+)\/([^/]+)\/(\d+)\/embedded/) if (matches) { return `https://jsfiddle.net/${matches[1]}/${matches[2]}/` } return }, icon: 'https://jsfiddle.net/img/favicon.png', } // [3] const embeds = [...defaultEmbedsToKeep, customEmbed] export default function CustomEmbedExample() { return (
{/* [4] */}
) } /** [1] tldraw has built-in support for embedding content from several popular apps. In this example, we extract the definitions for handling 'tldraw' and 'youtube' content, and discard the rest. [2] We will also add support for embedding JSFiddles. Please note that you have to specify an icon that will be displayed in the `EmbedDialog` component. [3] We concatenate the filtered embed definitions with our custom JSFiddle one. [4] We now pass the custom embed definitions to the `Tldraw` component. */ ``` -------- # Custom stroke and font sizes Category: Configuration Keywords: stroke, font, size, style Customize the available stroke and font sizes in tldraw. Override the `STROKE_SIZES`, and `FONT_SIZES` constants to override the default values. Note that this will change the stroke and font sizes for ALL instances of tldraw. For example, if you run this example locally, and then open another example, the changes will be present there too until you reload! ## App.tsx ```tsx import { FONT_SIZES, STROKE_SIZES, Tldraw } from 'tldraw' // Mutate the built-in stroke sizes STROKE_SIZES.s = 1 STROKE_SIZES.m = 2 STROKE_SIZES.l = 4 STROKE_SIZES.xl = 8 // Mutate the built-in font sizes FONT_SIZES.s = 12 FONT_SIZES.m = 16 FONT_SIZES.l = 20 FONT_SIZES.xl = 24 export default function StrokeAndFontSizesExample() { return (
) } ``` -------- # Environment detection with tlenv and tlenvReactive Category: Configuration Keywords: tlenv, tlenvReactive, environment, platform, browser, pointer, touch, mobile Detect platform, browser, and input device type. This example demonstrates tldraw's environment detection APIs for building platform-aware and device-adaptive interfaces: **Static detection (tlenv):** - Platform detection (macOS, Windows, iOS, Android) - Browser detection (Safari, Firefox, Chrome) - Platform-specific keyboard shortcuts (Cmd vs Ctrl) **Reactive detection (tlenvReactive with useValue):** - Pointer type detection (coarse/fine) - can change mid-session on hybrid devices - Real-time updates when switching between touch and mouse input The example shows practical usage like adapting button sizes based on pointer type - larger touch targets (48px) for coarse pointers (touch) and smaller targets (32px) for fine pointers (mouse/trackpad). Use `tlenv` for static properties that don't change during the session, and `tlenvReactive` with the `useValue` hook for properties that can change reactively, especially `isCoarsePointer` on touchscreen laptops. ## App.tsx ```tsx import { Tldraw, TldrawUiButton, TldrawUiIcon, tlenv, tlenvReactive, useValue } from 'tldraw' import 'tldraw/tldraw.css' import './environment-detection.css' // [1] function EnvironmentInfo() { // [2] const isCoarsePointer = useValue('coarse pointer', () => tlenvReactive.get().isCoarsePointer, [ tlenvReactive, ]) // [3] const buttonSize = isCoarsePointer ? '48px' : '32px' return (
{/* [4] Static detection with tlenv */}
Platform (tlenv): {tlenv.isIos && 'iOS'} {tlenv.isDarwin && !tlenv.isIos && 'macOS'} {tlenv.isAndroid && 'Android'} {!tlenv.isDarwin && !tlenv.isIos && !tlenv.isAndroid && 'Other'}
Browser: {tlenv.isSafari && 'Safari'} {tlenv.isFirefox && 'Firefox'} {tlenv.isChromeForIos && 'Chrome for iOS'} {!tlenv.isSafari && !tlenv.isFirefox && !tlenv.isChromeForIos && 'Other'}
Modifier key: {tlenv.isDarwin ? '⌘ Cmd' : 'Ctrl'}
{/* [5] Reactive detection with tlenvReactive */}
Pointer type (reactive): {isCoarsePointer ? 'Touch' : 'Mouse'}
{/* [6] Adaptive button based on pointer type */} alert(`Button size: ${buttonSize}`)} >
) } export default function RequestEnvironmentDetectionWithTlenvAndTlenvreactiveExample() { return (
) } /* This example demonstrates environment detection using tlenv and tlenvReactive. [1] Component that displays environment information [2] Subscribe to tlenvReactive using useValue hook. Since tlenvReactive is an Atom, call .get() to access its value. The isCoarsePointer property updates when users switch between touch and mouse input. [3] Calculate button size based on pointer type - touch needs larger targets (48px) while mouse can use smaller buttons (32px) [4] Display static environment detection with tlenv - platform (isDarwin, isIos, isAndroid) and browser (isSafari, isFirefox, isChromeForIos). Note: isIos is checked before isDarwin because iPadOS reports as Mac, making both isDarwin and isIos true on iPads. [5] Display reactive pointer detection that updates when input method changes [6] Adaptive button that changes size based on pointer type [7] Render component in front of canvas using InFrontOfTheCanvas slot Key differences: - tlenv: Static object, direct property access (tlenv.isDarwin) - tlenvReactive: Reactive Atom, requires useValue(() => tlenvReactive.get().property, [tlenvReactive]) */ ``` ## environment-detection.css ```css .environment-info { display: flex; flex-direction: column; gap: 8px; margin: 8px; padding: 12px; } ``` -------- # Deep links Category: Configuration Keywords: basic, intro, simple, quick, start Allow linking to specific parts of a tldraw canvas. Deep Links are URLs which point to a specific part of a document. We provide a comprehensive set of tools to help you create and manage deep links in your application. ## The `deepLinks` prop The highest-level API for managing deep links is the `deepLinks` prop on the `` component. This prop is designed for manipulating `window.location` to add a search param which tldraw can use to navigate to a specific part of the document. e.g. `https://my-app.com/document-name?d=v1234.-234.3.21` If you set `deepLinks` to `true` e.g. `` the following default behavior will be enabled: 1. When the editor initializes, before the initial render, it will check the current `window.location` for a search param called `d`. If found, it will try to parse the value of this param as a deep link and navigate to that part of the document. 2. 500 milliseconds after every time the editor finishes navigating to a new part of the document, it will update `window.location` to add the latest version of the `d` param. You can customize this behavior by passing a configuration object as the `deepLinks` prop. e.g. ```tsx ``` For full options see the [`TLDeepLinkOptions`](/reference/editor/reference/editor/TLDeepLinkOptions) API reference. ## Handling deep links manually We expose the core functionality for managing deep links as a set of methods and utilities. This gives you more control e.g. if you prefer not to use search params in the URL. ### Creating a deep link You can create an isolated deep link string using the [`createDeepLinkString`](/reference/editor/reference/editor/createDeepLinkString) helper which takes a [`TLDeepLink`](/reference/editor/reference/editor/TLDeepLink) descriptor object. ```tsx createDeepLinkString({ type: 'page', pageId: 'page:abc123' }) // => 'pabc123' createDeepLinkString({ type: 'shapes', shapeIds: ['shape:foo', 'shape:bar'] }) // => 'sfoo.bar' createDeepLinkString({ type: 'viewport', pageId: 'page:abc123', bounds: { x: 0, y: 0, w: 1024, h: 768, }, }) // => 'v0.0.1024.768.abc123' ``` If you do prefer to put this in a URL as a query param, you can use the [`Editor.createDeepLink`](/reference/editor/reference/editor/Editor#createDeepLink) method. ```tsx editor.createDeepLink({ to: { type: 'page', pageId: 'page:abc123' } }) // => 'https://my-app.com/document-name?d=pabc123' ``` ### Handling a deep link You can parse a deep link string with [`parseDeepLinkString`](/reference/editor/reference/editor/parseDeepLinkString) which returns a [`TLDeepLink`](/reference/editor/reference/editor/TLDeepLink) descriptor object. You can then call [`Editor.navigateToDeepLink`](/reference/editor/reference/editor/Editor#navigateToDeepLink) with this descriptor to navigate to the part of the document described by the deep link. `Editor#navigateToDeepLink` also can take a plain URL if the deep link is encoded as a query param. ```tsx editor.navigateToDeepLink(parseDeepLinkString('pabc123')) // or pass in a url editor.navigateToDeepLink({ url: 'https://my-app.com/document-name?d=pabc123' }) // or call without options to use the current `window.location` editor.navigateToDeepLink() ``` ### Listening for deep link changes You can listen for deep link changes with the [`Editor.registerDeepLinkListener`](/reference/editor/reference/editor/Editor#registerDeepLinkListener) method, which takes the same options as the `deepLinks` prop. ```tsx useEffect(() => { const unlisten = editor.registerDeepLinkListener({ paramName: 'page', getTarget(editor) { return { type: 'page', pageId: editor.getCurrentPageId() } }, onChange(url) { console.log('the new search params are', url.searchParams) }, debounceMs: 100, }) return () => { unlisten() } }, []) ``` ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function DeepLinksExample() { return (
) } ``` -------- # Frame colors Category: Configuration Keywords: frame, color Use the `showColors` option to display colored fills and headings on frame shapes. Use the `showColors` option to display colored fills and headings on frame shapes. Create a frame (keyboard shortcut **F**) and see how it looks with the `showColors` option enabled. ## App.tsx ```tsx import { FrameShapeUtil, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' const ConfiguredFrameShapeUtil = FrameShapeUtil.configure({ showColors: true }) export default function FrameColorsExample() { return ( <>
) } ``` -------- # Note resizing Category: Configuration Keywords: note, resize, sticky Make the note shape resizable. The editor's options include alternative resizing behavior for note shapes. Set `resizeMode` to either `none` for the default behavior or `scale` to allow a user to scale the note. ## App.tsx ```tsx import { NoteShapeUtil, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' // Configure the note shape util to allow scaling to resize const shapeUtils = [NoteShapeUtil.configure({ resizeMode: 'scale' })] export default function ResizeNoteExample() { return ( <>
{/* pass the configured shape utils to the editor */}
) } ``` -------- # Precise exact arrows Category: Configuration Keywords: arrow, configure, isPrecise, isExact Make arrows adopt "isExact" behavior instead of "isPrecise". Arrows can be precise, which means that instead of just pointing to the center of a shape, they point to a specific place within the shape. If they're precise, they can also be exact. When they're the exact, the arrow won't stop at the edge of the shape, it'll pass through the shape right the way to the point the arrow is targeting. By default, arrows are precise when moving slowly, and exact when holding the alt key. This example shows how to customize this behavior, so that a precise arrow is always exact. ## App.tsx ```tsx import { ArrowShapeUtil, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' const shapeUtils = [ ArrowShapeUtil.configure({ shouldBeExact: (editor, isPrecise) => isPrecise, }), ] export default function ArrowsPreciseExactExample() { return (
) } ``` -------- # Disable pages Category: Configuration Keywords: basic, intro, simple, quick, start, page Disabling page-related UI for single-page use cases. You can set the `maxPages` option to `1` to disable the page selector and other UI related to managing pages. ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' // You can set the `maxPages` option to 1 to disable UI related to managing multiple pages. export default function DisablePagesExample() { return (
) } ``` -------- # Minimal Category: Configuration Keywords: select, tool, editor, bare, bones Use the `` component to render a bare-bones editor with minimal built-in shapes and tools. This example shows how the `` component can be used to render a bare-bones editor. It uses minimal built-in shapes and tools. ## App.tsx ```tsx /* eslint-disable import/no-extraneous-dependencies */ import { Editor, TldrawEditor, createShapeId } from '@tldraw/editor' import 'tldraw/tldraw.css' import { MiniBoxShapeUtil } from './MiniBoxShape' import { MiniSelectTool } from './MiniSelectTool' // There's a guide at the bottom of this page! // [1] const myTools = [MiniSelectTool] const myShapeUtils = [MiniBoxShapeUtil] // [2] export default function OnlyEditorExample() { return (
{ editor .selectAll() .deleteShapes(editor.getSelectedShapeIds()) .createShapes([ { id: createShapeId(), type: 'box', x: 100, y: 100, }, ]) }} components={{ // [3] OnTheCanvas: () => { return (

Double click to create or delete shapes.

Click or Shift+Click to select shapes.

Click and drag to move shapes.

) }, }} />
) } /* This example shows how to use the TldrawEditor component on its own. This is useful if you want to create your own custom UI, shape and tool interactions. [1] We create a custom tool and shape util arrays. These are arrays of classes that extend the built-in state node and shape util classes. Check out MiniSelectTool.ts and MiniBoxShapeUtil.tsx to see how they work. Or check out the custom config example for a more in-depth look at how to create custom tools and shapes. There is an even simpler implementation of the select tool in MicroSelectTool.tsx, but it isn't used in this example. [2] We pass our custom tools and shape utils to the TldrawEditor component. We also pass in our custom background component to the background prop and set the initial state to the 'select' tool. */ ``` ## MicroSelectTool.ts ```ts import { StateNode, TLClickEventInfo, TLPointerEventInfo, createShapeId } from 'tldraw' // There's a guide at the bottom of this file! //[1] export class MicroSelectTool extends StateNode { static override id = 'select' //[2] 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 } editor.selectNone() break } case 'shape': { editor.select(info.shape.id) break } } } //[3] 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.createShapes([ { id: createShapeId(), type: 'box', x: currentPagePoint.x - 50, y: currentPagePoint.y - 50, props: { w: 100, h: 100, }, }, ]) break } case 'shape': { editor.deleteShapes([info.shape.id]) break } } } } /* This is a very small example of a state node that implements a "select" tool. It doesn't implement any children states. The state handles two events: onPointerDown [2] and onDoubleClick [2]. When the user points down on the canvas, it deselects all shapes; and when they point a shape it selects that shape. When the user double clicks on the canvas, it creates a new shape; and when they double click on a shape, it deletes that shape. [1] This is where we define our state node by extending the StateNode class. Since there are no children states We can give it an id and define methods we want to override to handle events. [2] onPointerDown The user clicked on something, let's figure out what it was. We can access the editor via this.editor, and then use it to check if we hit a shape. If we did then we call the onPointerDown method again with the shape as the target, select the shape, and return. If we didn't hit a shape then we deselect all shapes. [3] onDoubleClick The user double clicked on something, let's do the same as above. If we hit a shape then we call the onDoubleClick method again with the shape as the target, delete it, and return. If we didn't hit a shape then we create a new shape at the pointer's position. */ ``` ## MiniBoxShape.tsx ```tsx import { BaseBoxShapeUtil, HTMLContainer, TLShape } from 'tldraw' // There's a guide at the bottom of this page! const BOX_TYPE = 'box' // [1] declare module 'tldraw' { export interface TLGlobalShapePropsMap { [BOX_TYPE]: { w: number; h: number; color: string } } } // [2] export type MiniBoxShape = TLShape // [3] export class MiniBoxShapeUtil extends BaseBoxShapeUtil { //[a] static override type = BOX_TYPE //[b] override getDefaultProps(): MiniBoxShape['props'] { return { w: 100, h: 100, color: '#efefef' } } //[c] component(shape: MiniBoxShape) { return (
) } //[d] indicator(shape: MiniBoxShape) { return } } /* This is our shape util, in tldraw all shapes extend the shape util class. In this example we're extending the built-in BaseBoxShapeUtil class. This class provides the functionality for our shape. [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 color as the shape's properties. [2] Define the shape type using TLShape with the shape's type as a type argument. [3] The shape util itself. [a] The type of shape this util is for, this should match the shape type we defined in [2]. [b] The default props for our shape. These will be used when creating a new shape. [c] The component for our shape. This returns JSX and is what will be rendered on the canvas. The HtmlContainer component is a div that provides some useful styles. [d] The indicator for our shape, this also returns JSX. This is what will be rendered on the canvas when the shape is selected. */ ``` ## MiniSelectTool.ts ```ts import { StateNode, TLClickEventInfo, TLPointerEventInfo, TLShape, createShapeId } from 'tldraw' // There's a guide at the bottom of this file! //[1] export class MiniSelectTool extends StateNode { static override id = 'select' static override children() { return [IdleState, PointingState, DraggingState] } static override initial = 'idle' } //[2] class IdleState extends StateNode { static override id = 'idle' //[a] 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 } editor.selectNone() break } case 'shape': { if (editor.inputs.getShiftKey()) { editor.select(...editor.getSelectedShapeIds(), info.shape.id) } else { if (!editor.getSelectedShapeIds().includes(info.shape.id)) { editor.select(info.shape.id) } this.parent.transition('pointing', info) } break } } } //[b] 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.createShapes([ { id: createShapeId(), type: 'box', x: currentPagePoint.x - 50, y: currentPagePoint.y - 50, props: { w: 100, h: 100, }, }, ]) break } case 'shape': { editor.deleteShapes([info.shape.id]) break } } } } //[3] class PointingState extends StateNode { static override id = 'pointing' //[a] override onPointerUp(info: TLPointerEventInfo) { this.parent.transition('idle', info) } //[b] override onPointerMove() { if (this.editor.inputs.getIsDragging()) { this.parent.transition('dragging', { shapes: [...this.editor.getSelectedShapes()] }) } } } //[4] class DraggingState extends StateNode { static override id = 'dragging' //[a] private initialDraggingShapes: TLShape[] = [] //[b] override onEnter(info: { shapes: TLShape[] }) { this.initialDraggingShapes = info.shapes } //[c] override onPointerUp(info: TLPointerEventInfo) { this.parent.transition('idle', info) } //[d] override onPointerMove() { const { initialDraggingShapes } = this const originPagePoint = this.editor.inputs.getOriginPagePoint() const currentPagePoint = this.editor.inputs.getCurrentPagePoint() this.editor.updateShapes( initialDraggingShapes.map((shape) => { return { ...shape, x: shape.x + (currentPagePoint.x - originPagePoint.x), y: shape.y + (currentPagePoint.y - originPagePoint.y), } }) ) } } /* This is where we implement our select tool. In tldraw, tools are part of the tldraw state chart. Check out the docs for more info: https://tldraw.dev/docs/editor#State-Chart Our state node [1] has three children: idle [2], pointing [3], and dragging [4]. Only one child state can be "active" at a time. The parent state's initial active state is "idle". Certain events received by the child states will cause the parent state to transition to another child state, making that state active instead. Note that when `transition()` is called, the parent state will call the new active state(s)'s `onEnter` method with the second argument passed to the transition method. This is useful for passing data between states. [1] This is where we define our state node by extending the StateNode class. We give it an id, a list of children states, and its initial active state. [2] The idle state is the tool's default state. This is where most of the action is. We have some handy methods available to help us handle events: [a] onPointerDown The user clicked on something, let's figure out what it was. We can access the editor via this.editor, and then use it to check if we hit a shape. If we did then we call the onPointerDown method again with the shape as the target, select the shape and transition to the pointing state. Otherwise we deselect everything. [b] onDoubleClick The user double clicked on something, let's do the same thing as above. If we hit a shape then we call the onDoubleClick method again with the shape as the target, and delete the shape. Otherwise we create a new shape. [3] The pointing state is something of a transitionary state. Its job is to transition to the dragging state when the user starts dragging, or go back to the idle state on pointer up. [a] onPointerUp The user let go of the mouse, let's go back to the idle state. [b] onPointerMove The user moved the mouse, let's double check they're dragging. If they are then let's transition to the dragging state and pass it the shapes that are being dragged. [4] The dragging state is where we actually move the shapes around. It's job is to update the position of the shapes being dragged, and transition back to the idle state when the user lets go of the mouse. [a] initialDraggingShapes We'll use this to keep track of the shapes being dragged when we enter the state. [b] onEnter When we enter the dragging state, we'll save the shapes being dragged. [c] onPointerUp The user let go of the mouse, let's go back to the idle state. [d] onPointerMove The user moved the mouse, let's update the position of the shapes being dragged using editor.updateShapes(). */ ``` -------- # Sublibraries Category: Configuration Keywords: ui, components, utils Tldraw is built from several sublibraries - like the editor, default shapes & tools, and UI. Tldraw is built from several sublibraries - like the editor, default shapes & tools, and UI. For full customization, you can use these sublibraries directly, or replace them with your own. ## App.tsx ```tsx import { useEffect } from 'react' import { ContextMenu, DEFAULT_SUPPORTED_IMAGE_TYPES, DEFAULT_SUPPORT_VIDEO_TYPES, DefaultContextMenuContent, TldrawEditor, TldrawHandles, TldrawOverlays, TldrawScribble, TldrawSelectionForeground, TldrawShapeIndicators, TldrawUi, defaultAddFontsFromNode, defaultBindingUtils, defaultEditorAssetUrls, defaultShapeTools, defaultShapeUtils, defaultTools, registerDefaultExternalContentHandlers, registerDefaultSideEffects, tipTapDefaultExtensions, useEditor, useToasts, useTranslation, } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! // [1] const defaultComponents = { Scribble: TldrawScribble, ShapeIndicators: TldrawShapeIndicators, CollaboratorScribble: TldrawScribble, SelectionForeground: TldrawSelectionForeground, Handles: TldrawHandles, Overlays: TldrawOverlays, } const allDefaultTools = [...defaultTools, ...defaultShapeTools] const defaultTextOptions = { tipTapConfig: { extensions: tipTapDefaultExtensions, }, addFontsFromNode: defaultAddFontsFromNode, } //[2] export default function ExplodedExample() { return (
) } function InsideEditorAndUiContext() { const editor = useEditor() const toasts = useToasts() const msg = useTranslation() // [3] useEffect(() => { registerDefaultExternalContentHandlers(editor, { maxImageDimension: 5000, maxAssetSize: 10 * 1024 * 1024, // 10mb acceptedImageMimeTypes: DEFAULT_SUPPORTED_IMAGE_TYPES, acceptedVideoMimeTypes: DEFAULT_SUPPORT_VIDEO_TYPES, toasts, msg, }) const cleanupSideEffects = registerDefaultSideEffects(editor) return () => { cleanupSideEffects() } }, [editor, msg, toasts]) return ( ) } /* The tldraw library is built from many sublibraries. This example shows how to build the tldraw component from its subcomponents for max customisation. You can edit, omit or add to these subcomponents to create your app. [1] Here we've imported some components from the tldraw library which we will later pass to the TldrawEditor component. These components are not part of the more minimal defaults, so we need to import them separately. For help creating your own components to pass into the components prop check out the custom components example. [2] Here we've passed the default components object to the TldrawEditor component. Along with default tools and shapeutils, You could input your own custom shapes/tools here. For help creating your own shapes/tools check out the custom config example. We also set the initial state to 'select' and render the UI, context menu and canvas components. You could add your own custom components here, omit these ones, and/or change the initial state of the application to whatever you want. [3] Inside of the editor and UI context, we need to set up extra pieces to get the editor working with our default shapes and tools. We register the default external content handlers, which sets up handling for things like images and pasted content. We also register the default side effects, which react to changes to the editor's store. */ ``` -------- # Controlling the canvas Category: Editor API Keywords: api, create shape, update shape, mount, context Manipulate the contents of the canvas using the editor API. You can manipulate the contents of the canvas using the editor API. This example creates and updates shapes, selects and rotates them, and zooms the camera. ## App.tsx ```tsx import { DefaultColorStyle, Editor, TLGeoShape, TLShapePartial, Tldraw, toRichText, createShapeId, useEditor, } from 'tldraw' import 'tldraw/tldraw.css' import { useEffect } from 'react' // There's a guide at the bottom of this file! //[1] export default function APIExample() { const handleMount = (editor: Editor) => { // Create a shape id const id = createShapeId('hello') // Create a shape editor.createShapes([ { id, type: 'geo', x: 128 + Math.random() * 500, y: 128 + Math.random() * 500, props: { geo: 'rectangle', w: 120, h: 100, dash: 'draw', color: 'blue', size: 'm', }, }, ]) // Get the created shape const shape = editor.getShape(id)! // Update the shape editor.updateShape({ id, type: 'geo', props: { h: shape.props.h * 3, richText: toRichText('hello world!'), }, }) // Rotate the shape around its center editor.rotateShapesBy([id], Math.PI / 8) // Zoom the camera to fit the shape editor.zoomToFit() // Select the shape editor.select(id) } return (
) } //[2] const InsideOfEditorContext = () => { const editor = useEditor() useEffect(() => { let i = 0 const interval = setInterval(() => { const selection = [...editor.getSelectedShapeIds()] editor.selectAll() editor.setStyleForSelectedShapes(DefaultColorStyle, i % 2 ? 'blue' : 'light-blue') editor.setStyleForNextShapes(DefaultColorStyle, i % 2 ? 'blue' : 'light-blue') editor.setSelectedShapes(selection) i++ }, 1000) return () => { clearInterval(interval) } }, [editor]) return null } /* Introduction: This example shows how to use the tldraw editor instance to make changes to the canvas. The editor instance is tldraw's "god object". You can use the app to do just about everything that's possible in tldraw. Internally, the canvas component and all shapes, tools, and UI components use this instance to send events, observe changes, and perform actions. There are two main ways to use the editor: [1] The tldraw component shares its editor instance via its onMount callback prop. When you define a function for the onMount callback, it receives the editor instance as an argument. You can use this to manipulate the canvas. [2] Another (sneakier) way to access the current app is through React context. The Tldraw component provides the context, so you can add children to the component and access the app through the useEditor hook. This is cool. */ ``` -------- # Save and load snapshots Category: Editor API Keywords: Sand and load a snapshot of the editor's contents. Use `getSnapshot()` and `loadSnapshot()` to save and restore the editor's contents. ## App.tsx ```tsx import { useCallback, useEffect, useState } from 'react' import { TLEditorSnapshot, Tldraw, getSnapshot, loadSnapshot, useEditor } from 'tldraw' import 'tldraw/tldraw.css' import _jsonSnapshot from './snapshot.json' // There's a guide at the bottom of this file! const jsonSnapshot = _jsonSnapshot as any as TLEditorSnapshot // [1] function SnapshotToolbar() { const editor = useEditor() const save = useCallback(() => { // [2] const { document, session } = getSnapshot(editor.store) // [3] localStorage.setItem('snapshot', JSON.stringify({ document, session })) }, [editor]) const load = useCallback(() => { const snapshot = localStorage.getItem('snapshot') if (!snapshot) return // [4] loadSnapshot(editor.store, JSON.parse(snapshot)) }, [editor]) const [showCheckMark, setShowCheckMark] = useState(false) useEffect(() => { if (showCheckMark) { const timeout = setTimeout(() => { setShowCheckMark(false) }, 1000) return () => clearTimeout(timeout) } return }) return (
Saved ✅
) } export default function SnapshotExample() { return (
) } /* [1] We'll add a toolbar to the top-right of the editor viewport that allows the user to save and load snapshots. [2] Call `getSnapshot(editor.store)` to get the current state of the editor [3] The 'document' state is the set of shapes and pages and images etc. The 'session' state is the state of the editor like the current page, camera positions, zoom level, etc. You probably need to store these separately if you're building a multi-user app, so that you can store per-user session state. For this example we'll just store them together in localStorage. [4] Call `loadSnapshot()` to load a snapshot into the editor You can omit the `session` state, or load it later on its own. e.g. loadSnapshot(editor.store, { document }) then optionally later loadSnapshot(editor.store, { session }) [5] You can load an initial snapshot into the editor by passing it to the `snapshot` prop. */ ``` -------- # Coordinate system transformations Category: Editor API Keywords: coordinates, screenToPage, pageToScreen, pageToViewport, getViewportScreenBounds, getViewportPageBounds, viewport, transformation Convert between screen, page, and viewport coordinate systems. This example demonstrates how to work with tldraw's three coordinate systems: - **Screen space**: Pixel coordinates relative to the browser window (from DOM events) - **Page space**: Coordinates in the infinite canvas space (where shapes live) - **Viewport space**: Coordinates relative to the editor container (useful for UI positioning) The key transformation methods demonstrated are: - `editor.screenToPage(point)` - Convert DOM event coordinates to canvas coordinates - `editor.pageToScreen(point)` - Position DOM elements relative to shapes - `editor.pageToViewport(point)` - Get coordinates relative to the editor container - `editor.getViewportScreenBounds()` - Get visible area bounds in screen coordinates - `editor.getViewportPageBounds()` - Get visible area bounds in page coordinates Move your mouse to see coordinates in all three systems. Pan and zoom the canvas to see how screen coordinates stay constant while page coordinates change. Create and select a shape to see a DOM overlay positioned using pageToScreen(). ## App.tsx ```tsx import { useEffect, useState } from 'react' import { Tldraw, TLEditorComponents, track, useEditor } from 'tldraw' import 'tldraw/tldraw.css' import './coordinate-system.css' // [1] const CoordinateDebugPanel = track(() => { const editor = useEditor() const [mouseScreen, setMouseScreen] = useState({ x: 0, y: 0 }) const [mousePage, setMousePage] = useState({ x: 0, y: 0 }) const [mouseViewport, setMouseViewport] = useState({ x: 0, y: 0 }) // [2] useEffect(() => { const handlePointerMove = (e: PointerEvent) => { const screenPoint = { x: e.clientX, y: e.clientY } const pagePoint = editor.screenToPage(screenPoint) const viewportPoint = editor.pageToViewport(pagePoint) setMouseScreen(screenPoint) setMousePage(pagePoint) setMouseViewport(viewportPoint) } const container = editor.getContainer() container.addEventListener('pointermove', handlePointerMove) return () => { container.removeEventListener('pointermove', handlePointerMove) } }, [editor]) // [3] const viewportScreenBounds = editor.getViewportScreenBounds() const viewportPageBounds = editor.getViewportPageBounds() return (

Coordinate Systems

Mouse Position

Screen: ({mouseScreen.x.toFixed(0)}, {mouseScreen.y.toFixed(0)})
Page: ({mousePage.x.toFixed(0)}, {mousePage.y.toFixed(0)})
Viewport: ({mouseViewport.x.toFixed(0)}, {mouseViewport.y.toFixed(0)})
{/* [4] */}

Viewport Bounds

Screen: ({viewportScreenBounds.x.toFixed(0)}, {viewportScreenBounds.y.toFixed(0)},{' '} {viewportScreenBounds.w.toFixed(0)}×{viewportScreenBounds.h.toFixed(0)})
Page: ({viewportPageBounds.x.toFixed(0)}, {viewportPageBounds.y.toFixed(0)},{' '} {viewportPageBounds.w.toFixed(0)}×{viewportPageBounds.h.toFixed(0)})
) }) // [5] const SelectedShapeOverlay = track(() => { const editor = useEditor() const selectedShapeIds = editor.getSelectedShapeIds() if (selectedShapeIds.length !== 1) return null const selectedShapeId = selectedShapeIds[0] const shape = editor.getShape(selectedShapeId) if (!shape) return null const pageBounds = editor.getShapePageBounds(selectedShapeId) if (!pageBounds) return null // [6] const topLeftViewport = editor.pageToViewport({ x: pageBounds.x, y: pageBounds.y }) return (
Page: ({pageBounds.x.toFixed(0)}, {pageBounds.y.toFixed(0)})
) }) // [7] const components: TLEditorComponents = { InFrontOfTheCanvas: () => ( <> ), } export default function RequestCoordinateSystemTransformationsExample() { return (
) } /* Introduction: This example demonstrates tldraw's coordinate system transformations. The editor uses three coordinate systems: screen space (browser pixels), page space (infinite canvas), and viewport space (relative to the editor container). Understanding these transformations is essential for custom tools, UI overlays, and interactive features. [1] The `CoordinateDebugPanel` displays real-time coordinate information. We use `track` to make it reactive to editor state changes, ensuring it updates when the camera moves or zooms. [2] We track mouse movement to demonstrate coordinate transformations. For each pointer move: - Screen coordinates come from the DOM event (clientX, clientY) - Page coordinates are computed using `screenToPage()` - accounting for pan and zoom - Viewport coordinates are computed using `pageToViewport()` - relative to the editor container [3] We also display the viewport bounds in both coordinate systems using `getViewportScreenBounds()` and `getViewportPageBounds()`. These methods show the visible area of the canvas in screen and page coordinates respectively. Watch how the screen bounds stay constant while page bounds change as you pan and zoom the canvas. [4] The viewport bounds display shows position (x, y) and size (w×h) for both coordinate systems. This is useful for visibility checks, culling, and understanding what portion of the infinite canvas is currently visible. [5] The `SelectedShapeOverlay` demonstrates `pageToScreen()` by positioning a DOM element above the selected shape. This component tracks editor state to update when selection or camera changes. [6] We convert the shape's page position to viewport coordinates using `pageToViewport()`. This is the correct transformation for positioning DOM elements inside `InFrontOfTheCanvas`, which is positioned relative to the editor container (not the browser window). Using `pageToScreen()` here would cause the overlay to be displaced when the editor has an offset from the browser origin (e.g., a sidebar). [7] We use the `InFrontOfTheCanvas` component slot to render our UI. Components here are positioned in screen space - they don't scale with zoom but maintain their screen position. */ ``` ## coordinate-system.css ```css .coordinate-debug-panel { position: absolute; top: 64px; left: 16px; width: 280px; background: white; border: 1px solid #ccc; padding: 12px; font-family: system-ui, sans-serif; font-size: 12px; z-index: 10000; user-select: none; } .coordinate-debug-panel h3 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; } .coordinate-debug-panel h4 { margin: 8px 0 4px 0; font-size: 11px; font-weight: 600; color: #666; } .coordinate-section { margin-bottom: 8px; } .coordinate-row { display: flex; justify-content: space-between; padding: 2px 0; } .coordinate-label { color: #666; } .coordinate-value { font-family: monospace; font-size: 11px; } .shape-overlay { position: absolute; pointer-events: none; z-index: 10000; background: #333; color: white; padding: 4px 8px; font-size: 11px; font-family: monospace; white-space: nowrap; } ``` -------- # Create an arrow Category: Editor API Keywords: arrow, between Create an arrow between two shapes. This example shows how you can use the Editor API to create an arrow between two shapes. ## App.tsx ```tsx import { createShapeId, Editor, TLArrowBinding, Tldraw, TLShapeId, Vec } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! export default function CreateArrowExample() { return ( <>
{ // Only do this on an empty canvas if (editor.getCurrentPageShapeIds().size > 0) return const shapeAId = createShapeId() const shapeBId = createShapeId() editor.createShapes([ { id: shapeAId, type: 'geo', x: 100, y: 100, }, { id: shapeBId, type: 'geo', x: 400, y: 400, }, ]) createArrowBetweenShapes(editor, shapeAId, shapeBId) }} />
) } function createArrowBetweenShapes( editor: Editor, startShapeId: TLShapeId, endShapeId: TLShapeId, options = {} as { parentId?: TLShapeId start?: Partial> end?: Partial> } ) { const { start = {}, end = {}, parentId } = options // [1] const { normalizedAnchor: startNormalizedAnchor = { x: 0.5, y: 0.5 }, isExact: startIsExact = false, isPrecise: startIsPrecise = false, } = start const { normalizedAnchor: endNormalizedAnchor = { x: 0.5, y: 0.5 }, isExact: endIsExact = false, isPrecise: endIsPrecise = false, } = end const startTerminalNormalizedPosition = Vec.From(startNormalizedAnchor) const endTerminalNormalizedPosition = Vec.From(endNormalizedAnchor) const parent = parentId ? editor.getShape(parentId) : undefined if (parentId && !parent) throw Error(`Parent shape with id ${parentId} not found`) const startShapePageBounds = editor.getShapePageBounds(startShapeId) const endShapePageBounds = editor.getShapePageBounds(endShapeId) const startShapePageRotation = editor.getShapePageTransform(startShapeId).rotation() const endShapePageRotation = editor.getShapePageTransform(endShapeId).rotation() if (!startShapePageBounds || !endShapePageBounds) return const startTerminalPagePosition = Vec.Add( startShapePageBounds.point, Vec.MulV( startShapePageBounds.size, Vec.Rot(startTerminalNormalizedPosition, startShapePageRotation) ) ) const endTerminalPagePosition = Vec.Add( endShapePageBounds.point, Vec.MulV( startShapePageBounds.size, Vec.Rot(endTerminalNormalizedPosition, endShapePageRotation) ) ) const arrowPointInParentSpace = Vec.Min(startTerminalPagePosition, endTerminalPagePosition) if (parent) { arrowPointInParentSpace.setTo( editor.getShapePageTransform(parent.id)!.applyToPoint(arrowPointInParentSpace) ) } const arrowId = createShapeId() editor.run(() => { editor.markHistoryStoppingPoint('creating_arrow') editor.createShape({ id: arrowId, type: 'arrow', // [2] x: arrowPointInParentSpace.x, y: arrowPointInParentSpace.y, props: { // [3] start: { x: arrowPointInParentSpace.x - startTerminalPagePosition.x, y: arrowPointInParentSpace.x - startTerminalPagePosition.x, }, end: { x: arrowPointInParentSpace.x - endTerminalPagePosition.x, y: arrowPointInParentSpace.x - endTerminalPagePosition.x, }, }, }) editor.createBindings([ { fromId: arrowId, toId: startShapeId, type: 'arrow', props: { terminal: 'start', normalizedAnchor: startNormalizedAnchor, isExact: startIsExact, isPrecise: startIsPrecise, }, }, { fromId: arrowId, toId: endShapeId, type: 'arrow', props: { terminal: 'end', normalizedAnchor: endNormalizedAnchor, isExact: endIsExact, isPrecise: endIsPrecise, }, }, ]) }) } /* Introduction: This example shows how to create an arrow between two shapes. [1] The normalized anchor is the position inside the shape that the arrow connects to, where 0 0 is the top left corner and 1 1 is the bottom right. `isPrecise` needs to be enabled for this position to be used, otherwise it targets the center of the shape. By default, arrows don't intersect shapes they're connected to, and instead gracefully touch the outside of the shape's geometry. You can turn this off and make an arrow intersect a shape by setting `isExact` to true. [2] The arrow shape's position is in parent space, which in this case means the page. [3] The arrow's start and end positions are "local", which means they're relative to the arrow's position. Note: You don't need to set the arrow's start and end positions if they're bound to another shape, as it gets calculated automatically. */ ``` -------- # Custom clipping shape Category: Editor API Keywords: clipping, shape # Custom clipping shape example This example demonstrates the extensible clipping system in tldraw, showing how to create custom shapes that can clip their children with any polygon geometry. ## Key implementation details ### ShapeUtil methods The clipping system uses two methods in the `ShapeUtil` base class: ```typescript // Returns the clip path polygon in local coordinates getClipPath(shape: Shape): Vec[] | undefined // Determines which children should be clipped shouldClipChild(child: TLShape): boolean ``` ### Circle clip shape The `CircleClipShapeUtil` demonstrates: - **Custom Geometry**: Uses a polygon approximation of a circle for clipping. - **Conditional Clipping**: Can be enabled/disabled via shape properties or other state. - **Selective Clipping**: Text shapes can be selectively excluded from clipping via override. - **Visual Indicators**: Different appearance based on clipping state. ## Usage 1. Use the **Circle Clip Tool** (circle icon) in the toolbar to create circular clipping shapes 2. Click the **"✂️ Toggle Clipping"** button in the top-left to enable/disable clipping for all circle shapes globally 3. Click the **"📝 Text Clipping Override"** button to toggle whether text shapes should be clipped (when override is ON, text shapes are not clipped regardless of global setting) 4. The example starts with demo content already clipped by a circular shape ## Technical notes - Clip paths are defined in the shape's local coordinate system - The Editor automatically transforms them to page space for rendering - Multiple clipping ancestors are supported (intersected together) - Performance is optimized through computed caching - The `shouldClipChild` method can be used to selectively exclude certain shapes from clipping if needed. ## App.tsx ```tsx import { createShapeId, DefaultToolbar, DefaultToolbarContent, TLComponents, Tldraw, TldrawUiMenuItem, TLGeoShapeProps, TLTextShapeProps, TLUiOverrides, toRichText, useEditor, useIsToolSelected, useTools, useValue, } from 'tldraw' import 'tldraw/tldraw.css' import { CircleClipShapeTool } from './CircleClipShapeTool' import { CircleClipShapeUtil, isClippingEnabled$ } from './CircleClipShapeUtil' import './CustomClipping.css' // There's a guide at the bottom of this file! // [1] const shapeUtils = [CircleClipShapeUtil] const tools = [CircleClipShapeTool] // [2] const customUiOverrides: TLUiOverrides = { tools: (editor: any, tools: any) => { return { ...tools, 'circle-clip': { id: 'circle-clip', label: 'Circle Clip', icon: 'color', kbd: 'c', onSelect() { editor.setCurrentTool('circle-clip') }, }, } }, } // [3] function ToggleClippingButton() { const editor = useEditor() const clippingEnabled = useValue('isClippingEnabled', () => isClippingEnabled$.get(), [editor]) return (
) } // [4] function CustomToolbar() { const tools = useTools() const isCircleClipSelected = useIsToolSelected(tools['circle-clip']) return ( ) } // [5] const components: TLComponents = { Toolbar: CustomToolbar, InFrontOfTheCanvas: ToggleClippingButton, } // [6] export default function CustomClippingExample() { return (
{ editor.setCurrentTool('select') const clipShapeId = createShapeId() editor.createShape({ id: clipShapeId, type: 'circle-clip', x: 200, y: 200, props: { w: 300, h: 300, }, }) editor.createShape({ type: 'text', x: 0, y: 100, parentId: clipShapeId, props: { size: 'l', textAlign: 'middle', richText: toRichText('This text is clipped to the circle! ✂️'), } satisfies Partial, }) editor.createShape({ type: 'geo', x: 100, y: 290, parentId: clipShapeId, props: { geo: 'rectangle', w: 200, h: 100, fill: 'solid', color: 'blue', richText: toRichText('Oops you found me!'), } satisfies Partial, }) editor.zoomToFit() }} />
) } /* Introduction: This example demonstrates the extensible clipping system in tldraw, showing how to create custom shapes that can clip their children with any polygon geometry. The clipping system uses two key methods: `getClipPath` to define the clip boundary and `shouldClipChild` to control which children get clipped. [1] We define arrays to hold our custom shape util and tool. It's important to do this outside of any React component so that these arrays don't get redefined on every render. [2] Here we define UI overrides to add our custom circle clip tool to the toolbar. The `tools` override allows us to add new tools with custom icons, labels, and keyboard shortcuts. [3] The ToggleClippingButton component demonstrates how to create global state management for clipping. It uses the `isClippingEnabled$` atom to toggle clipping on/off for all circle clip shapes. [4] The CustomToolbar component shows how to integrate custom tools into the main toolbar. We use `useIsToolSelected` to highlight the active tool and `TldrawUiMenuItem` to render the tool button. [5] We define custom components to override the default toolbar and add our toggle button in front of the canvas. The `components` prop allows us to customize various parts of the tldraw UI. [6] This is where we render the Tldraw component with our custom shape utils, tools, components, and overrides. The onMount callback sets up the initial demo content. For more details on the clipping implementation, see CircleClipShapeUtil.tsx and CircleClipShapeTool.tsx. */ ``` ## CircleClipShapeTool.tsx ```tsx import { StateNode, TLEventHandlers } from 'tldraw' export class CircleClipShapeTool extends StateNode { static override id = 'circle-clip' override onEnter(): void { this.editor.setCursor({ type: 'cross', rotation: 0 }) } override onPointerDown(info: Parameters[0]) { if (info.target === 'canvas') { const originPagePoint = this.editor.inputs.getOriginPagePoint() this.editor.createShape({ type: 'circle-clip', x: originPagePoint.x - 100, y: originPagePoint.y - 100, props: { w: 200, h: 200, }, }) } } } ``` ## CircleClipShapeUtil.tsx ```tsx import { BaseBoxShapeUtil, Circle2d, Geometry2d, PI2, RecordProps, SVGContainer, T, TLResizeInfo, TLShape, Vec, atom, clamp, resizeBox, toDomPrecision, } from 'tldraw' const CIRCLE_CLIP_TYPE = 'circle-clip' declare module 'tldraw' { export interface TLGlobalShapePropsMap { [CIRCLE_CLIP_TYPE]: { w: number h: number } } } export type CircleClipShape = TLShape export const isClippingEnabled$ = atom('isClippingEnabled', true) // The stroke width used when rendering the circle const STROKE_WIDTH = 3 export class CircleClipShapeUtil extends BaseBoxShapeUtil { static override type = CIRCLE_CLIP_TYPE static override props: RecordProps = { w: T.number, h: T.number, } override canBind() { return false } override canReceiveNewChildrenOfType(shape: TLShape) { return !shape.isLocked } override providesBackgroundForChildren(): boolean { return true } override getDefaultProps(): CircleClipShape['props'] { return { w: 200, h: 200, } } override getGeometry(shape: CircleClipShape): Geometry2d { const radius = Math.min(shape.props.w, shape.props.h) / 2 return new Circle2d({ radius, x: shape.props.w / 2 - radius, y: shape.props.h / 2 - radius, isFilled: true, }) } override getClipPath(shape: CircleClipShape): Vec[] | undefined { // Generate a polygon approximation of the circle. // We inset the clip path by half the stroke width so that children are // clipped to the inner edge of the stroke, not the center line. const centerX = shape.props.w / 2 const centerY = shape.props.h / 2 const outerRadius = Math.min(shape.props.w, shape.props.h) / 2 const clipRadius = outerRadius - STROKE_WIDTH / 2 const segments = clamp(Math.round((PI2 * clipRadius) / 8), 3, 360) // More segments = smoother circle const points: Vec[] = [] for (let i = 0; i < segments; i++) { const angle = (i / segments) * Math.PI * 2 const x = centerX + Math.cos(angle) * clipRadius const y = centerY + Math.sin(angle) * clipRadius points.push(new Vec(x, y)) } return points } override shouldClipChild(_child: TLShape): boolean { // For now, clip all children - we removed the onlyClipText feature for simplicity return isClippingEnabled$.get() } override component(shape: CircleClipShape) { const radius = Math.min(shape.props.w, shape.props.h) / 2 const centerX = shape.props.w / 2 const centerY = shape.props.h / 2 const clippingEnabled = isClippingEnabled$.get() return ( {/* Visual indicator */} {clippingEnabled ? '✂️' : '○'} ) } override indicator(shape: CircleClipShape) { const radius = Math.min(shape.props.w, shape.props.h) / 2 const centerX = shape.props.w / 2 const centerY = shape.props.h / 2 return ( ) } override onResize(shape: CircleClipShape, info: TLResizeInfo) { return resizeBox(shape, info) } } ``` ## CustomClipping.css ```css .CustomClipping-toggleButton { position: absolute; top: 64px; left: 12px; pointer-events: all; z-index: 1000; } .CustomClipping-button { padding: 8px 16px; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .CustomClipping-button--enabled { background-color: #ea4335; } .CustomClipping-button--disabled { background-color: #34a853; } ``` -------- # Zoom to bounds Category: Editor API Keywords: zoom, camera, bounds, inset Zoom the camera to specific bounds using the editor's `zoomToBounds` function. This example shows how to use the `zoomToBounds` method to programmatically zoom the camera to specific areas of the canvas. The example creates two boxes at different positions and provides buttons to zoom to each one. Note that the bounds the camera ultimately moves to may be different than the bounds you pass into the function. This is because the values of the bounds are massaged to maintain your viewport's aspect ratio. Also, if an inset is not specified, a default one will be set, adding inset padding to the bounds. ## App.tsx ```tsx import { Box, createShapeId, Tldraw, TldrawUiButton, useEditor } from 'tldraw' import './zoom-to-bounds.css' export default function ZoomToBoundsExample() { const zoomBox1 = new Box(50, 100, 900, 720) const zoomBox2 = new Box(1000, 500, 500, 400) return (
{ editor.createShapes([ { id: createShapeId(), type: 'geo', x: zoomBox1.x, y: zoomBox1.y, isLocked: true, props: { w: zoomBox1.w, h: zoomBox1.h, color: 'violet', }, }, { id: createShapeId(), type: 'geo', x: zoomBox2.x, y: zoomBox2.y, isLocked: true, props: { w: zoomBox2.w, h: zoomBox2.h, color: 'blue', }, }, ]) }} components={{ TopPanel: () => { const editor = useEditor() return (
editor.zoomToBounds(zoomBox1, { inset: 72 })} > Zoom to violet box editor.zoomToBounds(zoomBox2, { inset: 72, animation: { duration: 200 } }) } > Zoom to blue box editor.zoomToBounds(Box.Common([zoomBox1, zoomBox2]), { inset: 200, animation: { duration: 200 }, }) } > Zoom to both boxes
) }, }} />
) } ``` ## zoom-to-bounds.css ```css .control-panel { display: flex; flex-direction: row; flex-wrap: wrap; margin: 8px; } ``` -------- # Create a video shape Category: Editor API Keywords: asset, video Create a video shape using a local asset. This example shows how to create a `TLVideoAsset` for a video file that's hosted locally and add a `VideoShape` referencing that asset to the canvas. ## App.tsx ```tsx import { useCallback } from 'react' import { AssetRecordType, Editor, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function LocalVideosExample() { const handleMount = useCallback((editor: Editor) => { const assetId = AssetRecordType.createId() const videoWidth = 640 const videoHeight = 360 editor.createAssets([ { id: assetId, type: 'video', typeName: 'asset', props: { name: 'fluid.mp4', src: '/fluid.mp4', w: videoWidth, h: videoHeight, mimeType: 'video/mp4', isAnimated: true, }, meta: {}, }, ]) editor.createShape({ type: 'video', x: (window.innerWidth - videoWidth) / 2, y: (window.innerHeight - videoHeight) / 2, props: { assetId, w: videoWidth, h: videoHeight, }, }) }, []) return (
) } ``` -------- # Create an image shape Category: Editor API Keywords: asset, record, create asset Create an image shape using a local asset. This example shows how to use local images in the built-in `ImageShape` shape. You must first create an asset that holds the source of the image, then create a shape that references the asset. ## App.tsx ```tsx import { useCallback } from 'react' import { AssetRecordType, Editor, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! export default function LocalImagesExample() { // [1] const handleMount = useCallback((editor: Editor) => { //[2] const assetId = AssetRecordType.createId() const imageWidth = 1200 const imageHeight = 675 //[2] editor.createAssets([ { id: assetId, type: 'image', typeName: 'asset', props: { name: 'tldraw.png', src: '/tldraw.png', // You could also use a base64 encoded string here w: imageWidth, h: imageHeight, mimeType: 'image/png', isAnimated: false, }, meta: {}, }, ]) //[3] editor.createShape({ type: 'image', // Let's center the image in the editor x: (window.innerWidth - imageWidth) / 2, y: (window.innerHeight - imageHeight) / 2, props: { assetId, w: imageWidth, h: imageHeight, }, }) }, []) return (
) } /* This is an example of how you can add a locally hosted image to the editor. We need to first create an asset that holds the source image [2], and then create the Image shape itself [3]. Because this is a Next.js app, we can use the `public` folder to store the image locally, your framework may have a different way of serving static assets. If you want to allow users to upload the images please take a look at the hosted images example. [1] We'll access the editor instance via the `onMount` callback. Check out the API example for another way to do this. [2] Assets are records that store data about shared assets like images, videos, etc. Each image has an associated asset record, so we'll create that first. We need an `assetId` so that we can later associate it with the image. [3] We create the image shape and pass in the `assetId` that we created earlier. This will link our image shape to the asset record. Notice that we create the shape with the same dimensions as the image, later on the user may resize the image, but we don't want to resize our asset, this is one of the reasons why it's important to keep assets and shapes separate. */ ``` -------- # Dynamic tools with setTool and removeTool Category: Editor API Keywords: setTool, removeTool, dynamic, tools, state chart, toolbar Dynamically add and remove tools from the editor and toolbar after initialization. The `setTool` and `removeTool` methods allow you to add and remove tools from the editor's state chart on demand, after the editor has been initialized. This example shows how to dynamically add a tool that appears in the toolbar when added and disappears when removed. This is useful when you need to conditionally enable or disable tools based on user permissions, feature flags, or other runtime conditions. ## App.tsx ```tsx import { useMemo, useState } from 'react' import { DefaultToolbar, DefaultToolbarContent, Editor, StateNode, TLComponents, TLTextShape, TLUiAssetUrlOverrides, TLUiOverrides, Tldraw, TldrawUiButton, TldrawUiMenuItem, toRichText, useIsToolSelected, useTools, } from 'tldraw' import 'tldraw/tldraw.css' import './dynamic-tools.css' // There's a guide at the bottom of this file! const OFFSET = 12 // [1] class HeartTool extends StateNode { static override id = 'heart' override onEnter() { this.editor.setCursor({ type: 'cross', rotation: 0 }) } 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 uiOverrides: TLUiOverrides = { tools(editor, tools) { // Create a tool item in the ui's context. tools.heart = { id: 'heart', icon: 'heart-icon', label: 'Heart', kbd: 'r', onSelect: () => { editor.setCurrentTool('heart') }, } return tools }, } // [3] export const customAssetUrls: TLUiAssetUrlOverrides = { icons: { 'heart-icon': '/heart-icon.svg', }, } // [4] export default function DynamicToolsExample() { const [editor, setEditor] = useState(null) const [isHeartToolEnabled, setIsHeartToolEnabled] = useState(false) // [5] const components: TLComponents = useMemo( () => ({ Toolbar: (props) => { const tools = useTools() const isHeartSelected = useIsToolSelected(tools['heart']) return ( {isHeartToolEnabled && ( )} ) }, InFrontOfTheCanvas: () => { const toggleHeartTool = () => { if (!editor) return if (isHeartToolEnabled) { // [6] editor.removeTool(HeartTool) // Switch to select tool if we're currently on the heart tool if (editor.getCurrentToolId() === 'heart') { editor.setCurrentTool('select') } } else { // [7] editor.setTool(HeartTool) } setIsHeartToolEnabled(!isHeartToolEnabled) } return (
{isHeartToolEnabled ? '💔 Remove Heart Tool' : '💖 Add Heart Tool'}
) }, }), [editor, isHeartToolEnabled] ) return (
setEditor(editor)} // pass in our custom asset urls assetUrls={customAssetUrls} />
) } /* Introduction: This example demonstrates how to use the `setTool` and `removeTool` methods to dynamically add and remove tools from the editor's state chart after initialization. When the tool is added, it also appears in the toolbar dynamically. This is useful when you need to conditionally enable or disable tools based on runtime conditions like user permissions, feature flags, or application state. [1] We define a simple HeartTool that extends StateNode. It creates a heart emoji sticker when you click on the canvas. This tool is NOT passed to the Tldraw component initially - it will be added dynamically using setTool. [2] We define UI overrides to add the heart tool to the UI context. This makes it available for the toolbar component to reference, even if the tool hasn't been added to the state chart yet. [3] We override the Toolbar component to conditionally show the heart tool. The tool only appears in the toolbar when it exists in the state chart (isHeartToolEnabled is true). This creates a nice dynamic behavior where the toolbar updates when you add/remove the tool. [4] We pass the overrides and components to the Tldraw component. The toggle button will appear on top of the canvas, and clicking it will add/remove the heart tool dynamically. When added, the tool appears in the toolbar and can be used immediately. [5] We define the components object. We override the Toolbar component to conditionally show the heart tool. The tool only appears in the toolbar when it exists in the state chart (isHeartToolEnabled is true). This creates a nice dynamic behavior where the toolbar updates when you add/remove the tool. [6] We remove the heart tool from the state chart if it exists, and adds it back if it doesn't. [7] We add the heart tool to the state chart if it doesn't exist. */ ``` ## dynamic-tools.css ```css .toggle-button-container { position: absolute; top: 16px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: auto; } ``` -------- # Focus the editor Category: Editor API Keywords: instance, state, keyboard shortcuts Manually manage the editor's focus to enable or disable keyboard shortcuts. In this example, we drive the editor's focus in order to turn on and off keyboard shortcuts. The editor's focus is different from—but usually corresponds to—the browser's concept of "focus", which is related to the document's [active element](https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement). Unfortunately, the browser's focus cannot be relied on to determine whether the editor's keyboard shortcuts should work. While its possible to detect whether the document's active element is a descendant of the Tldraw component's own element, it's not 100% reliable. For example, iframes are not considered descendants of their parents, and many menus are portalled into different parts of the document tree. For these reasons, the responsibility falls to you, dear developer, to manage focus for your Tldraw editor, especially in cases where there are more than one editor on the same page. ## App.tsx ```tsx import { useRef } from 'react' import { Editor, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' import './editor-focus.css' export default function EditorFocusExample() { const editorRef = useRef(null) return (

You should be able to type in this text input without worrying about triggering editor shortcuts even when the editor is focused.

Controlled Focus

{ const editor = editorRef.current if (!editor) return if (e.target.checked) { editor.focus() // optional } else { editor.blur() // optional } }} />

The checkbox focuses and blurs the editor.

When the editor is “focused”, its keyboard shortcuts will work and scrolling over the editor will scroll the canvas. When it is not focused, the keyboard shortcuts will not work and scrolling over the editor will not move the canvas.

{ editorRef.current = editor }} />
) } ``` ## editor-focus.css ```css .tl-container__focused { outline: 1px solid var(--tl-color-primary); } ``` -------- # Lock camera zoom Category: Editor API Keywords: camera, lock, zoom Lock the camera at a specific zoom level. Need to lock the camera at its current zoom level? You can use the camera controls API to keep the zoom level from changing. In this example, press Shift+K to lock the camera at its current zoom level. Press Shift+L to unlock the camera and allow it to zoom again. ## App.tsx ```tsx import { Tldraw, TLUiOverrides } from 'tldraw' import 'tldraw/tldraw.css' const DEFAULT_CAMERA_STEPS = [0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8] const overrides: TLUiOverrides = { actions(editor, actions) { actions.lockCameraZoom = { id: 'lock-camera-zoom', kbd: 'shift+k', onSelect() { const isCameraZoomLockedAlready = editor.getCameraOptions().zoomSteps.length === 1 editor.setCameraOptions({ zoomSteps: isCameraZoomLockedAlready ? DEFAULT_CAMERA_STEPS : [editor.getZoomLevel()], }) }, } return actions }, } export default function BasicExample() { return (
) } ``` -------- # Search text on the canvas Category: Editor API Keywords: zoom, pan, camera bounds, search, text Search through all the text on the canvas. A simple example of how to use the API to search for text in shapes and display results in the `HelperButtons` part of the user interface. Press Ctrl+F or Cmd+F to open the search bar. ## App.tsx ```tsx import { TLComponents, TLUiOverrides, Tldraw, atom, track } from 'tldraw' import 'tldraw/tldraw.css' import { TextSearchPanel } from './TextSearchPanel' import './text-search.css' export const showSearch = atom('showSearch', false) const components: TLComponents = { HelperButtons: TextSearchPanel, } const overrides: TLUiOverrides = { actions(_editor, actions) { return { ...actions, 'text-search': { id: 'text-search', label: 'Search', kbd: 'cmd+f,ctrl+f', onSelect() { if (!showSearch.get()) { showSearch.set(true) } }, }, } }, } const TextSearchExample = track(() => { return (
) }) export default TextSearchExample ``` ## TextSearchPanel.tsx ```tsx import { useEffect, useRef, useState } from 'react' import { EASINGS, Editor, TLShape, TldrawUiButton, track, useEditor } from 'tldraw' import { showSearch } from './TextSearchExample' interface SearchResult { text: string shape: TLShape } function moveToShape(editor: Editor, shape: TLShape) { const bounds = editor.getShapePageBounds(shape.id) if (!bounds) return editor.setSelectedShapes([shape.id]) editor.zoomToSelection({ animation: { duration: 500, easing: EASINGS.easeInOutCubic }, }) } function keyDown(e: React.KeyboardEvent) { if (e.key === 'Escape') { showSearch.set(false) } } function getShapesWithText(editor: Editor, text: string): SearchResult[] { if (!text || text.length === 0) return [] const shapes = editor.getCurrentPageShapes() const result: SearchResult[] = [] shapes.forEach((shape) => { const util = editor.getShapeUtil(shape) const shapeText = util.getText(shape) if (shapeText && shapeText.includes(text)) { result.push({ text: shapeText, shape }) } }) return result.sort((a, b) => a.text.localeCompare(b.text)) } export const TextSearchPanel = track(() => { const editor = useEditor() const [searchText, setSearchText] = useState('') const inputRef = useRef(null) const isVisible = showSearch.get() useEffect(() => { if (isVisible) { setSearchText('') inputRef.current?.focus() } }, [isVisible]) if (!isVisible) return null const results = getShapesWithText(editor, searchText) return (
setSearchText(e.target.value)} > {results.map((result) => { return ( moveToShape(editor, result.shape)} > {result.text} ) })}
) }) ``` ## text-search.css ```css .text-search-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); } .text-search-panel-button { border-radius: var(--tl-radius-4); outline-offset: -1px; } .text-search-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; } .text-search-input { border-radius: var(--tl-radius-2); margin: 4px; } ``` -------- # Shape animation Category: Editor API Keywords: animate, animation, shape, easing, transition, movement, rotation, opacity Animate shapes using `animateShape()` and `animateShapes()`. This example demonstrates how to animate shapes using the editor's animation API. It shows: - **Basic shape animation** - Animating a single shape's position using `animateShape()` - **Rotation animation** - Spinning a shape 360 degrees - **Opacity animation** - Fading shapes in and out - **Combined animations** - Animating multiple properties (position, rotation, opacity) simultaneously - **Multiple shape animation** - Animating all shapes at once with `animateShapes()` - **Different easing functions** - Using `EASINGS.easeInOutCubic`, `EASINGS.easeInOutQuad`, and `EASINGS.easeOutCubic` The animation system automatically interpolates between the current and target values of animated properties over the specified duration. ## App.tsx ```tsx import { createShapeId, EASINGS, TLComponents, Tldraw, TldrawUiButton, useEditor, useValue, } from 'tldraw' import 'tldraw/tldraw.css' import './shape-animation.css' // [1] function AnimationControls() { const editor = useEditor() // [2] const animatePosition = () => { const shape = editor.getOnlySelectedShape() if (!shape) return editor.animateShape( { ...shape, x: shape.x + 200, y: shape.y + 100 }, { animation: { duration: 800, easing: EASINGS.easeInOutCubic } } ) } // [3] const animateRotation = () => { const shape = editor.getOnlySelectedShape() if (!shape) return editor.animateShape( { ...shape, rotation: shape.rotation + Math.PI * 2 }, { animation: { duration: 1000, easing: EASINGS.easeInOutCubic } } ) } // [4] const animateFade = () => { const shape = editor.getOnlySelectedShape() if (!shape) return editor.animateShape( { ...shape, opacity: shape.opacity > 0.5 ? 0.2 : 1 }, { animation: { duration: 600, easing: EASINGS.easeInOutQuad } } ) } // [5] const animateAll = () => { const shape = editor.getOnlySelectedShape() if (!shape) return editor.animateShape( { ...shape, x: shape.x + 150, y: shape.y - 100, rotation: shape.rotation + Math.PI, opacity: 0.3, }, { animation: { duration: 1200, easing: EASINGS.easeInOutCubic } } ) } // [6] const animateMultiple = () => { const shapeIds = editor.getCurrentPageShapeIds() const updates = Array.from(shapeIds).map((id) => { const shape = editor.getShape(id) if (!shape) return null return { ...shape, x: shape.x + (Math.random() - 0.5) * 200, y: shape.y + (Math.random() - 0.5) * 200, rotation: shape.rotation + (Math.random() - 0.5) * Math.PI, } }) editor.animateShapes(updates, { animation: { duration: 1000, easing: EASINGS.easeOutCubic } }) } const hasOneSelected = useValue( 'has one selected', () => editor.getSelectedShapeIds().length !== 1, [editor] ) return (
Animate position Animate rotation Fade in/out Animate all Animate multiple shapes
) } const components: TLComponents = { TopPanel: AnimationControls, } export default function AnimationShapesExample() { return (
{ // Create some initial shapes for the demo const id = createShapeId() editor.createShapes([ { id, type: 'geo', x: 200, y: 200, props: { w: 100, h: 100, color: 'blue', }, }, { id: createShapeId(), type: 'geo', x: 400, y: 300, props: { w: 80, h: 80, color: 'red', geo: 'ellipse', }, }, { id: createShapeId(), type: 'geo', x: 600, y: 200, props: { w: 120, h: 90, color: 'green', geo: 'triangle', }, }, ]) // Select the first shape editor.select(id) }} />
) } /* [1] Create a component with buttons to trigger different animations. Use the `useEditor` hook to access the editor instance. [2] `animateShape()` animates a single shape to new property values. Pass a partial shape with the target values and animation options. The `easing` property accepts functions from the `EASINGS` object. In this example, we move the shape to a new position with cubic easing. [3] Animate rotation by specifying a target rotation value in radians. Here we rotate the shape 360 degrees (2π radians) with smooth easing. [4] Opacity can be animated between 0 and 1. This creates a fade effect. We toggle between low and high opacity values using quadratic easing. [5] Multiple properties can be animated simultaneously. This example combines position, rotation, and opacity changes in a single animation. [6] `animateShapes()` animates multiple shapes at once. Build an array of shape partials and pass them all together. All shapes will animate with the same duration and easing function. */ ``` ## shape-animation.css ```css .animation-controls { display: flex; flex-wrap: wrap; margin: 8px; } ``` -------- # Align and distribute shapes Category: Editor API Keywords: align, distribute, layout, position, arrange Align and distribute shapes using the editor's built-in methods. This example demonstrates how to use the `alignShapes` and `distributeShapes` methods to programmatically arrange shapes on the canvas. The example creates 5 shapes at different positions and provides buttons to align them (left, center-horizontal, right, top, center-vertical, bottom) or distribute them (horizontal, vertical). Select multiple shapes and click the buttons to see the alignment and distribution in action. Note that align operations require at least 2 shapes, while distribute operations require at least 3 shapes. ## App.tsx ```tsx import { useRef } from 'react' import { createShapeId, Tldraw, TldrawUiButton, useEditor } from 'tldraw' import 'tldraw/tldraw.css' import './align-and-distribute-shapes.css' // [1] const ALIGN_OPERATIONS = [ { operation: 'left', label: 'Align left' }, { operation: 'center-horizontal', label: 'Align center H' }, { operation: 'right', label: 'Align right' }, { operation: 'top', label: 'Align top' }, { operation: 'center-vertical', label: 'Align center V' }, { operation: 'bottom', label: 'Align bottom' }, ] as const function ControlPanel({ originalPositions, }: { originalPositions: React.RefObject> }) { const editor = useEditor() return (
{ALIGN_OPERATIONS.map(({ operation, label }) => ( { // [2] const selectedIds = editor.getSelectedShapeIds() if (selectedIds.length > 1) { editor.alignShapes(selectedIds, operation) } }} > {label} ))} {DISTRIBUTE_OPERATIONS.map(({ operation, label }) => ( { // [4] const selectedIds = editor.getSelectedShapeIds() if (selectedIds.length > 2) { editor.distributeShapes(selectedIds, operation) } }} > {label} ))} { const shapes = editor.getCurrentPageShapes() editor.run(() => { for (const shape of shapes) { const originalPos = originalPositions.current?.get(shape.id) if (originalPos) { editor.updateShape({ ...shape, x: originalPos.x, y: originalPos.y, }) } } }) }} > Reset positions
) } // [3] const DISTRIBUTE_OPERATIONS = [ { operation: 'horizontal', label: 'Distribute horizontal' }, { operation: 'vertical', label: 'Distribute vertical' }, ] as const export default function RequestAlignAndDistributeShapesExample() { const originalPositions = useRef(new Map()) return (
{ const shapes = [ { id: createShapeId(), type: 'geo' as const, x: 100, y: 100, props: { w: 100, h: 100, color: 'blue' as const, }, }, { id: createShapeId(), type: 'geo' as const, x: 300, y: 200, props: { w: 120, h: 80, color: 'red' as const, }, }, { id: createShapeId(), type: 'geo' as const, x: 500, y: 150, props: { w: 80, h: 120, color: 'green' as const, }, }, { id: createShapeId(), type: 'geo' as const, x: 150, y: 400, props: { w: 100, h: 100, color: 'violet' as const, }, }, { id: createShapeId(), type: 'geo' as const, x: 400, y: 450, props: { w: 90, h: 90, color: 'orange' as const, }, }, ] for (const shape of shapes) { originalPositions.current.set(shape.id, { x: shape.x, y: shape.y }) } editor.createShapes(shapes) editor.selectAll() }} components={{ TopPanel: () => , }} />
) } /* [1] Define an array of all align operations with their labels. This makes it easy to render buttons for each operation without repetition. The available operations are: left, center-horizontal, right (horizontal alignment), and top, center-vertical, bottom (vertical alignment). [2] The alignShapes method requires at least 2 shapes to be selected. It aligns the selected shapes based on the specified operation. The shapes parameter can be either shape IDs or shape objects. [3] Define an array of distribute operations. Distribution evenly spaces shapes along the specified axis: horizontal or vertical. [4] The distributeShapes method requires at least 3 shapes to be selected. It distributes shapes evenly along the horizontal or vertical axis, maintaining equal spacing between them. */ ``` ## align-and-distribute-shapes.css ```css .control-panel { display: flex; flex-direction: row; flex-wrap: wrap; margin: 8px; } ``` -------- # Lasso select tool Category: Editor API Keywords: tools, state machine, custom tool, selection, lasso, overlays, editor atom, freehand drawing Add a lasso select tool to tldraw. You can build different types of selection tools in tldraw. This example creates a lasso select tool that lets you freehand draw to select shapes on the canvas. It uses a reactive `atom` to reactively store the lasso points, and an `Overlay` to draw the lasso onto the canvas. Only shapes that are fully enclosed in the lasso will be selected. ## App.tsx ```tsx import { useMemo } from 'react' import { DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialogContent, DefaultToolbar, DefaultToolbarContent, getStrokePoints, getSvgPathFromStrokePoints, TLComponents, Tldraw, TldrawOverlays, TldrawUiMenuItem, TLUiOverrides, useEditor, useIsToolSelected, useTools, useValue, } from 'tldraw' import { LassoingState, LassoSelectTool } from './LassoSelectTool' // There's a guide at the bottom of this file! //[1] const uiOverrides: TLUiOverrides = { tools(editor, tools) { tools['lasso-select'] = { id: 'lasso-select', icon: 'color', label: 'Lasso Select', kbd: 'w', //w for wrangle 🤠 onSelect: () => { editor.setCurrentTool('lasso-select') }, } return tools }, } //[2] const components: TLComponents = { Toolbar: (props) => { const tools = useTools() const isLassoSelected = useIsToolSelected(tools['lasso-select']) return ( ) }, KeyboardShortcutsDialog: (props) => { const tools = useTools() return ( ) }, //[a] Overlays: () => ( <> ), } //[3] function LassoSelectSvgComponent() { const editor = useEditor() // const { addToast } = useToasts() //[a] const lassoPoints = useValue( 'lasso points', () => { if (!editor.isIn('lasso-select.lassoing')) return [] const lassoing = editor.getStateDescendant('lasso-select.lassoing') as LassoingState return lassoing.points.get() }, [editor] ) //[b] const svgPath = useMemo(() => { const smoothedPoints = getStrokePoints(lassoPoints) const svgPath = getSvgPathFromStrokePoints(smoothedPoints, true) return svgPath }, [lassoPoints]) //[c] return ( <> {lassoPoints.length > 0 && ( )} ) } export default function LassoSelectToolExample() { return (
) } /* This example shows how to build a lasso select tool using the StateNode and EditorAtom classes. For a simpler example of how to build a selection tool, see the `MiniSelectTool` in the only-editor example. If you want to see an even simpler select tool that doesn't implement any child states, see the `MicroSelectTool` in the same example. [1] Here are the UI overrides for the lasso select tool, which we'll pass into the component. It adds a new tool to the toolbar and keyboard shortcuts dialog, as well as sets the keyboard shortcut. More info about this in the 'add-tool-to-toolbar' and 'custom-config' examples. [2] This is the set of custom components for the lasso select tool, which we'll also pass into the component. It adds a new toolbar and keyboard shortcuts dialog. More info about this in the 'add-tool-to-toolbar' and 'custom-config' examples. [a] The `Overlays` component override is where we'll pass in the component that will draw the lasso. We need to make sure that we pass in the component as well, so that we get all the tldraw default overlays. We use `Overlays` instead of `InFrontOfTheCanvas` because `Overlays` get camera transforms applied to them automatically, so the points will always render where we want them to. [3] This component reads the lasso points from the lasso select tool and draws the lasso itself onto the Overlays layer. It is worth noting that this is only necessary for rendering the lasso. [a] Here we're using the tldraw's `useValue` hook to read the lasso points from the tool's state. We use `editor.isIn()` to check if we're in the lassoing state, then `editor.getStateDescendant()` to get the lassoing state instance. The thing that allows us to get these points reactively is the `lassoing.points.get()` call. This is because `LassoingState`'s `points` attribute is an instance of the `atom` class. As you'll see in `LassoSelectTool.tsx`, we're using an `atom` to store the lasso points. [b] Here we're smoothing the lasso points using tldraw's freehand library's `getStrokePoints` function, then converting the smoothed points to an SVG path. [c] Here we're actually defining the SVG path that we use to draw the lasso onto the Overlays layer. There are a couple things to note here. - One is `className="tl-overlays__item"` and `aria-hidden="true"` attributes of the svg. This class name is necessary for the svg to render at all, and the aria hidden attribute makes it accessible to screen readers. - The other noteworthy line here is: `strokeWidth="calc(2px / var(--tl-zoom))"` which takes advantage of one of tldraw's global css variables to always draw the svg with the same apparent stroke width at different zoom levels. */ ``` ## LassoSelectTool.ts ```ts import { atom, pointInPolygon, polygonsIntersect, StateNode, TLPointerEventInfo, TLShape, VecModel, } from 'tldraw' // There's a guide at the bottom of this file! export class LassoSelectTool extends StateNode { static override id = 'lasso-select' static override children() { return [IdleState, LassoingState] } static override initial = 'idle' } export class IdleState extends StateNode { static override id = 'idle' override onPointerDown(info: TLPointerEventInfo) { const { editor } = this editor.selectNone() this.parent.transition('lassoing', info) } } //[1] export class LassoingState extends StateNode { static override id = 'lassoing' info = {} as TLPointerEventInfo markId = null as null | string //[a] points = atom('lasso points', []) override onEnter(info: TLPointerEventInfo) { this.points.set([]) this.markId = null this.info = info this.startLasso() } private startLasso() { this.markId = this.editor.markHistoryStoppingPoint('lasso start') } //[b] override onPointerMove(): void { this.addPointToLasso() } private addPointToLasso() { const { inputs } = this.editor const { x, y, z } = inputs.getCurrentPagePoint().toFixed() const newPoint = { x, y, z } this.points.set([...this.points.get(), newPoint]) } //[c] private getShapesInLasso() { const { editor } = this const shapes = editor.getCurrentPageRenderingShapesSorted() const lassoPoints = this.points.get() const shapesInLasso = shapes.filter((shape) => { return this.doesLassoFullyContainShape(lassoPoints, shape) }) return shapesInLasso } private doesLassoFullyContainShape(lassoPoints: VecModel[], shape: TLShape): boolean { const { editor } = this const geometry = editor.getShapeGeometry(shape) const pageTransform = editor.getShapePageTransform(shape) const shapeVertices = pageTransform.applyToPoints(geometry.vertices) const allVerticesInside = shapeVertices.every((vertex) => { return pointInPolygon(vertex, lassoPoints) }) // Early return if any vertex is not inside the lasso if (!allVerticesInside) { return false } // If any shape edges intersect with the lasso, then we know it can't be fully contained by the lasso because of like the mean value theorem or something. if (geometry.isClosed) { if (polygonsIntersect(shapeVertices, lassoPoints)) { return false } } return true } override onPointerUp(): void { this.complete() } override onComplete() { this.complete() } //[d] complete() { const { editor } = this const shapesInLasso = this.getShapesInLasso() editor.setSelectedShapes(shapesInLasso) editor.setCurrentTool('select') } } /* This is where we define the actual lasso select tool and its functionality. For a general guide on how to built tools with child states, see the `MiniSelectTool` in the only-editor example. [1] The main meat of this tool is in the `LassoingState` class. This is the state that is active when the user has the tool selected and holds the mouse down. [a] The `points` attribute is an instance of the `atom` class. This makes the entire thing work by allowing us to reactively read the lasso points from the `Overlays` layer (which we then use to draw the lasso). As the user moves the mouse, `points` will be updated. [b] `onPointerMove()`, which is called when the user moves the mouse, calls `addPointToLasso()`, which adds the current mouse position in page space to `points`. [c] `getShapesInLasso()`, alongside `doesLassoFullyContainShape()` handles the logic of figuring out which shapes on the canvas are fully contained within the lasso. [d] `onPointerUp()`, which is called when the user releases the mouse, calls the state's `complete()` function. This gets all shapes inside the lasso and selects all of them using the editor's `setSelectedShapes()` function. In general, if we wanted to add more functionality to the lasso select, we could: - live update the selection as the user moves the mouse, similar to how the default select and brush select tools work - use modifier keys to add or subtract from the selection instead of just setting the selection - properly handle what happens when we select a shape that's grouped with other shapes (do we select the shape within the group or move up a level and select the entire group? what about layers?) - extend the default selection tool to allow for lasso selection when a hotkey is pressed, similar to the brush select tool - add a little bit of leeway to the lasso selection logic to allow for shapes that are mostly, but not fully, enclosed in the lasso to be selected */ ``` -------- # Locked shapes Category: Editor API Keywords: lock, unlock, locked, isLocked, ignoreShapeLock, template, background Lock shapes to prevent user editing, and use `ignoreShapeLock` to modify them programmatically. - **Locked template** — The blue shapes are locked on mount. Try dragging them! - **Scatter/Reset** — Uses `editor.run({ ignoreShapeLock: true })` to move locked shapes ## App.tsx ```tsx import { createShapeId, Tldraw, TldrawUiButton, TLShapeId, toRichText, useEditor } from 'tldraw' import 'tldraw/tldraw.css' // [1] const TEMPLATE_IDS: TLShapeId[] = [ createShapeId('t1'), createShapeId('t2'), createShapeId('t3'), createShapeId('t4'), ] // [2] function ControlPanel() { const editor = useEditor() // [3] Update locked shapes using ignoreShapeLock option // Without ignoreShapeLock: true, these updates would be blocked const handleScatter = () => { editor.run( () => { editor.updateShapes( TEMPLATE_IDS.map((id) => ({ id, type: 'geo', x: 50 + Math.random() * 300, y: 50 + Math.random() * 300, })) ) }, { ignoreShapeLock: true } ) } const handleReset = () => { editor.run( () => { editor.updateShapes([ { id: TEMPLATE_IDS[0], type: 'geo', x: 100, y: 100 }, { id: TEMPLATE_IDS[1], type: 'geo', x: 250, y: 100 }, { id: TEMPLATE_IDS[2], type: 'geo', x: 100, y: 250 }, { id: TEMPLATE_IDS[3], type: 'geo', x: 250, y: 250 }, ]) }, { ignoreShapeLock: true } ) } return (
Scatter Reset
) } const components = { TopPanel: ControlPanel, } // [4] export default function LockedShapesExample() { return (
{ // Skip if shapes already exist if (editor.getShape(TEMPLATE_IDS[0])) { editor.zoomToFit({ animation: { duration: 0 } }) return } // [5] Create locked template shapes const shapeProps = { geo: 'rectangle' as const, w: 130, h: 130, dash: 'dashed' as const, color: 'light-blue' as const, fill: 'semi' as const, richText: toRichText('Locked'), } editor.createShapes([ { id: TEMPLATE_IDS[0], type: 'geo', x: 100, y: 100, props: shapeProps }, { id: TEMPLATE_IDS[1], type: 'geo', x: 250, y: 100, props: shapeProps }, { id: TEMPLATE_IDS[2], type: 'geo', x: 100, y: 250, props: shapeProps }, { id: TEMPLATE_IDS[3], type: 'geo', x: 250, y: 250, props: shapeProps }, ]) // [6] Lock them immediately editor.toggleLock(TEMPLATE_IDS) editor.zoomToFit({ animation: { duration: 0 } }) }} />
) } /* This example demonstrates the key distinction between locked shapes and programmatic updates: Locked shapes prevent ALL user interaction (dragging, deleting, etc.), but programs can still modify them using the ignoreShapeLock option. This is useful for shapes that should be fixed in place by the user but need to be repositioned programmatically. [1] Pre-defined shape IDs so we can reference them later. [2] Control panel with action buttons. [3] Both buttons use editor.run() with { ignoreShapeLock: true } to bypass the lock constraint. This option allows programmatic updates even though user interactions on these shapes are blocked. [4] The main component sets up the editor. [5] On mount, we create a 2x2 grid of template shapes. [6] We immediately lock them with toggleLock(). The key behavior: users cannot move or delete these shapes, but the Scatter/Reset buttons can still reposition them programmatically. Try it: - Try dragging any template shape (won't work - they're locked by the user interface) - Click Scatter or Reset to see how programmatic updates work with ignoreShapeLock: true */ ``` -------- # Reactive inputs Category: Editor API Keywords: input, mouse, pointer, position, velocity, reactive, state Reactively track mouse positions and velocities using the editor's inputs manager. The editor's inputs manager provides reactive access to pointer state including current, previous, and origin positions (in both screen and page space), as well as pointer velocity. All properties are backed by reactive atoms that automatically trigger updates when they change. This example demonstrates how to use `useValue` to subscribe to these reactive inputs and display them in real-time as the user moves their mouse. ## App.tsx ```tsx import { useState } from 'react' import { Editor, Tldraw, useValue } from 'tldraw' import 'tldraw/tldraw.css' import './reactive-inputs.css' // There's a guide at the bottom of this file! export default function ReactiveInputsExample() { const [editor, setEditor] = useState(null) return (
{editor && }
) } function ReactiveInputsPanel({ editor }: { editor: Editor }) { // [2] const currentScreenPoint = useValue( 'current screen point', () => editor?.inputs.getCurrentScreenPoint(), [editor] ) const currentPagePoint = useValue( 'current page point', () => editor?.inputs.getCurrentPagePoint(), [editor] ) const previousScreenPoint = useValue( 'previous screen point', () => editor?.inputs.getPreviousScreenPoint(), [editor] ) const previousPagePoint = useValue( 'previous page point', () => editor?.inputs.getPreviousPagePoint(), [editor] ) const originScreenPoint = useValue( 'origin screen point', () => editor?.inputs.getOriginScreenPoint(), [editor] ) const originPagePoint = useValue('origin page point', () => editor?.inputs.getOriginPagePoint(), [ editor, ]) const pointerVelocity = useValue('pointer velocity', () => editor?.inputs.getPointerVelocity(), [ editor, ]) return (

Reactive inputs

{/* [3] */}
Current screen point
x: {currentScreenPoint?.x.toFixed(2) ?? '0.00'}
y: {currentScreenPoint?.y.toFixed(2) ?? '0.00'}
Current page point
x: {currentPagePoint?.x.toFixed(2) ?? '0.00'}
y: {currentPagePoint?.y.toFixed(2) ?? '0.00'}
Previous screen point
x: {previousScreenPoint?.x.toFixed(2) ?? '0.00'}
y: {previousScreenPoint?.y.toFixed(2) ?? '0.00'}
Previous page point
x: {previousPagePoint?.x.toFixed(2) ?? '0.00'}
y: {previousPagePoint?.y.toFixed(2) ?? '0.00'}
Origin screen point
x: {originScreenPoint?.x.toFixed(2) ?? '0.00'}
y: {originScreenPoint?.y.toFixed(2) ?? '0.00'}
Origin page point
x: {originPagePoint?.x.toFixed(2) ?? '0.00'}
y: {originPagePoint?.y.toFixed(2) ?? '0.00'}
{/* [4] */}
Pointer velocity
x: {pointerVelocity?.x.toFixed(4) ?? '0.0000'}
y: {pointerVelocity?.y.toFixed(4) ?? '0.0000'}
px/ms
) } /* [1] Store the editor instance in React state so we can access it from the component. [2] Use the `useValue` hook to subscribe to reactive input state. The hook takes three arguments: - A name for debugging - A function that returns the value to track - Dependencies array (similar to useEffect) The editor's inputs manager exposes several reactive properties: - getCurrentScreenPoint() / getCurrentPagePoint() - Current pointer position - getPreviousScreenPoint() / getPreviousPagePoint() - Previous pointer position - getOriginScreenPoint() / getOriginPagePoint() - Position where pointer went down - getPointerVelocity() - Pointer velocity in pixels per millisecond All of these are backed by reactive atoms, so calling them inside a `useValue` callback will automatically trigger updates when they change. [3] Screen points are relative to the editor's container, while page points are in the canvas's coordinate space (accounting for zoom and pan). The origin points track where the most recent pointer down event occurred. [4] Pointer velocity is calculated and updated by the tick manager. It represents the speed and direction of pointer movement in pixels per millisecond, useful for detecting quick gestures or implementing physics-based interactions. */ ``` ## reactive-inputs.css ```css .reactive-inputs-panel { width: 250px; flex-shrink: 0; padding: 16px; background: #f8f9fa; border-left: 2px solid #e0e0e0; overflow-y: auto; font-family: system-ui, -apple-system, sans-serif; } @media (max-width: 768px) { .reactive-inputs-panel { display: none; } } .reactive-inputs-panel h3 { margin: 0 0 16px 0; font-size: 16px; font-weight: 600; color: #1a1a1a; } .reactive-inputs-content { display: flex; flex-direction: column; gap: 12px; } .input-group { padding: 10px; background: white; border: 1px solid #e0e0e0; border-radius: 4px; } .input-label { font-size: 11px; font-weight: 600; color: #666; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; } .input-value { font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: 13px; color: #1a1a1a; line-height: 1.5; } .input-hint { font-size: 11px; color: #999; font-style: italic; } ``` -------- # Toggle focus mode Category: Editor API Keywords: focus, mode, editor, state, instance How to enable focus mode by using the editor API. This example demonstrates how to enable focus mode when the editor mounts. Focus mode provides a distraction-free environment by hiding the UI elements and focusing on the canvas content. ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function FocusModeExample() { return (
{ // [1] editor.updateInstanceState({ isFocusMode: true }) }} />
) } /** * This example demonstrates how to enable focus mode when the editor mounts. * * [1] The editor's instance state is updated on mount to enable focus mode. */ ``` -------- # Interaction end callback Category: Editor API Keywords: callback, interaction, drag, resize, rotate, tool Control behavior after dragging, resizing, or rotating shapes. When programmatically starting interactions like translating, resizing, or rotating, you can use the `onInteractionEnd` option to control what happens when the interaction completes. Pass a string to transition to a specific tool, or a function to execute custom logic. In this example, we set the fill of a shape after we finish translating to be patterned. ## App.tsx ```tsx import { StateNode, TLPointerEventInfo, Tldraw, createShapeId } from 'tldraw' import 'tldraw/tldraw.css' // [1] class QuickShapeTool extends StateNode { static override id = 'quick-shape' override onEnter() { this.editor.setCursor({ type: 'cross', rotation: 0 }) } override onPointerDown(info: TLPointerEventInfo) { const currentPagePoint = this.editor.inputs.getCurrentPagePoint() const shapeId = createShapeId() this.editor.createShape({ id: shapeId, type: 'geo', x: currentPagePoint.x - 50, y: currentPagePoint.y - 50, props: { w: 100, h: 100, fill: 'solid' }, }) this.editor.setSelectedShapes([shapeId]) // [2] this.editor.setCurrentTool('select.translating', { ...info, target: 'shape', shape: this.editor.getShape(shapeId), isCreating: true, // [3] onInteractionEnd: () => { // Change fill to semi-transparent after dragging this.editor.updateShape({ id: shapeId, type: 'geo', props: { fill: 'pattern' }, }) // Return to our custom tool this.editor.setCurrentTool('quick-shape') }, }) } } export default function InteractionEndExample() { return (
) } /* [1] Create a simple tool that creates shapes on click. This demonstrates using onInteractionEnd to control what happens after the user drags the new shape. [2] After creating the shape, transition to the translating state so the user can immediately position it. [3] Pass a function as onInteractionEnd to execute custom logic when dragging ends. Here we change the shape's fill style and return to our custom tool. You can also pass a string like 'quick-shape' or 'select.idle' to simply transition to that tool. */ ``` -------- # Easter egg styles Category: Editor API Keywords: easter egg, white color, fill styles, labelColor, hidden features Use tldraw's collection of easter egg styles programmatically. tldraw includes several "easter egg" styles that aren't visible in the default UI but can be accessed through keyboard shortcuts or set programmatically. These styles include: - **White** (`Option+T`): A white color option for shapes - **Fill** (`Option+F`) - An alternative solid fill variant - **Lined fill** (`Option+Shift+F`) - A lined fill pattern variant - **Label color** - A separate color property for text labels on shapes, independent from the shape's main color - **Scale** - A scale property for shapes, independent from the shape's size, available via the **Dynamic size** preference This example programmatically creates shapes demonstrating each of these easter egg styles. While these styles aren't publicly exposed in the default UI, they can be useful for specific use cases or when you need more control over styling. ## App.tsx ```tsx import { Editor, Tldraw, toRichText } from 'tldraw' import 'tldraw/tldraw.css' export default function EasterEggStylesExample() { return (
{ editor.createShapes([ { type: 'geo', x: 0, y: 0, props: { geo: 'rectangle', w: 250, h: 250, color: 'blue', fill: 'fill', // Easter egg: Fill style (keyboard shortcut: Option+F) richText: toRichText('Fill\n(Option+F)'), }, }, { type: 'geo', x: 300, y: 0, props: { geo: 'rectangle', w: 250, h: 250, color: 'blue', fill: 'lined-fill', // Easter egg: Lined fill style (keyboard shortcut: Option+Shift+F) richText: toRichText('Lined fill\n(Option+Shift+F)'), }, }, { type: 'geo', x: 600, y: 0, props: { geo: 'rectangle', w: 250, h: 250, color: 'white', // Easter egg: White color (keyboard shortcut: Option+T) fill: 'fill', richText: toRichText('White \n(Option+T)'), }, }, { type: 'geo', x: 900, y: 0, props: { geo: 'rectangle', w: 250, h: 250, color: 'blue', richText: toRichText('Label color'), labelColor: 'red', // Separate label color }, }, { type: 'geo', x: 1200, y: 0, props: { geo: 'rectangle', w: 250, h: 250, color: 'blue', scale: 2.5, richText: toRichText('Scale'), // Scale (available via the Dynamic size preference) }, }, ]) editor.zoomToFit() }} />
) } ``` -------- # Inspector panel Category: UI & theming Keywords: inspector, properties, props, selection, panel Display an inspector panel that shows the properties of the currently selected shape. This example demonstrates how to create an inspector panel that displays all properties of the currently selected shape. When a single shape is selected, the panel shows the shape's type, position, dimensions, and all custom properties. The panel updates reactively as you select different shapes or modify their properties. The inspector shows: - Basic shape properties (id, type, position, rotation, etc.) - Shape-specific props (dimensions, colors, styles, etc.) - Meta information and other attributes This is useful for debugging, educational purposes, or creating admin interfaces where you need to inspect shape data. ## App.tsx ```tsx import { useState } from 'react' import { Editor, EditorProvider, Tldraw, useEditor, useIsDarkMode, useValue } from 'tldraw' import 'tldraw/tldraw.css' import './inspector-panel.css' // Inspector Panel Component that shows selected shape properties function InspectorPanel() { const editor = useEditor() // Get the currently selected shapes, updates reactively const selectedShapes = useValue('selected shapes', () => editor.getSelectedShapes(), [editor]) // Get shared styles when multiple shapes are selected const sharedStyles = useValue( 'shared styles', () => { if (selectedShapes.length <= 1) return null return editor.getSharedStyles() }, [editor, selectedShapes] ) // Get bindings involving the selected shape (only for single selection) const bindings = useValue( 'bindings', () => { if (selectedShapes.length !== 1) return [] return editor.getBindingsInvolvingShape(selectedShapes[0].id) }, [editor, selectedShapes] ) const isDarkMode = useIsDarkMode() const selectedShape = selectedShapes.length === 1 ? selectedShapes[0] : null if (selectedShapes.length === 0) { return (

Inspector

No shape selected

) } if (selectedShapes.length > 1) { return (

Inspector

{sharedStyles && sharedStyles.size > 0 && (

Shared Styles

{Array.from(sharedStyles.entries()).map(([styleProp, sharedStyle]) => ( ))}
)}

{selectedShapes.length} shapes selected

) } // Single shape selected return (

Inspector

{Object.entries(selectedShape!).map(([key, value]) => { if (key === 'props') return null // Skip props, we'll show them separately return })}
{selectedShape!.props && Object.keys(selectedShape!.props).length > 0 && (

Shape Props

{Object.entries(selectedShape!.props).map(([key, value]) => ( ))}
)} {bindings.length > 0 && (

Bindings ({bindings.length})

{bindings.map((binding) => ( ))}
)}
) } // Component to render a shared style row function SharedStyleRow({ styleProp, sharedStyle }: { styleProp: any; sharedStyle: any }) { const formatStyleValue = (style: any): string => { if (style.type === 'mixed') { return '(mixed)' } else if (style.type === 'shared') { return typeof style.value === 'string' ? `"${style.value}"` : String(style.value) } return String(style) } const getStyleClass = (style: any): string => { return style.type === 'mixed' ? 'mixed-style' : 'shared-style' } return (
{styleProp.id.replace('tldraw:', '')}: {formatStyleValue(sharedStyle)}
) } // Component to render an individual property row function PropertyRow({ name, value, path: _path }: { name: string; value: any; path: string }) { const [isExpanded, setIsExpanded] = useState(false) const isObject = value !== null && value !== undefined && typeof value === 'object' const formatValue = (val: any): string => { if (val === null || val === undefined) return String(val) if (typeof val === 'string') return `"${val}"` if (typeof val === 'object') { if (Array.isArray(val)) return `Array(${val.length})` // Handle rich text objects if ('richText' in val && val.richText) { return `"${val.richText[0]?.children?.[0]?.text || ''}"` } return `Object(${Object.keys(val).length} keys)` } return String(val) } const formatJsonValue = (val: any): string => { try { return JSON.stringify(val, null, 2) } catch (_e) { return String(val) } } if (isObject) { return (
{name}: {formatValue(value)}
{isExpanded && (
{formatJsonValue(value)}
)}
) } return (
{name}: {formatValue(value)}
) } // Component to render a binding row function BindingRow({ binding, selectedShapeId }: { binding: any; selectedShapeId: string }) { const editor = useEditor() // Determine the relationship const isFrom = binding.fromId === selectedShapeId const otherShapeId = isFrom ? binding.toId : binding.fromId const relationship = isFrom ? 'from' : 'to' // Get info about the other shape const otherShape = useValue( 'other shape', () => { return editor.getShape(otherShapeId) }, [editor, otherShapeId] ) return (
{binding.type} ({relationship})
{otherShape && ( )} {binding.props && Object.keys(binding.props).length > 0 && ( )}
) } export default function InspectorPanelExample() { const [editor, setEditor] = useState(null) return (
{editor && ( )}
) } ``` ## inspector-panel.css ```css .example-container { display: flex; height: 100vh; width: 100%; } .canvas-container { flex: 1; height: 100vh; } .inspector-panel { width: 300px; background: white; border-left: 1px solid #ccc; padding: 16px; font-family: monospace; font-size: 12px; overflow-y: auto; } .inspector-panel h3 { margin: 0 0 16px 0; font-size: 14px; font-weight: bold; } .inspector-panel h4 { margin: 16px 0 8px 0; font-size: 12px; font-weight: bold; color: #666; text-transform: uppercase; } .inspector-panel h4:first-child { margin-top: 0; } .inspector-panel p { margin: 0; color: #666; font-style: italic; } .inspector-section { margin-bottom: 16px; } .inspector-section:last-child { margin-bottom: 0; } .property-row { margin-bottom: 4px; line-height: 1.4; } .property-row-header { display: flex; align-items: center; } .property-name { flex: 0 0 1; font-weight: bold; margin-right: 8px; } .property-value { flex: 1; word-break: break-all; color: #333; } /* Shared style indicators */ .shared-style { color: #0066cc; } .mixed-style { color: #ff6600; font-style: italic; } .toggle-button { background: #e0e0e0; border: 1px solid #ccc; border-radius: 2px; width: 18px; height: 18px; font-size: 12px; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; line-height: 1; margin-right: 8px; } .toggle-button:hover { background: #d0d0d0; } .toggle-button:active { background: #c0c0c0; } .property-json { margin-top: 4px; margin-left: 8px; background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px; padding: 8px; overflow-x: auto; } .property-json pre { margin: 0; font-size: 11px; line-height: 1.3; color: #444; white-space: pre-wrap; word-break: break-word; } /* Binding-specific styles */ .binding-row { margin-bottom: 12px; padding: 8px; background: #f9f9f9; border-radius: 4px; border-left: 3px solid #007acc; } .binding-header { display: flex; align-items: center; margin-bottom: 4px; gap: 8px; } .binding-type { font-weight: bold; color: #007acc; text-transform: uppercase; font-size: 11px; } .binding-direction { font-size: 10px; color: #666; background: #e0e0e0; padding: 2px 6px; border-radius: 2px; } .binding-props { margin-top: 4px; } .binding-prop { margin-left: 8px; } .binding-row:last-child { margin-bottom: 0; } /* Simple dark mode */ .inspector-panel-dark { background: #2a2a2a; border-color: #555; color: #e0e0e0; } .inspector-panel-dark h4 { color: #aaa; } .inspector-panel-dark p { color: #aaa; } .inspector-panel-dark .property-value { color: #ddd; } .inspector-panel-dark .toggle-button { background: #555; border-color: #666; color: #e0e0e0; } .inspector-panel-dark .toggle-button:hover { background: #666; } .inspector-panel-dark .toggle-button:active { background: #777; } .inspector-panel-dark .property-json { background: #1e1e1e; border-color: #555; } .inspector-panel-dark .property-json pre { color: #ddd; } .inspector-panel-dark .binding-row { background: #333; border-left-color: #4da6ff; } .inspector-panel-dark .binding-type { color: #4da6ff; } .inspector-panel-dark .binding-direction { background: #555; color: #ccc; } ``` -------- # Toolbar groups Category: UI & theming Keywords: Create groups within the toolbar to separate related concepts. ## App.tsx ```tsx import { useMemo, useState } from 'react' import { ArrowDownToolbarItem, ArrowLeftToolbarItem, ArrowRightToolbarItem, ArrowUpToolbarItem, CloudToolbarItem, DefaultToolbar, DiamondToolbarItem, DrawToolbarItem, EllipseToolbarItem, EraserToolbarItem, HandToolbarItem, HexagonToolbarItem, HighlightToolbarItem, LaserToolbarItem, OvalToolbarItem, RectangleToolbarItem, RhombusToolbarItem, SelectToolbarItem, StarToolbarItem, TLComponents, Tldraw, TldrawUiMenuGroup, TldrawUiMenuItem, TriangleToolbarItem, } from 'tldraw' import 'tldraw/tldraw.css' export default function BasicExample() { const [orientation, setOrientation] = useState<'horizontal' | 'vertical'>('horizontal') const components = useMemo((): TLComponents => { return { Toolbar: () => ( setOrientation(orientation === 'horizontal' ? 'vertical' : 'horizontal') } label="Toggle orientation" icon={🔄} /> ), } }, [orientation]) return (
) } ``` -------- # Vertical toolbar Category: UI & theming Keywords: Switch from a horizontal toolbar at the bottom of the screen to a vertical one on the left. Tldraw supports a vertical toolbar out of the box. Override the toolbar component to set the orientation of the default toolbar to vertical. ## App.tsx ```tsx import { DefaultToolbar, TLComponents, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' const components: TLComponents = { Toolbar: () => , } export default function BasicExample() { return (
) } ``` -------- # Add a tool to the toolbar Category: UI & theming Keywords: Add your custom tool to the toolbar. You can make an icon for your custom tool appear on tldraw's toolbar. To do this you will need to override the toolbar component, pass in a custom component for the keyboard shortcuts dialog, and pass in an asset url for your icon. This example shows how to do that. For more information on how to implement custom tools, check out the custom tool example. ## App.tsx ```tsx import { DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialogContent, DefaultToolbar, DefaultToolbarContent, TLComponents, TLUiAssetUrlOverrides, TLUiOverrides, Tldraw, TldrawUiMenuItem, useIsToolSelected, useTools, } from 'tldraw' import 'tldraw/tldraw.css' import { StickerTool } from './sticker-tool-util' // There's a guide at the bottom of this file! // [1] const uiOverrides: TLUiOverrides = { tools(editor, tools) { // Create a tool item in the ui's context. tools.sticker = { id: 'sticker', icon: 'heart-icon', label: 'Sticker', kbd: 's', onSelect: () => { editor.setCurrentTool('sticker') }, } return tools }, } // [2] const components: TLComponents = { Toolbar: (props) => { const tools = useTools() const isStickerSelected = useIsToolSelected(tools['sticker']) return ( ) }, KeyboardShortcutsDialog: (props) => { const tools = useTools() return ( {/* Ideally, we'd interleave this into the tools group */} ) }, } // [3] export const customAssetUrls: TLUiAssetUrlOverrides = { icons: { 'heart-icon': '/heart-icon.svg', }, } // [4] const customTools = [StickerTool] export default function ToolInToolbarExample() { return (
) } /* Introduction: You can make an icon for your custom tool appear on tldraw's toolbar. To do this you will need to override the toolbar component, pass in a custom component for the keyboard shortcuts dialog, and pass in an asset url for your icon. This example shows how to do that. For more information on how to implement custom tools, check out the custom tool example. [1] First, we define the uiOverrides object. We can override the tools function to add our custom tool to the ui's context. We can also override the toolbar function to add our custom tool to the toolbar. We are going to splice it into the toolbar so it appears in between the eraser and arrow tools. [2] Next, we want to override the default keyboard shortcuts dialog so that the shortcut for our custom tool appears in the dialog. We don't want to change its appearance very much, so we can use the DefaultKeyboardShortcutsDialog component and pass in the DefaultKeyboardShortcutsDialogContent component. With the useTools hook, we can get the tools from context and pass in the sticker tool to the keyboard shortcuts dialog. This will make the keyboard shortcut for the sticker tool appear in the dialog. [3] We need to make sure the editor knows where to find the icon for our custom tool. We do this by defining the customAssetUrls object and passing in the asset url for our icon. [4] Finally, we define the customTools array. This array contains the custom tool class. We then pass the customTools array, the uiOverrides object, the components object, and the customAssetUrls object to the Tldraw component as props. This will make the icon for the custom tool appear on the toolbar. */ ``` ## sticker-tool-util.tsx ```tsx import { StateNode, toRichText } from 'tldraw' // Check out the custom tool example for a more detailed explanation of the tool class. const OFFSET = 12 export class StickerTool extends StateNode { static override id = 'sticker' override onEnter() { this.editor.setCursor({ type: 'cross', rotation: 0 }) } override onPointerDown() { const currentPagePoint = this.editor.inputs.getCurrentPagePoint() this.editor.createShape({ type: 'text', x: currentPagePoint.x - OFFSET, y: currentPagePoint.y - OFFSET, props: { richText: toRichText('❤️') }, }) } } ``` -------- # Remove a tool from the toolbar Category: UI & theming Keywords: remove, tool You can remove a tool from the user interface. Using overrides, you can remove a tool from the toolbar, keyboard shortcuts, and other parts of the user interface. The tool will still be present in the application but not accessible to the user. ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function RemoveToolExample() { return ( <>
{ // Remove the text tool delete tools.text return tools }, }} />
) } ``` -------- # Change default colors Category: UI & theming Keywords: colours, styles, palette, style panel Change the tldraw theme colors. While there's currently no way to add or remove our colors from our default shapes' colors, this example shows how you can adjust the values for the default color styles. Note that this will change the colors for ALL instances of tldraw. For example, if you run this example locally, and then open another example, the changes will be present there too until you reload! ## App.tsx ```tsx import { DefaultColorThemePalette, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! // [1] DefaultColorThemePalette.lightMode.black.solid = 'aqua' export default function ChangingDefaultColorsExample() { return (
) } /* [1] The default color theme is exported from the tldraw library. You can modify it directly outside of the React lifecycle, so that your changes are used when the component mounts. Remember that you can't add or remove colors here yet. These colors are used by our default shapes and we need to make sure that we don't end up with a color that we can't handle, or else this could get propagated to other users in a multiplayer session. At the moment, if you want to create new colors, you will need to create your own shapes that understand those colors. We're working on making this easier! */ ``` -------- # Change default styles Category: UI & theming Keywords: size, styles, default Change the default value for a style prop. Want to set the default value for a property to something other than it's built-in default? In this example we make the size style have small as its default value. ## App.tsx ```tsx import { DefaultSizeStyle, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' DefaultSizeStyle.setDefaultValue('s') export default function ChangingDefaultStyleExample() { return (
) } ``` -------- # Changing menus Category: UI & theming Keywords: menu, context menu, toolbar, main menu, page menu, panel Customize tldraw's menus, including the toolbar, main menu, context menu, page menu, and more. You can use the `components` prop to override tldraw's default menus. You can provide a React component of your own, import our default component and edit it, or return null to hide it completely. This example demonstrates how to do this with every menu in tldraw, including the following: - Toolbar - Main menu - Context menu - Page menu - Actions menu - Debug menu - Help menu - Keyboard shortcuts dialog - Navigation panel - Quick actions panel - Style panel - Zoom menu ## App.tsx ```tsx import { DefaultActionsMenu, DefaultActionsMenuContent, DefaultColorStyle, DefaultContextMenu, DefaultContextMenuContent, DefaultDebugMenu, DefaultDebugMenuContent, DefaultHelpMenu, DefaultHelpMenuContent, DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialogContent, DefaultMainMenu, DefaultMainMenuContent, DefaultPageMenu, DefaultQuickActions, DefaultQuickActionsContent, DefaultStylePanel, DefaultStylePanelContent, DefaultToolbar, DefaultToolbarContent, DefaultZoomMenu, DefaultZoomMenuContent, TLComponents, Tldraw, TldrawUiButton, TldrawUiButtonLabel, TldrawUiMenuGroup, TldrawUiMenuItem, TLUiContextMenuProps, TLUiKeyboardShortcutsDialogProps, TLUiStylePanelProps, useEditor, useIsToolSelected, useTools, } from 'tldraw' import 'tldraw/tldraw.css' //[1] function CustomActionsMenu() { return (
{ window.open('https://x.com/tldraw', '_blank') }} />
) } //[2] function CustomContextMenu(props: TLUiContextMenuProps) { return (
{ window.open('https://x.com/tldraw', '_blank') }} />
) } //[3] function CustomDebugMenu() { return (
{ window.open('https://x.com/tldraw', '_blank') }} />
) } //[4] function CustomHelpMenu() { return (
{ window.open('https://x.com/tldraw', '_blank') }} />
) } //[5] function CustomKeyboardShortcutsDialog(props: TLUiKeyboardShortcutsDialogProps) { return (
{ window.open('https://x.com/tldraw', '_blank') }} />
) } //[6] function CustomMainMenu() { return (
{ window.open('https://x.com/tldraw', '_blank') }} />
) } //[7] function CustomNavigationPanel() { return
here you are
} //[8] function CustomPageMenu() { return (
) } //[9] function CustomQuickActions() { return (
window.alert('code')} />
) } //[10] function CustomStylePanel(props: TLUiStylePanelProps) { const editor = useEditor() return (
{ editor.setStyleForSelectedShapes(DefaultColorStyle, 'red') }} > Red
{ editor.setStyleForSelectedShapes(DefaultColorStyle, 'green') }} > Green
) } //[11] function CustomToolbar() { const editor = useEditor() const tools = useTools() const isScreenshotSelected = useIsToolSelected(tools['rhombus-2']) return (
{ editor.selectAll().deleteShapes(editor.getSelectedShapeIds()) }} title="delete all" > 🧨
) } //[12] function CustomZoomMenu() { return (
{ window.open('https://x.com/tldraw', '_blank') }} />
) } const components: TLComponents = { ActionsMenu: CustomActionsMenu, ContextMenu: CustomContextMenu, DebugMenu: CustomDebugMenu, HelpMenu: CustomHelpMenu, KeyboardShortcutsDialog: CustomKeyboardShortcutsDialog, MainMenu: CustomMainMenu, NavigationPanel: CustomNavigationPanel, PageMenu: CustomPageMenu, QuickActions: CustomQuickActions, StylePanel: CustomStylePanel, Toolbar: CustomToolbar, ZoomMenu: CustomZoomMenu, } export default function CustomActionsMenuExample() { return (
) } /* You can use the `components` prop to override tldraw's default menus. You can provide a React component of your own, import our default component and edit it, or return null to hide it completely. This example demonstrates how to do this with every menu in tldraw. [1] The actions menu is a dropdown menu that can be found in the top-left of the tldraw component, or just above the toolbar on smaller screens. It contains actions related to editing shapes such as grouping, rotating or changing shape order. [2] Create some shapes, select them and right click the selection to see the custom context menu. [3] The debug menu contains helpful menu items for debugging the tldraw component. To show the debug menu, turn on debug mode in the preferences. [4] The help menu contains menu items to change the language of the application, and to open the keyboard shortcuts dialog. [5] The keyboard shortcuts dialog is a modal that shows all the keyboard shortcuts available in tldraw. You can open it via the help menu. [6] The main menu contains important submenus: Edit, Shape, Preferences etc. To open the main menu, click the hamburger icon in the top left corner of the tldraw component. [7] The navigation panel is in the bottom left of the tldraw component at larger breakpoints. It contains zoom controls and a mini map. [8] The page menu contains options for creating and editing pages. To open the page menu, click the page name in the top left of the tldraw component. [9] The quick actions menu is a dropdown menu that appears in the Main Menu, or above the toolbar on smaller screens. [10] The style panel is a panel that appears on the right side of the tldraw component. It contains options to change the style of shapes, such as color, stroke width, and opacity. [11] The toolbar contains tools to create shapes, select shapes, and more. [12] The zoom menu is in the bottom left of the tldraw component, the button to open it is labeled with a percentage indicating the editor's current zoom level. */ ``` -------- # Format rich text on multiple shapes Category: UI & theming Keywords: rich text, format, tiptap, multiple Add a toggle button to the style panel that allows you to make all text bold (or remove bold) from multiple selected shapes at once. This example demonstrates how to work with rich text formatting programmatically so that you can apply formatting changes to multiple shapes simultaneously. Rich text in tldraw uses TipTap's document structure. Text nodes can have `marks` array that contains formatting information like `{ type: 'bold' }`. The example includes helper functions to: - `makeAllTextBold()`: Recursively traverses the rich text document and adds bold marks to all text nodes - `removeBoldFromAllText()`: Recursively removes bold marks from all text nodes - `isAllTextBold()`: Checks if all text nodes in a rich text document have bold marks ## App.tsx ```tsx import { DefaultStylePanel, DefaultStylePanelContent, ExtractShapeByProps, TLComponents, TLRichText, TLShape, TLUiStylePanelProps, Tldraw, TldrawUiButton, TldrawUiButtonIcon, TldrawUiButtonLabel, useEditor, useValue, } from 'tldraw' import 'tldraw/tldraw.css' type ShapeWithRichText = ExtractShapeByProps<{ richText: TLRichText }> function isShapeWithRichText(shape: TLShape | null | undefined): shape is ShapeWithRichText { return !!(shape && 'richText' in shape.props) } /** * Recursively processes rich text content to make all text nodes bold. * Preserves the structure of the document while adding bold marks to all text. */ function makeAllTextBold(richText: TLRichText): TLRichText { if (!richText || !richText.content) { return richText } const processNode = (node: any): any => { if (node.type === 'text') { // Check if bold mark already exists const hasBold = node.marks?.some((mark: any) => mark.type === 'bold') if (hasBold) { return node } // Add bold mark to text node return { ...node, marks: [...(node.marks || []), { type: 'bold' }], } } if (node.content && Array.isArray(node.content)) { // Recursively process child nodes return { ...node, content: node.content.map(processNode), } } return node } return { ...richText, content: richText.content.map(processNode), } } /** * Recursively checks if all text nodes in rich text have bold marks. */ function isAllTextBold(richText: TLRichText): boolean { if (!richText || !richText.content) { return false } const checkNode = (node: any): boolean => { if (node.type === 'text') { // If it's a text node, check if it has a bold mark const hasBold = node.marks?.some((mark: any) => mark.type === 'bold') return hasBold } if (node.content && Array.isArray(node.content)) { // Recursively check child nodes return node.content.every(checkNode) } // Non-text nodes without content are considered "bold" (they don't need bold) return true } return richText.content.every(checkNode) } /** * Removes bold marks from all text nodes in rich text. */ function removeBoldFromAllText(richText: TLRichText): TLRichText { if (!richText || !richText.content) { return richText } const processNode = (node: any): any => { if (node.type === 'text') { // Remove bold marks from text node const marks = node.marks?.filter((mark: any) => mark.type !== 'bold') || [] return { ...node, marks: marks.length > 0 ? marks : undefined, } } if (node.content && Array.isArray(node.content)) { // Recursively process child nodes return { ...node, content: node.content.map(processNode), } } return node } return { ...richText, content: richText.content.map(processNode), } } function CustomStylePanel(props: TLUiStylePanelProps) { const editor = useEditor() // Get currently selected shapes const selectedShapes = useValue('selected shapes', () => editor.getSelectedShapes(), [editor]) // Filter to only shapes with rich text const shapesWithRichText = selectedShapes.filter(isShapeWithRichText) const hasRichTextSelection = shapesWithRichText.length > 0 // Check if all selected shapes with rich text have all their text bold const allBold = useValue( 'all bold', () => { if (shapesWithRichText.length === 0) return false return shapesWithRichText.every((shape) => { const richText = shape.props.richText return richText && isAllTextBold(richText) }) }, [shapesWithRichText] ) const handleToggleBold = () => { if (!hasRichTextSelection) return editor.run(() => { shapesWithRichText.forEach((shape) => { const richText = shape.props.richText if (!richText) return const newRichText = allBold ? removeBoldFromAllText(richText) : makeAllTextBold(richText) editor.updateShape({ id: shape.id, type: shape.type, props: { richText: newRichText }, }) }) }) } return (
Bold All Text
) } const components: TLComponents = { StylePanel: CustomStylePanel, } export default function RichTextFormatOnMultipleShapesExample() { return (
) } ``` -------- # Hide UI components Category: UI & theming Keywords: hide, ui, overrides Hide individual UI components. UI components can be hidden by providing `null` as their value within `components`. In this example, all configurable UI components are hidden. ## App.tsx ```tsx import { TLUiComponents, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' // The type here is include only to ensure this example contains all possible ui components, const components: Required = { ContextMenu: null, ActionsMenu: null, HelpMenu: null, ZoomMenu: null, MainMenu: null, Minimap: null, StylePanel: null, PageMenu: null, NavigationPanel: null, Toolbar: null, KeyboardShortcutsDialog: null, QuickActions: null, HelperButtons: null, DebugPanel: null, DebugMenu: null, SharePanel: null, MenuPanel: null, TopPanel: null, CursorChatBubble: null, RichTextToolbar: null, ImageToolbar: null, VideoToolbar: null, Dialogs: null, Toasts: null, A11y: null, FollowingIndicator: null, } export default function UiComponentsHiddenExample() { return (
) } ``` -------- # Menu system hover Category: UI & theming Keywords: menu, hover, dropdown, programmatic, control Programmatically control dropdown menus via hover interactions using the editor's menu tracking API. This example demonstrates how to open and close menus programmatically using the `editor.menus` API. Instead of relying solely on click triggers, you can control menu state in response to any UI event—in this case, hovering over specific zones. The key APIs used are: - `editor.menus.addOpenMenu(id)` - Register a menu as open - `editor.menus.deleteOpenMenu(id)` - Close a specific menu - `useMenuIsOpen(id)` - Subscribe to menu state reactively This pattern is useful for building custom toolbars, navigation systems, or any UI where menus should respond to external events rather than just their own triggers. ## App.tsx ```tsx import { Tldraw, TldrawUiButton, TldrawUiButtonLabel, TldrawUiDropdownMenuContent, TldrawUiDropdownMenuItem, TldrawUiDropdownMenuRoot, TldrawUiDropdownMenuTrigger, useEditor, useMenuIsOpen, } from 'tldraw' import 'tldraw/tldraw.css' import './menu-system-hover.css' // [1] function HoverControlledMenu() { const editor = useEditor() const [isOpen] = useMenuIsOpen('hover-menu') return (
{/* [2] */}
editor.menus.addOpenMenu('hover-menu')} > Hover to open menu
{/* [3] */}
editor.menus.deleteOpenMenu('hover-menu')} > Hover to close menu
{/* [4] */} Menu {isOpen ? '(open)' : '(closed)'} Menu item 1 Menu item 2 Menu item 3
) } export default function MenuSystemHoverExample() { return (
) } /* This example shows how to programmatically control menus using hover events. [1] The HoverControlledMenu component uses useMenuIsOpen to track the current state of our menu. The hook returns a tuple where the first element is a boolean indicating whether the menu is open. [2] The first hover zone calls editor.menus.addOpenMenu('hover-menu') on mouse enter. This registers the menu as open in the global menu tracking system. The TldrawUiDropdownMenuRoot will automatically respond to this state change. [3] The second hover zone calls editor.menus.deleteOpenMenu('hover-menu') on mouse enter, which closes the menu. [4] The TldrawUiDropdownMenuRoot is linked to our menu ID ('hover-menu'). It automatically syncs with the menu tracking system, so when we call addOpenMenu or deleteOpenMenu, the dropdown responds accordingly. You can also click the trigger button to toggle the menu normally. */ ``` ## menu-system-hover.css ```css .hover-menu-container { position: absolute; top: 100px; left: 0; display: flex; flex-direction: column; gap: var(--tl-space-3); padding: var(--tl-space-3); z-index: var(--tl-layer-panels); } .hover-zone { padding: var(--tl-space-3) var(--tl-space-5); border-radius: var(--tl-radius-2); font-size: 13px; font-weight: 500; cursor: default; user-select: none; transition: opacity 0.15s ease; background-color: var(--tl-color-low); pointer-events: auto; } .hover-zone:hover { opacity: 0.8; } .hover-zone-open { color: var(--tl-color-success); } .hover-zone-close { color: var(--tl-color-danger); } ``` -------- # Screen reader accessibility Category: UI & theming Keywords: accessibility, a11y, screen reader, aria Implement accessible custom shapes and custom screen reader announcements. This example demonstrates two key aspects of screen reader accessibility in tldraw: ## Custom shapes with screen reader descriptions When shapes are selected, the announcement system uses the `getAriaDescriptor()` and `getText()` methods from ShapeUtil to generate appropriate accessibility announcements. - **`getAriaDescriptor()`** - Returns alt-text-style descriptions for accessibility announcements. When a shape is selected, this description is announced to screen reader users. It returns a combined announcement like "Meeting Notes - Discussed Q4 planning" that describes the card's purpose and content. - **`getText()`** - Returns visible text content from shapes (for text extraction and search). This is used for text extraction, search functionality, and as a fallback for accessibility if `getAriaDescriptor()` is not provided. ## Custom screen reader announcements The example also demonstrates how to use the `useA11y()` hook to create custom screen reader announcements for your UI components. Screen readers use ARIA live regions to announce dynamic content changes to users. The example shows three common use cases: - **Polite announcements**: Use `priority: 'polite'` for informational messages that don't require immediate attention (e.g., "Action completed for 2 shapes"). These announcements wait for the screen reader to finish its current announcement. - **Assertive announcements**: Use `priority: 'assertive'` for important messages that need immediate attention (e.g., validation errors). These announcements interrupt the current screen reader output. - **State change announcements**: Announce state changes to keep users informed of the current application state. The announcements are rendered in an off-screen ARIA live region by the `DefaultA11yAnnouncer` component, making them accessible to screen readers without cluttering the visual interface. ## App.tsx ```tsx import { useState } from 'react' import { BaseBoxShapeUtil, HTMLContainer, RecordProps, T, Tldraw, TldrawUiButton, TLShape, useA11y, useEditor, } from 'tldraw' import 'tldraw/tldraw.css' import './screen-reader-accessibility.css' const CARD_SHAPE_TYPE = 'note-card' declare module 'tldraw' { export interface TLGlobalShapePropsMap { [CARD_SHAPE_TYPE]: { w: number; h: number; title: string; description: string } } } type CardShape = TLShape export class CardShapeUtil extends BaseBoxShapeUtil { static override type = CARD_SHAPE_TYPE static override props: RecordProps = { w: T.number, h: T.number, title: T.string, description: T.string, } getDefaultProps(): CardShape['props'] { return { w: 300, h: 180, title: 'Untitled', description: '', } } // [1] override getAriaDescriptor(shape: CardShape): string | undefined { const { title, description } = shape.props if (description) { return `${title} - ${description}` } return title } // [2] override getText(shape: CardShape): string | undefined { return `${shape.props.title}\n${shape.props.description}` } component(shape: CardShape) { const { title, description } = shape.props return (
{title}
{description &&
{description}
}
) } indicator(shape: CardShape) { return } } const customShapes = [CardShapeUtil] function CustomAnnouncementPanel() { const editor = useEditor() // [3] const a11y = useA11y() const [isEnabled, setIsEnabled] = useState(false) // [4] const handleActionConfirmation = () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length > 0) { // Simulate an action being performed a11y.announce({ msg: `Action completed for ${selectedShapes.length} shape${selectedShapes.length === 1 ? '' : 's'}`, priority: 'polite', }) } else { a11y.announce({ msg: 'No shapes selected', priority: 'polite', }) } } // [5] const handleValidation = () => { const selectedShapes = editor.getSelectedShapes() if (selectedShapes.length === 0) { a11y.announce({ msg: 'Validation failed: No shapes selected', priority: 'assertive', }) } else { const hasText = selectedShapes.some((shape) => { const util = editor.getShapeUtil(shape) return util.getText(shape) }) if (!hasText) { a11y.announce({ msg: 'Validation failed: Selected shapes must contain text', priority: 'assertive', }) } else { a11y.announce({ msg: 'Validation passed', priority: 'polite', }) } } } // [6] const handleToggle = () => { const newState = !isEnabled setIsEnabled(newState) a11y.announce({ msg: `Feature ${newState ? 'enabled' : 'disabled'}`, priority: 'polite', }) } return (
Perform action Validate selection {isEnabled ? 'Disable' : 'Enable'} feature
) } export default function ScreenReaderAccessibilityExample() { return (
{ // [7] editor.createShape({ type: CARD_SHAPE_TYPE, x: 100, y: 100, props: { title: 'Meeting Notes', description: 'Discussed Q4 planning and team goals', }, }) editor.createShape({ type: CARD_SHAPE_TYPE, x: 450, y: 100, props: { title: 'Project Ideas', description: 'Brainstorming session for new features', }, }) editor.createShape({ type: CARD_SHAPE_TYPE, x: 100, y: 320, props: { title: 'Action Items', description: 'Follow up with design team by Friday', }, }) }} />
) } /* Introduction: This example demonstrates how to create accessible custom shapes and custom screen reader announcements in tldraw. [1] The getAriaDescriptor() method provides accessibility-specific descriptions for screen readers. When a shape is selected, this description is announced to screen reader users. This is different from getText() - getAriaDescriptor() is specifically for accessibility announcements, not for text extraction or search. [2] The getText() method returns the visible text content of the shape. This is used for text extraction, search functionality, and as a fallback for accessibility if getAriaDescriptor() is not provided. It returns the title and description separated by a newline. [3] The useA11y() hook provides access to the accessibility manager. It must be called within a component that's rendered inside the Tldraw component. [4] Polite announcements are used for informational messages that don't require immediate attention. They wait for the screen reader to finish its current announcement before speaking. This is appropriate for action confirmations and status updates. [5] Assertive announcements are used for critical messages that need immediate attention, such as validation errors. They interrupt the current screen reader output to ensure the user hears the message right away. [6] State change announcements help keep users informed about the current state of the application. Use polite priority for state changes unless they're critical. [7] Create three sample cards with different titles and descriptions. Try selecting different cards to hear how screen readers announce them using the getAriaDescriptor() method. The announcement will include the card's custom description followed by the shape type and position information. You can also use the buttons in the top panel to trigger custom announcements that demonstrate polite and assertive priority levels. */ ``` ## screen-reader-accessibility.css ```css .announcement-panel { display: flex; flex-direction: row; flex-wrap: wrap; margin: 8px; } .card-shape { padding: var(--tl-space-5); background-color: white; border: 1px solid var(--tl-color-low-border); border-radius: var(--tl-radius-3); display: flex; flex-direction: column; gap: var(--tl-space-3); } ``` -------- # Things on the canvas Category: UI & theming Keywords: in front of the canvas, scale, zoom, ui Add custom components to the canvas. This example shows how to add custom components to the canvas. Components can either float on top of the canvas unaffected by the camera, or be a part of the canvas itself. ## App.tsx ```tsx import { useState } from 'react' import { Tldraw, TLEditorComponents, track, useEditor } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! // [1] function MyComponent() { const [state, setState] = useState(0) const editor = useEditor() return ( <>

The count is {state}!

These components are on the canvas. They will scale with camera zoom like shapes.

The count is {state}!

Create and select a shape to see the in front of the canvas component

) } //[2] const MyComponentInFront = track(() => { const editor = useEditor() const selectionRotatedPageBounds = editor.getSelectionRotatedPageBounds() if (!selectionRotatedPageBounds) return null const pageCoordinates = editor.pageToViewport(selectionRotatedPageBounds.point) return (

This won’t scale with zoom.

) }) // [3] const components: TLEditorComponents = { OnTheCanvas: MyComponent, InFrontOfTheCanvas: MyComponentInFront, } // [4] export default function OnTheCanvasExample() { return (
) } /* This example shows how you can use the onTheCanvas and inFrontOfTheCanvas components. onTheCanvas components will behave similarly to shapes, they will scale with the zoom and move when the page is panned. inFrontOfTheCanvas components don't scale with the zoom, but still move when the page is panned. For another example that shows how to customize components, check out the custom components example. To have a component that ignores the camera entirely, you should check out the custom UI example. [1] This is our onTheCanvas component. We also stop event propagation on the pointer events so that we don't accidentally select shapes when interacting with the component. [2] This is our inFrontOfTheCanvas component. We want to render this next to a selected shape, so we need to make sure it's reactive to changes in the editor. We use the track function to make sure the component is re-rendered whenever the selection changes. Check out the signals example for more info: https://tldraw.dev/examples/signals Using the editor instance we can get the bounds of the selection box and convert them to screen coordinates. We then render the component at those coordinates. [3] This is where we define the object that will be passed to the Tldraw component prop. [4] This is where we render the Tldraw component. Let's pass the components object to the components prop. */ ``` -------- # Toasts and dialogs Category: UI & theming Keywords: ui, components, dialogs, toasts Add, remove and clear toasts and dialogs in your app using the `useToasts` and `useDialogs` hooks. To control toasts and dialogs your app, you can use the `useToasts` and `useDialogs` hooks. These hooks give you access to functions which allow you to add, remove and clear toasts and dialogs. Dialogs are especially customisable, allowing you to pass in a custom component to render as the dialog content. Alternatively, you can use the `ExampleDialog` component which is provided by the library. ## App.tsx ```tsx import { TLComponents, Tldraw, TldrawUiButton, TldrawUiButtonLabel, TldrawUiDialogBody, TldrawUiDialogCloseButton, TldrawUiDialogFooter, TldrawUiDialogHeader, TldrawUiDialogTitle, useDialogs, useToasts, } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file // [1] function MyDialog({ onClose }: { onClose(): void }) { return ( <> Title Description... Cancel Continue ) } // [2] function MySimpleDialog({ onClose }: { onClose(): void }) { return (

Title

Description...

) } const CustomSharePanel = () => { const { addToast } = useToasts() const { addDialog } = useDialogs() return (
) } const components: TLComponents = { SharePanel: CustomSharePanel, } export default function ToastsDialogsExample() { return (
) } /* To control toasts and dialogs your app, you can use the `useToasts` and `useDialogs` hooks. These hooks give you access to functions which allow you to add, remove and clear toasts and dialogs. Dialogs are especially customisable, allowing you to pass in a custom component to render as the dialog content. Alternatively, you can use the `ExampleDialog` component which is provided by the library. [1] The tldraw library provides a set of components that you can use to build your dialogs. The `onClose` function passed to the dialog component runs when the dialog closes or is dismissed, but you can also call it from buttons to close the dialog. [2] ...or you can build your own dialog component! */ ``` -------- # UI zones Category: UI & theming Keywords: top zone, share zone Inject custom components into tldraw's empty areas. Our default UI has two empty areas that you can fill - the `TopPanel` (in the top-center of the screen) and `SharePanel` (in the top right). You can set these zones to any React component you want. ## App.tsx ```tsx import { TLComponents, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! const components: TLComponents = { SharePanel: CustomShareZone, TopPanel: CustomTopZone, } // [1] export default function Example() { return (
) } // [2] function CustomTopZone() { return (

Top Zone

) } // [3] function CustomShareZone() { return (

Share Zone

) } /* This example shows how to pass in a custom component to the share panel and top panel. The share panel is in the top right corner above the style menu, the top panel is in the top center. [1] We pass in our custom components to the Tldraw topZone and shareZone props. [2] This is the component that will be rendered in the top zone. [3] This is the component that will be rendered in the share zone. */ ``` -------- # Hide the entire UI Category: UI & theming Keywords: Hide tldraw's UI with the `hideUi` prop. Hide tldraw's UI with the `hideUi` prop. This can be useful if you want a bare-bones editor, or if you want to build your own UI. ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' export default function HideUiExample() { return (
) } /* This example shows how to hide the UI of the editor. Simply pass the hideUi prop to the Tldraw component. This is useful if you want to build your own UI around the editor. See the custom ui example if you need help building your own UI. */ ``` -------- # Replace the entire UI Category: UI & theming Keywords: hide, ui, event listener Replace tldraw's UI with your own. If you want, you can replace tldraw's entire UI and start your own from scratch. This example shows how you might begin doing that, with keyboard shortcuts and buttons for selecting tools. ## App.tsx ```tsx import { useEffect } from 'react' import { Tldraw, track, useEditor } from 'tldraw' import 'tldraw/tldraw.css' import './custom-ui.css' // There's a guide at the bottom of this file! // [1] export default function CustomUiExample() { return (
) } // [2] const CustomUi = track(() => { const editor = useEditor() useEffect(() => { const handleKeyUp = (e: KeyboardEvent) => { switch (e.key) { case 'Delete': case 'Backspace': { editor.deleteShapes(editor.getSelectedShapeIds()) break } case 'v': { editor.setCurrentTool('select') break } case 'e': { editor.setCurrentTool('eraser') break } case 'x': case 'p': case 'b': case 'd': { editor.setCurrentTool('draw') break } } } window.addEventListener('keyup', handleKeyUp) return () => { window.removeEventListener('keyup', handleKeyUp) } }) return (
) }) /* This example shows how to create your own custom ui for the editor. [1] We render the Tldraw component with the `hideUi` prop. This will hide the default toolbar, style menu and pages menu. We also render our custom ui component inside the Tldraw component. This gives us access to the editor instance via React context. The context menu isn't hidden by the `hideUi` prop, if you want to hide it you can render the parts that make up the Tldraw component separately and omit the context menu. Check out the exploded example to see how to do this. [2] We use the `track` function to wrap our component. This makes our component reactive- it will re-render when the signals it is tracking change. Check out the signals example for more info: https://tldraw.dev/examples/signals We gain access to the editor instance via the `useEditor` hook. We use the `useEffect` hook to add event listeners for keyboard shortcuts. We use editor methods to change the current tool and delete shapes. */ ``` ## custom-ui.css ```css .custom-layout { position: absolute; inset: 0px; z-index: 300; pointer-events: none; } .custom-toolbar { position: absolute; top: 0px; left: 0px; width: 100%; display: flex; align-items: center; justify-content: center; padding: 8px; gap: 8px; } .custom-button { pointer-events: all; padding: 4px 12px; background: white; border: 1px solid black; border-radius: 64px; } .custom-button[data-isactive='true'] { background-color: black; color: white; } ``` -------- # Contextual toolbar Category: UI & theming Keywords: in front of the canvas, contextual Show a contextual toolbar above shapes when they are selected. This is an example of how to show a contextual toolbar which appears on top of the selected shapes. It is displayed above the canvas, but below other UI elements. Select shapes of different types and adjust the size of the shapes - this works just like adjusting the size from the Style panel on the right side. ## App.tsx ```tsx import { Box, DefaultSizeStyle, Tldraw, TldrawUiButtonIcon, TldrawUiContextualToolbar, TldrawUiToolbarButton, TLEditorComponents, TLEditorSnapshot, track, useEditor, } from 'tldraw' import 'tldraw/tldraw.css' import _jsonSnapshot from './snapshot.json' // There's a guide at the bottom of this file! const jsonSnapshot = _jsonSnapshot as any as TLEditorSnapshot const SIZES = [ { value: 's', icon: 'size-small' }, { value: 'm', icon: 'size-medium' }, { value: 'l', icon: 'size-large' }, { value: 'xl', icon: 'size-extra-large' }, ] as const // [1] const ContextualToolbarComponent = track(() => { const editor = useEditor() const showToolbar = editor.isIn('select.idle') // [2] const size = editor.getSharedStyles().get(DefaultSizeStyle) if (!size || !showToolbar) return null const currentSize = size.type === 'shared' ? size.value : undefined // [3] const getSelectionBounds = () => { const fullBounds = editor.getSelectionRotatedScreenBounds() if (!fullBounds) return undefined return new Box(fullBounds.x, fullBounds.y, fullBounds.width, 0) } return ( {SIZES.map(({ value, icon }) => { return ( editor.setStyleForSelectedShapes(DefaultSizeStyle, value)} > ) })} ) }) const components: TLEditorComponents = { InFrontOfTheCanvas: ContextualToolbarComponent, } export default function ContextualToolbar() { return (
) } /* This example shows how you can use our contextual toolbar primitive that can show up when you select a shape. It's using the `InFrontOfTheCanvas` component to render the toolbar. This allows us to render the toolbar on top of the shapes, but behind the existing UI. [1] This is our context toolbar. It's positioned absolutely on top of the selected shapes. [2] Different shapes support different style properties and this is how we get the styles that are supported by all selected shapes. If none of the selected shapes supported the `DefaultSizeStyle` we wouldn't show the toolbar at all. We also get the current value of the size property. If all the shapes have the same size then the type of the size property is `shared`. This will allow us to show the currently selected size in the toolbar. If the shapes have different sizes then the type of the size property is `mixed` and none of the sizes will be highlighted. [3] We pass in getSelectionBounds so that the toolbar knows where to position itself. You can use the selection bounds of the current shapes or some other logic. */ ``` -------- # Custom canvas components Category: UI & theming Keywords: custom, Background, SvgDefs, Brush, ZoomBrush, ShapeIndicator, Cursor, Canvas, CollaboratorBrush, CollaboratorCursor, CollaboratorHint, CollaboratorShapeIndicator, Grid, Scribble, CollaboratorScribble, SnapIndicator, Handles, Handle, Spinner, SelectionForeground, SelectionBackground, OnTheCanvas, InFrontOfTheCanvas, LoadingScreen Replace tldraw's on-canvas UI with your own. tldraw's on-canvas UI is built from replaceable React components. This example shows how to replace these default components with your own. Try dragging to select or using the eraser tool to see the custom components in this example. ## App.tsx ```tsx import { useRef } from 'react' import { Tldraw, TLEditorComponents, toDomPrecision, useTransform } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! const components: TLEditorComponents = { Brush: function MyBrush({ brush }) { const rSvg = useRef(null) useTransform(rSvg, brush.x, brush.y) const w = toDomPrecision(Math.max(1, brush.w)) const h = toDomPrecision(Math.max(1, brush.h)) return ( ) }, Scribble: ({ scribble, opacity, color }) => { return ( `${p.x},${p.y}`).join(' ')} stroke={color ?? 'black'} opacity={opacity ?? '1'} fill="none" /> ) }, SnapIndicator: null, } export default function CustomComponentsExample() { return (
) } /* This example shows how to change the default components that tldraw uses on the canvas via the `components` prop. Components include things like the background, the grid, handles, spinners etc. In this case we change the box that appears when drag-selecting shapes, and the scribble left behind when using the eraser and laser pointer. */ ``` -------- # Custom grid Category: UI & theming Keywords: grid, background, ui, layout Draw a custom grid on the canvas. This example shows how to draw a custom grid on the canvas. It uses a 2d canvas context to draw major and minor grid lines. ## App.tsx ```tsx import { useLayoutEffect, useRef } from 'react' import { TLComponents, Tldraw, approximately, useEditor, useIsDarkMode, useValue } from 'tldraw' import 'tldraw/tldraw.css' /** * There's a guide at the bottom of this file! */ const components: TLComponents = { // [1] Grid: ({ size, ...camera }) => { const editor = useEditor() // [2] const screenBounds = useValue('screenBounds', () => editor.getViewportScreenBounds(), []) const devicePixelRatio = useValue('dpr', () => editor.getInstanceState().devicePixelRatio, []) const isDarkMode = useIsDarkMode() const canvas = useRef(null) useLayoutEffect(() => { if (!canvas.current) return // [3] const canvasW = screenBounds.w * devicePixelRatio const canvasH = screenBounds.h * devicePixelRatio canvas.current.width = canvasW canvas.current.height = canvasH const ctx = canvas.current?.getContext('2d') if (!ctx) return // [4] ctx.clearRect(0, 0, canvasW, canvasH) // [5] const pageViewportBounds = editor.getViewportPageBounds() const startPageX = Math.ceil(pageViewportBounds.minX / size) * size const startPageY = Math.ceil(pageViewportBounds.minY / size) * size const endPageX = Math.floor(pageViewportBounds.maxX / size) * size const endPageY = Math.floor(pageViewportBounds.maxY / size) * size const numRows = Math.round((endPageY - startPageY) / size) const numCols = Math.round((endPageX - startPageX) / size) ctx.strokeStyle = isDarkMode ? '#555' : '#BBB' // [6] for (let row = 0; row <= numRows; row++) { const pageY = startPageY + row * size // convert the page-space Y offset into our canvas' coordinate space const canvasY = (pageY + camera.y) * camera.z * devicePixelRatio const isMajorLine = approximately(pageY % (size * 10), 0) drawLine(ctx, 0, canvasY, canvasW, canvasY, isMajorLine ? 3 : 1) } for (let col = 0; col <= numCols; col++) { const pageX = startPageX + col * size // convert the page-space X offset into our canvas' coordinate space const canvasX = (pageX + camera.x) * camera.z * devicePixelRatio const isMajorLine = approximately(pageX % (size * 10), 0) drawLine(ctx, canvasX, 0, canvasX, canvasH, isMajorLine ? 3 : 1) } }, [screenBounds, camera, size, devicePixelRatio, editor, isDarkMode]) // [7] return }, } export default function CustomGridExample() { return (
{ e.updateInstanceState({ isGridMode: true }) }} />
) } function drawLine( ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, width: number ) { ctx.beginPath() ctx.moveTo(x1, y1) ctx.lineTo(x2, y2) ctx.lineWidth = width ctx.stroke() } /** * This example demonstrates how to draw a custom grid component using a 2d canvas. * * 1. To add a custom grid you must override this Grid component. It is passed props for the camera position, along with the size of the grid in page space. * 2. In addition to updating when the camera moves, we want the grid to rerender if the screen bounds change, or if the devicePixelRatio changes, or if the theme changes. * 3. To avoid pixelation we want to render at the device's actual resolution, so we need to set the canvas size in terms of the devicePixelRatio. * 4. Start by clearing the canvas and making it transparent. * 5. Calculate the start and end offsets for the grid, in page space. * 6. Draw the grid lines. We draw major lines every 10 grid units. * 7. The 'tl-grid' class is important for correct rendering and interaction handling. */ ``` -------- # Drag and drop tray Category: UI & theming Keywords: Create a drag and drop tray to create shapes on the canvas. This example shows how to create a drag and drop tray with items that can be dragged onto the canvas to create shapes. The tray is positioned as a fixed UI element using the `InFrontOfTheCanvas` component, and includes interactive items that can be dragged to create shapes on the canvas. The interaction can be cancelled by pressing the Escape key. ## App.tsx ```tsx import { useMemo, useRef } from 'react' import { Box, TLEditorComponents, Tldraw, Vec, useAtom, useEditor, useQuickReactor, useValue, } from 'tldraw' import 'tldraw/tldraw.css' import './drag-and-drop-tray.css' import { TRAY_ITEMS, TrayItem } from './trayitems' // There's a guide at the bottom of this file! // [1] type DragState = | { name: 'idle' } | { name: 'pointing_item' item: TrayItem startPosition: Vec } | { name: 'dragging' item: TrayItem currentPosition: Vec } // [2] const DragAndDropTray = () => { const rTrayContainer = useRef(null) const rDraggingImage = useRef(null) const editor = useEditor() // [3] const dragState = useAtom('dragState', () => ({ name: 'idle', })) // [4] const { handlePointerUp, handlePointerDown } = useMemo(() => { let target: HTMLDivElement | null = null function handlePointerMove(e: PointerEvent) { const current = dragState.get() const screenPoint = new Vec(e.clientX, e.clientY) switch (current.name) { case 'idle': { break } case 'pointing_item': { const dist = Vec.Dist(screenPoint, current.startPosition) if (dist > 10) { // [a] dragState.set({ name: 'dragging', item: current.item, currentPosition: screenPoint, }) } break } case 'dragging': { // [b] dragState.set({ ...current, currentPosition: screenPoint, }) break } } } function handlePointerUp(e: React.PointerEvent) { const current = dragState.get() target = e.currentTarget as HTMLDivElement target.releasePointerCapture(e.pointerId) switch (current.name) { case 'idle': { break } case 'pointing_item': { dragState.set({ name: 'idle', }) break } case 'dragging': { // [c] const screenPoint = new Vec(e.clientX, e.clientY) const pagePoint = editor.screenToPage(screenPoint) editor.markHistoryStoppingPoint('create shape from tray') editor.createShape({ ...current.item.shapeBase, x: pagePoint.x - 50, // center on cursor at 100x100 y: pagePoint.y - 50, }) dragState.set({ name: 'idle', }) break } } removeEventListeners() } function handlePointerDown(e: React.PointerEvent) { e.preventDefault() target = e.currentTarget as HTMLDivElement target.setPointerCapture(e.pointerId) const itemIndex = target.dataset.drag_item_index! const item = TRAY_ITEMS[+itemIndex] if (!item) return const startPosition = new Vec(e.clientX, e.clientY) // [d] dragState.set({ name: 'pointing_item', item, startPosition, }) target.addEventListener('pointermove', handlePointerMove) document.addEventListener('keydown', handleKeyDown) } function handleKeyDown(e: KeyboardEvent) { const current = dragState.get() if (e.key === 'Escape' && current.name === 'dragging') { removeEventListeners() } } function removeEventListeners() { if (target) { target.removeEventListener('pointermove', handlePointerMove) document.removeEventListener('keydown', handleKeyDown) } dragState.set({ name: 'idle', }) } return { handlePointerDown, handlePointerUp, } }, [dragState, editor]) const state = useValue('dragState', () => dragState.get(), [dragState]) // [5] useQuickReactor( 'drag-image-style', () => { const current = dragState.get() const imageRef = rDraggingImage.current const trayContainerRef = rTrayContainer.current if (!imageRef || !trayContainerRef) return switch (current.name) { case 'idle': case 'pointing_item': { imageRef.style.display = 'none' break } case 'dragging': { const trayContainerRect = trayContainerRef.getBoundingClientRect() const box = new Box( trayContainerRect.x, trayContainerRect.y, trayContainerRect.width, trayContainerRect.height ) const viewportScreenBounds = editor.getViewportScreenBounds() const isInside = Box.ContainsPoint(box, current.currentPosition) if (isInside) { imageRef.style.display = 'none' } else { imageRef.style.display = 'block' imageRef.style.position = 'absolute' imageRef.style.pointerEvents = 'none' imageRef.style.left = '0px' imageRef.style.top = '0px' imageRef.style.transform = `translate(${current.currentPosition.x - viewportScreenBounds.x - 25}px, ${current.currentPosition.y - viewportScreenBounds.y - 25}px)` imageRef.style.width = '50px' imageRef.style.height = '50px' imageRef.style.fontSize = '40px' imageRef.style.display = 'flex' imageRef.style.alignItems = 'center' } } } }, [dragState] ) return ( <> {/* [6] */}
{TRAY_ITEMS.map((item, index) => (
{item.emoji}
))}
{/* [7] */}
{state.name === 'dragging' && state.item.emoji}
) } // [8] const components: TLEditorComponents = { InFrontOfTheCanvas: DragAndDropTray, } export default function DragAndDropTrayExample() { return (
) } /* Introduction: This example demonstrates how to create a drag-and-drop tray component that allows users to drag items from a custom UI tray and drop them onto the canvas as tldraw shapes. The example uses a state machine pattern to manage the drag interaction states and creates a custom UI component that renders in front of the canvas. [1] We define a union type `DragState` to represent the different states of our drag interaction: - `idle`: Nothing is being dragged - `pointing_item`: User has pressed down on an item but hasn't started dragging yet - `dragging`: User is actively dragging an item This state machine pattern helps us handle the complex drag interaction logic cleanly. [2] The main `DragAndDropTray` component uses two refs: - `rTrayContainer`: References the tray container div for bounds checking - `rDraggingImage`: References the dragging preview image that follows the cursor We also get access to the tldraw editor instance to create shapes and handle coordinate transformations. [3] We use tldraw's `useAtom` hook to create reactive state that can be observed and updated. The `dragState` atom holds our current drag state and automatically triggers re-renders when it changes. [4] The event handlers are memoized using `useMemo` to avoid recreating them on every render. The main logic handles the drag interaction: [a] When transitioning from `pointing_item` to `dragging`, we check if the user has moved their pointer more than 10 pixels from the start position to avoid accidental drags from simple clicks. [b] During dragging, we continuously update the current position to track the cursor. [c] When the drag ends (pointer up during dragging), we convert the screen coordinates to page coordinates and create a new shape at that position using the editor API. [d] When starting a drag (pointer down), we capture the pointer, get the item data from the DOM, and transition to the `pointing_item` state. [5] The `useQuickReactor` hook efficiently manages the drag preview image styling. It: - Hides the preview when not dragging - Shows/hides the preview based on whether the cursor is inside the tray bounds - Positions the preview image to follow the cursor during dragging - Applies appropriate styling for the drag preview [6] The tray UI renders each item from `TRAY_ITEMS` with pointer event handlers attached. We use `data-drag_item_index` to identify which item was clicked, allowing us to retrieve the correct item data during drag operations. [7] The drag preview image is a separate div that shows the emoji being dragged. It's only visible during the dragging state and follows the cursor position. [8] We configure the tldraw editor to include our custom tray component using the `components` prop. The `InFrontOfTheCanvas` component renders on top of the canvas, making it perfect for UI elements like our drag tray. */ ``` ## drag-and-drop-tray.css ```css .drag-tray { position: absolute; left: 10px; top: 50px; background: var(--tl-color-panel); border-radius: 9px; box-shadow: var(--tl-shadow-1); z-index: 1000; pointer-events: all; overflow: hidden; } .drag-tray-items { display: flex; flex-direction: column; gap: 4px; } .drag-tray-item { display: flex; align-items: center; justify-content: center; gap: 12px; width: 40px; height: 40px; border-radius: 8px; cursor: grab; transition: all 0.2s ease; user-select: none; font-size: 18px; } ``` ## trayitems.ts ```ts import { TLShape, toRichText } from 'tldraw' // [1] export type TrayItem = T extends T ? { id: string emoji: string label: string shapeBase: { type: T['type'] props: Partial } } : never export const TRAY_ITEMS: TrayItem[] = [ { id: 'snowman', emoji: '⛄', label: 'Snowman', shapeBase: { type: 'geo', props: { richText: toRichText('⛄'), size: 'm', }, }, }, { id: 'ice-cream', emoji: '🍦', label: 'Ice Cream', shapeBase: { type: 'geo', props: { richText: toRichText('🍦'), size: 'm', }, }, }, { id: 'smiley', emoji: '😊', label: 'Smiley', shapeBase: { type: 'geo', props: { richText: toRichText('😊'), size: 'm', }, }, }, { id: 'star', emoji: '⭐', label: 'Star', shapeBase: { type: 'geo', props: { richText: toRichText('⭐'), size: 'm', }, }, }, { id: 'heart', emoji: '❤️', label: 'Heart', shapeBase: { type: 'geo', props: { richText: toRichText('❤️'), size: 'm', }, }, }, ] ``` -------- # Error boundary Category: UI & theming Keywords: shape Customize the error fallback that appears when a shape throws an error. When something goes wrong in a shape, it won't crash the whole editor. An error fallback gets shown instead, which you can customize by overriding the `ShapeErrorFallback` component in the `components` prop. ## App.tsx ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' import { ErrorShapeUtil } from './ErrorShape' // There's a guide at the bottom of this file! // [1] const shapes = [ErrorShapeUtil] export default function ErrorBoundaryExample() { return (
Shape error! {String(error)}
, }} onMount={(editor) => { // [3] editor.createShape({ type: 'error' }) }} />
) } /* This example shows how to customize the error fallback that appears when a shape throws an error. We simulate this scenario by creating a shape that always throws an error when it renders. [1] This is the custom shape that always throws an error when it renders. Check out ErrorShape.ts to see how it works. [2] Pass in the custom error fallback component. [3] When the app starts, create our error shape so that we can see the custom error fallback. */ ``` ## ErrorShape.ts ```ts import { BaseBoxShapeUtil, TLShape } from 'tldraw' const ERROR_TYPE = 'error' declare module 'tldraw' { export interface TLGlobalShapePropsMap { [ERROR_TYPE]: { w: number; h: number } } } export type ErrorShape = TLShape export class ErrorShapeUtil extends BaseBoxShapeUtil { static override type = ERROR_TYPE getDefaultProps() { return { w: 100, h: 100 } } component() { throw new Error('Error!') } indicator() { throw new Error('Error!') } } ``` -------- # Selection UI Category: UI & theming Keywords: duplication controls, handles Add extra UI elements around the user's selection. You can use the `InFrontOfTheCanvas` component to show extra user interface elements around the user's selection. ## App.tsx ```tsx import { TLComponents, Tldraw, Vec, intersectLineSegmentPolygon, useEditor, useValue } from 'tldraw' import 'tldraw/tldraw.css' const components: TLComponents = { InFrontOfTheCanvas: () => { const editor = useEditor() const info = useValue( 'selection bounds', () => { const screenBounds = editor.getViewportScreenBounds() const rotation = editor.getSelectionRotation() const rotatedScreenBounds = editor.getSelectionRotatedScreenBounds() if (!rotatedScreenBounds) return return { // we really want the position within the // tldraw component's bounds, not the screen itself x: rotatedScreenBounds.x - screenBounds.x, y: rotatedScreenBounds.y - screenBounds.y, width: rotatedScreenBounds.width, height: rotatedScreenBounds.height, rotation: rotation, } }, [editor] ) if (!info) return return (
) }, } export default function BasicExample() { return (
) } /** * This button will duplicate the editor's current selected shapes in * a certain direction. Its rotation determines the appearance of the * button (its actual css rotation) as well as the direction in which * the duplicated shapes are offset from the original shapes. It's * zeroed to the right. */ function DuplicateInDirectionButton({ x, y, rotation, }: { x: number y: number rotation: number }) { const editor = useEditor() return ( ) } ``` -------- # Custom actions Category: UI & theming Keywords: keyboard, shortcut, copy, paste, group, align Customize and override tldraw's actions. This example shows how you can override tldraw's actions object to change keyboard shortcuts and add your own custom actions. In this case we've changed the delete action's shortcut to 'Shift + X', and we've added a new action that shows a toast when you press the 'S' key. For more information on keyboard shortcuts see the [keyboard shortcuts example](https://tldraw.dev/examples/keyboard-shortcuts). ## App.tsx ```tsx import { Tldraw, TLUiActionItem, TLUiActionsContextType } from 'tldraw' import 'tldraw/tldraw.css' export default function BasicExample() { return (
{ const myCustomAction: TLUiActionItem = { id: 'my-action', label: 'My action', icon: 'circle', // [2] kbd: 's', onSelect(source) { // [3] helpers.addToast({ title: `My action was selected from ${source}!` }) }, } // [4] const newActions: TLUiActionsContextType = { ...actions, 'my-action': myCustomAction, delete: { ...actions['delete'], kbd: 'shift+x', }, } return newActions }, }} />
) } /* Tldraw's actions can be fired via keyboard shortcuts, or from anywhere in the user interface via the `useActions` hook. This example shows how you can override tldraw's actions object via the Tldraw component's `overrides` prop. To learn more about using this actions via a customized menu, see the custom actions menu example. [2] For more information on keyboard shortcuts see the keyboard shortcuts example. [3] You can access UI helpers like addToast, removeToast, etc. from the helpers object. [4] Return a new object with the new actions added. You can also modify existing actions as shown here with the delete action. */ ``` -------- # Custom error capture Category: UI & theming Keywords: error, fallback, capture, sentry, boundary, crash Customize the error screen that appears when the editor crashes. This example shows how to capture errors in the editor and send them to your error tracking service (like Sentry). When a critical error occurs in the editor, tldraw displays an error screen with options to refresh or reset. You can customize this screen by overriding the `ErrorFallback` component in the `components` prop. The editor will annotate errors with additional information that you can use to troubleshoot issues. This is useful for capturing data about the user's application state at the time of the error, such as what tool was active or what the user's selected shapes were. There is a separate component, `ShapeErrorFallback`, that handles errors in individual shapes. There is a separate example showing how to use that component. By contrast, the `ErrorFallback` component handles editor-level errors that affect the entire application. ## App.tsx ```tsx import { getErrorAnnotations, Tldraw, TLEditorComponents, useEditor } from 'tldraw' import 'tldraw/tldraw.css' import './custom-error-capture.css' // There's a guide at the bottom of this file! // [1] const components: TLEditorComponents = { // [2] ErrorFallback: ({ error }) => { // [3] // eslint-disable-next-line local/no-at-internal const annotations = error instanceof Error ? getErrorAnnotations(error) : null return (

Something went wrong

{error instanceof Error ? error.message : String(error)}
{/* [4] */} {annotations && (
{JSON.stringify(annotations, null, 2)}
)}
) }, // [5] InFrontOfTheCanvas: () => { const editor = useEditor() return ( ) }, } export default function CustomErrorCaptureExample() { return (
{/* [7] */}
) } /* [1] Define your component overrides outside of the React component so that they're static. If you must define them inside of the component, be sure to use a `useMemo` hook to prevent them from being re-created on every component update. [2] The ErrorFallback component is displayed when a critical error occurs in the editor that would otherwise crash the entire application. This is different from ShapeErrorFallback, which handles errors in individual shapes only. The ErrorFallback component receives an error prop that contains information about what went wrong. You can customize this component to match your app's design and provide helpful recovery options to your users. [3] Use getErrorAnnotations to retrieve additional debugging information that tldraw attaches to errors. These annotations include tags (key-value pairs for categorization) and extras (additional context data). This is particularly useful for error reporting services like Sentry. [4] Display the annotations in a scrollable pre element so developers can see the additional context. In production, you might want to send these annotations to your error tracking service (like Sentry) instead of displaying them to users. [5] For this example, we've added a button using the InFrontOfTheCanvas component that intentionally triggers an error so you can see the custom ErrorFallback in action. In a real application, you wouldn't need this - the ErrorFallback would only appear when genuine errors occur. [6] We trigger an error by attempting to create a shape with an invalid type. This simulates what would happen if there was a critical error in the editor. [7] Pass your custom components to the Tldraw component via the components prop. */ ``` ## custom-error-capture.css ```css .custom-error__button { position: absolute; top: 100px; left: 100px; pointer-events: all; } .custom-error { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; background: #f5f5f5; } .custom-error__annotations { margin: 8px 0; padding: 8px; background: white; border: 1px solid #ccc; border-radius: 4px; font-size: 12px; max-width: 600px; max-height: 300px; overflow-y: auto; text-align: left; } ``` -------- # Custom indicators Category: UI & theming Keywords: indicators Change when indicators are shown and how they appear. This example shows how you can change when indicators are shown and how they appear. ## App.tsx ```tsx import { TLComponents, Tldraw, useEditor, useEditorComponents, useValue } from 'tldraw' import 'tldraw/tldraw.css' const components: TLComponents = { OnTheCanvas: () => { const editor = useEditor() // [1] const renderingShapes = useValue( 'rendering shapes', () => editor.getRenderingShapes().filter((_info) => true), [editor] ) // [2] const { ShapeIndicator } = useEditorComponents() if (!ShapeIndicator) return null return (
{renderingShapes.map(({ id }) => ( ))}
) }, // [3] // ShapeIndicators: () => { // return // }, } export default function IndicatorsLogicExample() { return (
{ if (editor.getCurrentPageShapeIds().size === 0) { editor.createShapes([ { type: 'geo', x: 100, y: 100, }, { type: 'geo', x: 500, y: 150, }, { type: 'geo', x: 100, y: 500, }, { type: 'geo', x: 500, y: 500, }, ]) } }} />
) } /* [1] Get which indicators to show (based on the shapes currently on screen). You could include logic here using the filter to narrow down which shapes you want to show the indicators for. [2] You could override the default ShapeIndicator component in this same TLComponents object, but the default (DefaultIndicator.tsx) has a lot of logic for where and how to display the indicator. [3] If all you want to do is show or hide all the indicators, you could create an override for the ShapeIndicators component that returns the DefaultShapeIndicators component with `hideAll` or `showAll` props set to true. */ ``` -------- # Custom keyboard shortcuts Category: UI & theming Keywords: dialog, overrides, actions, tools Replace tldraw's default keyboard shortcuts with your own. This example shows how you can replace tldraw's default keyboard shortcuts with your own, or add a shortcut for an action that doesn't have one. An example of how to add shortcuts for custom tools can be found in the custom-config example. - Toggle show grid by pressing 'x' - Select the Draw tool by pressing 'p' - Copy as png by pressing 'ctrl/cmd + 1' ## App.tsx ```tsx import { TLUiActionsContextType, TLUiOverrides, TLUiToolsContextType, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! // [1] const overrides: TLUiOverrides = { //[a] actions(_editor, actions): TLUiActionsContextType { const newActions = { ...actions, 'toggle-grid': { ...actions['toggle-grid'], kbd: 'x' }, 'copy-as-png': { ...actions['copy-as-png'], kbd: 'cmd+1,ctrl+1' }, } return newActions }, //[b] tools(_editor, tools): TLUiToolsContextType { const newTools = { ...tools, draw: { ...tools.draw, kbd: 'p' } } return newTools }, } // [2] export default function KeyboardShortcuts() { return (
) } /* This example shows how you can replace tldraw's default keyboard shortcuts with your own, or add a shortcut for an action that doesn't have one. An example of how to add shortcuts for custom tools can be found in the custom-config example. In this case we are overriding the toggle grid and draw tool shortcuts, and creating a shortcut for copy-as-png. An override of an existing shortcut will automatically update the keyboard shortcuts dialog. However, adding a new shortcut won't, we'll need to add it ourselves. You can describe modifier keys with the following syntax: shift: ! ctrl/cmd: $ alt: ? [1] We start by defining our overrides, this is an object with functions that we can use to change the ui. Keyboard shortcuts are associated with either tools (draw, eraser, etc) or actions (group, undo/redo etc). We're going to override two actions [a], one tool [b], and add a new shortcut to the keyboard shortcuts dialog [c]. [a] actions There are two actions we're modifying here, copy-as-png and toggle-grid. copy-as-png doesn't have an existing shortcut, but we can still add the property and later add it to the keyboard shortcuts dialog [c]. [b] tools We're overriding the draw tool's shortcut to 'p', maybe we want to rename it to the pen tool or something. [2] Finally, we pass our overrides object into the Tldraw component's overrides prop. Now when the component mounts, our overrides will be applied. If you open the keyboard shortcuts dialog, you'll see the changes we made. */ ``` -------- # Selection color condition Category: UI & theming Keywords: selection, color, condition, rectangle, geo shapes Change the selection color based on the types of shapes selected. This example shows how to change the selection color when the selection contains only rectangle geo shapes. The selection will appear red when all selected shapes are rectangles, and use the default blue color otherwise. The example uses the `react` function to listen for selection changes and updates the container's CSS class accordingly. ## App.tsx ```tsx import { Tldraw, react } from 'tldraw' import 'tldraw/tldraw.css' // There's a guide at the bottom of this file! // [1] export default function SelectionColorConditionExample() { return (
{ // [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() } }} /> ) : (
{shapeName}
)}
)} {!!children?.length && ( )} ) } export function ShapeList({ shapeIds, depth, parentIsSelected, parentIsHidden, }: { shapeIds: TLShapeId[] depth: number parentIsSelected?: boolean parentIsHidden?: boolean }) { if (!shapeIds.length) return null return (
{shapeIds.map((shapeId) => ( ))}
) } 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 (
editor.duplicateShapes(editor.getSelectedShapeIds())} > {/* [2] */} {msg('action.duplicate')} editor.deleteShapes(editor.getSelectedShapeIds())} > {msg('action.delete')}
) } // [3] const overrides: TLUiOverrides = { translations: { en: { 'action.duplicate': 'Make a copy', 'action.delete': 'Remove', }, es: { 'action.duplicate': 'Hacer una copia', 'action.delete': 'Eliminar', }, }, } // [4] const components: TLComponents = { TopPanel: CustomToolbar, } export default function CustomLanguageTranslationExample() { return (
{/* [5] */}
) } /* 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) }} >
) } function InlineBlock({ persistenceKey }: { persistenceKey: string }) { const { focusedEditor, setFocusedEditor } = useContext(focusedEditorContext) const [editor, setEditor] = useState() return (
{ if (!editor) return if (focusedEditor && focusedEditor !== editor) { blurEditor(focusedEditor) } editor.focus({ focusContainer: false }) setFocusedEditor(editor) }} onPointerDown={(e) => e.stopPropagation()} > { setEditor(editor) editor.setCurrentTool('hand') }} />
) } /* 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}