Skip to content

Motion blur (data-motion-blur): a mover inside another mover never blurs, even while the outer one holds still #7662

Open

Description

@jasonc00person

Problem

With data-motion-blur="on" on the composition root, a mover that sits inside another mover never blurs, even on frames where the outer one is standing still.

discoverMovers (packages/core/src/runtime/captureMotionBlur/discover.ts) makes one scan of the whole timeline: a candidate is any element that reaches MIN_SPEED (2 px per frame) at any step, and then only the outermost of nested candidates is kept:

const movers = candidates.filter((el) => !candidates.some((o) => o !== el && o.contains(el)));

So an element that moves once, anywhere in the composition, owns the blur for everything inside it for the whole composition.

The common shape this hits: a card rises in over the first half second, then holds for several seconds while something inside it moves (a list that scrolls, a feed that swipes, a counter chip that pops). The card is a candidate because of its entrance, so its children are dropped, and the swipe renders sharp on both tiers (filter and capture). Remove the card's entrance and the same swipe blurs correctly.

Measured on one such composition (a 392 px swipe over 0.3 s inside a card that rose in two seconds earlier), as the summed absolute error against an 8x supersampled render averaged over a 720° shutter, relative to the unblurred render: the engine's blur alone leaves 77 % of the error; with the inner mover blurred by other means it is 39 %.

data-motion-blur="off" does not help in the other direction: optedOut walks up the ancestors, so opting the card out opts out its whole subtree and the blur cannot be handed to the child.

Proposed solution

Any one of these would cover it. In order of how little they change:

  1. A narrower opt-out. data-motion-blur="off" keeps its meaning (the subtree), and a second value, say data-motion-blur="self", removes only that element from the candidates. The author marks the card, and its children become the outermost movers.
  2. An explicit opt-in below the root. data-motion-blur="on" on an element makes it a mover in its own right, whatever its ancestors do; an ancestor candidate that contains an opted-in element is dropped (or kept only when it is opted in too).
  3. Decide "outermost" per frame instead of once per composition: on a frame where the outer candidate's own speed is under MIN_SPEED, the fastest candidate inside it is the mover for that frame.

Alternatives considered

  • Restructuring the page so the inner mover is not a descendant of the card. It usually has to be one: the card's overflow: hidden is what clips the swipe.
  • Putting the card's entrance on a wrapper. The wrapper is then the candidate and still contains the child.
  • What we run today for these elements: a DOM-copy shutter (sixteen additive copies along the element's sampled trajectory) on the nested movers only, each marked data-motion-blur="off" so the engine leaves it alone, with the engine's blur on the root for everything else. It works and the two never overlap, but it costs sixteen clones per element, cannot blur a see-through fill accurately in an 8-bit compositor, and duplicates what the engine's filter tier already does better.

Additional context

Checked against 0.9.42: the filter above is unchanged. Related: #4833 asks for the engine's sub-frame blur on the CLI, which would also cover this case at render cost. The root-level blur is otherwise excellent: on text that rises it measured 35 % of the unblurred error against the same kind of reference.

Activity

  1. added
    bugSomething isn't working
    triage/needs-designEvidence is adequate; desired behavior or scope needs a maintainer decision.
    enhancementNew feature or request
    and removed
    bugSomething isn't working
    on Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesttriage/needs-designEvidence is adequate; desired behavior or scope needs a maintainer decision.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions