Documentation Index
Fetch the complete documentation index at: https://mintlify.com/tldraw/tldraw/llms.txt
Use this file to discover all available pages before exploring further.
Customize tldraw’s UI by replacing components, adding custom menus, or building entirely custom interfaces.
Overview
The tldraw UI is built with React components that you can:
- Replace with custom implementations
- Hide selectively
- Build from scratch using editor primitives
Hiding the default UI
The simplest customization is hiding the built-in UI:
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function MyApp() {
return (
<Tldraw hideUi />
)
}
The hideUi prop hides the toolbar, style panel, and page menu. The context menu remains visible by default.
Building a custom UI
Create your own interface using the useEditor hook:
Hide default UI and add custom component
import { Tldraw } from 'tldraw'
import { CustomUI } from './CustomUI'
export default function MyApp() {
return (
<Tldraw hideUi>
<CustomUI />
</Tldraw>
)
}
Access the editor with useEditor
import { useEditor, track } from 'tldraw'
import { useEffect } from 'react'
export const CustomUI = track(() => {
const editor = useEditor()
// Set up keyboard shortcuts
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 'd':
editor.setCurrentTool('draw')
break
}
}
window.addEventListener('keyup', handleKeyUp)
return () => window.removeEventListener('keyup', handleKeyUp)
}, [editor])
return (
<div className="custom-toolbar">
<button
data-active={editor.getCurrentToolId() === 'select'}
onClick={() => editor.setCurrentTool('select')}
>
Select
</button>
<button
data-active={editor.getCurrentToolId() === 'draw'}
onClick={() => editor.setCurrentTool('draw')}
>
Draw
</button>
<button
data-active={editor.getCurrentToolId() === 'eraser'}
onClick={() => editor.setCurrentTool('eraser')}
>
Eraser
</button>
</div>
)
})
Wrap your custom UI component with track() to make it reactive. Without this, the UI won’t update when editor state changes.
Replacing specific components
Replace individual UI components while keeping the rest:
import { Tldraw, TLEditorComponents, useEditor } from 'tldraw'
function CustomToolbar() {
const editor = useEditor()
return (
<div style={{ padding: 20, pointerEvents: 'all' }}>
<button onClick={() => editor.setCurrentTool('select')}>Select</button>
<button onClick={() => editor.setCurrentTool('draw')}>Draw</button>
<button onClick={() => editor.deleteShapes(editor.getSelectedShapeIds())}>
Delete
</button>
</div>
)
}
const components: Partial<TLEditorComponents> = {
Toolbar: CustomToolbar,
}
export default function MyApp() {
return <Tldraw components={components} />
}
Available component slots
You can replace these components:
UI Components
Canvas Components
import { TLUiComponents } from 'tldraw'
const components: Partial<TLUiComponents> = {
Toolbar: CustomToolbar,
StylePanel: CustomStylePanel,
PageMenu: CustomPageMenu,
NavigationPanel: CustomNavigationPanel,
QuickActions: CustomQuickActions,
HelperButtons: CustomHelperButtons,
DebugPanel: CustomDebugPanel,
DebugMenu: CustomDebugMenu,
MenuPanel: CustomMenuPanel,
TopPanel: CustomTopPanel,
SharePanel: CustomSharePanel,
ContextMenu: CustomContextMenu,
}
import { TLEditorComponents } from 'tldraw'
const components: Partial<TLEditorComponents> = {
Background: CustomBackground,
Brush: CustomBrush,
Scribble: CustomScribble,
SnapIndicator: CustomSnapIndicator,
Handles: CustomHandles,
CollaboratorHint: CustomCollaboratorHint,
Grid: CustomGrid,
}
Custom canvas components
Customize the canvas rendering:
import { TLEditorComponents, toDomPrecision, useTransform } from 'tldraw'
import { useRef } from 'react'
const components: TLEditorComponents = {
Brush: function CustomBrush({ brush }) {
const rSvg = useRef<SVGSVGElement>(null)
useTransform(rSvg, brush.x, brush.y)
const w = toDomPrecision(Math.max(1, brush.w))
const h = toDomPrecision(Math.max(1, brush.h))
return (
<svg ref={rSvg} className="tl-overlays__item">
<rect className="tl-brush" stroke="red" fill="none" width={w} height={h} />
</svg>
)
},
Scribble: ({ scribble, opacity, color }) => {
return (
<svg className="tl-overlays__item">
<polyline
points={scribble.points.map((p) => `${p.x},${p.y}`).join(' ')}
stroke={color ?? 'blue'}
opacity={opacity ?? '1'}
fill="none"
/>
</svg>
)
},
SnapIndicator: null, // Hide snap indicators
}
export default function MyApp() {
return <Tldraw components={components} />
}
Add buttons to existing UI areas:
import { Tldraw, TLUiComponents, useEditor } from 'tldraw'
function ExportButton() {
const editor = useEditor()
return (
<button
style={{ pointerEvents: 'all', fontSize: 18 }}
onClick={async () => {
const shapeIds = editor.getCurrentPageShapeIds()
if (shapeIds.size === 0) return alert('No shapes on canvas')
const { blob } = await editor.toImage([...shapeIds], {
format: 'png',
background: false
})
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = 'canvas-export.png'
link.click()
URL.revokeObjectURL(link.href)
}}
>
Export
</button>
)
}
const components: TLUiComponents = {
SharePanel: ExportButton,
}
export default function MyApp() {
return <Tldraw components={components} />
}
Custom menus and dialogs
Use tldraw’s menu primitives:
import {
Tldraw,
TLUiComponents,
TldrawUiMenuItem,
TldrawUiMenuGroup,
useEditor
} from 'tldraw'
function CustomMenu() {
const editor = useEditor()
return (
<TldrawUiMenuGroup id="custom-menu">
<TldrawUiMenuItem
id="export-png"
label="Export as PNG"
icon="external-link"
onSelect={() => {
// Export logic
}}
/>
<TldrawUiMenuItem
id="clear-canvas"
label="Clear canvas"
icon="trash"
onSelect={() => {
editor.deleteShapes(editor.getCurrentPageShapeIds())
}}
/>
</TldrawUiMenuGroup>
)
}
Overriding styles
Customize tldraw’s appearance with CSS:
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import './custom-tldraw-styles.css'
export default function MyApp() {
return <Tldraw />
}
/* Customize toolbar */
.tlui-toolbar {
background: #1e1e1e;
border-radius: 12px;
}
/* Customize buttons */
.tlui-button {
color: #ffffff;
}
.tlui-button[data-state='selected'] {
background: #0066ff;
}
/* Customize canvas */
.tl-canvas {
background: #f5f5f5;
}
/* Hide specific elements */
.tlui-style-panel {
display: none;
}
Keyboard shortcuts
Add custom keyboard shortcuts:
import { useEditor } from 'tldraw'
import { useEffect } from 'react'
export function KeyboardShortcuts() {
const editor = useEditor()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl/Cmd + E for export
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
e.preventDefault()
exportCanvas(editor)
}
// Ctrl/Cmd + Shift + D for duplicate
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'd') {
e.preventDefault()
editor.duplicateShapes(editor.getSelectedShapeIds())
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [editor])
return null
}
Modify the right-click context menu:
import {
Tldraw,
TLUiOverrides,
menuItem,
useEditor
} from 'tldraw'
const uiOverrides: TLUiOverrides = {
contextMenu(editor, schema, { isMobile }) {
// Add custom menu item
schema.push(
menuItem({
id: 'custom-action',
label: 'Custom action',
icon: 'check',
onSelect: () => {
console.log('Custom action triggered')
},
})
)
return schema
},
}
export default function MyApp() {
return <Tldraw overrides={uiOverrides} />
}
Dark mode toggle
Add a theme switcher:
import { Tldraw, useEditor } from 'tldraw'
import { track } from '@tldraw/state'
const DarkModeToggle = track(() => {
const editor = useEditor()
const isDarkMode = editor.user.getIsDarkMode()
return (
<button
style={{ pointerEvents: 'all' }}
onClick={() => {
editor.user.updateUserPreferences({
isDarkMode: !isDarkMode
})
}}
>
{isDarkMode ? '☀️ Light' : '🌙 Dark'}
</button>
)
})
const components = {
SharePanel: DarkModeToggle,
}
export default function MyApp() {
return <Tldraw components={components} />
}
Responsive UI
Adapt UI based on screen size:
import { Tldraw, useEditor, track } from 'tldraw'
import { useEffect, useState } from 'react'
const ResponsiveUI = track(() => {
const editor = useEditor()
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
return (
<div className={isMobile ? 'mobile-toolbar' : 'desktop-toolbar'}>
{isMobile ? (
<select
value={editor.getCurrentToolId()}
onChange={(e) => editor.setCurrentTool(e.target.value)}
>
<option value="select">Select</option>
<option value="draw">Draw</option>
<option value="eraser">Eraser</option>
</select>
) : (
<>
<button onClick={() => editor.setCurrentTool('select')}>Select</button>
<button onClick={() => editor.setCurrentTool('draw')}>Draw</button>
<button onClick={() => editor.setCurrentTool('eraser')}>Eraser</button>
</>
)}
</div>
)
})
Best practices
Use track() for reactive components
Always wrap custom UI components with track() to ensure they re-render when editor state changes:const MyComponent = track(() => {
const editor = useEditor()
return <div>{editor.getCurrentToolId()}</div>
})
UI elements need pointerEvents: 'all' to be interactive:<div style={{ pointerEvents: 'all' }}>
<button>Click me</button>
</div>
Always remove event listeners in cleanup functions:useEffect(() => {
const handler = () => {}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
Next steps
Custom tools
Add custom tools to your UI
Events and side effects
React to user interactions
Persistence
Save and restore UI state