Deck format
View as markdownThe decks guide covers the workflow — save_artifact with a document or a
patch, the draft and its revision, going live. This is the document
itself: what goes in the document you save, field by field.
Read it before you write slides. The format's value is motion, charts, tables and structure; a correct-but-static result — bullets on slides — wastes it and is the single most common failure. Worse, a few fields fail silently: a mistyped property is ignored, a chart with the wrong data shape renders as empty bars, a font the document doesn't carry falls back without a word. None of that is visible in the JSON. This guide is how you get it right the first time — which matters more here than anywhere, because over the connector you are building blind: there is no browser rendering the slide back to you.
Start from a valid document, then change it
A new deck starts from a whole document you save — begin from the minimal
valid document below, with size, theme and one slide, and add to it. Once
the deck exists, get_artifact_files { artifact, version: "draft" } gives you
its document plus its revision. Build on it: change what you mean to, and
save the change — a patch, or the whole document — with the revision you
read.
The server escaping, the #bento-doc block, the shell — none of that is yours
to touch. You send JSON; the platform stores it, renders the player, and serves
it. Never put a literal </script> worry into your head — that's the server's
job, not yours.
Two backstops, and their limits
You can't see the render, so lean on what you can:
- A save checks the document strictly. A missing required field, a bad
element
type,size/themeabsent, or a field the document, a slide or an element of its type does not have — it refuses the save and names what is wrong and the nearest field that exists, rather than saving something that won't open or silently drops your styling. But it does not read inside an element's own options (a chart'soption, a table'sstyle, where unknown keys are ignored) and it cannot catch a chart whose data is the wrong shape. The field names below are exact for exactly this reason. - Whoever opens
<url>/editgets the full editor —window.bento.validate()(reports overflow, dead effects, broken refs, un-embedded fonts), a Fit height to text button, and their own eyes. A human catching a typo is the pitch of decks. But don't rely on it to finish your work: build to the guardrails so the deck is right before anyone opens it.
Make a GREAT deck — map material to the feature built for it
| When the material is… | Reach for | Why |
|---|---|---|
| numbers to compare (trend, magnitude, share) | a chart | bars/lines read instantly |
| named things placed against two axes (a 2×2, a positioning matrix) | a scatter chart with {name, value:[x,y]} items |
each point labels itself and names itself on hover, and the placement stays editable |
| a spec / pricing / feature grid (rows × columns) | a table | structured cells beat 20 text boxes; it styles cohesively |
| the same thing changing across consecutive slides | morph: same element id on both + transition:"morph" on the later |
the shared elements glide — the signature move, almost always missed |
| a point to drill into | a state slide (stateOf + an element link) |
keeps the linear story clean; detail one click away |
| a hero / full-slide image | full-bleed image + scrim rect + text, with ken-burns | a static photo feels dead; a slow drift feels intentional |
| a headline number | big text + fx:{countUp:true} |
the count-up earns the attention |
| a sequence / flow / timeline | a line or path with a dash-march loop, or morph a highlight through the steps |
motion carries the eye along |
| repeated chrome / a logo | keep its id stable across slides |
it morphs in place instead of popping in each time |
| a demo clip / soundbite | a media element (embed short, link long) | a live clip beats a screenshot of one |
Copy-paste recipes
Morph a title + accent bar between two slides — identical ids, transition:"morph" on the second:
// slide 1
{ "id":"s1","transition":"none","elements":[
{ "id":"headline","type":"text","x":96,"y":140,"w":900,"h":200,"html":"Big claim.","fontSize":120,"fontWeight":900,"color":"#111","align":"left","valign":"top","lineHeight":1,"rotation":0,"opacity":1 },
{ "id":"bar","type":"shape","shape":"rect","x":96,"y":380,"w":320,"h":16,"fill":"#aa412a","stroke":"none","strokeWidth":0,"radius":0,"rotation":0,"opacity":1 } ] }
// slide 2 — same ids, new frames → they animate between them
{ "id":"s2","transition":"morph","elements":[
{ "id":"headline","type":"text","x":96,"y":84,"w":500,"h":80,"html":"Big claim.","fontSize":40,"fontWeight":900,"color":"#888","align":"left","valign":"top","lineHeight":1,"rotation":0,"opacity":1 },
{ "id":"bar","type":"shape","shape":"rect","x":96,"y":170,"w":16,"h":450,"fill":"#aa412a","stroke":"none","strokeWidth":0,"radius":0,"rotation":0,"opacity":1 } ] }
A bar chart — bar/line data is PLAIN NUMBERS (see chart rules below):
{ "id":"c1","type":"chart","x":96,"y":260,"w":1088,"h":380,"rotation":0,"opacity":1,"preset":"bar","option":{
"xAxis":{"type":"category","data":["2022","2023","2024","2025"]},
"yAxis":{"type":"value"},
"series":[{"type":"bar","data":[420,780,1300,2450],"itemStyle":{"color":"#141310"},"barWidth":90}],
"tooltip":{"trigger":"item","formatter":"{b}: {c}"} },
"fx":{"enter":"fade-up"} }
A comparison table — a real HTML table; cells take the same inline-html subset as text:
{ "id":"tbl1","type":"table","x":240,"y":220,"w":800,"h":260,"rotation":0,"opacity":1,
"header":true,
"columns":[{"w":1.4},{"w":1},{"w":1}],
"rows":[
{ "cells":[{"html":"Plan"},{"html":"Price","align":"right"},{"html":"Seats","align":"right"}] },
{ "cells":[{"html":"Team"},{"html":"$29"},{"html":"5"}] },
{ "cells":[{"html":"Business"},{"html":"$79"},{"html":"25"}] } ],
"style":{"headerBg":"#1E2A3A","headerColor":"#fff","zebra":"rgba(30,42,58,0.05)",
"borderColor":"rgba(30,42,58,0.14)","borderWidth":1,"cellPadX":16,"cellPadY":11,
"fontSize":18,"color":"#1E2A3A","radius":10} }
A state slide reached by clicking a node — the clickable element is on the parent; the state lives adjacent:
// on the parent slide, an element the viewer clicks:
{ "id":"node-ingest","type":"shape","shape":"ellipse","x":330,"y":180,"w":74,"h":74,"fill":"#0B0E1E","stroke":"#7A5CFF","strokeWidth":2,"radius":0,"rotation":0,"opacity":1,"link":"state-ingest" }
// a hidden state slide (arrow keys skip it; ← returns to the parent):
{ "id":"state-ingest","stateOf":"parent-slide-id","transition":"morph","name":"INGEST","elements":[ /* … */
{ "id":"dismiss","type":"shape","shape":"rect","x":0,"y":0,"w":1280,"h":720,"fill":"rgba(0,0,0,0)","stroke":"none","strokeWidth":0,"radius":0,"rotation":0,"opacity":1,"link":"parent-slide-id" } ] }
Full-bleed hero image with ken-burns + scrim + text:
{ "id":"photo","type":"image","x":0,"y":0,"w":1280,"h":720,"src":"asset:hero","fit":"cover","radius":0,"rotation":0,"opacity":1,"fx":{"ambient":"kenburns","ken":{"dir":"drift","scale":1.09,"duration":22}} },
{ "id":"scrim","type":"shape","shape":"rect","x":0,"y":0,"w":1280,"h":720,"fill":"rgba(10,14,26,0.55)","stroke":"none","strokeWidth":0,"radius":0,"rotation":0,"opacity":1 },
{ "id":"htitle","type":"text","x":96,"y":460,"w":1000,"h":180,"html":"On top of the photo.","fontSize":76,"fontWeight":800,"color":"#fff","align":"left","valign":"top","lineHeight":1.05,"rotation":0,"opacity":1,"fx":{"enter":"fade-up"} }
Reference the image with "asset:hero" and put the material in doc.assets
under hero — "hero": "asset:dashboard-dark", the material's key or
identifier; see the library guide for finding it. The key becomes real bytes
when a version is saved, so the saved file stays whole.
Minimal valid document
size and theme (including fontFamily) are required — the deck will not
open without them — and elements should carry the full field set shown.
{
"format": "bento/slides", "version": 1, "title": "My deck",
"size": { "width": 1280, "height": 720 },
"theme": { "background": "#0b0f19", "color": "#f8fafc",
"accent": "#aa412a", "fontFamily": "system-ui, sans-serif" },
"slides": [
{ "id": "s1", "background": "#0b0f19", "transition": "none",
"notes": "speaker notes here",
"elements": [
{ "id": "t1", "type": "text", "x": 96, "y": 260, "w": 1088, "h": 160,
"rotation": 0, "opacity": 1,
"html": "Hello.",
"fontSize": 88, "fontFamily": "system-ui, sans-serif",
"fontWeight": 800, "color": "#f8fafc",
"align": "left", "valign": "top", "lineHeight": 1.1 }
] }
]
}
Charts render silently wrong unless you follow these
The engine is charts-lite — it reads the ECharts option shape and ignores every key it doesn't implement, with no warning. The two failures that bite:
- Bar/line series data must be plain numbers.
[420, 780, 1300], not[{value:420, itemStyle:{…}}]— an object item coerces to 0, so your bars render flat and empty. Pie takes{name, value}items, and scatter takes[x, y]pairs or{name, value:[x, y]}. Colour by series (series[].itemStyle.color), not per item. labelon a bar/line series does nothing — value labels above bars are read for pie and scatter only. To show numbers on a cartesian chart, put them in a table beside it or in text elements.
Template formatters only ({b} {c} {d}), never functions — the JSON must
be pure. What charts-lite honours:
- top level —
color,series,xAxis,yAxis,legend,grid,tooltip,textStyle,dataZoom - any series —
type,name,data,yAxisIndex,itemStyle.color - bar —
itemStyle.borderRadius; line —smooth,symbol,symbolSize,lineStyle.color,lineStyle.width,lineStyle.type(solid|dashed|dotted— the same pattern a dashed line shape draws, so use it for a series that isn't real: a forecast, a target, a counterfactual),areaStyle.color - pie —
radius,label.formatter(orlabel:false),itemStyle.borderColor,itemStyle.borderWidth - scatter —
symbolSize,label(show,positionright|left|top|bottom,formatter,fontSize,fontWeight,color) - axes —
type,data,min,max,axisLabel(show,fontSize,fontWeight,color,formatter),axisLine(show),splitLine(show) - legend —
show,top,bottom,textStyle.fontSize,textStyle.fontWeight
A scatter point can carry a name, and that is usually the point. [[21.8, 81.9]] is a position and nothing else: it cannot be labelled, and the best its
tooltip can say is its own coordinates. Give the item a name and it labels
itself beside the dot and names itself on hover:
{ "type":"scatter","name":"Candidate venture","symbolSize":8,
"itemStyle":{"color":"#94A3B8"},
"label":{"position":"right","fontSize":12,"fontWeight":600,"color":"#94A3B8"},
"data":[ {"name":"23artifacts","value":[6.4,61.6]},
{"name":"23rides","value":[70.5,86.2]} ] }
That is what turns a 2×2 positioning matrix into a real chart rather than a
picture of one — one series per band (focal vs the rest) so colour and symbol
size stay per-series, xAxis/yAxis min/max pinned to 0/100 so the
centre lands in the centre, and axisLabel:{show:false},
axisLine:{show:false}, splitLine:{show:false} when the slide draws its own
cross and quadrant tags. A scatter's x axis is a value axis: give xAxis no
data, or the points won't be drawn at all.
Dual axis for two series on very different scales (e.g. volume + a %): make
yAxis an ARRAY of two {type:"value"} axes (give the 2nd
axisLabel:{formatter:"{value}%"}), point the odd series at it with
"yAxisIndex":1, and render that one as a line over the bars.
Charts inherit the deck's palette automatically; set option.color only to
override it.
The rules that make decks feel designed
- Morph = shared ids. A slide with
"transition":"morph"tweens any element whoseidmatches the previous slide — position, size, colour, gradients. Carry 2–4 ids through the deck and rearrange them per slide. Emit deterministic ids so this keeps working across edits. morphIddecouples morph identity fromid. The pairing key ismorphId || id, so an element can keep its ownidand set"morphId":"running-head"to morph against a differently-named element on the next slide. Unique within a slide. Plain sharedidstill works and is simplest when you control both slides.- Entrances:
fx:{ enter:"fade-up", order:0 }— equalorder= simultaneous. On a morph arrival the rule is per element: one that has a morph partner on the previous slide is already in motion, sofx.enterandfx.countUpare skipped for it; one that is new to the slide runs both normally (and, absentfx.enter, gets an automatic fade-and-rise so nothing just pops in). So a headline count-up or a panel sweeping in is fine on a morph slide — just make sure it's new to that slide. - Ken-burns:
fx:{ ambient:"kenburns", ken:{ dir:"drift|out|in", scale:1.08, duration:20 } }—driftloops,out/insettle once on entry. - Loops (
fx.loop):{ type:"dash-march", distance:18, duration:1.4 }marches stroke dashes — it needs astrokeand a dash pattern (strokeStyle:"dashed"); on a solid stroke there is nothing to see.{ type:"motion-path", path:"M0,0 C60,-40 140,40 200,0", duration:6 }drifts the element along a path relative to its resting position. Never put an entrance tween on a motion-path element — they fight over the same transform. - Interactivity: element
link:"<slide-id>"jumps on click; a slide withstateOf:"<parent-id>"is a hidden variant reached only by links (arrow keys skip it, ← returns to parent). Give clickable things a padded transparent rect as the hit target, not the text itself. - Numbers count up with
fx:{ countUp:true }. - Speaker notes (
notes, per slide) are part of the document — write them.
Layout guardrails
Canonical canvas 1280×720 (
doc.sizecan differ — read it first).Keep 96px side margins → right-most content
x ≤ 1184.Column arithmetic on 1280×720 inside 96px margins (content band 1088px wide), already done — use these rather than computing your own:
Split Width xpositionsGutter 2 columns 528 96, 656 32 3 columns 340 96, 470, 844 34 4 columns 254 96, 374, 652, 930 24 60 / 40 (text + image) 624 / 432 96, 752 32 A title band
y:72 h:84over content starting aty:208leaves 416px of content height above a 96px bottom margin.One accent colour; two typefaces max.
themesets deck defaults — thethemesguide carries the workspace brand and how it's meant to be applied.Fonts belong to the DOCUMENT, not the app. A
fontFamilynaming a face the document doesn't carry falls back silently — and it will usually look right to you, because you're the one with it installed; everyone else gets the fallback. Either carry the woff2 or name a full system stack and mean it ("'Fraunces', Georgia, serif", never a bare family name).Carry it by reference, not inline. A woff2 is material of kind
font(seelibrary), and a deck points at it the same way it points at an image — a saved version inlines the bytes, the draft stays small enough to rewrite on every save, and the typeface is added once for every deck you ever make:"assets": { "brand-600": "asset:inter-600" }, "fonts": [{ "family": "Inter", "asset": "brand-600", "weight": 600 }]Then
"fontFamily": "Inter, system-ui, sans-serif"renders in the real face for everyone. A woff2 data URI inline indoc.assetsalso works and is what a standalone.bento.htmlneeds, but a 278 KB one has to travel in every save — which is how decks end up with system-font chrome around figures in the real brand faces, disagreeing with themselves on one slide.
Layouts and `role`
doc.layouts is a supported top-level array of Slide-shaped templates the
editor offers under Apply layout; every deck also gets five built-ins scaled
to its doc.size. When generating, the part that matters is role: any
text element can carry "role": "title" | "subtitle" | "body" | "kicker".
Applying a layout matches donor to target by id first, then by role + type —
so roles are what let someone restyle your deck later without re-typing it.
One key per element, and the deck feels native to the editor.
Dynamic fields (tokens in text `html`)
Resolve at render time (the model keeps the raw token, so numbering updates
automatically): {{page}}, {{pages}} (position among non-state slides;
zero-pad with {{page:2}}→"06"), {{title}}, {{date}}, {{time}}, and the
document properties {{author}}, {{company}}, {{subject}}, {{event}} —
set those in an optional top-level "meta": {author, company, subject, event, keywords}. Great for title slides and footers.
Gotchas
- Don't invent property names — unknown keys are ignored, so a typo means your styling silently doesn't apply. The field names above are exact.
docIdis the document's identity — never regenerate it when editing; round-trip it through whatget_artifact_filesread.- Don't set
readonlyortemplate— those are standalone-file flags. Hosted, view-vs-edit is decided by who holds theeditcapability, and the platform strips these anyway. Going live is the save'sas. - Charts and fonts fail quietly — the two silent failures above are the ones that will land you an empty chart or a wrong typeface with no error. Re-read those sections before you trust a chart or name a font.