From 937cdf396c29fa2826ba0836c1e6f1a1466e5064 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 30 Jun 2026 16:14:23 +0200 Subject: [PATCH 1/3] Animate cropped view-transition group corner radius A cropped view-transition layer gets `overflow: clip` on its `::view-transition-group` to handle aspect-ratio change, but that clip is a square rectangle - so a morph between two rounded boxes loses its corner radius mid-animation (the corners snap square until the box settles). Measure each corner's radius on the old and new elements and animate `borderTopLeftRadius`/etc. on the group from one to the other, timed as the group (`layout`) so the radius tracks the morphing box and its bounce. Corners square at both ends are skipped. No source flattening: a snapshot is a paint of the live DOM, so squaring an element just for its capture flashes one real square frame on screen (the reason the previous attempt was removed). Instead we rely on `object-fit: cover` cropping each snapshot's own baked corners off-screen mid-morph, leaving the animated clip as the only visible corner; at the endpoints the baked radius and the clip radius coincide. Also adds the `.crop(true)` the avatar-profile example's comment already described (circle 50% -> card 20px), in both directions. Co-Authored-By: Claude Opus 4.8 --- dev/html/public/examples/avatar-profile.html | 8 +- packages/motion-dom/src/view/start.ts | 88 +++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/dev/html/public/examples/avatar-profile.html b/dev/html/public/examples/avatar-profile.html index d7a2ac3bfd..5a3d1608c2 100644 --- a/dev/html/public/examples/avatar-profile.html +++ b/dev/html/public/examples/avatar-profile.html @@ -199,7 +199,9 @@

Maya Powell

