About excalidraw
excalidraw is a skill for creating, editing, and analyzing Excalidraw `.excalidraw` scene files. It supports direct JSON authoring for small changes and a script-based build, lint, and render loop for larger diagrams.
Key features
- 01Create and edit `.excalidraw` scene files
- 02Author scenes by hand as JSON
- 03Build diagrams with a JavaScript toolchain
- 04Convert Mermaid diagrams to Excalidraw scenes
- 05Lint geometry and render SVG previews
Use cases
- 01Create architecture and flow diagrams
- 02Edit or extend an existing scene
- 03Summarize a scene by examining its elements
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ djalmajr/ skills --skill excalidrawAfter installing, type/excalidrawin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
examples/architecture.example.mjsreferences/element-reference.mdminimal-scene.excalidrawscripts/.gitignoredom-shim.mjsexcalidraw-lib.mjslint-scene.mjspackage.jsonREADME.mdrender.mjssetup.shscripts/vendor/entry.mjsDetails
From the project READMEexcalidraw
Produce and edit .excalidraw files — a flat scene of elements (rectangles,
text, arrows) on an infinite canvas. Two ways to author, picked by scale:
- By hand (JSON) — for a handful of boxes, or editing an existing scene (recolor, rename, re-wire, append). The element format and binding/grouping rules below are what you need — and they're also what the programmatic path emits, so read them either way.
- Programmatically — for anything bigger or that you'll maintain. The toolchain in
scripts/builds scenes with Excalidraw's ownconvertToExcalidrawElements(it owns the gotchas: reciprocal binding, container labels, text sizing, z-order), renders a faithful SVG preview withexportToSvg, and lints geometry. See Programmatic authoring below.
.excalidrawis Excalidraw's own on-disk JSON format. Mermaid is a separate thing but a useful input: for standard shapes (flowchart, sequence, class, ER) write Mermaid and convert it to a native Excalidraw scene — the editor's built-in "Mermaid to Excalidraw", orfromMermaid()inscripts/. Either way the file you then edit is Excalidraw JSON, which is what this skill covers.
When to use
- Creating a
.excalidrawfile from scratch (architecture diagram, flow, boxes-and-arrows). - Editing an existing scene: add a section, replace part of it, recolor/rename a box, re-wire arrows.
- Reading a scene to summarize or reason about it (build an outline from the elements).
If a real Excalidraw editor is available and the user just wants to draw, prefer the editor. This skill is for generating/editing the file directly — pick the by-hand path for small or edit work, the programmatic path (next) for non-trivial diagrams.
Programmatic authoring (the preview-and-lint loop)
For non-trivial or maintained diagrams, don't hand-place dozens of elements — use the
JS toolchain in scripts/. It wraps the official Excalidraw functions,
so its output can't drift from the format:
convertToExcalidrawElements(skeleton)— Excalidraw's own compiler. Hand it high-level shapes plus which boxes an arrow connects; it returns valid elements with reciprocal binding, container labels, measured text, ids and z-order. The single biggest correctness win — the Gotchas further down become its job, not yours.exportToSvg(scene)— renders the real look (rough strokes, fonts), so previews don't lie the way an approximate renderer would.
One-time setup (installs the headless toolchain + bundles @excalidraw/*; needs node +
npm, plus canvas/jsdom for headless text measurement):
sh scripts/setup.sh
Then loop — build → lint → render → look → iterate:
node examples/architecture.example.mjs # build with the Diagram builder
node scripts/lint-scene.mjs /tmp/arch.excalidraw # arrow crossings / label overflow / overlap
node scripts/render.mjs /tmp/arch.excalidraw # -> /tmp/arch.svg (faithful)
rsvg-convert -w 1200 /tmp/arch.svg -o /tmp/arch.png # rasterize, then actually LOOK
Looking at the render is the most important step. Valid JSON still produces unreadable diagrams — arrows through unrelated boxes, labels off-card. The linter catches the mechanical ones; your eyes catch the routing. Iterate on the builder code, not on the JSON.
The Diagram builder (scripts/excalidraw-lib.mjs) gives card(), zone(), note()
and arrow(a, b, label, {waypoints, dashed, color}); arrow() binds both ends and
routes through absolute waypoints (keep them axis-aligned for clean orthogonal lines).
fromMermaid() converts Mermaid for standard diagram types (optional dependency).
The element-format reference below still matters — it's what the builder emits, what you edit by hand on small scenes, and how you reason about an existing file.
The file envelope
Every .excalidraw file is this top-level object:
{
"type": "excalidraw",
"version": 2,
"source": "your-tool-name",
"elements": [ /* the scene, in draw order */ ],
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": null },
"files": {}
}
type/versionare fixed ("excalidraw"/2for the current schema).sourceis a free string stamped by the writer (e.g."my-tool").elementsis the whole scene — array order is paint order (later elements draw on top). Put rectangles before the text/arrows that sit on them.appState— keep it minimal;viewBackgroundColoris the only field most files need.files— binary blobs for embedded images, keyed by file-id. Empty{}for diagrams.
The element model
Elements are a discriminated union on type. Every element carries this common block
(values below are the safe defaults):
| Field | Type | Default / notes |
|---|---|---|
id | string | Unique within the scene. Any string works ("el-1", a nanoid…). |
type | string | "rectangle" | "text" | "arrow" (others exist; these three cover most diagrams). |
x,y | number | Top-left on the canvas. Y grows downward, X rightward, px units. |
width,height | number | Bounding size. For arrows this is the extent of points. |
angle | number | Rotation in radians. 0 = upright. |
strokeColor | string | Outline/text color. Default #1e1e1e. |
backgroundColor | string | Fill. "transparent" for unfilled shapes. |
fillStyle | string | "solid" | "hachure" | "cross-hatch". Use "solid". |
strokeWidth | number | 1 thin, 2 bold (default), 4 extra-bold. |
strokeStyle | string | "solid" | "dashed" | "dotted". |
roughness | number | Sketchiness: 0 architect (clean), 1 artist (default), 2 cartoonist. |
opacity | number | 0–100. |
groupIds | string[] | Group membership — see Groups. [] = ungrouped. |
frameId | string | null | Owning frame id, or null. |
roundness | object | null | Corner/curve style — see per-type below. |
seed | number | Arbitrary integer (Excalidraw's hand-drawn RNG seed). Any int. |
versionNonce | number | Arbitrary integer; changes on every edit. |
version | number | Edit counter; start at 1, bump on each change. |
isDeleted | boolean | false. Soft-deleted elements stay in the file with true. |
boundElements | array | Back-references to bound arrows/labels — see Binding. |
updated | number | Epoch-ms timestamp (or any monotonic int for deterministic output). |
link | string | null | Optional hyperlink. |
locked | boolean | false. |
index(z-order): the Excalidraw editor adds a fractional-index string per element ("a0","a1", …) to track stacking order. When hand-authoring, omit it — the editor assigns indices on load from your array order. Do not set it on some elements and not others; partial indices corrupt the z-order.
rectangle
{ "type": "rectangle", "roundness": { "type": 3 }, /* + common fields */ }
roundness: { "type": 3 }→ rounded corners.roundness: null→ sharp corners.- A box with a label is a rectangle plus a separate text element (see Labels).
text
{
"type": "text",
"text": "Login service",
"originalText": "Login service",
"fontSize": 16,
"fontFamily": 2,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"lineHeight": 1.25,
"baseline": 13,
"autoResize": true,
"roundness": null
/* + common fields */
}
textis what renders;originalTextmirrors it (Excalidraw keeps both — keep them equal unless you know why not).fontFamily:1= hand-drawn (Virgil),2= normal (Helvetica, default),3= code (Cascadia/monospace).width/heightmust match the rendered text or the editor will look wrong until it recomputes. With no font metrics available, approximate:width ≈ longestLineChars × fontSize × 0.58,height ≈ lineCount × fontSize × 1.25.baseline ≈ round(fontSize × 0.8). SettingautoResize: truelets the editor correct sizing on load.textAlign:"left" | "center" | "right".verticalAlign:"top" | "middle" | "bottom".
arrow
{
"type": "arrow",
"points": [[0, 0], [0, 80]],
"lastCommittedPoint": null,
"startBinding": { "elementId": "el-1", "focus": 0, "gap": 4 },
"endBinding": { "elementId": "el-2", "focus": 0, "gap": 4 },
"startArrowhead": null,
"endArrowhead": "arrow",
"roundness": { "type": 2 }
/* + common fields */
}
pointsare relative to the arrow's ownx/y. The first point is[0, 0]; the arrow'sx/yis that first point in absolute canvas coordinates.width/heightare the bounding extent of the points. A common mistake is storing absolute points — don't.- Multi-segment (orthogonal/elbow) arrows are just more points:
[[0,0],[0,40],[120,40]]draws down-then-right. Keep segments axis-aligned (purely horizontal or vertical) for clean routing. roundness: { "type": 2 }rounds the bends;nullkeeps sharp elbows.endArrowhead/startArrowhead:"arrow"(default end),null(none), or"triangle","dot","bar".- Color arrows via
strokeColor(a muted#495057reads well as default).
Binding — make arrows follow their shapes
Binding is two-way and reciprocal. To bind an arrow between two shapes you must touch three places, or the arrow won't track when shapes move:
arrow.startBinding = { elementId: from.id, focus: 0, gap: 4 }arrow.endBinding = { elementId: to.id, focus: 0, gap: 4 }- push a back-reference into each shape's
boundElements:from.boundElements.push({ id: arrow.id, type: "arrow" })and the same forto.
focus(-1..1) is where along the bound edge the arrow attaches;0= centered.gapis the px gap the arrowhead keeps from the shape (2–6 looks right).- If you only set the arrow's bindings but not the shapes'
boundElements, the arrow renders but detaches the moment a shape is dragged. Always do both directions. - Prefer the builder.
diagram.arrow(a, b)does all three for you via the official compiler — reach for hand-written bindings only when editing raw JSON. Either way,lint-scene.mjsnow fails (exit 1) on any arrow that is unbound, one-way, or dangling, so run it before calling a scene done: an arrow that doesn't follow on move is an error, not a style nit.
Labels — text inside a box (two ways)
A. Container-bound label (Excalidraw-native). The text becomes a child the editor auto-centers and wraps inside the box:
- text:
containerId = rect.id, and position it inside the rect. - rect:
boundElementsincludes{ "type": "text", "id": text.id }. The editor reflows the label when the box resizes. Best when a human will keep editing.
B. Free-floating label (deterministic). The text is an independent element placed by
absolute x/y inside the box, with containerId: null and not referenced in the
box's boundElements. Simpler to emit and fully predictable, but it won't auto-reflow.
Good for generated, static diagrams. A common pattern is a "card": a rounded rect + a title
text near the top-left + an optional smaller body text below it, all sharing the same
groupIds.
Groups
A group is just a shared id in every member's groupIds — there is no separate group
object. To group elements, give them all the same group-id string in groupIds. Nested
groups stack ids (outermost last). To draw a labelled "container" visually, add a dashed
rectangle behind the members (strokeStyle: "dashed", muted stroke, backgroundColor: "transparent") — it's a normal rectangle, optionally in the same group.
Programmatic path: diagram.group(ids, { title }) makes a real group from cards you
already placed — it stamps the shared groupId onto every part of each card (rect + title +
body) and draws the optional dashed frame + label behind them. Use it whenever blocks
should move together as a unit; pass the same ids you gave card(). (groupIds survives
convertToExcalidrawElements, so the builder just sets it on the skeleton.)
Coordinates & layout conventions
- Infinite canvas; pick any origin (e.g. start near
0,0). Y is down. - Lay columns left→right by increasing
x; stack rows top→down by increasingy. - Leave gutters between boxes (e.g. 80–90px between columns, ~28px between stacked boxes) so arrows have room and don't cross unrelated shapes.
- Don't overlap boxes if you want arrows to read cleanly. Overlaps and arrows passing through unrelated boxes are the usual cause of a messy scene.
- A readable, Excalidraw-friendly palette: default stroke
#1e1e1e; muted greys#495057/#868e96/#adb5bd; accents blue#1971c2, orange#e8590c, violet#7048e8, green#2f9e44. Use color to encode meaning (e.g. one hue per edge kind), not decoration.
Reading an existing scene
Parse the JSON and walk elements. To produce a compact outline (easier for a model to
reason over than raw JSON):
- rectangle → its label is the text whose
containerIdequals the rect id, or a free-floating text positioned inside the rect's bounds. - arrow → resolve
startBinding.elementId/endBinding.elementIdto their shapes' labels and renderArrow: "A" → "B". - Skip
isDeleted: trueelements. Cap very large scenes (e.g. first ~200 elements).
Example outline line shapes: Rectangle "Login", Arrow: "Login" → "Session" (label: "auth").
Updating sections of a scene
The three operations you'll actually perform on an existing file:
1. Append a new block below existing content
Don't guess coordinates — measure and shift:
- Compute the union bounding box of the existing elements (
minX/minYandmaxX = max(x+width),maxY = max(y+height)). - Compute the union box of the incoming elements.
dy = existing.maxY + gap − incoming.minY(agapof ~60px reads well).dx = 0.- Translate every incoming element by
(dx, dy)— just add to eachx/y. Because arrowpointsare relative to the arrow's ownx/y, movingx/ymoves the whole arrow; no point rewriting. Bindings reference ids, so they survive the shift untouched. - Concatenate:
existing.concat(shiftedIncoming).
2. Replace a region
- Identify the elements to drop (by id, or by a shared
groupIdsentry). - Remove them from
elements(hard delete), or setisDeleted: trueto keep history. - Re-wire dangling arrows: for any arrow whose
startBinding/endBindingpointed at a removed shape, either delete the arrow too, or repoint the binding'selementIdto the replacement shape and update that shape'sboundElements. Also remove stale entries from the surviving shapes'boundElementsthat reference deleted arrows. - Insert the replacement elements (and bind their new arrows per Binding).
3. Edit an element in place
Mutate fields directly, then signal the change so the editor reconciles cleanly:
- bump
version(version + 1), setversionNonceto a new integer, setupdatedto now. - For text changes, update both
textandoriginalText, and recomputewidth/height(or rely onautoResize: true). - For move/resize, update
x/y/width/height; bound arrows follow automatically via their bindings (no need to edit the arrow). - To recolor, change
strokeColor/backgroundColor.
Gotchas (the things that actually break a scene)
The programmatic path (convertToExcalidrawElements) handles the first four
automatically — it sets reciprocal binding, relative points, text sizing and z-order
for you. These bite mainly when hand-authoring.
- One-way binding → arrows detach on drag. Always set the arrow's
*Bindingand the shapes'boundElements. - Absolute arrow points → arrow renders in the wrong place. Points are relative; first
is
[0,0]. - Missing text
width/height→ mis-sized labels until reload. Approximate them, or setautoResize: true. - Partial
indexfields → corrupted z-order. Omitindexentirely when hand-authoring. - Overlapping boxes / arrows through unrelated shapes → unreadable diagram. Keep gutters and route orthogonally.
- Soft-delete leftovers → a shape with
isDeleted: truestill leavesboundElementsentries pointing at it elsewhere; clean those up if you want the bound arrows gone. text/originalTextdrift → keep them equal unless intentionally different.
Reference & worked example
references/element-reference.md— exhaustive per-field tables, value enumerations (fontFamily, arrowhead, roundness, appState keys), and binding/grouping semantics.references/minimal-scene.excalidraw— a complete, loadable two-box-and-an-arrow scene you can copy and adapt.scripts/— the programmatic toolchain:excalidraw-lib.mjs(theDiagrambuilder +writeScene/writeSvg/fromMermaid),render.mjs(faithful SVG export),lint-scene.mjs(geometry linter),setup.sh(one-time install + bundle). Seescripts/README.md.examples/architecture.example.mjs— a runnable end-to-end diagram built with the builder.
Source of truth
The element format above is the minimal subset that real Excalidraw loads, validated
against scenes from Excalidraw 0.18.1. The editor may add or carry extra fields (e.g.
index, custom per-element data) on round-trips; the fields documented here are sufficient
to author files the editor opens cleanly. When in doubt about a field this skill doesn't
cover, draw the shape once in the Excalidraw editor, save, and inspect the resulting JSON —
a round-tripped file is the ground truth.