Skip to content

Resolve selector/Element targets in animateView()#3761

Merged
mattgperry merged 44 commits into
mainfrom
worktree-view-target
Jun 23, 2026
Merged

Resolve selector/Element targets in animateView()#3761
mattgperry merged 44 commits into
mainfrom
worktree-view-target

Conversation

@mattgperry

@mattgperry mattgperry commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

What

animateView() (the View Transitions integration in motion-dom) only ever animated the root layer and pre-named string layers. Targets other than "root" were never resolved to elements or assigned a view-transition-name, so selector/Element targets silently did nothing — the implementation carried three TODOs describing exactly this gap, with no E2E coverage.

This finishes non-root target resolution, adds the automatic view-transition-name management the docs already promise, and makes cross-aspect-ratio morphs look right out of the box.

API

  • .add(selector | Element) — the target API. Resolves matching elements and assigns each a script-visible view-transition-name, in two passes (before the update → old snapshot; after → new snapshot) via a per-transition Map<Element, string>, so a persistent element keeps its name and morphs as a single group. An element that already has an author-defined name is reused as-is (not auto-renamed); auto/match-element are overridden because their generated name isn't exposed to script (so it can't be targeted for WAAPI animations). A bare .add() auto-enables the layout/morph — no explicit .layout() needed.
  • .add(from, to) — pairs two different elements into one shared-element morph: from is resolved in the old snapshot, to in the new, and both are forced onto one generated name so the browser tweens one into the other. Symmetric — reverse the args to morph back (open: .add(card, ".modal"), close: .add(".modal", card)). Crop applies across the pair (old corners → new), so a card → modal morph is rounded automatically with no manual view-transition-name anywhere.
  • .crop()on by default: clips the morph (overflow: clip) + object-fit: cover so cross-aspect-ratio morphs don't distort or overflow (the UA default does), and animates per-corner border-radius from the old element's measured radii to the new element's. .crop(false) opts a subject out.
  • .layout(transition?) — sets the morph's transition (timing / stagger / delay). The morph is auto-enabled by .add(), so this is only a transition setter — no keyframes. On the implicit root subject it doubles as the opt-in for the whole-page crossfade (how the Netflix demo fades its backdrop/grid). .enter(keyframes, transition?) / .exit(keyframes, transition?) keep their keyframes — the new/old layer animations.
  • Per-element stagger — a delay function resolves per resolved element across a selector.
  • Inferred enter/exit from-values.exit({ opacity: 0 }) animates from an inferred 1 rather than applying instantly (falsy targets like 0 are no longer skipped).
  • Removed .get()/.new()/.old() — not part of the documented surface.

Generated names are removed in transition.finished.finally. The three TODOs in start.ts are gone.

Why not just rely on the platform?

Audited CSS View Transitions L1 and L2: neither offers a JS path for this, and view-transition-name: auto/match-element deliberately hides the generated name from script — which is exactly the handle our WAAPI engine needs to attach per-layer animations and read them back. So Motion must assign names it controls.

Testing

  • Unit (assign-names.test.ts): unique-name generation, same-element reuse, forced names (the new end of a pair), author-name skip, auto/match-element override, cleanup, and the no-startViewTransition fallback with an Element target. Full motion-dom suite green (482 passed).
  • E2E (tests/view/view-targets.spec.ts, 15 specs × Chromium + WebKit): Element target animates its new layer; a selector matching 3 elements → 3 distinct named layers; an already-named element keeps its author name (not auto-renamed); a bare .add() auto-enables a group morph; a paired .add(from, to) morphs two different elements as one layer in both directions (old element hidden, and old element removed); per-element stagger (incl. default-option staggering of a browser-generated morph, and indexing from the new snapshot when update() replaces the nodes); overlapping .add() subjects on one element keep both buckets (.a enter + .b exit); default crop (clip + object-fit + animated radius); individual corner radii; .crop(false) opt-out; .crossfade(); and .exit({ opacity: 0 }) animating from an inferred 1.
  • Visual demos in dev/html/public/examples/ — the Netflix card→modal morph (paired .add(), manually verified open + close + a no-console-error smoke run), circular clip-path enter/leave, crop cover-vs-overflow explainer, stagger + auto-layout.

