framer-motion 12.42.0 → 12.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4595,6 +4595,19 @@
4595
4595
  }
4596
4596
  }
4597
4597
 
4598
+ /**
4599
+ * The four corner-radius longhands. Shared so the projection mixer, scale
4600
+ * corrector, WAAPI px-value set and view-transition crop pass don't each carry
4601
+ * their own copy. Order is irrelevant - every consumer mixes/corrects/animates
4602
+ * each corner independently.
4603
+ */
4604
+ const cornerRadiusProps = [
4605
+ "borderTopLeftRadius",
4606
+ "borderTopRightRadius",
4607
+ "borderBottomRightRadius",
4608
+ "borderBottomLeftRadius",
4609
+ ];
4610
+
4598
4611
  const pxValues = new Set([
4599
4612
  // Border props
4600
4613
  "borderWidth",
@@ -4603,10 +4616,7 @@
4603
4616
  "borderBottomWidth",
4604
4617
  "borderLeftWidth",
4605
4618
  "borderRadius",
4606
- "borderTopLeftRadius",
4607
- "borderTopRightRadius",
4608
- "borderBottomRightRadius",
4609
- "borderBottomLeftRadius",
4619
+ ...cornerRadiusProps,
4610
4620
  // Positioning props
4611
4621
  "width",
4612
4622
  "maxWidth",
@@ -5897,6 +5907,12 @@
5897
5907
  }
5898
5908
 
5899
5909
  const definitionNames = ["layout", "enter", "exit", "new", "old"];
5910
+ /**
5911
+ * Whether a computed border-radius is square (every component zero). Splitting
5912
+ * on whitespace handles two-value/elliptical radii like "0px 20px" - a leading
5913
+ * `parseFloat` alone would misread the non-zero vertical radius as square.
5914
+ */
5915
+ const isSquareRadius = (radius) => radius.split(" ").every((value) => parseFloat(value) === 0);
5900
5916
  /**
5901
5917
  * The `ViewTransitionTarget` buckets driving each generated layer type, in
5902
5918
  * priority order - the inverse of `chooseLayerType`. The new view is driven by
@@ -6083,16 +6099,48 @@
6083
6099
  return transition;
6084
6100
  };
6085
6101
  /**
6086
- * Each layer's box size per snapshot, so `finalizeCrop` can tell whether a
6087
- * morph's aspect ratio changed (the only case worth cropping).
6102
+ * Resolve a layer's group (`layout`) timing to plain WAAPI values: native
6103
+ * ms `delay`/`duration` and a baked `ease`. The single source of group
6104
+ * timing, shared by the generated-group retiming and the crop corner-radius
6105
+ * pass so the rounded clip animates on exactly the box's timing. It returns
6106
+ * no generator `type` (the WAAPI-only `NativeAnimation` rejects a string
6107
+ * type) nor `repeat`/`times` (which the group's `updateTiming` ignores), so
6108
+ * none of them can leak into the radius animation and desync it.
6109
+ */
6110
+ const resolveGroupTiming = (name) => {
6111
+ const [index, total] = staggerPosition(name, "group");
6112
+ const transition = resolveLayerTransition(layerTargets.get(name), "group", "layout", index === -1 ? 0 : index, total);
6113
+ transition.duration && (transition.duration = secondsToMilliseconds(transition.duration));
6114
+ const { delay = 0, duration, ease } = applyGeneratorOptions(transition);
6115
+ return { delay: secondsToMilliseconds(delay), duration, ease };
6116
+ };
6117
+ /**
6118
+ * Each layer's measured box + corner radii per snapshot. The box lets
6119
+ * `finalizeCrop` tell whether a morph's aspect ratio changed (the only case
6120
+ * worth cropping); the radii let a cropped morph's group clip animate each
6121
+ * corner from the old element's radius to the new element's, keeping it
6122
+ * rounded where `overflow: clip` would otherwise square the corners.
6123
+ *
6124
+ * We never flatten the source for capture (a snapshot is a paint of the live
6125
+ * DOM, so squaring an element just for its capture would flash one real
6126
+ * square frame). For an aspect-changing morph `object-fit: cover` crops each
6127
+ * snapshot's own baked corners off-screen mid-morph, so the animated clip is
6128
+ * the only visible corner; a near-same-aspect forced crop (`.crop(true)`)
6129
+ * can't hide the outgoing snapshot's silhouette, but the endpoints coincide.
6088
6130
  */
6089
- const cropBox = new Map();
6131
+ const cropMeasurements = new Map();
6090
6132
  const measureLayers = (phase) => nameRegistry.forEach((name, element) => {
6091
- const rect = element.getBoundingClientRect?.();
6133
+ const el = element;
6134
+ const rect = el.getBoundingClientRect?.();
6092
6135
  if (rect && rect.height) {
6093
- const entry = cropBox.get(name) ?? {};
6094
- entry[phase] = { width: rect.width, height: rect.height };
6095
- cropBox.set(name, entry);
6136
+ const style = getComputedStyle(el);
6137
+ const radii = {};
6138
+ for (const corner of cornerRadiusProps) {
6139
+ radii[corner] = style[corner];
6140
+ }
6141
+ const entry = cropMeasurements.get(name) ?? {};
6142
+ entry[phase] = { width: rect.width, height: rect.height, radii };
6143
+ cropMeasurements.set(name, entry);
6096
6144
  }
6097
6145
  });
6098
6146
  /**
@@ -6122,7 +6170,7 @@
6122
6170
  * never "changed".
6123
6171
  */
6124
6172
  const aspectChanged = (name) => {
6125
- const box = cropBox.get(name);
6173
+ const box = cropMeasurements.get(name);
6126
6174
  if (!box?.old || !box?.new || !box.old.height || !box.new.height) {
6127
6175
  return false;
6128
6176
  }
@@ -6366,6 +6414,31 @@
6366
6414
  }
6367
6415
  continue;
6368
6416
  }
