Skip to content

Animate cropped view-transition group corner radius#3768

Merged
mattgperry merged 3 commits into
mainfrom
view-cropped-corner-radius
Jun 30, 2026
Merged

Animate cropped view-transition group corner radius#3768
mattgperry merged 3 commits into
mainfrom
view-cropped-corner-radius

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

Problem

A cropped view-transition layer gets overflow: clip on its ::view-transition-group (added by the aspect-aware crop) to keep object-fit: cover from overflowing on aspect 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, then pop back to rounded.

This regressed when aspect-aware cropping landed (60d7c72bc): the commit kept overflow: clip but dropped the corner-radius animation, leaving the comments that describe "animated corner radii" with no implementation behind them.

Fix

  • In measureLayers, also record each corner's computed radius per snapshot (cropRadii).
  • After ready, for every cropped layer, animate borderTopLeftRadius/TopRight/BottomRight/BottomLeft on ::view-transition-group(name) from the old element's radius → the new element's, timed as the group (layout) so the corner tracks the morphing box and its spring bounce.
  • Corners that are square at both ends are skipped (no radius animation added for a sharp-cornered crop).

Why no source flattening

The earlier (removed) attempt flattened the live element's border-radius to 0 for the capture, so the group clip could be the only corner. That's a dead end: a snapshot is a paint of the live DOM, so squaring an element just for its capture flashes one real square frame on screen.

Instead we rely on object-fit: cover: for an aspect-changing morph it scales each snapshot past the box and crops its baked corners off-screen mid-morph, leaving the animated group clip as the only visible corner. At the endpoints the baked radius and the clip radius coincide, so there's no seam.

Known limitation: for a near-same-aspect crop (e.g. the avatar→profile case, forced on with .crop(true)), cover is ~identity and doesn't crop the baked corner away, so the outgoing snapshot's silhouette stays faintly visible through the crossfade. There's no way to remove it without the capture-flatten flash. High-contrast colours exaggerate it; it's subtle on real palettes.

Demo change

Adds the .crop(true) the avatar-profile example's own comment already described (circle 50% → card 20px), in both the open and close directions.

How to check it out

git checkout view-cropped-corner-radius
cd packages/motion-dom && yarn build
cd ../../dev/html && yarn dev
# open http://localhost:8000/examples/now-playing.html  (auto-crops, aspect change)
# open http://localhost:8000/examples/avatar-profile.html  (.crop(true), circle→card)

Follow-ups (not in this PR)

  • Playwright regression test in tests/view/ asserting the group's border-radius is non-zero mid-morph.
  • CHANGELOG entry once the approach is locked.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a visual regression introduced with aspect-aware cropping: when a ::view-transition-group has overflow: clip, corner radii were squared mid-morph. The fix measures each element's computed per-corner border-radius at both snapshots and, after ready, enqueues a NativeAnimation on the group pseudo-element for each corner that differs from zero, timed identically to the group layout animation.

  • measureLayers now calls getComputedStyle per element and stores all four border-radius longhands into a new cropRadii map alongside the existing cropBox size map.
  • New animation block in the ready handler iterates cropRadii, skips layers not in croppedNames, and emits per-corner animations on ::view-transition-group(name) using the resolved group/layout timing.
  • Demo (avatar-profile.html) adds .crop(true) to both directions of the avatar→profile morph so the circle-to-rounded-card transition exercises the new path.

Confidence Score: 4/5

The change is self-contained within the view-transition animation path and introduces no new side effects on non-cropped layers.

The core fix is well-structured and consistent with the existing explicit-animation pattern in the file. Two minor gaps exist: no Playwright test accompanies the fix (explicitly required by CLAUDE.md), and a same-value no-op animation can be enqueued for single-snapshot layers forced through .crop(true). Neither affects the happy-path morph behavior.

packages/motion-dom/src/view/start.ts — specifically the new cropRadii.forEach block and the absence of a corresponding test in tests/view/.

Important Files Changed

Filename Overview
packages/motion-dom/src/view/start.ts Adds cropRadii measurement in measureLayers and a new animation block in the ready handler that drives per-corner border-radius on ::view-transition-group for cropped layers. Timing and snapshot ordering are correct; two minor edge cases exist (missing test per repo policy, no-op animation for same-value one-sided layers).
dev/html/public/examples/avatar-profile.html Adds .crop(true) to both the open and close transitions so the circle→card morph animates corner radius cleanly; the inline comment explains the trade-off (box-shadow clipped during morph). Change is purely a demo update.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant startViewAnimation
    participant Browser as Browser VT API
    participant ReadyHandler

    Caller->>startViewAnimation: animateView(...).add(...).crop(true)
    startViewAnimation->>startViewAnimation: resolveLayers(old)
    startViewAnimation->>startViewAnimation: measureLayers(old) → cropBox + cropRadii[old]
    startViewAnimation->>Browser: startViewTransition(callback)
    Browser->>startViewAnimation: callback()
    startViewAnimation->>startViewAnimation: update() — DOM mutation
    startViewAnimation->>startViewAnimation: resolveLayers(new)
    startViewAnimation->>startViewAnimation: measureLayers(new) → cropBox + cropRadii[new]
    startViewAnimation->>startViewAnimation: finalizeCrop() → croppedNames
    startViewAnimation->>startViewAnimation: commitViewCSS() overflow:clip on group
    Browser-->>ReadyHandler: transition.ready resolves
    ReadyHandler->>ReadyHandler: retime browser-generated animations
    ReadyHandler->>ReadyHandler: cropRadii.forEach(name) skip if not cropped
    loop each corner TL / TR / BR / BL
        ReadyHandler->>Browser: NativeAnimation on ::view-transition-group(name) borderXxxRadius old→new
    end
    ReadyHandler-->>Caller: GroupAnimation
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 startViewAnimation
    participant Browser as Browser VT API
    participant ReadyHandler

    Caller->>startViewAnimation: animateView(...).add(...).crop(true)
    startViewAnimation->>startViewAnimation: resolveLayers(old)
    startViewAnimation->>startViewAnimation: measureLayers(old) → cropBox + cropRadii[old]
    startViewAnimation->>Browser: startViewTransition(callback)
    Browser->>startViewAnimation: callback()
    startViewAnimation->>startViewAnimation: update() — DOM mutation
    startViewAnimation->>startViewAnimation: resolveLayers(new)
    startViewAnimation->>startViewAnimation: measureLayers(new) → cropBox + cropRadii[new]
    startViewAnimation->>startViewAnimation: finalizeCrop() → croppedNames
    startViewAnimation->>startViewAnimation: commitViewCSS() overflow:clip on group
    Browser-->>ReadyHandler: transition.ready resolves
    ReadyHandler->>ReadyHandler: retime browser-generated animations
    ReadyHandler->>ReadyHandler: cropRadii.forEach(name) skip if not cropped
    loop each corner TL / TR / BR / BL
        ReadyHandler->>Browser: NativeAnimation on ::view-transition-group(name) borderXxxRadius old→new
    end
    ReadyHandler-->>Caller: GroupAnimation
Loading

Reviews (1): Last reviewed commit: "Animate cropped view-transition group co..." | Re-trigger Greptile

Comment on lines 795 to +848
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],
})
)
}
})

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 Missing regression test for the corner-radius fix

CLAUDE.md requires a failing test to be written before implementing any bug fix or feature. No Playwright test (or any other kind) is included here, and the PR description explicitly defers one. Without a test asserting that border-radius on ::view-transition-group is non-zero mid-morph, a future refactor of measureLayers or commitViewCSS could silently re-introduce the regression that this PR fixes.

The described test (tests/view/ – asserting a non-zero border-radius at the midpoint of a cropped morph) is a straightforward Playwright addition and would directly guard the new code path.

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!

Comment thread packages/motion-dom/src/view/start.ts Outdated
const to =
radii.new?.[corner] || radii.old?.[corner] || "0px"
// Nothing to round if both ends are square.
if (parseFloat(from) === 0 && parseFloat(to) === 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 No-op radius animation created for same-value one-sided morphs

For a layer that is in croppedNames via an explicit .crop(true) override but exists only in one snapshot (enter- or exit-only), both from and to collapse to the same value via the || fallback — e.g. from = radii.new?.[corner], to = radii.new?.[corner] || radii.old?.[corner] = same string. The parseFloat(from) === 0 && parseFloat(to) === 0 guard only skips the corner when both are zero, so a non-zero single-snapshot radius still enqueues an animation that tweens a value to itself. Adding from === to as an early-exit condition alongside the all-zero check would prevent the wasted animation.

Suggested change
if (parseFloat(from) === 0 && parseFloat(to) === 0) {
if ((parseFloat(from) === 0 && parseFloat(to) === 0) || from === to) {

mattgperry and others added 2 commits June 30, 2026 17:25
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 <noreply@anthropic.com>
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<string, {old?,new?}>` (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 <noreply@anthropic.com>
@mattgperry mattgperry merged commit 718ccc7 into main Jun 30, 2026
4 of 5 checks passed
@mattgperry mattgperry deleted the view-cropped-corner-radius branch June 30, 2026 17:52
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