framer-motion 12.42.1 → 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
  }
@@ -6415,36 +6463,85 @@
6415
6463
  const isMorphCrossfade = (name.type === "old" || name.type === "new") &&
6416
6464
  !!stagger?.old &&
6417
6465
  !!stagger?.new;
6418
- const timingType = name.type.startsWith("group") || isMorphCrossfade
6419
- ? "group"
6420
- : name.type;
6421
- const [index, total] = staggerPosition(name.layer, timingType);
6422
- const transitionName = timingType === "group" ? "layout" : "";
6423
- let animationTransition = resolveLayerTransition(targetDefinition, timingType, transitionName, index === -1 ? 0 : index, total);
6424
- /**
6425
- * The crossfade should resolve at the spring's *perceptual*
6426
- * (visual) duration - the geometry can keep bouncing, but the
6427
- * opacity shouldn't drag through the settle. So capture
6428
- * `visualDuration` before `applyGeneratorOptions` replaces it
6429
- * with the full overshoot duration, and use it for the fade.
6430
- */
6431
- const visualDuration = animationTransition.visualDuration;
6432
- animationTransition.duration && (animationTransition.duration = secondsToMilliseconds(animationTransition.duration));
6433
- animationTransition =
6434
- applyGeneratorOptions(animationTransition);
6435
- const duration = isMorphCrossfade && visualDuration !== undefined
6436
- ? secondsToMilliseconds(visualDuration)
6437
- : animationTransition.duration;
6438
- const easing = isMorphCrossfade
6439
- ? "linear"
6440
- : mapEasingToNativeEasing(animationTransition.ease, animationTransition.duration);
6441
- effect.updateTiming({
6442
- delay: secondsToMilliseconds(animationTransition.delay ?? 0),
6443
- duration,
6444
- easing,
6445
- });
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);
6446
6506
  animations.push(new NativeAnimationWrapper(animation));
6447
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
+ });
6448
6545
  resolve(new GroupAnimation(animations));
6449
6546
  })
6450
6547
  .catch(() =>
@@ -7824,12 +7921,7 @@
7824
7921
  const scaleCorrectors = {
7825
7922
  borderRadius: {
7826
7923
  ...correctBorderRadius,
7827
- applyTo: [
7828
- "borderTopLeftRadius",
7829
- "borderTopRightRadius",
7830
- "borderBottomLeftRadius",
7831
- "borderBottomRightRadius",
7832
- ],
7924
+ applyTo: [...cornerRadiusProps],
7833
7925
  },
7834
7926
  borderTopLeftRadius: correctBorderRadius,
7835
7927
  borderTopRightRadius: correctBorderRadius,
@@ -8757,13 +8849,7 @@
8757
8849
  return transform || "none";
8758
8850
  }
8759
8851
 
8760
- const borderLabels = [
8761
- "borderTopLeftRadius",
8762
- "borderTopRightRadius",
8763
- "borderBottomLeftRadius",
8764
- "borderBottomRightRadius",
8765
- ];
8766
- const numBorders = borderLabels.length;
8852
+ const numBorders = cornerRadiusProps.length;
8767
8853
  const asNumber = (value) => typeof value === "string" ? parseFloat(value) : value;
8768
8854
  const isPx = (value) => typeof value === "number" || px.test(value);
8769
8855
  function mixValues(target, follow, lead, progress, shouldCrossfadeOpacity, isOnlyMember) {
@@ -8778,7 +8864,7 @@
8778
8864
  * Mix border radius
8779
8865
  */
8780
8866
  for (let i = 0; i < numBorders; i++) {
8781
- const borderLabel = borderLabels[i];
8867
+ const borderLabel = cornerRadiusProps[i];
8782
8868
  let followRadius = getRadius(follow, borderLabel);
8783
8869
  let leadRadius = getRadius(lead, borderLabel);
8784
8870
  if (followRadius === undefined && leadRadius === undefined)