6417
+ /**
6418
+ * Drop the orphaned half of the default crossfade. The UA
6419
+ * fades old out and new in as a *pair*; if the opposing half
6420
+ * was explicitly overridden with something other than an
6421
+ * opacity fade (a clip or transform reveal), this side's
6422
+ * default opacity fade has no partner - left to run it would
6423
+ * dissolve what should be a static backdrop (e.g.
6424
+ * `.new({ clipPath })` should reveal over a still old view,
6425
+ * not fade the old out around the growing clip). Cancel it -
6426
+ * and its `plus-lighter` sibling on the same pseudo, which
6427
+ * would otherwise flash bright where the two opaque layers
6428
+ * overlap. A genuine crossfade (the opposing side also fading
6429
+ * opacity) keeps both halves and is handled above.
6430
+ */
6431
+ const opposite = name.type === "old"
6432
+ ? "new"
6433
+ : name.type === "new"
6434
+ ? "old"
6435
+ : undefined;
6436
+ if (opposite &&
6437
+ explicitlyAnimated.has(`${name.layer}:${opposite}`) &&
6438
+ !opacityAnimated.has(`${name.layer}:${opposite}`)) {
6439
+ animation.cancel();
6440
+ continue;
6441
+ }
6369
6442
  /**
6370
6443
  * Otherwise retime the browser-generated animation to
6371
6444
  * Motion's timing. This auto-enables the layout (group)
@@ -6390,36 +6463,85 @@
6390
6463
  const isMorphCrossfade = (name.type === "old" || name.type === "new") &&
6391
6464
  !!stagger?.old &&
6392
6465
  !!stagger?.new;
6393
- const timingType = name.type.startsWith("group") || isMorphCrossfade
6394
- ? "group"
6395
- : name.type;
6396
- const [index, total] = staggerPosition(name.layer, timingType);
6397
- const transitionName = timingType === "group" ? "layout" : "";
6398
- let animationTransition = resolveLayerTransition(targetDefinition, timingType, transitionName, index === -1 ? 0 : index, total);
6399
- /**
6400
- * The crossfade should resolve at the spring's *perceptual*
6401
- * (visual) duration - the geometry can keep bouncing, but the
6402
- * opacity shouldn't drag through the settle. So capture
6403
- * `visualDuration` before `applyGeneratorOptions` replaces it
6404
- * with the full overshoot duration, and use it for the fade.
6405
- */
6406
- const visualDuration = animationTransition.visualDuration;
6407
- animationTransition.duration && (animationTransition.duration = secondsToMilliseconds(animationTransition.duration));
6408
- animationTransition =
6409
- applyGeneratorOptions(animationTransition);
6410
- const duration = isMorphCrossfade && visualDuration !== undefined
6411
- ? secondsToMilliseconds(visualDuration)
6412
- : animationTransition.duration;
6413
- const easing = isMorphCrossfade
6414
- ? "linear"
6415
- : mapEasingToNativeEasing(animationTransition.ease, animationTransition.duration);
6416
- effect.updateTiming({
6417
- delay: secondsToMilliseconds(animationTransition.delay ?? 0),
6418
- duration,
6419
- easing,
6420
- });
6466
+ let timing;
6467
+ if (name.type.startsWith("group")) {
6468
+ // group + group-children follow the resolved group
6469
+ // timing - the single source shared with the crop
6470
+ // corner-radius pass below.
6471
+ const { delay, duration, ease } = resolveGroupTiming(name.layer);
6472
+ timing = {
6473
+ delay,
6474
+ duration,
6475
+ easing: mapEasingToNativeEasing(ease, duration),
6476
+ };
6477
+ }
6478
+ else {
6479
+ const timingType = isMorphCrossfade ? "group" : name.type;
6480
+ const [index, total] = staggerPosition(name.layer, timingType);
6481
+ const transitionName = timingType === "group" ? "layout" : "";
6482
+ let animationTransition = resolveLayerTransition(targetDefinition, timingType, transitionName, index === -1 ? 0 : index, total);
6483
+ /**
6484
+ * The crossfade should resolve at the spring's
6485
+ * *perceptual* (visual) duration - the geometry can keep
6486
+ * bouncing, but the opacity shouldn't drag through the
6487
+ * settle. So capture `visualDuration` before
6488
+ * `applyGeneratorOptions` replaces it with the full
6489
+ * overshoot duration, and use it for the fade.
6490
+ */
6491
+ const visualDuration = animationTransition.visualDuration;
6492
+ animationTransition.duration && (animationTransition.duration = secondsToMilliseconds(animationTransition.duration));
6493
+ animationTransition =
6494
+ applyGeneratorOptions(animationTransition);
6495
+ timing = {
6496
+ delay: secondsToMilliseconds(animationTransition.delay ?? 0),
6497
+ duration: isMorphCrossfade && visualDuration !== undefined
6498
+ ? secondsToMilliseconds(visualDuration)
6499
+ : animationTransition.duration,
6500
+ easing: isMorphCrossfade
6501
+ ? "linear"
6502
+ : mapEasingToNativeEasing(animationTransition.ease, animationTransition.duration),
6503
+ };
6504
+ }
6505
+ effect.updateTiming(timing);
6421
6506
  animations.push(new NativeAnimationWrapper(animation));
