Skip to content

Resolve selector/Element targets in animateView()#3761

Open
mattgperry wants to merge 27 commits into
mainfrom
worktree-view-target
Open

Resolve selector/Element targets in animateView()#3761
mattgperry wants to merge 27 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) — resolves matching elements and assigns each a unique, 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. Author-defined names are respected; 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.
  • .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: 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.
  • 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, author-name skip, auto/match-element override, cleanup, and the no-startViewTransition fallback with an Element target. Full motion-dom suite green (479 passed).
  • E2E (tests/view/view-targets.spec.ts, Chromium + WebKit): Element target animates its new layer; a selector matching 3 elements → 3 distinct named layers; a pre-named .addName layer 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 inferred 1.
  • Visual demos in 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

  • 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.
  • Positional sugar (.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.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>
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