8.5 KiB
Obsidian Canvas JSON Specification
Canvas files are JSON with two top-level keys: nodes (array) and edges (array).
Obsidian reads and writes them as UTF-8 JSON files with .canvas extension.
This reference aligns with the JSON Canvas 1.0 open specification. All structures support arbitrary additional fields ([key: string]: any) for forward compatibility. Obsidian will preserve unknown fields when reading and writing canvas files.
ID format: The JSON Canvas 1.0 spec recommends 16-character lowercase hexadecimal IDs (e.g., "a1b2c3d4e5f67890"). Obsidian itself generates IDs in this format. The descriptive ID examples in this reference ("text-title-4821", "img-cover-7823") are an alternative naming convention that this plugin uses for human readability. Both are valid JSON Canvas. Use whichever fits your workflow.
Coordinate System
x increases →
┌─────────────────────────────────
│ (-920, -2400) (0, -2400)
│
y │ (-920, 0) (0, 0) ← origin
↓ │
│ (-920, 540) (500, 540)
- Origin (0, 0) is the center of the canvas viewport.
- x increases rightward. Negative x = left of center.
- y increases downward. Negative y = above center.
- Node
xandyare the top-left corner of the node, not the center. - Obsidian pans to fit all nodes on first open.
Node Types
Text node
Renders markdown content as a styled card.
{
"id": "text-title-4821",
"type": "text",
"text": "# Heading\n\nParagraph with **bold** and `code`.",
"x": -400,
"y": -300,
"width": 400,
"height": 120,
"color": "6"
}
text: markdown string. Use\nfor newlines.- Minimum readable size: width ≥ 200, height ≥ 60.
coloris optional. Omit for default (no color).
File node
Renders an image, PDF, markdown note, or other vault file inline.
{
"id": "img-cover-7823",
"type": "file",
"file": "_attachments/images/example.png",
"x": -900,
"y": -100,
"width": 420,
"height": 236
}
file: vault-relative path (not absolute, not~/).- Supported:
.png.jpg.webp.gif.pdf.md.canvas - For
.mdfiles: renders as a preview card. - For
.pdffiles: renders the first page as preview. - No
colorfield for file nodes: color is ignored.
Group node (Zone)
A labeled rectangular region. Does not clip or contain nodes. It's a visual guide. Nodes placed "inside" a group are just positioned within its bounding box.
{
"id": "zone-branding-3391",
"type": "group",
"label": "Brand Identity",
"x": -920,
"y": -880,
"width": 1060,
"height": 290,
"color": "6",
"background": "_attachments/images/grid-bg.png",
"backgroundStyle": "cover"
}
label: shown at the top of the group box.color: colors the group border and label.background(optional): vault-relative path to a background image for the group.backgroundStyle(optional): how the background is rendered."cover": fills the group, cropping if needed (default-ish behavior)"ratio": preserves aspect ratio, fits inside the group"repeat": tiles the image
- Groups do not affect auto-layout: they are purely visual containers.
Link node
Renders a web URL as an embedded preview card.
{
"id": "link-karpathy-2233",
"type": "link",
"url": "https://github.com/karpathy",
"x": 200,
"y": -300,
"width": 400,
"height": 120
}
url: must be a validhttps://URL.- Obsidian fetches the Open Graph preview (title, description, thumbnail).
Edges
Connections between nodes. Usually empty for mood boards.
{
"id": "e-hub-cidx",
"fromNode": "hub",
"fromSide": "right",
"fromEnd": "none",
"toNode": "c-idx",
"toSide": "left",
"toEnd": "arrow",
"label": "concepts",
"color": "5"
}
Required fields: id, fromNode, toNode. Everything else is optional.
fromNode/toNode: IDs of the source and target nodes.fromSide/toSide(optional):"top""bottom""left""right". If omitted, Obsidian auto-calculates the best side based on relative node positions.fromEnd(optional): end-cap on the source side. Defaults to"none". Values:"none"|"arrow".toEnd(optional): end-cap on the target side. Defaults to"arrow": note the asymmetric default vsfromEnd. Values:"none"|"arrow".label(optional): text shown on the edge.color(optional): same color palette as nodes ("1"–"6"or hex).
Most edges represent directed relationships, so the asymmetric defaults (fromEnd: "none", toEnd: "arrow") produce a single arrow pointing from source to target without specifying anything explicitly.
Color Reference
| Code | Color | Hex (approx) | Use case |
|---|---|---|---|
"1" |
Red / Tomato | #e03e3e | Warnings, archive |
"2" |
Orange | #d09035 | Active work |
"3" |
Yellow / Gold | #d0a023 | WIP, notes |
"4" |
Green / Teal | #448361 | Content, sources |
"5" |
Blue / Cyan | #3ea7d3 | Navigation, info |
"6" |
Purple / Violet | #9063d2 | Title, identity |
Omit color entirely for the default (no border color, transparent label).
Image Sizing Guidelines
Calculate from actual image dimensions using PIL or identify:
python3 -c "from PIL import Image; img=Image.open('path.png'); print(img.width, img.height)"
# or
identify -format '%w %h' path.png
| Aspect ratio | Condition | Canvas width | Canvas height |
|---|---|---|---|
| 16:9 (wide) | ratio 1.6–2.0 | 420 | 236 |
| 2:1 (ultra wide) | ratio > 2.0 | 440 | 220 |
| 4:3 | ratio 1.2–1.6 | 380 | 285 |
| 1:1 (square) | ratio 0.9–1.1 | 280 | 280 |
| 3:4 | ratio 0.6–0.9 | 240 | 320 |
| 9:16 (portrait) | ratio < 0.6 | 200 | 356 |
| any | 400 | 520 | |
| Unknown | fallback | 320 | 240 |
Auto-Positioning Pseudocode
function place_node(canvas, zone_label, new_w, new_h):
zone = find group node where label == zone_label
padding = 20
if zone not found:
max_y = max(n.y + n.height for n in canvas.nodes) + 60
return (-400, max_y)
# Nodes visually inside zone
inside = [n for n in canvas.nodes
if n.type != 'group'
and zone.x <= n.x < zone.x + zone.width
and zone.y <= n.y < zone.y + zone.height]
if inside is empty:
return (zone.x + padding, zone.y + padding)
# Rightmost point in zone
rightmost = max(n.x + n.width for n in inside)
next_x = rightmost + 40
if next_x + new_w > zone.x + zone.width - padding:
# Overflow → new row
bottom_of_row = max(n.y + n.height for n in inside)
return (zone.x + padding, bottom_of_row + padding)
# Same row
row_y = min(n.y for n in inside) # align to top of existing row
return (next_x, row_y)
Full Example: Two-Zone Canvas
{
"nodes": [
{
"id": "title-0001",
"type": "text",
"text": "# Brand Reference\n\n**AI Marketing Hub** visual assets",
"x": -920, "y": -2440, "width": 560, "height": 180, "color": "6"
},
{
"id": "zone-logos",
"type": "group",
"label": "Logos & Icons",
"x": -920, "y": -2200, "width": 1800, "height": 320, "color": "6"
},
{
"id": "img-logo-pro",
"type": "file",
"file": "_attachments/images/example.png",
"x": -900, "y": -2180, "width": 420, "height": 236
},
{
"id": "img-icon-free",
"type": "file",
"file": "_attachments/images/example-icon.png",
"x": -440, "y": -2180, "width": 280, "height": 280
},
{
"id": "zone-covers",
"type": "group",
"label": "Skill Covers",
"x": -920, "y": -1820, "width": 1800, "height": 340, "color": "3"
},
{
"id": "img-seo",
"type": "file",
"file": "_attachments/images/example-cover.png",
"x": -900, "y": -1800, "width": 420, "height": 236
}
],
"edges": []
}
Common Mistakes
- Wrong path format: use
_attachments/images/file.pngnot/home/user/...or~/... - ID collision: always read existing IDs before generating a new one
- Negative y confusion:
y: -2400is ABOVEy: -1000(more negative = higher up) - Group does not clip: placing a node "inside" a group is just positioning it within the group's bounding box: there is no parent-child relationship in the JSON
- Missing height on text nodes: Obsidian will render the text but may clip it if height is too small. Use height ≥ content-lines × 24.