Resolve selector/Element targets in animateView()#3761
Conversation
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>
| 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 { |
There was a problem hiding this comment.
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.
| 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" | |
| ) { |
| @@ -28,13 +34,45 @@ export function startViewAnimation( | |||
| }) | |||
| } | |||
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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!
`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>
|
Thanks for the review — addressed in P1 — P2 — fallback / P2 — module-level Separately, this branch also migrates |
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>
What
animateView()(the View Transitions integration inmotion-dom) only ever animated therootlayer and pre-named string layers. Targets other than"root"were never resolved to elements or assigned aview-transition-name, so selector/Elementtargets 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-namemanagement the docs already promise, and makes cross-aspect-ratio morphs look right out of the box.API
.add(selector | Element)— resolves matching elements and assigns each a unique, script-visibleview-transition-name, in two passes (before the update → old snapshot; after → new snapshot) via a per-transitionMap<Element, string>, so a persistent element keeps its name and morphs as a singlegroup. Author-defined names are respected;auto/match-elementare 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..addName(name)— targets a pre-named layer (the former bare-string behaviour). Splitting this out from.add()removes the selector-vs-name ambiguity..crop()— on by default: clips the morph (overflow: clip) +object-fit: coverso cross-aspect-ratio morphs don't distort or overflow (the UA default does), and animates per-cornerborder-radiusfrom the old element's measured radii to the new element's..crop(false)opts a subject out.delayfunction resolves per resolved element across a selector..exit({ opacity: 0 })animates from an inferred1rather than applying instantly (falsy targets like0are no longer skipped)..get()/.new()/.old()— not part of the documented surface.Generated names are removed in
transition.finished.finally. The three TODOs instart.tsare 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-elementdeliberately 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
assign-names.test.ts): unique-name generation, same-element reuse, author-name skip,auto/match-elementoverride, cleanup, and the no-startViewTransitionfallback with anElementtarget. Fullmotion-domsuite green (479 passed).tests/view/view-targets.spec.ts, Chromium + WebKit):Elementtarget animates itsnewlayer; a selector matching 3 elements → 3 distinct named layers; a pre-named.addNamelayer still works and is not auto-renamed (negative control); a bare.add()auto-enables a group morph; per-element stagger delays; default crop (clip +object-fit+ animated radius); individual corner radii;.crop(false)opt-out; and.exit({ opacity: 0 })animating from an inferred1.dev/html/public/examples/(Netflix card→modal, circular clip-path enter/leave, crop cover-vs-overflow explainer, stagger + auto-layout) for manual exploration.Notes / follow-ups
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..add(sel, keyframes, opts)) intentionally not added — keyframes are bucket-specific, so the explicit.enter()/.exit()/.layout()chaining is the surface for now.dev/html/src/imports/view.jsis a small motion-dom-only fixture shim (the sharedinc.jsalso importsframer-motion, which isn't needed here).🤖 Generated with Claude Code