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/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/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 983538ba51..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,6 +22,16 @@ import { hasTarget } from "./utils/has-target"
const definitionNames = ["layout", "enter", "exit", "new", "old"] as const
+type CornerRadii = Record<(typeof cornerRadiusProps)[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
@@ -284,24 +295,63 @@ 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).
+ * 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 cropBox = new Map<
+ 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 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 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 }
}
>()
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 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)
}
})
@@ -332,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
}
@@ -703,64 +753,125 @@ 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))
}
+ /**
+ * 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.
+ */
+ cropMeasurements.forEach((entry, name) => {
+ if (!croppedNames.has(name)) return
+
+ // 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 cornerRadiusProps) {
+ // `||` (not `??`) so an empty measurement falls back to
+ // the other snapshot rather than an invalid keyframe.
+ const from =
+ entry.old?.radii[corner] ||
+ entry.new?.radii[corner] ||
+ "0px"
+ const to =
+ 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
+
+ animations.push(
+ new NativeAnimation({
+ element: document.documentElement,
+ name: corner,
+ pseudoElement: `::view-transition-group(${name})`,
+ keyframes: [from, to],
+ delay,
+ duration,
+ ease,
+ })
+ )
+ }
+ })
+
resolve(new GroupAnimation(animations))
})
.catch(() =>
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 ({