Notes / follow-ups

  • Element-scoped capture (animateView(element) + element.startViewTransition) is deferred to a follow-up. It's spec-aligned (Chrome/Edge 147+), but Playwright's pinned Chromium predates 147, so the subtree-scoped path can't be E2E-verified in CI and had accrued scoped-only morph/teardown bugs that need a real Chrome 147 dev loop. This PR is the fully-verified document-level surface.
  • Keyframes aren't a positional .add() argument — its second argument is a paired target (above), not keyframes; keyframes stay on .enter()/.exit() (and .layout() takes only a transition).
  • dev/html/src/imports/view.js is a small motion-dom-only fixture shim (the shared inc.js also imports framer-motion, which isn't needed here).

🤖 Generated with Claude Code

animateView() targets other than "root" were never resolved to elements
or assigned a view-transition-name, so selector/Element targets did
nothing and the implementation carried three TODOs. Add automatic name
management:

- .add(selector | Element) resolves matching elements and assigns a
  unique, script-visible view-transition-name to each, across both
  captures (before and after the update) so persistent elements morph as
  a single group. Author-named elements are respected; `auto` is
  overridden (its generated name isn't exposed to script).
- .addName(name) targets a pre-named layer (the former bare-string
  behaviour), removing the selector/name ambiguity.
- .get(Element) now routes through resolution (Element targets were
  previously inert).
- animateView(element, update) overload scopes selector resolution to
  the element and uses element.startViewTransition() when available.

Generated names are removed when the transition finishes. Adds unit
tests for the name helper and the no-startViewTransition fallback, plus
Playwright E2E (element target, 3-element selector, pre-named
regression) passing on Chromium and WebKit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown

Greptile Summary

This PR completes the long-standing animateView() non-root target resolution: selector and Element targets are now resolved to DOM elements, each automatically assigned a unique script-visible view-transition-name across two passes (before and after the DOM update), and a new element.startViewTransition() overload scopes the transition to a subtree. The three original TODOs in start.ts are gone, backed by unit tests and Playwright E2E tests for all three target modes.

  • assign-names.ts (new): resolves ElementOrSelector to elements, reuses the same generated name for persistent elements via a per-transition Map, respects author-defined names, overrides auto, and cleans up in transition.finished.finally.
  • index.ts: adds add() / addName() API, a scope field for element-scoped transitions, and an overloaded animateView(element, update, options?) signature.
  • start.ts: wires resolveLayers (called twice — before and after update()) into the transition lifecycle and routes layerTargets to the WAAPI animation loop.

Confidence Score: 3/5

The core two-pass name resolution logic is solid, but one edge case in the new assign-names.ts utility produces wrong behavior rather than a graceful fallback.

The match-element CSS keyword (View Transitions L2) is not excluded in the author-name guard alongside auto. An element carrying that keyword is returned as the literal string match-element, which then gets used as a pseudo-element selector so the WAAPI animation silently targets a nonexistent layer. The PR description explicitly calls out that auto and match-element both hide their generated names from script and must be overridden; the test suite covers auto but not match-element. The rest of the two-pass resolution, name registry, cleanup, and new API surface are well-structured.

packages/motion-dom/src/view/utils/assign-names.ts — the match-element gap; packages/motion-dom/src/view/start.ts — worth confirming the fallback path wires through to notifyReady.

Important Files Changed

Filename Overview
packages/motion-dom/src/view/utils/assign-names.ts New utility; contains the match-element keyword omission bug and a module-level counter that grows unboundedly.
packages/motion-dom/src/view/start.ts Integration of name resolution into the view transition lifecycle looks correct; the no-startViewTransition fallback path warrants a check that notifyReady is reached.
packages/motion-dom/src/view/index.ts Adds add(), addName(), scope, and resolveDefs; overloaded animateView() signature is clean and backwards-compatible.
packages/motion-dom/src/view/tests/assign-names.test.ts Good coverage for unique names, reuse, author-name skip, and auto override; match-element override case is missing a test.
tests/view/view-targets.spec.ts Playwright E2E specs cover Element, selector, and pre-named target paths with appropriate test.skip guards for unsupported browsers.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant ViewTransitionBuilder
    participant startViewAnimation
    participant assignViewTransitionNames
    participant browser as Browser / ViewTransition

    Caller->>ViewTransitionBuilder: animateView(update).add(el).enter(kf)
    ViewTransitionBuilder->>startViewAnimation: startViewAnimation(builder)
    startViewAnimation->>assignViewTransitionNames: resolveLayers() before update
    assignViewTransitionNames-->>startViewAnimation: layerTargets Map (name to target)
    Note over browser: old snapshot captured
    startViewAnimation->>browser: document.startViewTransition(callback)
    browser->>startViewAnimation: callback()
    startViewAnimation->>Caller: await update()
    startViewAnimation->>assignViewTransitionNames: resolveLayers() after update (reuse names)
    Note over browser: new snapshot captured
    browser-->>startViewAnimation: transition.ready
    startViewAnimation->>browser: NativeAnimation per layerTarget
    browser-->>startViewAnimation: transition.finished
    startViewAnimation->>assignViewTransitionNames: releaseViewTransitionNames(assigned)
    startViewAnimation-->>Caller: GroupAnimation resolved
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant ViewTransitionBuilder
    participant startViewAnimation
    participant assignViewTransitionNames
    participant browser as Browser / ViewTransition

    Caller->>ViewTransitionBuilder: animateView(update).add(el).enter(kf)
    ViewTransitionBuilder->>startViewAnimation: startViewAnimation(builder)
    startViewAnimation->>assignViewTransitionNames: resolveLayers() before update
    assignViewTransitionNames-->>startViewAnimation: layerTargets Map (name to target)
    Note over browser: old snapshot captured
    startViewAnimation->>browser: document.startViewTransition(callback)
    browser->>startViewAnimation: callback()
    startViewAnimation->>Caller: await update()
    startViewAnimation->>assignViewTransitionNames: resolveLayers() after update (reuse names)
    Note over browser: new snapshot captured
    browser-->>startViewAnimation: transition.ready
    startViewAnimation->>browser: NativeAnimation per layerTarget
    browser-->>startViewAnimation: transition.finished
    startViewAnimation->>assignViewTransitionNames: releaseViewTransitionNames(assigned)
    startViewAnimation-->>Caller: GroupAnimation resolved
Loading

Reviews (1): Last reviewed commit: "Resolve selector/Element targets in anim..." | Re-trigger Greptile

Comment on lines +38 to +44
if (current && current !== "none" && current !== "auto") {
/**
* The author already named this layer - target it as-is and leave
* it to them to clean up.
*/
name = current
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 match-element keyword not excluded from author-name detection

The condition skips "none" and "auto" but not "match-element" (CSS View Transitions L2). An element whose view-transition-name is match-element falls into the name = current branch and returns the literal string "match-element". That string then becomes the pseudo-element selector ::view-transition-new(match-element), which targets the CSS keyword rather than any real layer — no WAAPI animation attaches and the element silently goes unanimated. The PR description explicitly calls out auto/match-element as cases that must be overridden.

Suggested change
if (current && current !== "none" && current !== "auto") {
/**
* The author already named this layer - target it as-is and leave
* it to them to clean up.
*/
name = current
} else {
if (
current &&
current !== "none" &&
current !== "auto" &&
current !== "match-element"
) {

Comment on lines 30 to 35
@@ -28,13 +34,45 @@ export function startViewAnimation(
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fallback path and notifyReady wiring

When document.startViewTransition is absent the function returns early before nameRegistry, assigned, and layerTargets are set up. The unit test confirms the update runs, but no assertion verifies that the builder then() settlement is reached via notifyReady. If addToQueue in queue.ts does not route the resolved GroupAnimation back to builder.notifyReady, callers using .add(el).enter(…).then(callback) would have their then callback silently stall in the fallback path. Does the queue mechanism in queue.ts pipe the Promise returned by startViewAnimation back to builder.notifyReady? If not, .then() on the builder silently stalls in the no-startViewTransition fallback.

@@ -0,0 +1,67 @@
import { ElementOrSelector, resolveElements } from "../../utils/resolve-elements"

let nameCount = 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Module-level nameCount never resets across page lifetime

nameCount is a module singleton that increments for every generated name and is never reset. In a long-running SPA the counter grows indefinitely, and in tests order-dependence of numeric IDs can make snapshot values non-deterministic across test files. A per-transition counter or resetting after releaseViewTransitionNames would make this self-contained.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

mattgperry and others added 2 commits June 17, 2026 16:21
`view-transition-name: match-element` (CSS View Transitions L2) generates
a name the browser keeps internal - like `auto` - so an element using it
must be assigned a name we control rather than treated as already-named.
Add it to the override condition and cover both keywords in the unit
test. Also assert the no-startViewTransition fallback settles the builder
via its returned GroupAnimation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turborepo 2.0 renamed the `pipeline` field to `tasks`; the repo pins
turbo 2.9.14, so `turbo run` (e.g. `yarn build` in CI setup) errored on
the old schema. Rename the field - task definitions are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mattgperry

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — addressed in a3ce27a and e5e31104:

P1 — match-element not excluded (real bug, fixed). Correct catch — view-transition-name: match-element generates a name the browser keeps internal, exactly like auto, so treating it as an author name produced a dead ::view-transition-new(match-element) selector. Added it to the override condition and parametrized the unit test to cover both auto and match-element.

P2 — fallback / notifyReady wiring (verified, no change needed). Yes, the queue pipes it back: queue.ts start() does startViewAnimation(builder).then((animation) => builder.notifyReady(animation)), and the fallback resolves a GroupAnimation([]) through that same path. The fallback test awaits the builder — which can only resolve if notifyReady fires — and now also asserts the resolved value is defined, so a silent stall would fail the test.

P2 — module-level nameCount (keeping, by design). The global monotonic counter is intentional: names must be unique across concurrent transitions (the queue and element-scoped startViewTransition can overlap), and a per-transition counter reset to 0 would collide between two simultaneous transitions. It's a single integer (no memory growth), and the tests assert the motion-view-<n> shape + uniqueness via a Set rather than exact numbers, so there's no determinism issue.

Separately, this branch also migrates turbo.json from the pre-2.0 pipeline key to tasks — the repo pins turbo 2.9.14, so turbo run (and thus yarn build in the CircleCI setup job) was erroring on the old schema.

mattgperry and others added 24 commits June 22, 2026 10:11
A vanilla dev/html demo of the new target resolution: the grid is
auto-named via `.add(".grid .card:not(.is-active)")` and exits together,
the detail copy enters via `.add(".detail-body")`, and the clicked
poster morphs into the hero (pre-named shared element). Live spring/tween
controls to play with the timing.

Open dev/html (vite) and visit /examples/netflix.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two improvements to animateView() target resolution:

- `.add()`/`.addName()` now register the subject and, by default, retime
  the browser's generated `group` animation instead of cancelling it - so
  a resolved/named element morphs (layout animation) without an explicit
  `.layout()`. Explicit enter/exit/layout keyframes still override the
  relevant layer, and the opacity crossfade is preserved.
- A `delay`/`stagger()` function on a target is resolved per element
  across the target's resolved layers (index, total) rather than once as
  delay(0, 1), so `.add(".card").exit({...}, { delay: stagger(0.05) })`
  staggers.

Adds Playwright coverage for both: a bare `.add()` produces a group
layer, and stagger produces distinct per-element delays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use `.addName("hero")` for the shared-element morph, stagger the grid
enter/exit, and show a clear message if motion-dom isn't built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use two shared elements - card <-> modal panel (`card-box`) and poster
<-> hero image (`hero`) - so the clicked card expands into the modal and
its poster morphs into the hero, rather than crossfading the page. The
modal's text and buttons ride inside the `card-box` layer's image, and
the backdrop fades via the root crossfade, so nothing is lifted into a
flat-painted layer that ignores z-index (which caused the overlay
stacking and exit-flicker issues).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tile grid with Reveal (staggered enter), Hide (staggered exit) and
Shuffle (auto-layout morph to new positions), with live stagger/duration
controls - showcasing per-element stagger and bare-`.add()` morphs
without the layering constraints of an expanding modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The poster (2:3) -> hero (16:9) and card -> modal box change aspect
ratio, so the captured old/new snapshots (default width:100% / height:
auto) overflowed the morphing box with their original shape. Clip each
image-pair and set the snapshots to object-fit: cover so they fill and
crop the morphing box instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cross-aspect-ratio morphs (e.g. a 2:3 thumbnail into a 16:9 hero) need
`::view-transition-image-pair(name) { overflow: clip }` plus `object-fit:
cover` on the old/new snapshots, or the old shape overflows the morphing
box. `.crop(objectFit = "cover")` records that intent per subject, and
the engine injects the CSS for the subject's resolved layer names into
the transition stylesheet - so authors stop hand-writing pseudo-element
CSS. Covered by a Playwright test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swap the gradient posters for photos (Lorem Picsum, Unsplash-sourced) so
the card->modal and poster->hero morphs recrop a real image across aspect
ratios, and replace the hand-written clip/object-fit CSS with `.crop()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
picsum.photos (and external image hosts generally) can be blocked or
slow, leaving the demo blank. Generate inline SVG data-URI posters
instead - no network, always render - while still giving the card->modal
and poster->hero morphs real raster content to recrop across aspect
ratios. Swap `poster(...)` for an `images.unsplash.com` URL for real
photography.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.crop() now measures each cropped layer's computed border-radius before
and after the update, then animates the group's clip radius between them
(at the morph timing) on top of the clip + object-fit. So a cropped morph
between rounded elements (e.g. a rounded card into a rounded modal) keeps
its corners instead of clipping square. Measurement is one DOM pass per
phase, gated on .crop() use; only the border-radius shorthand for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give the card, modal and hero border-radii so .crop() animates the clip
corners as the card morphs into the modal and the poster into the hero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Measure each cropped layer's four corner radii (rather than the
border-radius shorthand) before and after the update, and animate each
corner of the group's clip independently. So a layer with mismatched or
per-corner radii (e.g. top-rounded, bottom-square) morphs each corner
cleanly instead of failing to interpolate. Square corners (0 -> 0) are
skipped. Adds a per-corner Playwright test alongside the uniform one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hero had a uniform 18px radius, but inside the modal it renders with
24px top corners (following the modal) and a square bottom (flush to the
body). So the morph animated to 18px-all-round and then snapped to the
live shape on teardown. Give .hero `24px 24px 0 0` so the (per-corner)
crop morph lands exactly on the live element - no snap - and it doubles
as a per-corner showcase (bottom corners animate 8px -> 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`.get()` (back-compat) and `.new()`/`.old()` aren't part of the public
surface, so remove them and their now-dead internal handling (the new/old
buckets, choose-layer-type cases, ViewTransitionTarget fields). Targeting
is `.add()` (resolve + auto-name) and `.addName()` (pre-named); the layers
are driven by `.enter()`/`.exit()`/`.layout()`/`.crossfade()`. Updated the
fixtures that used `.new()` to the equivalent `.enter()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- examples/crop-compare.html: two thumbnails expanding side by side, one
  `.crop()` (cover) and one `.crop("contain")`, so the difference is
  visible mid-morph.
- examples/clip.html: a tile grid that reveals/hides with a circular
  clip-path iris (toggleable against opacity), with stagger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The explicit-animation loop did `if (!valueKeyframes) continue`, which
skipped any falsy single value - including `opacity: 0` - before it
reached the from-value inference. So `.exit({ opacity: 0 })` produced no
animation and the layer vanished instantly; only the explicit array
`{ opacity: [1, 0] }` worked. Guard on `== null` instead, so `0` flows
through and the from value is inferred (exit fades from 1, enter from 0).
Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The browser default (old/new snapshots at width:100%/height:auto)
overflows on any aspect-ratio change, which is almost never wanted. So
clip + object-fit: cover + animated corner radii are now ON by default
for every resolved/named morph target (except root). `.crop(false)` opts
a subject out; the `objectFit` argument is gone (cover only). Tests cover
default-on and the opt-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Netflix drops the now-redundant `.crop()` calls; crop-compare contrasts
the default (cropped) morph with `.crop(false)` (which overflows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
examples/scoped.html runs `animateView(scope, …)` on a box while a CSS
spinner animates *outside* the scope. On Chrome/Edge 147+ (element-scoped
support) the spinner keeps spinning through the morph; on the document-
capture fallback it freezes for the duration. A status line reports which
mode the current browser is in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An element-scoped transition (`animateView(element, …)` on Chrome 147+)
generates its pseudo-elements under the scope element, not :root - so
Motion's crop CSS and WAAPI animations, which targeted document.document
Element / `::view-transition-*`, didn't match and the browser default
ran (losing corner radius, ignoring the spring). Drive the scope element
instead: animations target it, getViewAnimations filters by it, and the
injected CSS is prefixed with a `[data-motion-view-scope]` selector that
matches it. The document path is unchanged (verified by the suite); the
scoped path needs a 147 browser to verify and the scoped.html box now
animates its radius so that's easy to eyeball.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Element-scoped (and nested) transitions add a
`::view-transition-group-children(name)` pseudo that wraps a group's
descendant groups. The layer-info regex only matched
old|new|group|image-pair, so Motion skipped group-children and left it at
the browser default (~250ms) while the rest of the morph ran at the
configured duration. Since the morphing element sits inside that
container, it snapped when the container finished early. Match
group-children and retime it on the layout timing like its group.
Verified on Chrome 147 via the scoped verifier (group-children was the
lone 250ms row among 1500ms layers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revert the animateView(element) overload and the scope-aware engine
changes (elementScoped/vtRoot/vtSelector targeting, scoped
startViewTransition, data-motion-view-scope CSS prefixes, scoped name
resolution) plus the scoped.html verifier.

element.startViewTransition is Chrome 147+, which Playwright's bundled
Chromium predates, so the scoped path can't be E2E-verified in CI and
had accrued scoped-only morph/teardown bugs that need a real Chrome 147
dev loop. Pulling it out keeps this PR to the fully-verified
document-level surface.

Kept: .add()/.addName() target resolution + auto-layout, per-element
stagger, default-on .crop() with animated per-corner border-radius,
enter/exit from-value inference, and the group-children retiming fix
(general nested-transition correctness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correctness:
- Resolve stagger/function delays on the morph-retime and crop-radius
  paths (a function reaching secondsToMilliseconds gave NaN -> threw).
- Stop a skipped/throwing transition from hanging the queue forever:
  reject `ready`/fallback properly and always advance the queue.
- Make per-element stagger index/total per-snapshot, so replacing the
  matched nodes no longer inflates delays (and skip layers absent from a
  snapshot).
- Merge overlapping .add() subjects that resolve to the same element so
  neither subject's animations are dropped.
- Let an explicit .layout() corner radius win over the default crop
  (no competing radius animation).
- Fall back from an empty measured radius instead of emitting an invalid
  keyframe.

Cleanup:
- measureCrop reads known (registry) elements and only scans the DOM for
  pre-named (.addName) cropped layers.
- Single layerBuckets table for the group/new/old <-> layout/enter/exit
  mapping; shared resolveLayerTransition for option merging.
- Remove the unreachable isCrossfade branch (the browser's plus-lighter
  is a static style, not an animated keyframe; explicit opacity runs
  under it), and the now-unused hasOpacity helper.

Tests: Jest queue error-handling + Playwright pages for default-option
stagger, node-replacement stagger, overlapping subjects, explicit crop
radius, and crossfade. All fail on the prior code and pass now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mattgperry and others added 17 commits June 23, 2026 10:03
assignViewTransitionNames interleaved a getComputedStyle read with an
inline setProperty write per resolved element, so each write dirtied
styles and the next element's read forced a style recalc - O(n) recalcs
for an n-element selector. Read all current names up front, then assign,
so it's a single recalc. Behaviour is unchanged.
Drop the pre-named-layer API; .add(selector | Element) is the sole
target surface for this PR. An element that already has an author
view-transition-name is still reused by .add() (it's not auto-renamed),
so the only lost capability is targeting a name with no element to
resolve - which is the shared-element-by-name case, deferred to the
.addName() follow-up alongside element-scoped.

Knock-on cleanups:
- measureCrop's full-document querySelectorAll("*") fallback only
  existed to locate .addName() cropped layers by name; every cropped
  layer now comes from .add() and is in the registry, so it's dead code
  and removed (also resolving the audit's scalability flag).
- Test fixtures + the netflix demo switch .addName("x") -> .add(sel) on
  the already-named elements; layer names are unchanged. The netflix
  card->modal morph keeps Motion timing via Phase-3 retiming but no
  longer gets Motion's crop (shared-element crop needs .addName()).
- Repurpose the prenamed E2E test to assert .add() reuses an author
  name rather than generating one.
A second .add() argument pairs two *different* elements - the first
resolved in the old snapshot, the second in the new - onto one shared
generated name, so they morph into each other. It's symmetric: the same
call reversed morphs back (open: .add(card, ".modal"); close:
.add(".modal", card)). Crop applies across the pair (old element's
corners -> new element's), and no manual view-transition-name is needed.

- index.ts: .add(subject, newSubject?) records the pair.
- assign-names: a forcedNames arg replays the old end's name(s) onto the
  new end instead of generating fresh per-element names.
- start.ts: resolveLayers names the old target in the old snapshot, then
  forces the same name(s) onto the paired new target in the new one;
  crop reads each end in its own snapshot (new is registered last, so it
  wins the `new` reading).
- Netflix demo: card<->modal + poster<->hero now use paired .add(),
  dropping every manual/CSS view-transition-name. Verified both morph
  directions visually + a no-console-error open/close smoke run.
- Tests: unit (forced names) + E2E for both directions (open: old
  element hidden; close: old element removed), Chromium + WebKit.
Shuffle called .add(".tile") with no delay, so the Stagger slider did
nothing for it - every tile morphed to its new cell at once. Add
.layout({}, { delay: stagger(amount()) }): the empty keyframes don't
replace the browser morph, they just retime it, and start.ts resolves
the delay per tile (by new-grid index) so they cascade. Verified the
group-morph delays stagger on Chromium + WebKit.
.add() already auto-enables the morph, so .layout()'s keyframes argument
was a leftover from before that. The only thing it uniquely did was put
explicit keyframes (border-radius, in practice) on the group layer, and
crop now handles radius by measuring the elements. Drop the keyframes
arg so .layout() is just:

  .layout(options?)   // set the morph's transition (timing/stagger/...)

On the implicit `root` subject .layout() is also how you opt the page
into the transition (the root crossfade) - its only job in the Netflix
demo (verified: root IS captured, no `:root { view-transition-name:
none }` is stamped).

- start.ts: drop the now-dead "explicit corner radius wins over crop"
  check (no layout keyframes can reach it any more).
- Demos: .layout({}) -> .layout(); .layout({}, {delay}) -> .layout({delay}).
- Tests: remove the explicit-radius fixture/test (crop covers it);
  rewrite the overlap test to merge two real buckets (.a enter + .b exit)
  rather than layout keyframes.
- Unit 482 + E2E 30 (Chromium + WebKit) green.
A paired `.add(from, to)` named the `from` element in the old snapshot
but never cleared it. If `from` is still rendered in the new snapshot -
hidden with `visibility: hidden` (or just left in place) rather than
`display: none`/removed - it kept the name and collided with `to`, which
inherits the same name: "Unexpected duplicate view-transition-name".

Transfer the name(s): capture the `from` elements in the old phase and
remove their `view-transition-name` in the new phase, before forcing the
shared name(s) onto `to`. So the name lives on `from` in the old snapshot
and only on `to` in the new - a clean morph regardless of how `from` is
hidden.

The view-pair open-direction fixture now hides `#a` with
`visibility: hidden` (was `display: none`, which masked the bug) and
asserts the name was transferred off it.
Self-contained ports of the new animateView showcases, adapted to our
dev/html shim (window.MotionDOM) and verified to run (no console errors,
correct view-transition layers) on Chromium:

- now-playing  — mini player bar <-> full player, paired .add(from, to)
- avatar-profile — avatar circle <-> profile card, paired .add(from, to)
- filter-gallery — one .add(".card").enter().exit() reflow (newcomers
  enter, leavers exit, survivors morph - all in a single pass)
- lightbox — root .enter() backdrop fade + paired image .add() morph

These exercise the API gaps we're working through: inner-content scaling
in a morph (avatar/now-playing), sibling-group stacking (now-playing
art over player), and the combined enter+exit and root+element chains
(which both work).
.add() hides the generated view-transition-name, so CSS keyed to a layer
(group z-index/stacking, custom group/image-pair keyframes, old/new
tweaks) was unreachable - the blocker behind the app-store and
family-dialog examples.

.class(name) tags each resolved element (both ends of a pair) with
`view-transition-class`, so authors target the generated layers by class
- `::view-transition-group(.name)`, `-old/-new`, `-image-pair` - without
the name. A shared class suits .add()'s one-to-many resolution better
than a unique name would, and it's additive (Motion still owns the name
for WAAPI). Classes are cleaned up with the names on finish.

Verified end-to-end: `::view-transition-group(.tag) { z-index: 99 }`
reaches the generated group layer (482 unit + 32 E2E, both engines).
enter/exit are appear/leave animations, but they were applied to every
layer with a new/old pseudo - including survivors (elements present in
both snapshots, which morph). So an element that merely moved also faded
(enter opacity) and scaled (enter/exit scale): e.g. cards dipping during
a filter-gallery reflow, in both directions.

Skip enter/exit for a survivor (a name with both an old and a new
snapshot position); it just morphs. `.crossfade()` is the exception - it
flags its enter/exit so they still dissolve a survivor's old <-> new.

- Regression test (view-survivor): a moving element must NOT get a
  `scale` keyframe on its old/new layers - the browser morph never adds
  one, so a scale there is leaked enter/exit. Fails pre-fix on both.
- view-stagger / view-overlap fixtures rewritten to use real newcomers;
  they had been asserting enter-on-survivors (the old buggy behaviour).
  overlap now checks the narrower, still-valid merge guarantee: a second
  .add() of the same element can't drop the first's enter bucket.
- Verified the filter-gallery photos->all reflow: 0 survivors scale.
enter/exit conflated presence (appear/leave) with which view (new/old) -
fine for a 1:1 transition, but .add() selects elements whose role isn't
known until the snapshots are taken. Separate the two:

- .new(kf) / .old(kf): view-targeted, ungated - animate the new/old view
  whenever it exists, including a survivor's (crossfades, slide-throughs).
- .enter(kf) / .exit(kf): presence-gated - a pure newcomer's new view, a
  pure leaver's old view. They WIN over new/old on a shared property.
- Single-value origin inference generalised: an enter mirrors the
  matching exit value (a defined exit reverses into enter for free), else
  per-type defaults (opacity 0/1, scale 0.85).
- .crossfade() removed - it's now .new({opacity:1}).old({opacity:0}). The
  crossfade flag/exception is gone; cancelling browser-generated layers
  now tracks what we actually animated (a Set), not keyframe presence, so
  a survivor's default cross-fade is retimed rather than dropped.

Tests: view-crossfade migrated to .new()/.old(); a slide-through
(transform on a survivor) fixture added. 482 unit + 36 E2E, both engines.

Note: the view path passes keyframes straight to element.animate(), so
Motion's x/y shorthands (compiled to transform only via the value
pipeline) don't apply here - use transform/translate/scale directly.
x/y are Motion transform shorthands, not CSS properties - the view path
hands keyframes straight to element.animate(), so they have no effect on
a view-transition layer. Rather than silently no-op, warn once and skip
them, pointing at transform. Real CSS properties (incl. scale, rotate,
translate, transform) work as-is, so this is the only footgun.

Test: .new({ x, opacity }) drops x (no keyframe on the new layer) but
keeps opacity, and emits the warning.
animateView/ViewTransitionBuilder already reached "motion" via
framer-motion/dom's `export * from "motion-dom"`, but the option types
(ViewTransitionOptions, ViewTransitionTargetDefinition,
ViewTransitionTarget, ViewTransitionAnimationDefinition) weren't exported
from view/index, so they didn't flow through. Add `export type * from
"./types"` so the full public surface - function + types - is importable
from "motion", "motion/react", and "framer-motion".

Verified with tsc that animateView, the types, and every builder method
(.add / .add(from,to) / .class / .crop / .layout / .enter / .exit /
.new / .old) resolve through the framer-motion/dom re-export chain.
- Preserve the UA plus-lighter blend when replacing an old/new fade, so
  explicit crossfades no longer darken mid-transition
- Resolve (not reject) an awaited animateView() when the browser skips or
  interrupts the transition; only a throwing update() rejects
- Don't inject the scale 0.85 origin onto a survivor's .new()/.old() (it
  popped persisting elements); gate non-opacity origins on enter/exit
- Name the extra elements of a paired morph whose `to` matches more nodes
  than the `from` produced names, sizing returned names to the resolved
  elements so nothing is silently dropped
- Re-commit crop CSS after the update so a cropped layer that only exists
  in the new snapshot is clipped too
- Track .class() elements separately from generated names so cleanup never
  strips an author's inline view-transition-name; drop the O(n^2) dedupe
- Re-own a stale motion-view-* name instead of adopting it as an author
  name; release names on a synchronous prelude throw
- Delete the old pair element from the registry on name transfer so crop
  measurement isn't ordered by (or re-reading) a stale element

Tests: Jest units for assign-names + the queue interrupt path; Playwright
regression fixtures for the blend, survivor-scale, pair-mismatch and
new-snapshot crop fixes (each verified to fail without the change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A surviving element's old + new views are the two halves of one
plus-lighter crossfade and must stay mirrored (alphas summing to 1) or the
additive blend flashes bright where both are partly visible. Two causes,
both fixed in the browser-generated retiming:

- Desync: the old fade was timed via the `.exit()` stagger (old-snapshot
  index) and the new fade via the `.enter()` stagger (new-snapshot index),
  so a reordered survivor got different delays on each half. Time a
  survivor's old/new as the group instead, so both halves share one delay.
- Easing: the fades inherited the bouncy spring, whose opacity overshoots
  1. Force linear easing on the crossfade; the spring stays on the group
  geometry.

Also narrow the explicit-crossfade blend keep introduced earlier: keep the
UA plus-lighter blend only when both sides actually fade opacity. A
slide/transform (.new/.old with transform, both layers opaque and
overlapping) now drops it, so it doesn't flash bright where the layers
cross.

Tests: a reordered-survivor fixture asserting old/new fades share a delay
and use linear easing; the slide fixture now asserts the blend is dropped
(the crossfade fixture already asserts it's kept). Both verified to fail
against the prior code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A per-layer/value `duration` (e.g. `.layout({ duration: 0.3 })` to time the
root crossfade / page dim) was silently discarded when the default options
were a spring: the merge kept the spring's `type`/`visualDuration`, the
spring prefers `visualDuration`, and `spring.applyToOptions` overwrites
`duration` with the computed settle time. So the dim was stuck at the
spring's full overshoot duration no matter what you set.

Add `mergeTransition(base, override)`: an explicit `duration` on the
override now drops the inherited `type`/`visualDuration`, making the layer a
plain tween of that duration (unless it asked for its own generator). Used
by both the group/old/new retiming and the explicit value merge. With no
override, layers still inherit the spring's settle (overshoot) duration as
before.

Test: spring default + `.layout({ duration: 0.3 })` -> root crossfade is
300ms while the un-overridden morph keeps its ~900ms spring settle (verified
to read 900ms, ignoring the override, without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The survivor old/new crossfade is auto-set to linear, but timed as the group
- which resolves a spring to its full settle (overshoot) duration. So the
opacity dragged through the bounce: e.g. a visualDuration 0.55 spring faded
over ~900ms, only ~60% done at the perceptual arrival point.

Time the crossfade by the spring's `visualDuration` when given (the geometry
keeps bouncing on the full settle), falling back to the resolved duration -
the spring's overshoot, or a tween's own duration - when it isn't. The fade
now resolves perceptually while the morph settles behind it.

Test: a spring morph with visualDuration 0.55 -> old/new fade is 300...550ms
linear while the group runs >700ms (verified to read 900ms pre-fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mattgperry mattgperry merged commit 378fc4c into main Jun 23, 2026
1 check was pending
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant