Motion
Play a creative as a short clip — the brand's own reveals, staggers and drifts — without changing the still.
Every creative can also be a clip: the background drifts, the copy enters in reading order, the button gets one nudge. Press K in the editor (or the clapperboard in the toolbar) to play it on the canvas. Press K or Esc to stop, and the canvas is exactly the still it was.
The one rule
The layout you author is the rest pose. Motion describes where an element comes from (a reveal) or how far it drifts (an ambient pass) — never where it sits. Turning motion off, exporting a PNG, and every preview thumbnail all render the still, byte for byte. Brand-fit keeps owning geometry; motion never moves an element's box.
What moves, and how
Motion resolves the way typography does: by role. A heading rises, body copy fades, the CTA pops and pulses once the intro has settled, the logo fades in first. The background stays still — a brand can give its photo backgrounds a slow drift toward the focal point (a Ken Burns) in its motion identity, and you can pin one on any picture, but nothing moves a picture the brand did not ask to move. The presets, eases, timings and staggers come from the brand's motion identity — the same signature every synced preview and every agent-built page reuses — so two brands with the same template move differently.
The Guide panel → Motion block is the clip:
| Control | What it does |
|---|---|
| All | The whole choreography — every element enters, the background drifts. |
| Background | Only the background moves. The pipeline's default: a still with life in it. |
| Off | Nothing moves. |
| Length | The clip in seconds (2–30). Long intros compress their stagger to fit the first 60%. |
The readout under it says what the planner will animate on the format you're looking at — "3 elements enter · 1 ambient", or why nothing does ("No photo background on this format").
Pinning one element
Select an element and open its Motion row. It reads "Brand default · Rise" until you pin a take: any reveal that applies to that kind of element (Rise, Fade, Pop, Slide, Wipe; Grow and Draw for panels and lines), or None so it does not enter. The background element offers its drift instead (Ken Burns, Float, Breathe, Spin) — off until you or the brand pick one. None silences that one slot and keeps everything else the pin carries — a dragged start, a length, an order — so picking a reveal again puts the element back where you had it; set its drift, emphasis and exit to None too and it sits the clip out entirely. The scope chip beside the label works like every other setting: a pin is shared across formats unless you make it format-only.
Under the reveal sits Exit — how the element leaves. Off by default: an ad ends on its authored layout unless you name a leave (Fade out, or Sink — the rise in reverse). An exit runs from the rest pose to its far pose and ends exactly on the clip's last frame, holding there; the still is untouched, and a brand can give a role an exit in its motion identity the same way it gives it a reveal.
Text has one more choice under the same row: Whole, By word or By line. By word runs the reveal one word at a time on the brand's tight stagger — the words rise in one after another — and By line does the same per wrapped line on the base stagger; the element after it waits for the last word, so a split heading never overlaps the line under it. It is a property of the enter, so the still is untouched, and a brand can make it the default for a role (headings by word, say) in its motion identity.
The timeline
Press K (or Play) and a track view opens beneath the canvas, above the transport: one row per element that moves on this format, with its reveal, drift, emphasis (a tick) and exit as bars on a 0…clip axis, and the playhead on the same clock the scrubber reads. The rows are the planner's choreography — nothing is authored until you touch it.
- Drag a reveal bar to pin when it starts. The element becomes pinned: its start is an absolute time from the clip's first frame, never compressed, and the other elements sequence around it in reading order as if it had left the queue.
- Drag the bar's right edge for the reveal's length.
- Double-click a row to unpin — back to the generated timing.
- The select on each row is the reveal (the bar) — "Auto · Rise" until you pin a take. Drift, Emph and Exit sit beside it as chips: a solid dot means the element pins its own preset there, a hollow dot means the brand's role motion supplies one, no dot means the slot is silent. Click a chip to change it — it is the same select the inspector's Motion row uses. The background row leads with its drift instead.
- The scope chip beside each row's name is the inspector's: all means the pin is shared across formats, the format's name means this format carries its own. Click it to switch — a drag on a row without its own pin writes the shared one, so give the 9:16 its own before retiming it alone.
- ← / → nudge an element's start by 10 ms (⇧ for 100 ms) while the timeline has focus — the row you tabbed to, otherwise the selected element. Inside a row's select the arrows stay the select's own. Click the axis to seek.
The inspector's Motion row shows the same numbers — Delay (the extra on top of the stagger while unpinned; the absolute Start once pinned), Duration, Order and the Pinned toggle — so anything dragged is also editable as a number, and Reset timing returns to the choreography.
Clip fit (Guide panel → Motion) says what happens when the intro would take more than 60% of the clip: Compress squeezes the generated delays (the default, and what every clip did before), Extend grows the clip so every authored delay runs verbatim (never past 30 s — the readout says "extended to"), Pin keeps clip and delays exactly and cuts any track that would run past the end. The planner's notes about what it had to cut or overlap show under the readout and in the timeline's header.
Every timeline edit is one undo step: a drag, a held arrow key, a scrubbed or typed number in the inspector each land as one entry, and a nudge on the timeline never also moves the element on the canvas. In the Guide panel, the scope and clip-fit buttons are one step each and the Length field is one step per edit.
A live background — Animate
Often the one thing you want is the picture itself to move: the lion turns its head toward the viewer, the sea keeps rolling, steam rises off the cup. Select the background image, open the Animate card under its content controls, write one sentence about what should move, and press Animate. A short clip is generated from the picture exactly as the layout crops it (the model gets your element's own render as its first frame — Seedance 2.5 by default, the one that is best at this right now), sized to the document's clip length so it loops with the choreography, and lands on the element for the format you are looking at. It plays in every animated output — K, the Video export, the Motion node — while every other layer stays exactly the still it is, on top of it. The still never changes, and the card says what a clip will cost before you press.
Only the picture moves. Copy, logo and buttons are separate layers, so they keep their own choreography (or none) over the live picture. Advanced holds the model, the length and the resolution; reference images and characters belong to the pipeline's Generate video node, not here. A clip is per format — animate the 9:16 separately if you export that too. In the Layers panel the picture gets a sublayer, Animated · 6s, under its row: the camera toggle switches the clip off for that format (the still shows in motion too; the clip is kept, so you can decide per format what plays) and the × removes it. Remove clip in the inspector does the same.
The job outlives everything: select something else, leave the editor, close the tab. The pill by the orb says a clip is rendering, a toast says when it is ready, and the clip lands on the element the next time you open that creative. One model policy to know: Seedance refuses a picture that may show a real person — the card says so and offers the same motion with Kling 3.0 instead.
Playback
The transport at the bottom of the canvas scrubs the clip, pauses, loops (on by default) and stops. While the clip is active the editor's auto-fit stands down — an element mid-reveal is not where it rests — and resumes the moment you stop.
What you see is what a render will contain: the editor and the renderer compile the same plan from the same brand identity, and both show a frame by setting its time rather than letting the browser run.
Exporting the clip
The Save button's menu has two rows for the clip, both disabled with a reason when nothing moves on the format you're looking at:
| Row | What you get |
|---|---|
| Video | An H.264 mp4 of this format (24 fps, silent, up to 30 s). It renders in the background — the row shows progress, and when it lands the clip becomes a take on the creative: the format tab gets a play mark, the ▶ beside it opens the player, and the board's card loops it. Creatives only. |
| Animated HTML | A single self-contained HTML5 page (fonts embedded) that plays the clip on load and loops. Works for templates too. |
| Export… → GIF | The clip as an animated GIF, for email, docs and chat where a video won't embed. 12 or 15 fps and a long-edge cap (480 / 720 / full) — a GIF is a 256-color file that grows with every frame, so it runs slower and smaller than the mp4 by default. Renders in the background and downloads when it lands. A file only, never a take on the creative. |
Every frame of the video is a real screenshot of the same plan the canvas plays, seeked to its time — so what you scrubbed to in the transport is what the file contains. The poster is the still.
Interaction presets — how a button answers a hover
A hover is not a motion preset: a preset's pose is transform, opacity and filter, and it cannot express a fill, while an ad never hovers anything. So the way a button or a link answers a hover lives with the style guide (styleGuide.buttons.hover, styleGuide.links.hover) as a closed set of recipes — tint, slide, wipe, lift, invert, underline-grow, none — and only borrows this page's timings: every recipe transitions on --brand-duration-base / --brand-duration-fast and --brand-ease, so retuning the brand's motion retunes its hovers too. One emitter renders the chosen recipe for the style guide's .button, the shadcn Button and the generic .brand-hover-* classes that motion.css ships for cards, nav items and links (--brand-hover-fill / --brand-hover-text recolour them). Reduced motion snaps every recipe into its hovered state. Pick it on the Design System page → the button's Hover section; see the design system page for the recipes.
Scroll effects are not presets either. A page that moves with the scroll has no clip and no duration, so it is not part of the preset registry. motion.css ships a small static set of .brand-scroll-* classes instead (a pinned stage, pictures that rise through their authored position, a picture that opens to full screen, a row that slides sideways), driven by CSS animation-timeline with no JavaScript. The rule is the one the ads follow: the authored layout is the rest pose, and reduced motion, a browser without support or a data-scroll-still ancestor all leave the page still. The sections that use them are listed on the design system page.
Decisions
A few calls behind the export, stated so nobody re-opens them by accident:
- No audio track. Every clip is silent; no ad platform we ship to has refused a silent mp4. A silent AAC track is a one-flag addition in
encodeFramesToMp4if one ever does. - No plan gating. Video, GIF and HTML export cost credits nowhere — the capture is puppeteer + ffmpeg on our own compute, rate-limited per user. Gating by plan is a switch we can add at the submit chokepoint, not a design.
- Function size is measured, not guessed.
/api/admin/motion-probereports the capture's per-stage timings and sustained fps on the deployment's own hardware; the CPU size for production captures is set from that after a deploy, and the first production export is the verification of the pinned ffmpeg pack. - Under
backgroundscope the logo and the CTA stay still. Background-only means exactly that — the pipeline default animates the photo and nothing else, so a batch of clips reads as one calm family; the full choreography is one scope switch away.
For agents
The same three fields are the whole vocabulary:
element.motion—{ enter, ambient, emphasis, exit, intensity, stagger, split, delayMs, durationMs, order, pinned, inlineSvg, inlineSvgRun }, orfalse.delayMsadds to the choreography's own lead + stagger unlesspinned: true, when it is the enter's absolute start from the clip's first frame (never compressed; the unpinned elements sequence around it). Per format viaformatOverrides[fmt].motion.split: "word" | "line"runs a text's enter per word or per line; a role can carry it as its default (roles.heading.split).doc.motion—{ durationMs, scope, clipFit, roles, presets, formats }.clipFitsays what happens when the intro would take more than 60% of the clip:compress(default — the generated delays scale down),extend(the clip grows so every authored delay is honoured verbatim, never past 30 s) orpin(clip and delays stay; a track that would run past the end is cut and reported). Per format underformats[fmt].clipFit.brand.motion—{ eases, durations, stagger, presets, roles, clipDurationMs }, merged over the system defaults; a theme carries the same shape and merges over the brand.
update_template_element / update_creative_element take motion; update_template / update_creative / create_template take the document's motion; update_brand_config writes the brand's — including its own presets (motion.presets, a MotionPreset[] merged by id over the system registry: { id, label, kind: "enter" | "ambient" | "emphasis" | "exit", pose, mid?, durationMs | "clip", ease, loop?, appliesTo }). Humans edit the brand's identity in Brand config → Motion (with a live replay): eases, timings, staggers, the clip length, what each role does (enter · split · drift · emphasis), and the Presets list — every system preset with a click-to-replay chip, Duplicate to make a brand copy you can edit (name, kind, the pose fields, wipe, zoom / pan for pictures, duration, ease, loop, what it applies to) or New preset to start from a plain rise. A brand preset shows up wherever a preset is picked — the role table, every element's Motion row in the editor, an agent's motion pin — and ships in dist/motion.css as its own class. The synced folder ships it as dist/motion.css / motion.json / motion.js, and the design-system export as tokens/motion.css + motion.css + motion.json, so a page built by a coding agent moves like the ads.
Bringing motion in from outside. A @keyframes block — Figma Motion's Dev Mode output, or hand-written CSS — imports as a brand preset with import_motion_preset (REST POST /api/brands/{brandId}/motion/presets/import, CLI braaand motion import-preset): paste one block, name the preset and its kind, and it lands in motion.presets exactly as the preset editor would have authored it, with the block's duration and cubic-bezier taken over (the curve becomes a brand ease, or reuses one the brand already has). Only the closed pose vocabulary fits — opacity, translate / scale / rotate, blur, a one-side wipe, one intermediate keyframe — and anything else (a colour, a size, a second mid frame, a per-keyframe timing function, a spring) is refused with every reason named rather than approximated; dryRun checks first. When the motion is richer than a pose, the next rung is an animated SVG asset: an image or logo whose SVG carries its own @keyframes or SMIL renders inline while the document plays (motion.inlineSvg, on by default when the file animates, false for a plain image), so the file's own animation is the element's reveal — it takes the enter slot on the timeline, drags and pins like any bar, and the exports carry it. Outside a clip it is FROZEN on its still frame rather than left running. Beyond that, a live picture.
The file's timing lives on the ASSET. "This mark's finished frame is at 1.2 s" is a fact about the file, not about a layout — it is true wherever you place it. So an animated SVG carries its own run in the asset library: open it in Assets, and the lightbox shows an Animation panel with the same In / Out / Loop / Before / After / Still frame controls the editor has, over a preview seated on the frame you are choosing. Set it once and every creative that uses that file inherits it; an element can still override any of it. The library also tags these files animated, so the Tag filter finds them, and shows each one on its still frame in the grid rather than looping on the wall clock.
Agents reach the same field with update_asset (metadata.svgRun — read the metadata first, it replaces wholesale) or braaand assets update <brandId> <assetId> --still 1.2.
Directing one placement — motion.inlineSvgRun. The file arrives with its own timeline; the run says which part of it plays and what happens either side. { startMs, endMs, loop, holdBefore, holdAfter, posterMs }, all optional, all times in ms on the file's timeline (where the bar SITS on the clip is still delayMs). It overrides the asset's run field by field, so trimming the out point on one element still inherits the asset's still frame. With neither set, the file plays as authored: whole, repeating only if it declares an endless animation, waiting on its first frame and holding its last.
loopis three states, not a switch. Unset follows the file — aninfinite/repeatCount="indefinite"animation loops, a one-shot plays once.falsemakes an endless mark play through and stop;truerepeats a one-shot for the rest of the clip. Asking an endless file to play once without giving it anendMsleaves it nothing to stop on: the run holds the clip and the plan says so in its warnings.startMs/endMstrim it — play 0.3 s → 1.2 s of a longer animation. An out point is also what gives an endless file an end, so it can play once. Both clamp into the file and never invert.posterMsis the still frame — what the picture shows whenever nothing is playing: the canvas at rest, every rendered PNG, the clip's own poster. It defaults to the run's last frame (the finished art), or its in point for an animation that never ends. This is not a nicety: an animated SVG left in an<img>runs on the wall clock and cannot be stopped or seeked, so the same document rendered twice came back as two different pictures. Inlined, it is frozen here — which is what makes a render reproducible, and what stops the canvas looping when nothing is playing. Set it on the asset unless one placement genuinely wants a different frame.holdBefore/holdAfterpick the frame parked either side of the bar:"first"(the run's in point) or"last"(its out point). Before the bar,"first"— the default — is what makes a mark draw itself in when the playhead arrives;"last"parks the finished art there from frame 0 instead, which is what an animation that draws itself out wants. After it,"last"is the default: the finished art stays.
Where a finished clip lives. Opening a creative always lands on the CANVAS, never on the player — the clip is one click away (▶ in the toolbar, the dot on its format tab) and its state lives in the Save menu's Rendered clip row: whether it is up to date or the design has changed since it was made, when that was, Play, and a download. The verdict is exact rather than a timestamp guess — the render stamps renderedHash (motionTakeFingerprint: the document's visual surface plus every motion field, the format, and the brand version) and the editor recomputes it on every edit. A clip made before that stamp existed reads as "Rendered earlier" and claims nothing. Known limit: an asset's bytes can change under a stable id, and no field in the fingerprint moves, so a clip built on a replaced photo still reads up to date.
In the editor these are the Motion row's In / Out / Loop / Before / After / Still frame (in seconds) — shown for any element that renders inline, whether or not the clip animates it, because a still frame that is in use is a still frame you can change — and an inline bar's right edge on the timeline drags the out point — its length is the slice of the animation that plays. One rule underneath all of it: the file is never released to run on its own, it is seated every frame at inlineSvgTimeMs(run, clock − delay) — the same number in the editor, the frame capture and the exported page, which is why what you scrub is what the mp4 contains.
A live picture is element.video (or formatOverrides[fmt].video): { url, posterUrl?, durationMs, source, model?, prompt?, frame? }, false on a format to keep it still there. Agents make one with animate_element({ brandId, creativeId, elementId, format?, prompt, durationSeconds? }) — it returns a jobId at once; poll get_video_job until terminal, and the clip is stamped onto the element when it lands (apply: false to only generate). Over REST it is POST /api/videos/animate (202 + jobId) and GET /api/brands/{brandId}/videos/jobs/{jobId}. Credit-metered per second of video, like every AI video generation.
The export is render_motion over MCP: output: "mp4" queues one clip per format as a background job (poll get_motion_render until terminal; the clips land on the creative as videos[] takes with kind: "motion"), output: "html" returns an animated page, output: "gif" queues an animated GIF per format (fps 12 | 15, gifSize 480 | 720 | 0 = full) as files on the job's results[] — never a take. Over REST it is POST /api/render/motion (mp4 → 202 + jobId, poll GET /api/brands/{brandId}/motion-renders/{jobId}; html → the file inline), and on the command line braaand render motion <brandId> <creativeId> [--format …] [--gif [--gif-size 480|720|full]] [--html]. A format nothing moves on is skipped and named; a creative already rendering answers 409 with the running job's id. No credits are spent — it is our own compute. The Motion pipeline node builds on this same plan.