Toasts and dialogs
Show toasts and dialogs from your own UI with the useToasts and useDialogs hooks.
import { Select as _Select } from 'radix-ui'
import { useState } from 'react'
import {
TLComponents,
Tldraw,
TldrawUiButton,
TldrawUiButtonLabel,
TldrawUiDialogBody,
TldrawUiDialogCloseButton,
TldrawUiDialogFooter,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
TLUiDialogProps,
useContainer,
useDialogs,
useToasts,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file
// [1]
function MyDialog({ onClose }: TLUiDialogProps) {
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Title</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 350 }}>
<p>
This dialog body holds a few sentences of text so you can see how longer content behaves.
Regular prose wraps onto multiple lines within the dialog width.
</p>
<p>
Long unbroken strings, like
https://example.com/a/really/long/url/that/cannot/wrap/onto/the/next/line, also break
instead of overflowing or being clipped.
</p>
</TldrawUiDialogBody>
<TldrawUiDialogFooter className="tlui-dialog__footer__actions">
<TldrawUiButton type="normal" onClick={onClose}>
<TldrawUiButtonLabel>Cancel</TldrawUiButtonLabel>
</TldrawUiButton>
<TldrawUiButton type="primary" onClick={onClose}>
<TldrawUiButtonLabel>Continue</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}
// [2]
function MySimpleDialog({ onClose }: TLUiDialogProps) {
return (
<div style={{ padding: 16 }}>
<h2>Title</h2>
<p>Description...</p>
<button onClick={onClose}>Okay</button>
</div>
)
}
// [3]
function MyDialogWithSelect({ onClose }: TLUiDialogProps) {
const container = useContainer()
const [value, setValue] = useState('a')
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Dialog with a select</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 350 }}>
<p>A select opened inside a modal is its own dismissable layer.</p>
<_Select.Root value={value} onValueChange={setValue}>
<_Select.Trigger
data-testid="dialog-select.trigger"
style={{ display: 'flex', alignItems: 'center', gap: 8 }}
>
<_Select.Value />
<_Select.Icon>▾</_Select.Icon>
</_Select.Trigger>
<_Select.Portal container={container}>
<_Select.Content
data-testid="dialog-select.content"
position="popper"
sideOffset={4}
style={{
backgroundColor: 'var(--tl-color-panel)',
boxShadow: 'var(--tl-shadow-3)',
borderRadius: 'var(--tl-radius-2)',
padding: 4,
zIndex: 'var(--tl-layer-canvas-overlays)',
}}
>
<_Select.Viewport>
{['a', 'b', 'c'].map((v) => (
<_Select.Item
key={v}
value={v}
data-testid={`dialog-select.item-${v}`}
style={{ padding: '4px 8px', cursor: 'pointer' }}
>
<_Select.ItemText>Option {v}</_Select.ItemText>
</_Select.Item>
))}
</_Select.Viewport>
</_Select.Content>
</_Select.Portal>
</_Select.Root>
</TldrawUiDialogBody>
<TldrawUiDialogFooter className="tlui-dialog__footer__actions">
<TldrawUiButton type="primary" onClick={onClose}>
<TldrawUiButtonLabel>Done</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}
// [4]
function MyNestedDialog({ onClose }: TLUiDialogProps) {
const { addDialog } = useDialogs()
return (
<div data-testid="dialog-parent" style={{ padding: 16 }}>
<h2>Parent dialog</h2>
<p>Opens another dialog on top of itself.</p>
<button
data-testid="dialog-parent.open-nested"
onClick={() => addDialog({ component: MyConfirmDialog })}
>
Open nested dialog
</button>
<button onClick={onClose}>Close</button>
</div>
)
}
function MyConfirmDialog({ onClose }: TLUiDialogProps) {
return (
<div data-testid="dialog-nested" style={{ padding: 16 }}>
<h2>Nested dialog</h2>
<button data-testid="dialog-nested.confirm" onClick={onClose}>
Confirm
</button>
</div>
)
}
// [5]
function CustomSharePanel() {
const { addToast } = useToasts()
const { addDialog } = useDialogs()
return (
<div style={{ padding: 16, gap: 16, display: 'flex', pointerEvents: 'all' }}>
<button
onClick={() => {
addToast({ title: 'Hello world!', severity: 'success' })
}}
>
Show toast
</button>
<button
onClick={() => {
addToast({
title: 'This is a very long toast title that keeps going and going',
description:
'Long descriptions and unbroken strings like https://example.com/a/really/long/url/that/cannot/wrap/onto/the/next/line wrap within the toast instead of overflowing.',
severity: 'info',
})
}}
>
Show long toast
</button>
<button
data-testid="show-dialog"
onClick={() => {
addDialog({
component: MyDialog,
onClose: () => addToast({ title: 'Dialog closed', severity: 'info' }),
})
}}
>
Show dialog
</button>
<button
onClick={() => {
addDialog({ component: MySimpleDialog })
}}
>
Show simple dialog
</button>
<button
data-testid="show-dialog-with-select"
onClick={() => {
addDialog({ component: MyDialogWithSelect })
}}
>
Show dialog with select
</button>
</div>
)
}
// [6]
function StackedDialogLauncher() {
const { addDialog } = useDialogs()
return (
<button
data-testid="show-nested-dialog"
style={{
position: 'absolute',
top: '50%',
left: 8,
transform: 'translateY(-50%)',
pointerEvents: 'all',
}}
onClick={() => addDialog({ component: MyNestedDialog })}
>
Show nested dialog
</button>
)
}
const components: TLComponents = {
SharePanel: CustomSharePanel,
InFrontOfTheCanvas: StackedDialogLauncher,
}
export default function ToastsDialogsExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} persistenceKey="toasts-and-dialogs-example" />
</div>
)
}
/*
The `useToasts` and `useDialogs` hooks return functions to add, remove, and
clear toasts and dialogs from anywhere inside `<Tldraw />`.
[1]
A dialog built from tldraw's dialog primitives (`TldrawUiDialogHeader`,
`TldrawUiDialogBody`, `TldrawUiDialogFooter`, and so on), so it matches the rest
of the UI. `onClose` is passed to your component by the dialog system; call it
from your own buttons to close the dialog. The `onClose` you pass to `addDialog`
is separate: it runs after the dialog closes, however it was dismissed (here we
show a toast from it).
[2]
...or render anything you like. The dialog system only supplies the modal
wrapper and the `onClose` callback.
[3]
Dialogs can contain their own popups, like this Radix select. Because tldraw's
dialog is a Radix dismissable layer, clicking outside an open select closes just
the select and leaves the dialog open; a second outside click closes the dialog.
Portal the select into `useContainer()` so it inherits tldraw's CSS variables.
[4]
Dialogs stack. A dialog can call `addDialog` to open another on top of itself,
and each one, including the topmost, keeps its own controls interactive.
[5]
The launcher buttons live in the `SharePanel` slot, top right of the UI.
[6]
The stacked-dialog launcher is rendered in `InFrontOfTheCanvas` instead, because
the share panel overflows off-screen on narrow mobile layouts and the e2e test
for stacked dialogs needs to reach it with a tap.
*/
useToasts() returns addToast, removeToast, and clearToasts; useDialogs() returns addDialog, removeDialog, and clearDialogs. Both hooks work in any component rendered inside <Tldraw />. This example puts a row of launcher buttons in the SharePanel slot.
addDialog takes a component that receives an onClose prop. Build the content from tldraw's dialog primitives (TldrawUiDialogHeader, TldrawUiDialogBody, TldrawUiDialogFooter) to match the default UI, or render anything you want. The example also shows a dialog containing a select menu, and a dialog that opens a second one on top of itself. Try opening the select and clicking outside it: the select closes but the dialog stays.