JSON Canvas: How to Build Interoperable Infinite Canvas Apps with an Open Format
Infinite canvas tools have exploded in popularity. Miro, Figma, Obsidian Canvas, Excalidraw — the spatial, freeform way of organizing information has become a dominant interface pattern. But there's a problem lurking beneath the surface: lock-in. Every canvas app stores your whiteboards, mind maps, and visual notes in its own proprietary format. Move to a different tool? Your data stays behind or arrives as a mangled export.
JSON Canvas is an open file format created to solve this. Originally built for Obsidian, it's now MIT-licensed and free for any application to adopt. In this article, we'll walk through the spec, implement a working parser and renderer, and explore real-world scenarios where adopting JSON Canvas can future-proof your application.
Why JSON Canvas Matters
The philosophy behind JSON Canvas is simple but powerful: your data belongs to you. When canvas files are stored in a documented, open, human-readable format, users gain:
- Longevity — Files survive the apps that created them
- Interoperability — Any tool can read and write the same canvas
- Readability — JSON is easy to inspect, debug, and version-control
- Extensibility — The spec leaves room for application-specific additions
This mirrors the success of Markdown for text. Before Markdown, notes were trapped in proprietary formats. After Markdown, portability became the norm. JSON Canvas aims to do the same for spatial data.
Understanding the Spec
A JSON Canvas file (.canvas extension) contains two top-level arrays:
{
"nodes": [...],
"edges": [...]
}
Both are optional — an empty canvas is valid.
Nodes
Nodes are the objects placed on the canvas. There are four types:
| Type | Description | Required Extra Field |
|---|---|---|
text |
Plain text with Markdown |
text (string) |
file |
Reference to a file |
file (string path) |
link |
Reference to a URL |
url (string) |
group |
Visual container |
label (optional string) |
Every node shares these required attributes:
{
"id": "node1",
"type": "text",
"x": 100,
"y": 200,
"width": 300,
"height": 200,
"text": "Hello, Canvas!"
}
Optional fields include color (hex or preset "1"-"6"), subpath for file nodes (e.g., "#heading"), and background/backgroundStyle for group nodes.
Nodes are ordered by z-index — first in the array renders below, last renders on top.
Edges
Edges connect nodes with lines:
{
"id": "edge1",
"fromNode": "node1",
"fromSide": "right",
"fromEnd": "arrow",
"toNode": "node2",
"toSide": "left",
"toEnd": "arrow",
"color": "4",
"label": "connects to"
}
fromSide and toSide can be top, right, bottom, or left. Endpoint shapes are none or arrow. Edges also support colors and labels.
Tutorial: Building a JSON Canvas Renderer
Let's build a minimal but functional canvas renderer in JavaScript that reads a .canvas file and renders it in the browser.
Step 1: The Canvas File
Create demo.canvas:
{
"nodes": [
{
"id": "idea1",
"type": "text",
"x": 50,
"y": 50,
"width": 260,
"height": 120,
"text": "# Core Idea\nBuild tools that respect user data ownership.",
"color": "4"
},
{
"id": "idea2",
"type": "text",
"x": 400,
"y": 50,
"width": 260,
"height": 120,
"text": "# Why?\nProprietary formats create lock-in.",
"color": "1"
},
{
"id": "ref1",
"type": "link",
"x": 400,
"y": 250,
"width": 260,
"height": 80,
"url": "https://jsoncanvas.org"
},
{
"id": "group1",
"type": "group",
"x": 30,
"y": 20,
"width": 660,
"height": 340,
"label": "Project Brainstorm"
}
],
"edges": [
{
"id": "e1",
"fromNode": "idea1",
"fromSide": "right",
"toNode": "idea2",
"toSide": "left",
"label": "leads to"
},
{
"id": "e2",
"fromNode": "idea2",
"fromSide": "bottom",
"toNode": "ref1",
"toSide": "top",
"color": "5"
}
]
}
Step 2: Parsing and Rendering
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>JSON Canvas Renderer</title>
<style>
#canvas {
position: relative;
width: 100vw;
height: 100vh;
background: #1e1e2e;
overflow: auto;
}
.node {
position: absolute;
border-radius: 8px;
border: 2px solid #444;
padding: 12px;
color: #cdd6f4;
font-family: sans-serif;
font-size: 14px;
box-sizing: border-box;
overflow: auto;
}
.node.group {
background: rgba(69, 71, 90, 0.3);
border: 2px dashed #585b70;
}
.node.link {
background: #313244;
border-color: #89b4fa;
}
.edge-label {
position: absolute;
background: #1e1e2e;
color: #a6adc8;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
pointer-events: none;
}
#edges {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
}
</style>
</head>
<body>
<div id="canvas">
<svg id="edges"></svg>
</div>
<script type="module">
const COLORS = {
'1': '#f38ba8', '2': '#fab387', '3': '#f9e2af',
'4': '#a6e3a1', '5': '#89dceb', '6': '#cba6f7'
};
function resolveColor(c) {
if (!c) return '#585b70';
return COLORS[c] || (c.startsWith('#') ? c : '#585b70');
}
async function renderCanvas(url) {
const data = await fetch(url).then(r => r.json());
const canvasEl = document.getElementById('canvas');
const svgEl = document.getElementById('edges');
const groups = (data.nodes || []).filter(n => n.type === 'group');
const others = (data.nodes || []).filter(n => n.type !== 'group');
for (const node of [...groups, ...others]) {
const el = document.createElement('div');
el.className = `node ${node.type}`;
el.style.left = node.x + 'px';
el.style.top = node.y + 'px';
el.style.width = node.width + 'px';
el.style.height = node.height + 'px';
if (node.color) {
el.style.borderColor = resolveColor(node.color);
}
if (node.type === 'text') {
el.innerHTML = marked.parse(node.text || '');
} else if (node.type === 'link') {
el.innerHTML = `<a href="${node.url}" target="_blank" style="color:#89b4fa">${node.url}</a>`;
} else if (node.type === 'group' && node.label) {
el.innerHTML = `<strong>${node.label}</strong>`;
}
canvasEl.appendChild(el);
}
const nodeMap = {};
for (const n of data.nodes || []) {
nodeMap[n.id] = n;
}
for (const edge of data.edges || []) {
const from = nodeMap[edge.fromNode];
const to = nodeMap[edge.toNode];
if (!from || !to) continue;
const fx = from.x + from.width / 2;
const fy = from.y + from.height / 2;
const tx = to.x + to.width / 2;
const ty = to.y + to.height / 2;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', fx);
line.setAttribute('y1', fy);
line.setAttribute('x2', tx);
line.setAttribute('y2', ty);
line.setAttribute('stroke', resolveColor(edge.color));
line.setAttribute('stroke-width', '2');
if (edge.toEnd !== 'none') {
line.setAttribute('marker-end', 'url(#arrow)');
}
svgEl.appendChild(line);
if (edge.label) {
const lbl = document.createElement('div');
lbl.className = 'edge-label';
lbl.textContent = edge.label;
lbl.style.left = ((fx + tx) / 2 - 20) + 'px';
lbl.style.top = ((fy + ty) / 2 - 10) + 'px';
canvasEl.appendChild(lbl);
}
}
}
const svg = document.getElementById('edges');
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
defs.innerHTML = '<marker id="arrow" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#cdd6f4"/></marker>';
svg.appendChild(defs);
renderCanvas('./demo.canvas');
</script>
</body>
</html>
This gives you a working canvas renderer in ~120 lines. It handles all four node types, edges with labels and arrows, and the preset color system.
Step 3: Writing a Canvas File
Building a .canvas file programmatically is straightforward:
function createCanvas(nodes, edges) {
return JSON.stringify({ nodes, edges }, null, 2);
}
const nodes = [
{ id: 'root', type: 'text', x: 400, y: 300, width: 200, height: 100,
text: '# Project Alpha\nKickoff notes', color: '4' },
{ id: 'task1', type: 'text', x: 100, y: 100, width: 200, height: 80,
text: '- Design mockups\n- API schema', color: '3' },
{ id: 'task2', type: 'text', x: 700, y: 100, width: 200, height: 80,
text: '- Write tests\n- CI pipeline', color: '3' },
{ id: 'spec', type: 'file', x: 100, y: 500, width: 200, height: 80,
file: 'docs/spec.md' }
];
const edges = [
{ id: 'e1', fromNode: 'root', fromSide: 'left', toNode: 'task1', toSide: 'right' },
{ id: 'e2', fromNode: 'root', fromSide: 'right', toNode: 'task2', toSide: 'left' },
{ id: 'e3', fromNode: 'root', fromSide: 'bottom', toNode: 'spec', toSide: 'top',
color: '6' }
];
const canvasJson = createCanvas(nodes, edges);
Real-World Scenarios
1. Knowledge Management Tools
If you're building a PKM (Personal Knowledge Management) tool, adopting .canvas as your storage format means Obsidian users can import their canvases directly, and vice versa. Your tool immediately gains interoperability with an ecosystem of apps.
2. Collaborative Whiteboarding
Team whiteboard tools can use JSON Canvas as an export format. Even if your internal representation is more complex, a .canvas export gives teams a portable backup they can open in any compatible app — no vendor lock-in.
3. Documentation and Diagramming
Technical documentation often benefits from spatial layouts. A tool that generates .canvas files from API specs, database schemas, or architecture diagrams gives users an editable, version-controllable artifact they can refine in Obsidian or any other canvas app.
4. Git-Friendly Visual Documentation
Because .canvas files are plain JSON, they diff cleanly in git. Teams can track changes to visual documentation the same way they track code — with history, blame, and merge (JSON merges better than binary formats).
FAQ and Troubleshooting
Q: Can I add custom fields not in the spec?
A: Yes! The spec is intentionally extensible. Add your own fields to nodes and edges — other apps will simply ignore unknown fields. This is how you can add app-specific features without breaking compatibility.
Q: What about embedded images?
A: Use file type nodes referencing relative paths (e.g., "file": "assets/diagram.png"). For absolute URLs, use link type nodes.
Q: How do I handle canvas panning and zooming?
A: The spec doesn't define viewport state. Track pan/zoom in your app's state, not in the .canvas file. The file represents the content, not the view.
Q: My edges render behind nodes. What's wrong?
A: SVG elements render in document order. Make sure your SVG layer sits below the node layer in the DOM, or use z-index / pointer-events: none on the SVG (as shown in the tutorial).
Q: Can a node be inside a group?
A: Group membership is implicit — any node whose position falls within a group's bounds is visually inside it. The spec doesn't use explicit parent-child relationships, making it simpler to parse.
Q: What if two apps interpret preset colors differently?
A: That's by design. Preset "1" through "6" map to semantic colors (red through purple) but exact values are app-specific. This lets each app match its own brand while maintaining semantic consistency.
Conclusion
JSON Canvas represents an important shift in how we think about canvas-based applications. By adopting an open, readable format, we give users ownership of their spatial data — the same way Markdown liberated text notes and CSV liberated spreadsheets.
The spec is small and well-defined enough to implement in an afternoon, yet expressive enough for real applications. Whether you're building a new canvas tool, adding export to an existing one, or just want git-friendly visual documentation, JSON Canvas is worth your attention.
The official spec is a single page. The GitHub repo is open source under MIT. Pick it up, try the tutorial above, and give your users the portability they deserve.
Have you built something with JSON Canvas? I'd love to hear about it in the comments!
Top comments (0)