Animate cropped view-transition group corner radius#3768
Conversation
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>
| 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], | ||
| }) | ||
| ) | ||
| } | ||
| }) |
There was a problem hiding this comment.
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!
| const to = | ||
| radii.new?.[corner] || radii.old?.[corner] || "0px" | ||
| // Nothing to round if both ends are square. | ||
| if (parseFloat(from) === 0 && parseFloat(to) === 0) { |
There was a problem hiding this comment.
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.
| if (parseFloat(from) === 0 && parseFloat(to) === 0) { | |
| if ((parseFloat(from) === 0 && parseFloat(to) === 0) || from === to) { |
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>
Problem
A cropped view-transition layer gets
overflow: clipon its::view-transition-group(added by the aspect-aware crop) to keepobject-fit: coverfrom 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 keptoverflow: clipbut dropped the corner-radius animation, leaving the comments that describe "animated corner radii" with no implementation behind them.Fix
measureLayers, also record each corner's computed radius per snapshot (cropRadii).ready, for every cropped layer, animateborderTopLeftRadius/TopRight/BottomRight/BottomLefton::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.Why no source flattening
The earlier (removed) attempt flattened the live element's
border-radiusto0for 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)theavatar-profileexample's own comment already described (circle50%→ card20px), in both the open and close directions.How to check it out
Follow-ups (not in this PR)
tests/view/asserting the group'sborder-radiusis non-zero mid-morph.🤖 Generated with Claude Code