Excalidraw

Notebook, Freeform, Drawing type of component.

Full documentation: https://excalidraw.2plot.dev — the dedicated dash-excalidraw documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.

Features The Excalidraw editor (pip dash package) supports:

Installation

Visit GitHub Repo

pip install dash-excalidraw

What's New in 0.1.0

Version 0.1.0 is a full rebuild on Excalidraw 0.18.x with a JSON-safe prop surface — every prop round-trips cleanly through Dash callbacks. There are no function props, no React children, no RegExp values: just JSON.

Props now fall into three groups:

KindDirectionProps
DeclarativePython → canvastheme, viewModeEnabled, zenModeEnabled, gridModeEnabled, isCollaborating, UIOptions, validateEmbeddable, interceptLinkOpens, hideExcalidrawLinks, langCode, name
Event snapshots (read-only)canvas → Pythonelements, appState, files, serializedData, externalizedSerializedData, sceneVersion, lastPointerDown, lastPointerUp, lastPointerMove, lastScrollChange, lastPaste, lastLibraryChange, lastLinkOpen, lastExport, lastFileAdded, lastExternalDrop
Command dispatchPython → canvas (imperative)command — write {id, type, payload} from a callback

Migrating from 0.0.x

Old (0.0.x)New (0.1.0)
onPointerUpdate, onPointerDown, onScrollChange, onPaste, onLibraryChange, onLinkOpenlastPointerMove, lastPointerDown, lastScrollChange, lastPaste, lastLibraryChange, lastLinkOpen
excalidrawAPI callbackcommand prop + lastExport event
renderTopRightUI, renderCustomStats, renderEmbeddable, generateIdForFileRemoved — function props cannot round-trip through JSON
isCollaborating defaulted TrueDefaults False — opt in explicitly
gridModeEnabled defaulted TrueDefaults False
height defaulted "400px"Defaults "600px"
appState output: {gridSize, viewBackgroundColor} onlyFull serializable appState
validateEmbeddable: RegExp[]validateEmbeddable: list[str] domain globs (compiled to RegExps internally)

Introduction

# File: docs/dash_excalidraw/introduction.py

import dash_excalidraw
from dash import Dash, html, dcc, callback, Input, Output, State
import json
import dash_mantine_components as dmc
from dash_ace import DashAceEditor

# _dash_renderer._set_react_version("18.2.0")

class CustomJSONDecoder(json.JSONDecoder):
    def decode(self, s):
        result = super().decode(s)
        return self._decode(result)

    def _decode(self, o):
        if isinstance(o, bool):
            return o
        if isinstance(o, dict):
            return {k: self._decode(v) for k, v in o.items()}
        if isinstance(o, list):
            return [self._decode(v) for v in o]
        if o == "true":
            return True
        if o == "false":
            return False
        if o == "null":
            return None
        return o


def custom_pprint(obj, indent=2):
    def format_value(v):
        if isinstance(v, (dict, list)):
            return custom_pprint(v, indent)
        elif v is True:
            return 'True'
        elif v is False:
            return 'False'
        elif v is None:
            return 'None'
        else:
            return repr(v)

    if isinstance(obj, dict):
        items = [f"{' ' * indent}{repr(k)}: {format_value(v)}" for k, v in obj.items()]
        return "{\n" + ",\n".join(items) + "\n}"
    elif isinstance(obj, list):
        items = [f"{' ' * indent}{format_value(v)}" for v in obj]
        return "[\n" + ",\n".join(items) + "\n]"
    else:
        return repr(obj)


initialCanvasData = {}

component = html.Div([
dmc.Tabs(
    [
        dmc.TabsList(
            [
                dmc.TabsTab(
                    "Dash Excalidraw",
                    # leftSection=DashIconify(icon="tabler:message"),
                    value="dashecalidraw-component",
                    style={'font-size': '1.5rem', 'color': 'light-dark(rgb(28, 126, 214), rgb(116, 192, 252))'}
                ),
                dmc.TabsTab(
                    "DashExcalidraw .json Output",
                    # leftSection=DashIconify(icon="tabler:settings"),
                    value="canvas-output",
                    style={'font-size': '1.5rem', 'color': 'light-dark(rgb(28, 126, 214), rgb(116, 192, 252))'}
                ),
            ]
        ),
        dmc.TabsPanel(dash_excalidraw.DashExcalidraw(
        id='excalidraw',
        width='100%',
        height='65vh',
        initialData=initialCanvasData,
        # validateEmbeddable=False,
        # isCollaborating=False,
    ), value="dashecalidraw-component"),
        dmc.TabsPanel(html.Div([
            html.Div(id='number-of-elements'),
            html.Div(id='output')
        ]), value="canvas-output"),
    ],
    value="dashecalidraw-component",
),
    # dcc.Interval(id='interval', interval=1000)
]
)


