JSON Canvas: How to Build Interoperable Infinite Canvas Apps with an Open Format
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. 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. A JSON Canvas file (.canvas extension) contains two top-level arrays: { "nodes": [...], "edges": [...] } Both are optional — an empty canvas is valid. 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 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. Let's build a minimal but functional canvas renderer in JavaScript that reads a .canvas file and renders it in the browser. 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" } ] } JSON Canvas Renderer #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; } 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 = `${node.url}`; } else if (node.type === 'group' && node.label) { el.innerHTML = `${node.label}`; } 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 = ''; svg.appendChild(defs); renderCanvas('./demo.canvas'); 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. 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); 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. 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. 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. 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). Q: Can I add custom fields not in the spec? Q: What about embedded images? 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? .canvas file. The file represents the content, not the view. Q: My edges render behind nodes. What's wrong? z-index / pointer-events: none on the SVG (as shown in the tutorial). Q: Can a node be inside a group? Q: What if two apps interpret preset colors differently? "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. 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!