6422
6507
  }
6508
+ /**
6509
+ * Round each cropped layer's clip. Its `::view-transition-group`
6510
+ * has `overflow: clip`, which would otherwise square the corners
6511
+ * mid-morph; animate each corner from the old element's radius to
6512
+ * the new element's so the crop stays rounded. Timed as the group
6513
+ * (`layout`) so the radius tracks the morphing box.
6514
+ */
6515
+ cropMeasurements.forEach((entry, name) => {
6516
+ if (!croppedNames.has(name))
6517
+ return;
6518
+ // Reuse the group's resolved timing - native ms delay/
6519
+ // duration + a baked ease, with no generator `type` or
6520
+ // repeat/times to leak into (or throw inside) NativeAnimation.
6521
+ const { delay, duration, ease } = resolveGroupTiming(name);
6522
+ for (const corner of cornerRadiusProps) {
6523
+ // `||` (not `??`) so an empty measurement falls back to
6524
+ // the other snapshot rather than an invalid keyframe.
6525
+ const from = entry.old?.radii[corner] ||
6526
+ entry.new?.radii[corner] ||
6527
+ "0px";
6528
+ const to = entry.new?.radii[corner] ||
6529
+ entry.old?.radii[corner] ||
6530
+ "0px";
6531
+ // Nothing to round if the corner is square at both ends.
6532
+ if (isSquareRadius(from) && isSquareRadius(to))
6533
+ continue;
6534
+ animations.push(new NativeAnimation({
6535
+ element: document.documentElement,
6536
+ name: corner,
6537
+ pseudoElement: `::view-transition-group(${name})`,
6538
+ keyframes: [from, to],
6539
+ delay,
6540
+ duration,
6541
+ ease,
6542
+ }));
6543
+ }
6544
+ });
6423
6545
  resolve(new GroupAnimation(animations));
6424
6546
  })
6425
6547
  .catch(() =>
@@ -7799,12 +7921,7 @@
7799
7921
  const scaleCorrectors = {
7800
7922
  borderRadius: {
7801
7923
  ...correctBorderRadius,
7802
- applyTo: [
7803
- "borderTopLeftRadius",
7804
- "borderTopRightRadius",
7805
- "borderBottomLeftRadius",
7806
- "borderBottomRightRadius",
7807
- ],
7924
+ applyTo: [...cornerRadiusProps],
7808
7925
  },
7809
7926
  borderTopLeftRadius: correctBorderRadius,
7810
7927
  borderTopRightRadius: correctBorderRadius,
@@ -8732,13 +8849,7 @@
8732
8849
  return transform || "none";
8733
8850
  }
8734
8851
 
8735
- const borderLabels = [
8736
- "borderTopLeftRadius",
8737
- "borderTopRightRadius",
8738
- "borderBottomLeftRadius",
8739
- "borderBottomRightRadius",
8740
- ];
8741
- const numBorders = borderLabels.length;
8852
+ const numBorders = cornerRadiusProps.length;
8742
8853
  const asNumber = (value) => typeof value === "string" ? parseFloat(value) : value;
8743
8854
  const isPx = (value) => typeof value === "number" || px.test(value);
8744
8855
  function mixValues(target, follow, lead, progress, shouldCrossfadeOpacity, isOnlyMember) {
@@ -8753,7 +8864,7 @@
8753
8864
  * Mix border radius
8754
8865
  */
8755
8866
  for (let i = 0; i < numBorders; i++) {
8756
- const borderLabel = borderLabels[i];
8867
+ const borderLabel = cornerRadiusProps[i];
8757
8868
  let followRadius = getRadius(follow, borderLabel);
8758
8869
  let leadRadius = getRadius(lead, borderLabel);
8759
8870
  if (followRadius === undefined && leadRadius === undefined)