Excalidraw JSON Format
When generating Excalidraw .excalidraw files programmatically, follow these requirements.
File Structure
{
"type": "excalidraw",
"version": 2,
"source": "claude",
"elements": [...],
"appState": {"viewBackgroundColor": "#ffffff"}
}
Critical: Text Elements Require Explicit Dimensions
Text elements MUST have width and height properties, otherwise they render as 0-size invisible boxes.
{
"type": "text",
"x": 50,
"y": 20,
"width": 600,
"height": 35,
"text": "Your text here",
"fontSize": 28,
"fontFamily": 1,
"strokeColor": "#000000",
"id": "unique_id",
"textAlign": "left",
"verticalAlign": "top"
}
Dimension Guidelines
- Estimate
widthbased on text length and fontSize (~10-12px per character at fontSize 14) - Estimate
heightbased on fontSize and line count: usefontSize * 2.5 * lineCountto avoid truncation- The Virgil hand-drawn font (fontFamily: 1) needs extra vertical space
- This is much more generous than typical font metrics but necessary for proper display
- Example: 4 lines at fontSize 12 → height of
12 * 2.5 * 4 = 120 - Single line title at fontSize 28 → height of
28 * 2.5 = 70
- For multi-line text, use
\nin the text string - When in doubt, round height UP significantly - slightly too large is far better than truncated
Common Element Types
Rectangle (Leaf Element with Fill)
{
"type": "rectangle",
"x": 30,
"y": 70,
"width": 200,
"height": 100,
"strokeColor": "#1864ab",
"backgroundColor": "#a5d8ff",
"fillStyle": "hachure",
"strokeWidth": 1,
"roundness": { "type": 3 },
"id": "unique_id"
}
Rectangle (Container - Transparent)
{
"type": "rectangle",
"x": 20,
"y": 50,
"width": 300,
"height": 400,
"strokeColor": "#1864ab",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"roundness": { "type": 3 },
"id": "container_id"
}
Ellipse
{
"type": "ellipse",
"x": 100,
"y": 100,
"width": 150,
"height": 80,
"strokeColor": "#2f9e44",
"backgroundColor": "#b2f2bb",
"fillStyle": "hachure",
"strokeWidth": 2,
"id": "unique_id"
}
Arrow
{
"type": "arrow",
"x": 200,
"y": 150,
"width": 100,
"height": 50,
"strokeColor": "#2f9e44",
"strokeWidth": 2,
"points": [
[0, 0],
[100, 50]
],
"id": "unique_id"
}
Arrow Bindings
To anchor arrows to shapes:
{
"startBinding": { "elementId": "source-id", "focus": 0, "gap": 1 },
"endBinding": { "elementId": "target-id", "focus": 0, "gap": 1 }
}
Styling: Color-Coded Sections with Transparent Containers
Use color to differentiate logical sections, but keep it tasteful:
Critical Rule: Container Boxes Must Be Transparent
Any box that contains other elements (wrapper/container boxes) MUST have "backgroundColor": "transparent".
Only leaf elements (boxes that don't contain other boxes) should have fills. This prevents visual clutter and ensures inner elements remain visible.
// GOOD: Container box is transparent
{
"type": "rectangle",
"id": "container",
"strokeColor": "#1864ab",
"backgroundColor": "transparent",
"strokeWidth": 2
}
// GOOD: Inner leaf box has fill
{
"type": "rectangle",
"id": "inner_item",
"strokeColor": "#1864ab",
"backgroundColor": "#a5d8ff",
"fillStyle": "hachure"
}
Recommended Color Palette (Open Color)
Use matching border and fill colors per section:
| Section Type | Border | Fill (light) | Use Case |
|---|---|---|---|
| Blue | #1864ab |
#a5d8ff |
Data sources, inputs |
| Orange | #e67700 |
#fff3bf |
Processing, signals |
| Purple | #862e9c |
#f3d9fa |
Graphs, relationships |
| Green | #2f9e44 |
#b2f2bb |
Core logic, ML/AI |
| Teal | #0c8599 |
#99e9f2 |
Outputs, consumers |
| Gray | #495057 |
#dee2e6 |
Neutral, annotations |
FluentUI-Aligned Colors (for Microsoft Projects)
For diagrams in Microsoft/FluentUI contexts, use these colors for design consistency:
| Purpose | Stroke | Background | Notes |
|---|---|---|---|
| Primary/Brand | #0078D4 |
#CFE4FA |
Microsoft Blue |
| Success | #107C10 |
#DFF6DD |
Green for positive flows |
| Error/Danger | #D13438 |
#FDE7E9 |
Red for errors |
| Warning | #F7630C |
#FFF4CE |
Orange for caution |
| Accent | #5C2D91 |
#E8DAEF |
Purple for emphasis |
| Neutral | #1e1e1e |
#F3F2F1 |
Default |
Text Color: Always Black
All text elements MUST use "strokeColor": "#000000" (black). Colored text on hachure-filled backgrounds is hard to read. Use box border/fill colors to convey grouping, not text color.
What to Avoid
- Colored text on filled boxes (use black text instead)
- Filling container/wrapper boxes (makes inner elements hard to see)
- Mismatched border and fill color families
- Too many different colors in one diagram (stick to 3-5 section colors max)
Font Families
1= Virgil (hand-drawn style, default)2= Helvetica3= Cascadia (monospace)
Bound Text (Container Labels)
To make text scale with its container, bind text to a shape:
- Add
boundElementsarray to the container:
{
"type": "rectangle",
"id": "my_box",
"boundElements": [{"type": "text", "id": "my_box_text"}],
...
}
- Add
containerIdto the text element:
{
"type": "text",
"id": "my_box_text",
"containerId": "my_box",
"textAlign": "center",
"verticalAlign": "middle",
...
}
Bound Text Notes
- Text alignment typically uses
"center"and"middle"for bound text - The container will auto-resize based on text content when edited
- Works with rectangles, ellipses, diamonds, and other shapes
Best Practices
- Use unique
idvalues for all elements - Position elements on a grid (multiples of 10 or 20)
- Keep consistent spacing between related elements
- Use color coding to group related concepts (matching border + fill per section)
- Container boxes must be transparent - only fill leaf elements
- All text must be black (
#000000) - use box colors for grouping, not text colors - Add key insight boxes at the top of diagrams with distinctive styling
- Include token counts or metrics on arrows to show data flow
- Prefer bound text for labels inside shapes - this ensures text scales with containers
- For standalone text (not in shapes), use very generous
heightvalues:fontSize * 2.5 * lineCount
Loading and Exporting via Browser
Use the Microsoft internal Excalidraw instance (https://aka.ms/excalidraw). Do NOT use excalidraw.com — it violates compliance rules.
Important: CSP restrictions
The MS-internal Excalidraw instance (aka.ms/excalidraw, hosted on jolly-ground-0a6b3831e.4.azurestaticapps.net) has strict Content Security Policy headers that block fetch() to 127.0.0.1 and localhost. Do NOT use a local HTTP server as a data bridge — it will fail with CSP violations.
Loading a diagram into Excalidraw
Use Playwright's native fileChooser event combined with a showOpenFilePicker override. This bypasses CSP entirely because no network requests are made.
Why the override is needed
- Excalidraw uses the File System Access API (
showOpenFilePicker/showSaveFilePicker), not<input type="file">— sobrowser_file_uploaddoes not work directly. - DragEvent drop is rejected by Excalidraw with "Couldn't load invalid file" — do not attempt drag-and-drop.
- The override converts the File System Access API call into a hidden
<input type="file">that Playwright'sfileChooserevent can intercept.
CRITICAL: The MS Excalidraw instance does NOT show a "Load from file" confirmation dialog. Clicking the Open button directly invokes showOpenFilePicker. Therefore the override MUST be set up BEFORE the Open button is clicked, and everything must happen in a single browser_run_code call.
Step-by-step flow
Navigate to
https://aka.ms/excalidrawand wait for it to load (SSO signs in automatically):browser_navigate to https://aka.ms/excalidraw Wait ~5 seconds for SSO + canvas to loadIf SSO doesn't auto-complete (e.g. "Pick an account" screen appears), click the user's account to finish sign-in, then wait for the canvas to load.
Load the file in a single atomic
browser_run_codecall — overrideshowOpenFilePicker, then click menu → Open, and catch the file chooser:async (page) => { // Step 1: Override showOpenFilePicker BEFORE any menu clicks await page.evaluate(() => { window.showOpenFilePicker = () => { return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.accept = ".excalidraw,.json"; input.style.display = "none"; input.addEventListener("change", () => { const file = input.files[0]; resolve([{ getFile: async () => file }]); input.remove(); }); document.body.appendChild(input); input.click(); }); }; }); // Step 2: Click the hamburger menu await page.locator('[data-testid="main-menu-trigger"]').click(); await page.waitForTimeout(800); // Step 3: Click Open and catch the file chooser simultaneously const [fileChooser] = await Promise.all([ page.waitForEvent("filechooser", { timeout: 10000 }), page.locator('[data-testid="load-button"]').click(), ]); // Step 4: Set the file await fileChooser.setFiles("/absolute/path/to/diagram.excalidraw"); await page.waitForTimeout(3000); return "Loaded!"; };Scroll to content and frame the diagram — the diagram loads off-screen. Use
browser_snapshotto check for a "Scroll back to content" button and click it. Then zoom out using the zoom-out button (button[aria-label="Zoom out"]) repeatedly until the full diagram fits in the viewport. Do NOT useCtrl+Shift+1— this shortcut does not work on the MS Excalidraw instance. There is also no publicwindow.excalidrawAPIavailable for programmatic zoom.
Exporting to PNG/SVG
Quick export (recommended): Screenshot
After loading the diagram, click "Scroll back to content" to center it, then use browser_take_screenshot to capture a PNG. This includes UI chrome but is fast and reliable:
browser_take_screenshot with filename "diagram.png"
Full export (no UI chrome)
Override showSaveFilePicker to capture the exported binary, then use Playwright's run_code to write it to disk:
async (page) => {
// Set up a promise to capture the exported data
await page.evaluate(() => {
window._exportedData = null;
window._exportReady = new Promise((resolve) => {
window._exportResolve = resolve;
});
window.showSaveFilePicker = async (opts) => ({
createWritable: async () => {
const chunks = [];
return {
write: async (data) => {
const buf = data instanceof Blob ? new Uint8Array(await data.arrayBuffer()) : data;
chunks.push(buf);
},
close: async () => {
const total = chunks.reduce((s, c) => s + c.length, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
merged.set(c, off);
off += c.length;
}
// Store as base64 for retrieval
let binary = "";
for (let i = 0; i < merged.length; i++) binary += String.fromCharCode(merged[i]);
window._exportedData = btoa(binary);
window._exportResolve();
},
};
},
});
});
// Click: menu → "Export image..." → "Export to PNG"
await page.locator('[data-testid="main-menu-trigger"]').click();
await page.locator('[data-testid="image-export-button"]').click();
await page.locator('[aria-label="Export to PNG"]').click();
// Wait for export and retrieve the base64 data
await page.evaluate(() => window._exportReady);
const b64 = await page.evaluate(() => window._exportedData);
// Write to disk (b64 is returned to the caller for saving via bash)
return b64;
};
Then save the base64 data to a file using Node (cross-platform — works on macOS, Linux, and Windows):
node -e "require('fs').writeFileSync('diagram.png', Buffer.from(process.argv[1], 'base64'))" "<base64_data>"
Note: If the base64 string is very large, the browser_run_code return value may be truncated. In that case, retrieve the data in chunks to avoid size limits:
// After export is ready, retrieve in chunks:
const size = await page.evaluate(() => window._exportedData.length);
const CHUNK = 50_000;
let out = "";
for (let start = 0; start < size; start += CHUNK) {
out += await page.evaluate(([s, c]) => window._exportedData.slice(s, s + c), [start, CHUNK]);
}
// Then save: node -e "require('fs').writeFileSync('diagram.png', Buffer.from(process.argv[1], 'base64'))" "$out"
If chunked retrieval still fails, browser_take_screenshot can be used as a last resort (note: this includes UI chrome).
Preserving Source Files
Always save the .excalidraw file alongside any converted PNG/SVG for future editing.