@callback(
    Output('output', 'children'),
    Output('number-of-elements', 'children'),
    Input('excalidraw', 'serializedData'),
)
def display_output(serializedData):
    if not serializedData:
        return 'No elements drawn yet', 'Number of elements: 0'

    # Parse the serialized data with custom decoder
    data = json.loads(serializedData, cls=CustomJSONDecoder)

    # Count the number of elements
    num_elements = len(data.get('elements', []))

    # Use custom pretty-print function
    output = custom_pprint(data, indent=2)

    # Add a key to force re-rendering
    return DashAceEditor(
        id='dash-ace-editor',
        value=f'{output}',
        theme='monokai',
        mode='python',
        tabSize=2,
        enableBasicAutocompletion=True,
        enableLiveAutocompletion=True,
        autocompleter='/autocompleter?prefix=',
        placeholder='Python code ...',
        style={'height': '500px', 'width': '80vw'}
    ),  html.Label(f"Number of elements: {num_elements}")



Simple Example

# File: docs/dash_excalidraw/simple_example.py

from dash_excalidraw import DashExcalidraw
from dash import Dash, html, dcc, callback, Input, Output, State

initialCanvasData = {}

component = html.Div([
        DashExcalidraw(
        id='excalidraw-simple',
        width='100%',
        height='80vh',
        initialData=initialCanvasData,
    )
    ])

Commands and the Export Round-Trip

Anything that used to require the removed excalidrawAPI callback is now an imperative command. Write a dict to the command prop from a Python callback:

@callback(Output("board", "command"), Input("update-btn", "n_clicks"),
          prevent_initial_call=True)
def send_command(_):
    return {
        "id": f"cmd-{uuid.uuid4()}",   # MUST be unique — drives de-duplication
        "type": "updateScene",          # see supported types below
        "payload": {...},               # shape depends on type
    }

Supported type values:

Scene mutationAsync export (round-trips via lastExport)Other
updateSceneexportToSvgsetActiveTool
resetSceneexportToBlobsetToast
addFilesexportToCanvastoggleSidebar
replaceFilesupdateLibrary
scrollToContent

Each dispatch is de-duplicated by id, and the component clears the prop once the action completes so React re-renders do not re-fire it.

Exports are async: dispatch the export command in one callback, then observe the result on the lastExport prop ({timestamp, id, type, result, error?}) in a separate callback. Always match on id or typelastExport holds the result of some export, not necessarily your latest command.

Draw something below, then click Export to SVG:

# File: docs/dash_excalidraw/export_roundtrip.py

"""Export round-trip example for dash-excalidraw 0.1.0.

Demonstrates the imperative `command` prop and the `lastExport` event:
a Python callback dispatches an `exportToSvg` (or `setActiveTool`)
command, and a second callback observes the async result on `lastExport`.
"""
import uuid

import dash_mantine_components as dmc
from dash import Input, Output, callback, ctx, html, no_update
from dash_excalidraw import DashExcalidraw

component = html.Div(
    [
        DashExcalidraw(
            id="excalidraw-export-canvas",
            width="100%",
            height="50vh",
            initialData={
                "elements": [],
                "appState": {},
                "scrollToContent": True,
            },
        ),
        dmc.Group(
            [
                dmc.Button(
                    "Export to SVG",
                    id="excalidraw-export-svg-btn",
                    color="blue",
                ),
                dmc.Button(
                    "Select Rectangle Tool",
                    id="excalidraw-export-tool-btn",
                    variant="light",
                    color="teal",
                ),
            ],
            mt="sm",
            mb="sm",
        ),
        html.Div(id="excalidraw-export-preview"),
    ]
)


@callback(
    Output("excalidraw-export-canvas", "command"),
    Input("excalidraw-export-svg-btn", "n_clicks"),
    Input("excalidraw-export-tool-btn", "n_clicks"),
    prevent_initial_call=True,
)
def dispatch_command(_svg_clicks, _tool_clicks):
    """Write a {id, type, payload} dict to `command` to act on the canvas.

    The `id` must be unique per dispatch — the component de-duplicates
    on it, then clears the prop once the action completes.
    """
    if ctx.triggered_id == "excalidraw-export-tool-btn":
        return {
            "id": f"tool-{uuid.uuid4()}",
            "type": "setActiveTool",
            "payload": {"type": "rectangle"},
        }
    return {
        "id": f"export-svg-{uuid.uuid4()}",
        "type": "exportToSvg",
        "payload": {"exportPadding": 20},
    }


@callback(
    Output("excalidraw-export-preview", "children"),
    Input("excalidraw-export-canvas", "lastExport"),
    prevent_initial_call=True,
)
def render_export(result):
    """Exports are async — observe them in a separate callback.

    Always match on `id`/`type`: `lastExport` is the result of *some*
    export, not necessarily the latest command you dispatched.
    """
    if not result or result.get("type") != "exportToSvg":
        return no_update
    if result.get("error"):
        return dmc.Alert(str(result["error"]), color="red", title="Export failed")
    return dmc.Paper(
        [
            dmc.Text("SVG export result:", size="sm", c="dimmed", mb="xs"),
            html.Iframe(
                srcDoc=result.get("result", ""),
                style={
                    "width": "100%",
                    "height": "300px",
                    "border": "none",
                    "background": "white",
                },
            ),
        ],
        withBorder=True,
        p="sm",
    )

