# How do I add cinematic motion to templates?

> Choreographed text reveals, counters, camera moves, film texture and per-render motion control for video templates, via Studio or the API.

- **URL**: https://orshot.com/docs/orshot-studio/motion

---

**Motion** is the cinematic layer on top of [animations](https://orshot.com/docs/orshot-studio/animations): staggered text reveals, count-up stats, relational choreography, press and sleep beats, SVG draw-on, camera moves and film texture. Enter/exit/ambient animations describe how a single layer arrives, lives and leaves; motion describes how the whole page *performs*.

Motion is designed API-first: everything on this page can be authored as JSON (by hand or by an AI agent through the [Orshot MCP](https://orshot.com/agents)), edited in Studio, and tuned per render through `modifications`.

## How it activates

Adding a `motion` object to **any element** (or to a **page**) switches that page's video rendering to the motion engine. Pages without motion render exactly as before, and legacy `transitions` on other layers keep working on a motion page. Static formats (`png`, `jpg`, `pdf`, …) ignore motion entirely and render the final designed state.

```json
{
  "id": "stat",
  "type": "text",
  "content": "0",
  "motion": {
    "count": { "from": 0, "to": 99.9, "duration": 1.4, "decimals": 1, "suffix": "%" }
  }
}
```

## Element motion

All keys and sub-params are optional unless marked. Out-of-range values are clamped, never rejected.

| Key | Params (defaults) | Effect |
| --- | --- | --- |
| `reveal` | `by: "word"\|"char"` (word), `stagger: 0.07`, `duration: 0.7`, `dy: 120` | Staggered text reveal. The headline entrance. Text layers only, up to 400 words |
| `typewriter` | `duration: 1.6`, `delay: 0` | Text types itself. Plain text layers only |
| `count` | `to` (required), `from: 0`, `duration: 1.4`, `decimals: 0`, `prefix`, `suffix` | Number counts up in place. The stat primitive |
| `wake` | `duration: 0.6`, `from: up\|down\|left\|right\|none`, `distance: 34`, `offset: 0` | Directional entrance. Under 0.3s reads as a hard cut-in |
| `after` | `element` (required, an element **id**), `offset: 0` | Start relative to another element **finishing** its entrance. The choreography primitive: chain layers instead of hand-timing `showAt` |
| `pulse` | `from: 0.97`, `to: 1.03`, `rate: 1.6`, `axis: "y"\|"both"` | Ambient scale breathing (a live badge, a recording dot) |
| `float` | `amp: 6`, `rate: 2.4` | Ambient vertical drift |
| `press` | `at` (required, seconds), `dip: 6` | One-shot press dip in pixels: a button being clicked, on its exact frame |
| `sleep` | `at` (required), `duration: 0.45` | The layer exits early, freeing the stage |
| `drawIn` | `duration: 0.9`, `delay: 0` | SVG strokes draw themselves. SVG layers only |
| `svg` | `animate: true`, `targets: [{ selector, drawIn?, spin?, pulse? }]` | Animate *inside* an SVG layer by CSS selector: animated textured backgrounds, spinning marks |
| `keyframes` | `[{ at (required), x, y, scale, rotate, opacity }]` | Explicit track when no behavior fits. Offsets are from the layer's designed position |

`after` chains adapt automatically: if a render-time modification makes a headline longer, everything chained after it re-times to the real reveal length.

## Page motion

Set on the page itself as `pages_data[n].motion`:

| Key | Params | Effect |
| --- | --- | --- |
| `look` | `grain: 0–0.2`, `vignette: 0–0.6` | Film texture over the whole page. Grain 0.04–0.08 and vignette 0.2–0.35 are tasteful ranges. Also editable in Studio from the timeline timer popover |
| `camera` | `moves: [{ at, duration, x, y, zoom, ease }]` | Whole-canvas camera. Every layer moves together; the grain/vignette stay fixed like a real lens |

Camera moves are absolute-time (`at` and `duration` both required), play in sequence, and the camera **holds** where a move leaves it. To return, author an explicit move back to `zoom: 1, x: 0, y: 0`. Keep moves sparse and motivated: one push-in while a key element animates beats constant drift.

## Studio controls

Select a layer and open the **Animate** tab:

- **Start** (in the Visibility section) sets `after`: choose "After *layer*" plus an **Offset** instead of computing `Show At` by hand. Layers that already depend on the current one are excluded, so you cannot create a cycle
- **Behaviors** hosts Count up, Press, Leave early (`sleep`) and Draw in
- **Look** (grain and vignette) lives in the timeline's timer popover
- The editor preview plays the exact same engine the renderer uses, so scrubbing the timeline shows the true output

Camera, `wake`, `keyframes` and `svg` targets are JSON/API-only by design.

## Validation and warnings

Template create and update validate motion at write time:

- **Structural problems fail with a 400** listing each issue: a missing or cyclic `after` reference, a non-numeric `count.to`, more than 200 animated layers or 2000 tweens per page, over 120s
- **Unknown or misspelled keys never fail.** They are ignored at render and returned in the response `warnings` array with a suggestion (`unknown motion param 'pluse' — did you mean 'pulse'?`). Always read `warnings`: a warned param is doing nothing
- Renders themselves never fail on motion. A page with an uncompilable plan falls back to rendering its legacy animations

## Per-render motion control

Every motion param is tunable at render time through `modifications`, using the stored schema's own path after a `.motion.` prefix:

```json
{
  "templateId": "your-template-id",
  "modifications": {
    "weekly_stat": "742",
    "cta.motion.press.at": 3.2,
    "hero.motion.wake.duration": 0.8,
    "badge.motion.pulse": null,
    "motion.look.grain": 0.1,
    "motion.camera.moves": [{ "at": 2, "duration": 1.5, "zoom": 1.06 }]
  }
}
```

- `layerId.motion.<path>` overrides (or **adds**) element motion; `motion.<path>` or `pageN@motion.<path>` addresses the page
- `null` removes a behavior for that render
- Array params (`camera.moves`, `keyframes`, `svg.targets`) take the whole array as the value
- **Count inference**: modifying a count layer's parameterized content to a number makes the counter count to that number. `"weekly_stat": "742"` is all a data-driven stat needs
- Unknown paths come back in the render response `warnings` (and the `X-Orshot-Warnings` header) with a did-you-mean; values are clamped exactly like stored params

## Recipes

- **Results story** — stat layers with `count` and `suffix`, supporting rows chained with `after`, one camera push at the payoff
- **Launch hero** — headline `reveal`, subtitle `after` the headline (offset 0.15), cards `wake` up 0.15s apart, CTA with `pulse` and a `press` at the end
- **Live badge** — `pulse` on a dot shape, `float` on its card
- **Animated background** — a full-canvas SVG layer with `svg.targets` drawing paths in and spinning a ring, plus `look.grain` 0.05

For the machine-readable version of this page (agents), see the `orshot://template-design-spec` MCP resource.