animateView(() => { buildProfile() avatar.style.visibility = "hidden" - }, opts).add(avatar, ".profile") + }, opts) + .add(avatar, ".profile") + .crop(true) } const close = () => { @@ -210,7 +212,9 @@

Maya Powell

document.querySelector(".profile")?.remove() document.querySelector(".profile-backdrop")?.remove() avatar.style.visibility = "visible" - }, opts).add(".profile", avatar) + }, opts) + .add(".profile", avatar) + .crop(true) } avatar.addEventListener("click", openProfile) diff --git a/packages/motion-dom/src/view/start.ts b/packages/motion-dom/src/view/start.ts index 983538ba51..a3bf37bedb 100644 --- a/packages/motion-dom/src/view/start.ts +++ b/packages/motion-dom/src/view/start.ts @@ -21,6 +21,20 @@ import { hasTarget } from "./utils/has-target" const definitionNames = ["layout", "enter", "exit", "new", "old"] as const +/** + * The four corner-radius longhands, measured per corner so a cropped layer's + * clip can animate mismatched/asymmetric radii cleanly (the shorthand wouldn't + * interpolate individual corners). + */ +const cornerProps = [ + "borderTopLeftRadius", + "borderTopRightRadius", + "borderBottomRightRadius", + "borderBottomLeftRadius", +] as const + +type CornerRadii = Record<(typeof cornerProps)[number], string> + /** * The `ViewTransitionTarget` buckets driving each generated layer type, in * priority order - the inverse of `chooseLayerType`. The new view is driven by @@ -295,13 +309,33 @@ export function startViewAnimation( } >() + /** + * Measured corner radii per layer per snapshot, so a cropped morph's group + * clip can animate each corner from the old element's radius to the new + * element's - keeping it rounded where `overflow: clip` would otherwise + * square the corners. We never flatten the source for capture (a snapshot is + * a paint of the live DOM, so squaring an element just for its capture would + * flash one real square frame); `object-fit: cover` already crops each + * snapshot's own baked corners off-screen mid-morph, so the animated clip is + * the only visible corner and at the endpoints the two coincide. + */ + const cropRadii = new Map() + const measureLayers = (phase: "old" | "new") => nameRegistry.forEach((name, element) => { - const rect = (element as HTMLElement).getBoundingClientRect?.() + const el = element as HTMLElement + const rect = el.getBoundingClientRect?.() if (rect && rect.height) { const entry = cropBox.get(name) ?? {} entry[phase] = { width: rect.width, height: rect.height } cropBox.set(name, entry) + + const style = getComputedStyle(el) + const corners = {} as CornerRadii + for (const corner of cornerProps) corners[corner] = style[corner] + const radii = cropRadii.get(name) ?? {} + radii[phase] = corners + cropRadii.set(name, radii) } }) @@ -761,6 +795,58 @@ export function startViewAnimation( animations.push(new NativeAnimationWrapper(animation)) } + /** + * Round each cropped layer's clip. Its `::view-transition-group` + * has `overflow: clip`, which would otherwise square the corners + * mid-morph; animate each corner from the old element's radius to + * the new element's so the crop stays rounded. Timed as the group + * (`layout`) so the radius tracks the morphing box. + */ + cropRadii.forEach((radii, name) => { + if (!croppedNames.has(name) || (!radii.old && !radii.new)) { + return + } + + const [index, total] = staggerPosition(name, "group") + const radiusOptions = resolveLayerTransition( + layerTargets.get(name), + "group", + "layout", + index === -1 ? 0 : index, + total + ) + + radiusOptions.duration &&= secondsToMilliseconds( + radiusOptions.duration + ) + radiusOptions.delay &&= secondsToMilliseconds( + radiusOptions.delay + ) + + for (const corner of cornerProps) { + // `||` (not `??`) so an empty measurement falls back to + // the other snapshot rather than an invalid keyframe. + const from = + radii.old?.[corner] || radii.new?.[corner] || "0px" + const to = + radii.new?.[corner] || radii.old?.[corner] || "0px" + // Nothing to round if both ends are square. + if (parseFloat(from) === 0 && parseFloat(to) === 0) { + continue + } + + animations.push( + new NativeAnimation({ + ...radiusOptions, + element: document.documentElement, + name: corner, + pseudoElement: `::view-transition-group(${name})`, + keyframes: [from, to], + }) + ) + } + }) + resolve(new GroupAnimation(animations)) }) .catch(() => From 5299aa65a2428c55728d28b68c0d3242052d8b7a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 30 Jun 2026 17:25:47 +0200 Subject: [PATCH 2/3] Resolve cropped-clip radius timing once; fix transition-option leak The corner-radius pass re-resolved the group transition and passed the user's full transition (type/repeat/times) straight into the WAAPI-only NativeAnimation, with two failure modes: - a string `type` (e.g. `{ type: "spring" }`) hit NativeAnimation's "no string type" invariant and threw inside the ready handler - caught by the outer .catch, which resolves an EMPTY GroupAnimation, dropping every animation. NativeAnimation has no spring (filesize), so a string type is intentionally unsupported. - `repeat`/`times` were honored by startWaapiAnimation (iterations/offset) but ignored by the group geometry's updateTiming, so the radius desynced from the box (and a bad `times` threw, dropping everything). Extract resolveGroupTiming() as the single source of group timing - native ms delay/duration + a baked ease, no type/repeat/times - and use it for both the generated-group retiming and the radius pass. The radius now animates on exactly the box's timing and can't be thrown or desynced by a caller's transition options. Also: skip a corner only when it's square across all components at both ends (isSquareRadius), so an elliptical "0px 20px" corner is no longer misread as square by a leading parseFloat and left unclipped; drop the dead (!radii.old && !radii.new) guard; correct the cover-crops-corners comment (only holds for aspect-changing morphs). Tests: assert a cropped morph animates the group border-radius (and .crop(false) doesn't), plus a fixture asserting repeat doesn't leak into the radius (iterations stay 1, in sync with the box). Co-Authored-By: Claude Opus 4.8 --- .../playwright/view/view-crop-timing.html | 87 ++++++++ packages/motion-dom/src/view/start.ts | 192 +++++++++++------- tests/view/view-targets.spec.ts | 22 ++ 3 files changed, 224 insertions(+), 77 deletions(-) create mode 100644 dev/html/public/playwright/view/view-crop-timing.html diff --git a/dev/html/public/playwright/view/view-crop-timing.html b/dev/html/public/playwright/view/view-crop-timing.html new file mode 100644 index 0000000000..69724c8433 --- /dev/null +++ b/dev/html/public/playwright/view/view-crop-timing.html @@ -0,0 +1,87 @@ + + + + + + +
+ + + + + diff --git a/packages/motion-dom/src/view/start.ts b/packages/motion-dom/src/view/start.ts index a3bf37bedb..4452b7eb2b 100644 --- a/packages/motion-dom/src/view/start.ts +++ b/packages/motion-dom/src/view/start.ts @@ -35,6 +35,14 @@ const cornerProps = [ type CornerRadii = Record<(typeof cornerProps)[number], string> +/** + * Whether a computed border-radius is square (every component zero). Splitting + * on whitespace handles two-value/elliptical radii like "0px 20px" - a leading + * `parseFloat` alone would misread the non-zero vertical radius as square. + */ +const isSquareRadius = (radius: string) => + radius.split(" ").every((value) => parseFloat(value) === 0) + /** * The `ViewTransitionTarget` buckets driving each generated layer type, in * priority order - the inverse of `chooseLayerType`. The new view is driven by @@ -297,6 +305,29 @@ export function startViewAnimation( return transition } + /** + * Resolve a layer's group (`layout`) timing to plain WAAPI values: native + * ms `delay`/`duration` and a baked `ease`. The single source of group + * timing, shared by the generated-group retiming and the crop corner-radius + * pass so the rounded clip animates on exactly the box's timing. It returns + * no generator `type` (the WAAPI-only `NativeAnimation` rejects a string + * type) nor `repeat`/`times` (which the group's `updateTiming` ignores), so + * none of them can leak into the radius animation and desync it. + */ + const resolveGroupTiming = (name: string) => { + const [index, total] = staggerPosition(name, "group") + const transition = resolveLayerTransition( + layerTargets.get(name), + "group", + "layout", + index === -1 ? 0 : index, + total + ) + transition.duration &&= secondsToMilliseconds(transition.duration) + const { delay = 0, duration, ease } = applyGeneratorOptions(transition) + return { delay: secondsToMilliseconds(delay), duration, ease } + } + /** * Each layer's box size per snapshot, so `finalizeCrop` can tell whether a * morph's aspect ratio changed (the only case worth cropping). @@ -315,9 +346,11 @@ export function startViewAnimation( * element's - keeping it rounded where `overflow: clip` would otherwise * square the corners. We never flatten the source for capture (a snapshot is * a paint of the live DOM, so squaring an element just for its capture would - * flash one real square frame); `object-fit: cover` already crops each - * snapshot's own baked corners off-screen mid-morph, so the animated clip is - * the only visible corner and at the endpoints the two coincide. + * flash one real square frame). For an aspect-changing morph `object-fit: + * cover` crops each snapshot's own baked corners off-screen mid-morph, so the + * animated clip is the only visible corner; a near-same-aspect forced crop + * (`.crop(true)`) can't hide the outgoing snapshot's silhouette, but the + * endpoints still coincide. */ const cropRadii = new Map() @@ -737,60 +770,78 @@ export function startViewAnimation( (name.type === "old" || name.type === "new") && !!stagger?.old && !!stagger?.new - const timingType = - name.type.startsWith("group") || isMorphCrossfade - ? "group" - : name.type - - const [index, total] = staggerPosition( - name.layer, - timingType - ) - const transitionName = - timingType === "group" ? "layout" : "" - let animationTransition = resolveLayerTransition( - targetDefinition, - timingType, - transitionName, - index === -1 ? 0 : index, - total - ) - /** - * The crossfade should resolve at the spring's *perceptual* - * (visual) duration - the geometry can keep bouncing, but the - * opacity shouldn't drag through the settle. So capture - * `visualDuration` before `applyGeneratorOptions` replaces it - * with the full overshoot duration, and use it for the fade. - */ - const visualDuration = animationTransition.visualDuration + let timing: { + delay: number + duration?: number + easing: string + } + if (name.type.startsWith("group")) { + // group + group-children follow the resolved group + // timing - the single source shared with the crop + // corner-radius pass below. + const { delay, duration, ease } = resolveGroupTiming( + name.layer + ) + timing = { + delay, + duration, + easing: mapEasingToNativeEasing( + ease, + duration! + ) as string, + } + } else { + const timingType = isMorphCrossfade ? "group" : name.type + const [index, total] = staggerPosition( + name.layer, + timingType + ) + const transitionName = + timingType === "group" ? "layout" : "" + let animationTransition = resolveLayerTransition( + targetDefinition, + timingType, + transitionName, + index === -1 ? 0 : index, + total + ) - animationTransition.duration &&= secondsToMilliseconds( - animationTransition.duration - ) + /** + * The crossfade should resolve at the spring's + * *perceptual* (visual) duration - the geometry can keep + * bouncing, but the opacity shouldn't drag through the + * settle. So capture `visualDuration` before + * `applyGeneratorOptions` replaces it with the full + * overshoot duration, and use it for the fade. + */ + const visualDuration = animationTransition.visualDuration - animationTransition = - applyGeneratorOptions(animationTransition) - - const duration = - isMorphCrossfade && visualDuration !== undefined - ? secondsToMilliseconds(visualDuration) - : animationTransition.duration - - const easing = isMorphCrossfade - ? "linear" - : (mapEasingToNativeEasing( - animationTransition.ease, - animationTransition.duration! - ) as string) - - effect.updateTiming({ - delay: secondsToMilliseconds( - animationTransition.delay ?? 0 - ), - duration, - easing, - }) + animationTransition.duration &&= secondsToMilliseconds( + animationTransition.duration + ) + + animationTransition = + applyGeneratorOptions(animationTransition) + + timing = { + delay: secondsToMilliseconds( + animationTransition.delay ?? 0 + ), + duration: + isMorphCrossfade && visualDuration !== undefined + ? secondsToMilliseconds(visualDuration) + : animationTransition.duration, + easing: isMorphCrossfade + ? "linear" + : (mapEasingToNativeEasing( + animationTransition.ease, + animationTransition.duration! + ) as string), + } + } + + effect.updateTiming(timing) animations.push(new NativeAnimationWrapper(animation)) } @@ -803,25 +854,12 @@ export function startViewAnimation( * (`layout`) so the radius tracks the morphing box. */ cropRadii.forEach((radii, name) => { - if (!croppedNames.has(name) || (!radii.old && !radii.new)) { - return - } + if (!croppedNames.has(name)) return - const [index, total] = staggerPosition(name, "group") - const radiusOptions = resolveLayerTransition( - layerTargets.get(name), - "group", - "layout", - index === -1 ? 0 : index, - total - ) - - radiusOptions.duration &&= secondsToMilliseconds( - radiusOptions.duration - ) - radiusOptions.delay &&= secondsToMilliseconds( - radiusOptions.delay - ) + // Reuse the group's resolved timing - native ms delay/ + // duration + a baked ease, with no generator `type` or + // repeat/times to leak into (or throw inside) NativeAnimation. + const { delay, duration, ease } = resolveGroupTiming(name) for (const corner of cornerProps) { // `||` (not `??`) so an empty measurement falls back to @@ -830,18 +868,18 @@ export function startViewAnimation( radii.old?.[corner] || radii.new?.[corner] || "0px" const to = radii.new?.[corner] || radii.old?.[corner] || "0px" - // Nothing to round if both ends are square. - if (parseFloat(from) === 0 && parseFloat(to) === 0) { - continue - } + // Nothing to round if the corner is square at both ends. + if (isSquareRadius(from) && isSquareRadius(to)) continue animations.push( new NativeAnimation({ - ...radiusOptions, element: document.documentElement, name: corner, pseudoElement: `::view-transition-group(${name})`, keyframes: [from, to], + delay, + duration, + ease, }) ) } diff --git a/tests/view/view-targets.spec.ts b/tests/view/view-targets.spec.ts index 3743a99adf..dfc0d8f2d0 100644 --- a/tests/view/view-targets.spec.ts +++ b/tests/view/view-targets.spec.ts @@ -148,6 +148,9 @@ test.describe("animateView() target resolution", () => { expect(result.css).toMatch( /::view-transition-old\(box\)[^{]*\{[^}]*object-fit:\s*cover/ ) + // The cropped morph rounds its square clip by animating the group's + // border-radius (8px -> 28px) - without it the corners square mid-morph. + expect(result.radiusAnimated).toBe(true) }) test(".crop(false) opts out of the default crop", async ({ page }) => { @@ -157,6 +160,25 @@ test.describe("animateView() target resolution", () => { expect(result.error).toBeNull() expect(result.css).not.toMatch(/object-fit/) + // No crop -> no overflow: clip -> no group-radius animation needed. + expect(result.radiusAnimated).toBe(false) + }) + + test("repeat/type don't leak into the cropped-clip radius animation", async ({ + page, + }) => { + await page.goto("view/view-crop-timing.html") + const result = await readResult(page) + test.skip(!result.supported, "No startViewTransition support") + + // A string `type` must not throw inside the WAAPI-only NativeAnimation + // and drop every animation. + expect(result.error).toBeNull() + // The radius reuses the group's resolved timing, which carries no + // `repeat`, so it runs once - in sync with the box geometry (also once). + // A leaking `repeat: 1` would make the radius run twice and desync. + expect(result.radiusIterations).toBe(1) + expect(result.groupIterations).toBe(1) }) test(".exit({ opacity: 0 }) animates from an inferred 1, not instantly", async ({ From 19195a4bfc77389afb9cfd06bb2fb9eaeb798e8f Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 30 Jun 2026 18:28:28 +0200 Subject: [PATCH 3/3] Dedupe corner-radius longhands; merge crop box/radii measurements The four corner-radius longhands were duplicated across four files (view crop pass, projection mixer, scale corrector, WAAPI px set). Extract a single `cornerRadiusProps` const and reference it everywhere - order is irrelevant since every consumer mixes/corrects/animates each corner independently. Also merge the crop pass's two parallel `Map` (box size + corner radii, keyed by the same name and filled in the same measure loop) into one `cropMeasurements` map - one allocation and one get/set pair per element per snapshot instead of two. Co-Authored-By: Claude Opus 4.8 --- .../src/animation/waapi/utils/px-values.ts | 7 +- .../src/projection/animation/mix-values.ts | 11 +-- .../src/projection/styles/scale-correction.ts | 8 +- .../motion-dom/src/utils/border-radius.ts | 12 +++ packages/motion-dom/src/view/start.ts | 79 ++++++++----------- 5 files changed, 53 insertions(+), 64 deletions(-) create mode 100644 packages/motion-dom/src/utils/border-radius.ts diff --git a/packages/motion-dom/src/animation/waapi/utils/px-values.ts b/packages/motion-dom/src/animation/waapi/utils/px-values.ts index b41cc2f065..c594764908 100644 --- a/packages/motion-dom/src/animation/waapi/utils/px-values.ts +++ b/packages/motion-dom/src/animation/waapi/utils/px-values.ts @@ -1,3 +1,5 @@ +import { cornerRadiusProps } from "../../../utils/border-radius" + export const pxValues = new Set([ // Border props "borderWidth", @@ -6,10 +8,7 @@ export const pxValues = new Set([ "borderBottomWidth", "borderLeftWidth", "borderRadius", - "borderTopLeftRadius", - "borderTopRightRadius", - "borderBottomRightRadius", - "borderBottomLeftRadius", + ...cornerRadiusProps, // Positioning props "width", "maxWidth", diff --git a/packages/motion-dom/src/projection/animation/mix-values.ts b/packages/motion-dom/src/projection/animation/mix-values.ts index eeadc29536..11f550d059 100644 --- a/packages/motion-dom/src/projection/animation/mix-values.ts +++ b/packages/motion-dom/src/projection/animation/mix-values.ts @@ -8,14 +8,9 @@ import { noop, } from "motion-utils" import type { ResolvedValues } from "../../node/types" +import { cornerRadiusProps } from "../../utils/border-radius" -const borderLabels = [ - "borderTopLeftRadius", - "borderTopRightRadius", - "borderBottomLeftRadius", - "borderBottomRightRadius", -] -const numBorders = borderLabels.length +const numBorders = cornerRadiusProps.length const asNumber = (value: AnyResolvedKeyframe) => typeof value === "string" ? parseFloat(value) : value @@ -54,7 +49,7 @@ export function mixValues( * Mix border radius */ for (let i = 0; i < numBorders; i++) { - const borderLabel = borderLabels[i] + const borderLabel = cornerRadiusProps[i] let followRadius = getRadius(follow, borderLabel) let leadRadius = getRadius(lead, borderLabel) diff --git a/packages/motion-dom/src/projection/styles/scale-correction.ts b/packages/motion-dom/src/projection/styles/scale-correction.ts index 030445024b..812554a30c 100644 --- a/packages/motion-dom/src/projection/styles/scale-correction.ts +++ b/packages/motion-dom/src/projection/styles/scale-correction.ts @@ -1,4 +1,5 @@ import { isCSSVariableName } from "../../animation/utils/is-css-variable" +import { cornerRadiusProps } from "../../utils/border-radius" import { correctBorderRadius } from "./scale-border-radius" import { correctBoxShadow } from "./scale-box-shadow" import type { ScaleCorrectorMap } from "./types" @@ -6,12 +7,7 @@ import type { ScaleCorrectorMap } from "./types" export const scaleCorrectors: ScaleCorrectorMap = { borderRadius: { ...correctBorderRadius, - applyTo: [ - "borderTopLeftRadius", - "borderTopRightRadius", - "borderBottomLeftRadius", - "borderBottomRightRadius", - ], + applyTo: [...cornerRadiusProps], }, borderTopLeftRadius: correctBorderRadius, borderTopRightRadius: correctBorderRadius, diff --git a/packages/motion-dom/src/utils/border-radius.ts b/packages/motion-dom/src/utils/border-radius.ts new file mode 100644 index 0000000000..b4713ffa1e --- /dev/null +++ b/packages/motion-dom/src/utils/border-radius.ts @@ -0,0 +1,12 @@ +/** + * The four corner-radius longhands. Shared so the projection mixer, scale + * corrector, WAAPI px-value set and view-transition crop pass don't each carry + * their own copy. Order is irrelevant - every consumer mixes/corrects/animates + * each corner independently. + */ +export const cornerRadiusProps = [ + "borderTopLeftRadius", + "borderTopRightRadius", + "borderBottomRightRadius", + "borderBottomLeftRadius", +] as const diff --git a/packages/motion-dom/src/view/start.ts b/packages/motion-dom/src/view/start.ts index 4452b7eb2b..b00abeb8bb 100644 --- a/packages/motion-dom/src/view/start.ts +++ b/packages/motion-dom/src/view/start.ts @@ -6,6 +6,7 @@ import { AnimationPlaybackControls, DOMKeyframesDefinition } from "../animation/ import { getValueTransition } from "../animation/utils/get-value-transition" import { mapEasingToNativeEasing } from "../animation/waapi/easing/map-easing" import { applyGeneratorOptions } from "../animation/waapi/utils/apply-generator" +import { cornerRadiusProps } from "../utils/border-radius" import { ElementOrSelector, resolveElements } from "../utils/resolve-elements" import type { ViewTransitionBuilder } from "./index" import { ViewTransitionTarget, ViewTransitionTargetDefinition } from "./types" @@ -21,19 +22,7 @@ import { hasTarget } from "./utils/has-target" const definitionNames = ["layout", "enter", "exit", "new", "old"] as const -/** - * The four corner-radius longhands, measured per corner so a cropped layer's - * clip can animate mismatched/asymmetric radii cleanly (the shorthand wouldn't - * interpolate individual corners). - */ -const cornerProps = [ - "borderTopLeftRadius", - "borderTopRightRadius", - "borderBottomRightRadius", - "borderBottomLeftRadius", -] as const - -type CornerRadii = Record<(typeof cornerProps)[number], string> +type CornerRadii = Record<(typeof cornerRadiusProps)[number], string> /** * Whether a computed border-radius is square (every component zero). Splitting @@ -329,46 +318,40 @@ export function startViewAnimation( } /** - * Each layer's box size per snapshot, so `finalizeCrop` can tell whether a - * morph's aspect ratio changed (the only case worth cropping). + * Each layer's measured box + corner radii per snapshot. The box lets + * `finalizeCrop` tell whether a morph's aspect ratio changed (the only case + * worth cropping); the radii let a cropped morph's group clip animate each + * corner from the old element's radius to the new element's, keeping it + * rounded where `overflow: clip` would otherwise square the corners. + * + * We never flatten the source for capture (a snapshot is a paint of the live + * DOM, so squaring an element just for its capture would flash one real + * square frame). For an aspect-changing morph `object-fit: cover` crops each + * snapshot's own baked corners off-screen mid-morph, so the animated clip is + * the only visible corner; a near-same-aspect forced crop (`.crop(true)`) + * can't hide the outgoing snapshot's silhouette, but the endpoints coincide. */ - const cropBox = new Map< + const cropMeasurements = new Map< string, { - old?: { width: number; height: number } - new?: { width: number; height: number } + old?: { width: number; height: number; radii: CornerRadii } + new?: { width: number; height: number; radii: CornerRadii } } >() - /** - * Measured corner radii per layer per snapshot, so a cropped morph's group - * clip can animate each corner from the old element's radius to the new - * element's - keeping it rounded where `overflow: clip` would otherwise - * square the corners. We never flatten the source for capture (a snapshot is - * a paint of the live DOM, so squaring an element just for its capture would - * flash one real square frame). For an aspect-changing morph `object-fit: - * cover` crops each snapshot's own baked corners off-screen mid-morph, so the - * animated clip is the only visible corner; a near-same-aspect forced crop - * (`.crop(true)`) can't hide the outgoing snapshot's silhouette, but the - * endpoints still coincide. - */ - const cropRadii = new Map() - const measureLayers = (phase: "old" | "new") => nameRegistry.forEach((name, element) => { const el = element as HTMLElement const rect = el.getBoundingClientRect?.() if (rect && rect.height) { - const entry = cropBox.get(name) ?? {} - entry[phase] = { width: rect.width, height: rect.height } - cropBox.set(name, entry) - const style = getComputedStyle(el) - const corners = {} as CornerRadii - for (const corner of cornerProps) corners[corner] = style[corner] - const radii = cropRadii.get(name) ?? {} - radii[phase] = corners - cropRadii.set(name, radii) + const radii = {} as CornerRadii + for (const corner of cornerRadiusProps) { + radii[corner] = style[corner] + } + const entry = cropMeasurements.get(name) ?? {} + entry[phase] = { width: rect.width, height: rect.height, radii } + cropMeasurements.set(name, entry) } }) @@ -399,7 +382,7 @@ export function startViewAnimation( * never "changed". */ const aspectChanged = (name: string) => { - const box = cropBox.get(name) + const box = cropMeasurements.get(name) if (!box?.old || !box?.new || !box.old.height || !box.new.height) { return false } @@ -853,7 +836,7 @@ export function startViewAnimation( * the new element's so the crop stays rounded. Timed as the group * (`layout`) so the radius tracks the morphing box. */ - cropRadii.forEach((radii, name) => { + cropMeasurements.forEach((entry, name) => { if (!croppedNames.has(name)) return // Reuse the group's resolved timing - native ms delay/ @@ -861,13 +844,17 @@ export function startViewAnimation( // repeat/times to leak into (or throw inside) NativeAnimation. const { delay, duration, ease } = resolveGroupTiming(name) - for (const corner of cornerProps) { + for (const corner of cornerRadiusProps) { // `||` (not `??`) so an empty measurement falls back to // the other snapshot rather than an invalid keyframe. const from = - radii.old?.[corner] || radii.new?.[corner] || "0px" + entry.old?.radii[corner] || + entry.new?.radii[corner] || + "0px" const to = - radii.new?.[corner] || radii.old?.[corner] || "0px" + entry.new?.radii[corner] || + entry.old?.radii[corner] || + "0px" // Nothing to round if the corner is square at both ends. if (isSquareRadius(from) && isSquareRadius(to)) continue