Initial Data

initialData seeds the scene on mount with {elements, appState, files, libraryItems, scrollToContent}. It is mount-only — updating the prop after render does nothing. To change the scene after mount, dispatch a command with type="updateScene":

@callback(
    Output("canvas", "command"),
    Input("restore-btn", "n_clicks"),
    State("store", "data"),
    prevent_initial_call=True,
)
def restore(_, snapshot):
    parsed = json.loads(snapshot)
    return {
        "id": f"restore-{uuid.uuid4()}",
        "type": "updateScene",
        "payload": {
            "elements": parsed.get("elements", []),
            "appState": parsed.get("appState", {}),
        },
    }

For persistence, stream serializedData into a dcc.Store — but guard against Excalidraw's mount-time empty scene (skip envelopes with no elements) or you'll clobber a valid snapshot on every page refresh. When the scene contains images, prefer persisting externalizedSerializedData, which strips inline base64 data: URIs.

Theme and View Modes

theme ("light" / "dark"), viewModeEnabled, zenModeEnabled, and gridModeEnabled are plain declarative props — set them from any callback and the canvas reflects the change:

# Follow your app's color scheme
clientside_callback(
    "function(scheme) { return scheme || 'light'; }",
    Output("canvas", "theme"),
    Input("color-scheme-store", "data"),
)

Component Properties

PropertyTypeDefaultDescription
idstring-Unique ID to identify this component in Dash callbacks
widthstring'100%'CSS width of the canvas container
heightstring'600px'CSS height of the canvas container (Excalidraw fills its parent)
initialDatadict-Initial scene on mount: {elements, appState, files, libraryItems, scrollToContent}. Mount-only — use command: updateScene afterwards
commanddict-Imperative dispatch: {id, type, payload}. De-duplicated by unique id; cleared after the action completes
theme'light' / 'dark''light'Canvas color theme
viewModeEnabledbooleanFalseView-only mode: disables drawing tools; pan/zoom still available
zenModeEnabledbooleanFalseZen mode hides most of the chrome
gridModeEnabledbooleanFalseSnap to grid and draw the grid background
isCollaboratingbooleanFalseRenders the collaborator UI; feed appState.collaborators yourself (no transport bundled)
UIOptionsdict-JSON-serializable subset of Excalidraw UIOptions: canvasActions, tools.image, welcomeScreen, dockedSidebarBreakpoint
validateEmbeddableboolean or list of strings-True allow all, False deny all, or domain globs (e.g. ["*.youtube.com"]) compiled to RegExps internally
interceptLinkOpensbooleanFalsePrevent default on link opens so Python can handle lastLinkOpen itself
hideExcalidrawLinksbooleanTrueHides Excalidraw's built-in GitHub/Discord/Twitter menu group
langCodestring'en'UI language code (e.g. en, fr-FR, zh-CN)
namestring-Drawing name — appears in the top bar and export filenames
autoFocusbooleanTrueFocus the canvas on mount
detectScrollbooleanTrueWhether Excalidraw listens to wheel-scroll events on the canvas
handleKeyboardGloballybooleanTrueKeyboard shortcuts work even when the canvas is not focused
libraryReturnUrlstring-Optional URL appended to the "Browse Library" button
pointerMoveThrottleMsnumber50Debounce interval for lastPointerMove writes (ms)
scrollThrottleMsnumber100Debounce interval for lastScrollChange writes (ms)
elementslist of dicts-Current element array (read-only from Python)
appStatedict-Full serializable app state (read-only from Python)
filesdict-Binary file entries: image id → {dataURL, mimeType, ...} (read-only)
serializedDatastring-JSON string of the canonical Excalidraw envelope {type, version, source, elements, appState, files}
externalizedSerializedDatastring-Same envelope with inline data: URIs stripped to null — persist this to avoid base64 bloat
sceneVersionnumber-Monotonic scene version — cheap change detection without diffing elements
lastExportdict-Result of the most recent export command: {timestamp, id, type, result, error?}
lastPointerDowndict-{timestamp, activeTool, pointer: {x, y}}
lastPointerUpdict-{timestamp, activeTool, pointer: {x, y}}
lastPointerMovedict-Throttled {timestamp, pointer, button, pointersMap}
lastScrollChangedict-Throttled {timestamp, scrollX, scrollY}
lastPastedict-{timestamp, data} snapshot of the last clipboard paste
lastLibraryChangedict-{timestamp, items} snapshot of the last library change
lastLinkOpendict-{timestamp, elementId, url} — fired on Cmd/Ctrl-click of a linked element
lastFileAddeddict-Fires when new files appear with inline data: dataURLs; batch under files, first file at top level
lastExternalDropdict-Fires on drops Excalidraw doesn't accept (non-image or multi-file): {timestamp, files, dropPoint, placeholderIds}

Helpers

The package also exports file-handling helpers for the image-externalization workflow:

from dash_excalidraw import decode_data_url, strip_inline_files, restore_inline_files

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: