> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Motion Blur

> Smear fast-moving elements along their path, in the browser editor preview and in renders. Off until you turn it on.

Motion blur gives fast-moving elements a real shutter smear. For each frame, HyperFrames reads where every mover was across the shutter interval and blurs it along that path. A box sliding across the frame leaves a streak instead of a sharp copy at one position.

It is **opt-in**. Nothing blurs until you add `data-motion-blur="on"` to the composition root.

## Turn it on

```html
<div
  id="root"
  data-composition-id="main"
  data-width="1920"
  data-height="1080"
  data-duration="10"
  data-motion-blur="on"
>
  ...
</div>
```

That is all. The same attribute drives the browser editor preview and `hyperframes render`.

| Attribute | Default | Meaning |
| - | - | - |
| `data-motion-blur` | none | `on` enables blur. `off` opts out, on the root or on any subtree. |
| `data-motion-blur-shutter` | `240` | Shutter angle in degrees, 0–720. Larger is a longer smear. |
| `data-motion-blur-phase` | centred | When the shutter opens, in degrees of a frame, -360 to 360. The window starts at `t + phase / 360 / fps` and lasts `shutter / 360 / fps`. `0` opens on the frame, so the smear trails ahead of it; unset centres it, the same as minus half the shutter. Matches After Effects' Shutter Phase. |
| `data-motion-blur-samples` | at least 32 | A fixed number of samples per frame, 2–64. Left unset, each mover gets at least 32, and about one per 2 px of smear when that is more, so a fast move never shows steps. |
| `data-motion-blur-events` | off | `on` runs timeline callbacks (`onUpdate` and similar) while blur looks for movers, and at each sample when a callback changes an element's inline style or its box. Needed when the motion is set from a callback, such as a `render(t)` function, which blur cannot otherwise see. |
| `data-motion-blur-tier` | `auto` | `auto` and `filter` draw with the filter tier; `capture` asks for the capture tier (below). |

## In the browser editor

The **Blur** tab in the browser editor's right dock sets it with one control: **Off**, **Subtle** or **Strong**. Subtle is the default blur (240° shutter, samples sized to the smear). Strong opens the shutter to 720° and the samples to 64. The panel writes the attributes above to the composition root, so a render uses the same setting, and Undo works. Subtle and Strong also turn on `data-motion-blur-events`, so motion set from a `render(t)` callback is found. Off removes `data-motion-blur` and `data-motion-blur-events` and leaves the other attributes where they are.

## What it blurs

Elements that move quickly in 2D: position, scale and rotation changes from GSAP, CSS animations and transitions, and the Web Animations API. Slow movers and static content are left sharp. A mover's glow and shadow travel with it, and the background behind it stays where it is.

## Two ways it is drawn

**Filter** puts an SVG filter on each mover: copies of the element along its path, averaged, and softened along the motion. It is what `data-motion-blur-tier="auto"` (the default) draws with. It works in any Chrome and over a WebGL canvas. On a four-whip test it cost about what a render without blur costs; on two real films it cost more than capture (see Cost and limits). Turns and zooms are only softened, not streaked.

**Capture** is opt-in with `data-motion-blur-tier="capture"`. It copies each mover out of the page with Chrome's [HTML in Canvas](/guides/html-in-canvas.md) API and redraws it along its path. It draws turns and zooms truthfully: a spinning tile smears along its arc and its centre stays sharp, and a zoom streaks outward. It has limits of its own, listed here (the filter tier's are listed under Cost and limits, and a few, such as `backdrop-filter`, apply to both), so ask for it only for a film whose spins and zooms need the streak:

* Anything in front of a mover is lost: a static panel, an overlay or a title card over a moving element is drawn behind its smear.
* `mix-blend-mode` and `backdrop-filter` on a mover or its ancestor are not applied.
* A CSS selector that reaches the root through its parent, such as `body > #root > .m`, stops matching once capture wraps the root.
* A mover sliding into an `overflow: hidden` or clipped card drags the clip edge along, two overlapping movers carry each other's pixels, and content past the frame edge leaves a thin band.
* In preview, the first frame after every play shows black for a moment.

Capture needs a browser with HTML in Canvas. Chrome 157 and 158 builds exposed it without a flag in headless and Chrome-for-Testing builds; whether stable Chrome does is unverified. Chrome 155 and earlier need `chrome://flags/#html-in-canvas`.

A page that asks for capture gets the filter tier instead when capture cannot run:

| Found in the composition | Why capture cannot draw it |
| - | - |
| A WebGL or WebGPU canvas anywhere in the document | Captured frozen. |
| `backface-visibility: hidden` inside a 3D context (including from an animation) | Drawn mirrored. |
| A transform on the root element | Ignored by the capture. |
| Opacity below 1, or `mix-blend-mode`, on the root element | Not applied by the capture. |
| An `iframe`, `object`, `embed` or custom element in the root | Not captured faithfully. |
| A nested capture canvas, such as a VFX block | Hangs the renderer. |
| A browser without HTML in Canvas, or three failed captures in a row | Capture is unavailable. |

This is decided once for the whole composition, from the first frame. A defect the scan misses (a WebGL canvas added late, say) is caught per frame instead: that one frame is rendered sharp and counted in `failedFrames`.

Blur is off, and stays off, for HDR renders and renders that use the engine's own sub-frame blur. Distributed renders (`plan`, `renderChunk`, `assemble`, as the AWS Lambda and Google Cloud Run adapters run them) and `--start`/`--end` range renders blur like a whole local render. Pass `--page-motion-blur off` (or `pageMotionBlur: "off"` in a distributed config) to render them sharp.

### Find out which one is drawing

```bash
npx hyperframes check
```

| Finding | Severity | Meaning |
| - | - | - |
| `motion_blur_filter_fallback` | info | The page asked for capture, which could not run, so the filter tier draws. The message names the cause. |
| `motion_blur_no_movers` | info | Blur is on but nothing moves fast enough to blur. If the motion is set from a callback, add `data-motion-blur-events="on"`. |
| `motion_blur_unverified` | info | The page asks for capture and the check browser cannot run it, so it drew with the filter tier. Render once to see capture. |

In the page, `window.__hfMotionBlur.report()` returns `state`, `tier`, `reason`, `reasonCode` and `movers`. A render logs one line per session with the tier, and a session-end line when any frames fell back to sharp (`failedFrames > 0`).

## Cost and limits

* **Render time.** It varies by film. On an 8 s test composition with four whips, a render took 9.8 s with capture, 5.2–5.4 s with the filter tier and 5.3–5.9 s with no blur (two runs each, including startup). On two real 1080p films the filter tier cost more than capture, measured as CPU seconds of the whole render (wall time was too noisy on a loaded machine): `spacex-launch` (77 movers, 39.5 s) took 547–552 CPU s with the filter tier, 302–320 with capture and 184–193 with no blur (three runs each, 2.9 times no blur); `k3-promo` (106 movers, 16.5 s) took 236, 205 and 117 (one run per arm, 2.0 times no blur). The two films do not order by mover count, and the cause of the difference was not isolated. The filter tier draws each mover as at least 32 offset copies per frame; capture adds a second paint per blurred frame.
* **Distributed renders.** Blur applies in every chunk, drawn with the filter tier on `auto`. Measured on two real 1080p films with plan, `renderChunk` and assemble at 240 frames per chunk, in a software-GPU Chrome 153 on a machine other work was loading (load average 12 to 25), one run per arm: summed chunk CPU seconds went from 155 to 246 (1.6 times) on `k3-promo` and from 338 to 545 (1.6 times) on `spacex-launch`; summed chunk wall time rose 1.2 and 1.4 times. A film with `data-motion-blur="on"` also plans screenshot capture instead of BeginFrame capture, and the measured cost includes that switch.
* **Editing.** The filter tier never wraps anything. With capture the root is wrapped only while playing or rendering; paused, it is plain DOM, so click-to-select works.
* **Capture only.** Blurred preview can run at half the frame rate on heavy compositions, and a `<video>` drawn through the capture looks about 2–3% darker in the browser editor preview than in normal playback. Renders are not affected.
* **Preview and render.** `auto` draws the same way in both at full preview quality. When a host lowers preview quality (Studio's `set-preview-quality` message), the filter tier's preview draws 16 copies at 1/2 and 8 at 1/4 and below; renders always draw 32. Against a sub-frame reference, 16 copies stayed within measurement noise of 32. 8 copies differ visibly on turning content. A page that asks for capture previews with the filter tier in a browser without HTML in Canvas, so a spin or zoom looks softer there than in the render.
* **Worker count.** Output is not bit-identical between worker counts (around 60 dB PSNR), about the size of the difference chunking already causes.
* **Not handled.** Pose errors under CSS `zoom`, `position: fixed` and `foreignObject`; a very short Web Animations API animation that falls between scan samples; a large see-through mover (a full-frame translucent sweep), which is left sharp while its fast children still blur. With capture, a mover is drawn above the static content, so a static element that should cover a mover does not.
* **Limits of the filter tier** (the default), as measured on one-pattern test compositions:
  * `::before` or `::after` content outside the mover's box is cut off.
  * A mover's own `clip-path` or mask gets hard edges: the smear is clipped to the shape it has at that frame.
  * A `position: fixed` child of a mover that moves via `left` jumps, because the filter makes the mover its containing block.
  * A `:last-child` selector on the root's children also matches the `<svg>` the tier adds to the root (`nth-last-*` works the same way but was not measured).
  * A visibility toggle inside the shutter window is drawn at full strength instead of half.
  * `backdrop-filter` on a mover differs from the 32-sample oracle in the sweep (the backdrop only).
