framer-motion 12.41.0 → 12.42.0

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.
@@ -5715,6 +5715,29 @@
5715
5715
  element.style?.setProperty("view-transition-class", className);
5716
5716
  classed.push(element);
5717
5717
  }
5718
+ /**
5719
+ * Set the element's `view-transition-group` so its layer reconstructs the DOM
5720
+ * hierarchy in the pseudo-tree (`contain`) - or stays flat (`none`). Tracked in
5721
+ * `grouped` for cleanup. When the element clips (any non-`visible` overflow) its
5722
+ * name is recorded in `clipChildren` so the caller can clip the nested children
5723
+ * (`::view-transition-group-children(name)`), mirroring the live clip through
5724
+ * the whole transition rather than only at the live-DOM handoff.
5725
+ *
5726
+ * Ignored by browsers without nested view-transition groups, where it harmlessly
5727
+ * degrades to the flat default.
5728
+ */
5729
+ function applyGroup(element, name, group, grouped, clipChildren) {
5730
+ if (!group)
5731
+ return;
5732
+ element.style?.setProperty("view-transition-group", group);
5733
+ grouped.push(element);
5734
+ if (group !== "none" && clipChildren) {
5735
+ const style = getComputedStyle(element);
5736
+ if (style.overflowX !== "visible" || style.overflowY !== "visible") {
5737
+ clipChildren.add(name);
5738
+ }
5739
+ }
5740
+ }
5718
5741
  /**
5719
5742
  * Resolve a selector/Element to elements and ensure each one carries a
5720
5743
  * `view-transition-name` we can target from script.
@@ -5728,7 +5751,7 @@
5728
5751
  * across both captures (before and after the update), which is what allows a
5729
5752
  * persistent element to animate as a single `group` layer.
5730
5753
  */
5731
- function assignViewTransitionNames(definition, registry, assigned, forcedNames, className, classed = []) {
5754
+ function assignViewTransitionNames(definition, registry, assigned, forcedNames, className, classed = [], group, grouped = [], clipChildren) {
5732
5755
  const elements = resolveElements(definition);
5733
5756
  /**
5734
5757
  * The new end of a paired morph: give each element the matching name from
@@ -5749,6 +5772,7 @@
5749
5772
  assigned.push(element);
5750
5773
  registry.set(element, name);
5751
5774
  tagClass(element, className, classed);
5775
+ applyGroup(element, name, group, grouped, clipChildren);
5752
5776
  return name;
5753
5777
  });
5754
5778
  }
@@ -5789,6 +5813,7 @@
5789
5813
  }
5790
5814
  registry.set(element, name);
5791
5815
  tagClass(element, className, classed);
5816
+ applyGroup(element, name, group, grouped, clipChildren);
5792
5817
  return name;
5793
5818
  });
5794
5819
  }
@@ -5798,13 +5823,16 @@
5798
5823
  * (they're not in `assigned`). Safe to call more than once (e.g. on both a
5799
5824
  * finished and an interrupted transition).
5800
5825
  */
5801
- function releaseViewTransitionNames(assigned, classed = []) {
5826
+ function releaseViewTransitionNames(assigned, classed = [], grouped = []) {
5802
5827
  for (const element of assigned) {
5803
5828
  element.style?.removeProperty("view-transition-name");
5804
5829
  }
5805
5830
  for (const element of classed) {
5806
5831
  element.style?.removeProperty("view-transition-class");
5807
5832
  }
5833
+ for (const element of grouped) {
5834
+ element.style?.removeProperty("view-transition-group");
5835
+ }
5808
5836
  }
5809
5837
 
5810
5838
  function chooseLayerType(valueName) {
@@ -5889,14 +5917,14 @@
5889
5917
  new: { opacity: 0, scale: 0.85 },
5890
5918
  old: { opacity: 1, scale: 1 },
5891
5919
  };
5892
- const cornerProps = [
5893
- "borderTopLeftRadius",
5894
- "borderTopRightRadius",
5895
- "borderBottomRightRadius",
5896
- "borderBottomLeftRadius",
5897
- ];
5920
+ /**
5921
+ * How much two box aspect ratios must differ before a morph is treated as
5922
+ * aspect-changing (and so worth cropping). Matches the projection engine's
5923
+ * `preserve-aspect` threshold, so small layout jitter doesn't trigger a crop.
5924
+ */
5925
+ const ASPECT_TOLERANCE = 0.2;
5898
5926
  function startViewAnimation(builder) {
5899
- const { update, targets, resolveDefs, noCrop, pairs, classNames, options: defaultOptions, } = builder;
5927
+ const { update, targets, resolveDefs, cropOverride, pairs, classNames, flatGroups, options: defaultOptions, } = builder;
5900
5928
  if (!document.startViewTransition) {
5901
5929
  // An async IIFE (not `new Promise(async …)`) so a throwing/rejecting
5902
5930
  // update rejects this promise rather than leaving it unsettled.
@@ -5920,8 +5948,21 @@
5920
5948
  * ever stripping an author's own inline `view-transition-name`.
5921
5949
  */
5922
5950
  const classed = [];
5951
+ /**
5952
+ * Elements we set a `view-transition-group` on (for nesting), tracked for
5953
+ * cleanup. `clipChildren` collects the names of nested parents that clip in
5954
+ * the live layout, so their `::view-transition-group-children` is clipped
5955
+ * through the transition - not just once the live DOM takes back over.
5956
+ */
5957
+ const grouped = [];
5958
+ const clipChildren = new Set();
5923
5959
  const layerTargets = new Map();
5924
5960
  const croppedNames = new Set();
5961
+ /**
5962
+ * Each layer's explicit `.crop(true | false)` override (by resolved name),
5963
+ * so `finalizeCrop` can let an author's choice win over the morph default.
5964
+ */
5965
+ const cropForName = new Map();
5925
5966
  /**
5926
5967
  * Each layer's stagger position (index + total) within its subject, per
5927
5968
  * snapshot. Resolving against the snapshot the layer belongs to keeps
@@ -5943,6 +5984,18 @@
5943
5984
  const resolveLayers = (phase) => {
5944
5985
  targets.forEach((target, definition) => {
5945
5986
  const className = classNames.get(definition);
5987
+ /**
5988
+ * Nest each resolved layer under its DOM-ancestor layer by default
5989
+ * (`contain`), so an ancestor's clip/transform/opacity reach it
5990
+ * through the transition; `.group(false)` opts a subject out (`none`)
5991
+ * to stay flat and escape. Skipped for root / pre-named layers, which
5992
+ * aren't elements we resolve and style.
5993
+ */
5994
+ const group = definition === "root" || !resolveDefs.has(definition)
5995
+ ? undefined
5996
+ : flatGroups.has(definition)
5997
+ ? "none"
5998
+ : "contain";
5946
5999
  let names;
5947
6000
  if (definition === "root" || !resolveDefs.has(definition)) {
5948
6001
  names = [definition];
@@ -5955,7 +6008,7 @@
5955
6008
  */
5956
6009
  if (phase === "old") {
5957
6010
  pairFrom.set(definition, resolveElements(definition));
5958
- names = assignViewTransitionNames(definition, nameRegistry, assigned, undefined, className, classed);
6011
+ names = assignViewTransitionNames(definition, nameRegistry, assigned, undefined, className, classed, group, grouped, clipChildren);
5959
6012
  pairNames.set(definition, names);
5960
6013
  }
5961
6014
  else {
@@ -5975,13 +6028,16 @@
5975
6028
  */
5976
6029
  nameRegistry.delete(el);
5977
6030
  }
5978
- names = assignViewTransitionNames(pairs.get(definition), nameRegistry, assigned, pairNames.get(definition), className, classed);
6031
+ names = assignViewTransitionNames(pairs.get(definition), nameRegistry, assigned, pairNames.get(definition), className, classed, group, grouped, clipChildren);
5979
6032
  }
5980
6033
  }
5981
6034
  else {
5982
- names = assignViewTransitionNames(definition, nameRegistry, assigned, undefined, className, classed);
6035
+ names = assignViewTransitionNames(definition, nameRegistry, assigned, undefined, className, classed, group, grouped, clipChildren);
5983
6036
  }
5984
- const cropped = definition !== "root" && !noCrop.has(definition);
6037
+ // Record any explicit `.crop(true | false)` per resolved name; the
6038
+ // crop set itself is computed later by `finalizeCrop` (it needs both
6039
+ // snapshots to know which morphs change aspect ratio).
6040
+ const override = cropOverride.get(definition);
5985
6041
  names.forEach((name, index) => {
5986
6042
  /**
5987
6043
  * If two subjects resolve to the same element, merge their
@@ -5991,8 +6047,8 @@
5991
6047
  layerTargets.set(name, existing && existing !== target
5992
6048
  ? { ...existing, ...target }
5993
6049
  : target);
5994
- if (cropped)
5995
- croppedNames.add(name);
6050
+ if (override !== undefined)
6051
+ cropForName.set(name, override);
5996
6052
  const stagger = layerStagger.get(name) ?? {};
5997
6053
  stagger[phase] = [index, names.length];
5998
6054
  layerStagger.set(name, stagger);
@@ -6027,44 +6083,62 @@
6027
6083
  return transition;
6028
6084
  };
6029
6085
  /**
6030
- * Measured corner radii per cropped layer, so the clip can animate each
6031
- * corner between the old and new elements. Per-corner (rather than the
6032
- * shorthand) so mismatched/individual radii interpolate cleanly.
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).
6033
6088
  */
6034
- const cropRadii = new Map();
6035
- const recordRadii = (style, name, phase) => {
6036
- const corners = {};
6037
- for (const corner of cornerProps)
6038
- corners[corner] = style[corner];
6039
- const entry = cropRadii.get(name) ?? {};
6040
- entry[phase] = corners;
6041
- cropRadii.set(name, entry);
6042
- };
6089
+ const cropBox = new Map();
6090
+ const measureLayers = (phase) => nameRegistry.forEach((name, element) => {
6091
+ const rect = element.getBoundingClientRect?.();
6092
+ if (rect && rect.height) {
6093
+ const entry = cropBox.get(name) ?? {};
6094
+ entry[phase] = { width: rect.width, height: rect.height };
6095
+ cropBox.set(name, entry);
6096
+ }
6097
+ });
6043
6098
  /**
6044
- * Cropped layers all come from `.add()`, so their elements are in the
6045
- * registry - read each one's corner radii directly. For a paired morph both
6046
- * ends share a name; the new-snapshot element is registered last, so it
6047
- * wins the `new` reading (and the old end the `old` reading).
6099
+ * With both snapshots measured, settle which layers crop. The default crops
6100
+ * only a morph whose aspect ratio *changes* between snapshots - the one case
6101
+ * where `object-fit: cover` does real work. A same-aspect morph or a
6102
+ * fade-only layer is left uncropped: its corners scale naturally (no flash
6103
+ * from squaring, no `overflow: clip` eating its shadow) and a backdrop can't
6104
+ * be clipped to nothing. An explicit `.crop(true | false)` overrides either
6105
+ * way. Runs after both snapshots are measured, since aspect needs both.
6048
6106
  */
6049
- const measureCrop = (phase) => {
6050
- if (!croppedNames.size)
6051
- return;
6052
- nameRegistry.forEach((name, element) => {
6053
- if (croppedNames.has(name)) {
6054
- recordRadii(getComputedStyle(element), name, phase);
6107
+ const finalizeCrop = () => {
6108
+ croppedNames.clear();
6109
+ for (const name of layerStagger.keys()) {
6110
+ if (name === "root")
6111
+ continue;
6112
+ // An explicit `.crop(true | false)` wins; otherwise crop a morph
6113
+ // whose aspect ratio changed.
6114
+ if (cropForName.get(name) ?? aspectChanged(name)) {
6115
+ croppedNames.add(name);
6055
6116
  }
6056
- });
6117
+ }
6118
+ };
6119
+ /**
6120
+ * Whether a layer is a morph whose box aspect ratio changed between
6121
+ * snapshots (beyond a small tolerance). Fade-only layers (one snapshot) are
6122
+ * never "changed".
6123
+ */
6124
+ const aspectChanged = (name) => {
6125
+ const box = cropBox.get(name);
6126
+ if (!box?.old || !box?.new || !box.old.height || !box.new.height) {
6127
+ return false;
6128
+ }
6129
+ return (Math.abs(box.old.width / box.old.height -
6130
+ box.new.width / box.new.height) > ASPECT_TOLERANCE);
6057
6131
  };
6058
6132
  /**
6059
6133
  * Write the persistent view-transition CSS: suppress root capture when the
6060
6134
  * root has no animations of its own; force linear timing (baked into the
6061
6135
  * keyframes, so we can retime later via updateTiming); and clip +
6062
6136
  * object-fit: cover every cropped morph (the UA default overflows on
6063
- * aspect-ratio change), with an animated border-radius added below.
6137
+ * aspect-ratio change).
6064
6138
  *
6065
6139
  * `css.commit` replaces rather than appends, so we re-set the full rule set
6066
- * each call - the second call (in the update callback) then picks up cropped
6067
- * layers that only exist in the new snapshot.
6140
+ * each call - the crop rules are only known after `finalizeCrop` runs in the
6141
+ * update callback, so the second call writes them.
6068
6142
  */
6069
6143
  const commitViewCSS = () => {
6070
6144
  if (!hasTarget("root", targets)) {
@@ -6075,33 +6149,49 @@
6075
6149
  css.set(`::view-transition-group(${name})`, { overflow: "clip" });
6076
6150
  css.set(`::view-transition-old(${name}), ::view-transition-new(${name})`, { width: "100%", height: "100%", "object-fit": "cover" });
6077
6151
  });
6152
+ /**
6153
+ * Clip the nested children of any layer that clips in the live layout,
6154
+ * so a wrapper crops its child for the whole morph (mirroring the DOM)
6155
+ * rather than only at the live-DOM handoff. No-op on browsers without
6156
+ * nested view-transition groups.
6157
+ */
6158
+ clipChildren.forEach((name) => {
6159
+ css.set(`::view-transition-group-children(${name})`, {
6160
+ overflow: "clip",
6161
+ });
6162
+ });
6078
6163
  css.commit(); // Write
6079
6164
  };
6080
6165
  const cleanup = () => {
6081
- releaseViewTransitionNames(assigned, classed);
6166
+ releaseViewTransitionNames(assigned, classed, grouped);
6082
6167
  css.remove(); // Write
6083
6168
  };
6084
6169
  const callback = async () => {
6085
6170
  await update();
6086
6171
  /**
6087
6172
  * Re-resolve so elements created by the update are named for the new
6088
- * snapshot, then measure the cropped layers' new border-radius.
6173
+ * snapshot, then measure them. With both snapshots measured we can
6174
+ * settle the crop set (aspect-changing morphs + forced).
6089
6175
  */
6090
- const croppedBefore = croppedNames.size;
6091
6176
  resolveLayers("new");
6092
- measureCrop("new");
6177
+ measureLayers("new");
6178
+ finalizeCrop();
6093
6179
  /**
6094
- * Re-commit the crop CSS only if the new snapshot introduced cropped
6095
- * layers, so a layer that exists only in the new snapshot is clipped
6096
- * too - without forcing a redundant style write on the common path.
6180
+ * Re-commit the crop CSS unconditionally: `finalizeCrop` is computed
6181
+ * here (after both snapshots are measured), so the clip rules must be
6182
+ * (re)written to match the settled set.
6097
6183
  */
6098
- if (croppedNames.size > croppedBefore)
6099
- commitViewCSS();
6184
+ commitViewCSS();
6100
6185
  };
6101
6186
  let transition;
6102
6187
  try {
6103
6188
  resolveLayers("old");
6104
- measureCrop("old");
6189
+ /**
6190
+ * Measure the old snapshot against the optimistic crop set (the new
6191
+ * snapshot doesn't exist yet, so aspect change can't be known here;
6192
+ * `finalizeCrop` settles it post-update).
6193
+ */
6194
+ measureLayers("old");
6105
6195
  commitViewCSS();
6106
6196
  transition = document.startViewTransition(callback);
6107
6197
  }
@@ -6330,38 +6420,6 @@
6330
6420
  });
6331
6421
  animations.push(new NativeAnimationWrapper(animation));
6332
6422
  }
6333
- /**
6334
- * Animate each cropped layer's clip corners between the old and
6335
- * new elements, so a cropped morph keeps rounded corners
6336
- * (handling individual per-corner radii).
6337
- */
6338
- cropRadii.forEach((radii, name) => {
6339
- if (!radii.old && !radii.new)
6340
- return;
6341
- const target = layerTargets.get(name);
6342
- const [index, total] = staggerPosition(name, "group");
6343
- const radiusOptions = resolveLayerTransition(target, "group", "layout", index === -1 ? 0 : index, total);
6344
- radiusOptions.duration && (radiusOptions.duration = secondsToMilliseconds(radiusOptions.duration));
6345
- radiusOptions.delay && (radiusOptions.delay = secondsToMilliseconds(radiusOptions.delay));
6346
- for (const corner of cornerProps) {
6347
- // `||` (not `??`) so an empty measurement (e.g. an
6348
- // un-rendered element) falls back rather than producing
6349
- // an invalid keyframe.
6350
- const from = radii.old?.[corner] || radii.new?.[corner] || "0px";
6351
- const to = radii.new?.[corner] || radii.old?.[corner] || "0px";
6352
- // Skip square corners - nothing to round.
6353
- if (parseFloat(from) === 0 && parseFloat(to) === 0) {
6354
- continue;
6355
- }
6356
- animations.push(new NativeAnimation({
6357
- ...radiusOptions,
6358
- element: document.documentElement,
6359
- name: corner,
6360
- pseudoElement: `::view-transition-group(${name})`,
6361
- keyframes: [from, to],
6362
- }));
6363
- }
6364
- });
6365
6423
  resolve(new GroupAnimation(animations));
6366
6424
  })
6367
6425
  .catch(() =>
@@ -6471,10 +6529,12 @@
6471
6529
  */
6472
6530
  this.resolveDefs = new Set();
6473
6531
  /**
6474
- * Subjects opted out of the default crop (clip + object-fit: cover +
6475
- * animated corner radii) via `.crop(false)`.
6532
+ * Per-subject crop override: `true` forces the crop (clip + object-fit:
6533
+ * cover + animated corner radii) on, `false` forces it off. A subject with
6534
+ * no entry uses the default - crop only a genuine morph (a layer present in
6535
+ * both snapshots), so a fade-only enter/exit isn't clipped to nothing.
6476
6536
  */
6477
- this.noCrop = new Set();
6537
+ this.cropOverride = new Map();
6478
6538
  /**
6479
6539
  * Subjects paired with a different new-snapshot target (the second `.add()`
6480
6540
  * argument), so two distinct elements share one name and morph into each
@@ -6487,6 +6547,14 @@
6487
6547
  * the opaque generated name.
6488
6548
  */
6489
6549
  this.classNames = new Map();
6550
+ /**
6551
+ * Subjects opted out of automatic group nesting via `.group(false)`. Their
6552
+ * layer stays a flat top-level group (`view-transition-group: none`) instead
6553
+ * of nesting under its DOM-ancestor layer - so it animates independently and
6554
+ * escapes an ancestor's clip/transform (e.g. an element that lifts out of a
6555
+ * card and flies across, which nesting would clip to the card).
6556
+ */
6557
+ this.flatGroups = new Set();
6490
6558
  this.notifyReady = noop;
6491
6559
  this.notifyReject = noop;
6492
6560
  this.readyPromise = new Promise((resolve, reject) => {
@@ -6524,14 +6592,37 @@
6524
6592
  return this;
6525
6593
  }
6526
6594
  /**
6527
- * Morphs are clipped + `object-fit: cover` (and their corners animate)
6528
- * by default. Call `.crop(false)` to opt this subject out and fall back
6529
- * to the browser default (which overflows on aspect-ratio change).
6595
+ * Control this subject's crop (clip + `object-fit: cover` + animated
6596
+ * corners). By default a subject auto-crops only when it actually morphs -
6597
+ * present in both snapshots (a survivor, or an `.add(a, b)` pair). A
6598
+ * fade-only enter/exit has no second box to crop against, so it's left to
6599
+ * the browser default; in particular the `overflow: clip` a crop adds would
6600
+ * otherwise clip a mis-sized enter/exit layer to nothing.
6601
+ *
6602
+ * `.crop(false)` forces the crop off (e.g. a text morph, where
6603
+ * `object-fit: cover` clips glyphs as the box grows); `.crop(true)` forces
6604
+ * it on for a non-morph the default wouldn't otherwise crop.
6530
6605
  */
6531
6606
  crop(enabled = true) {
6607
+ this.cropOverride.set(this.currentSubject, enabled);
6608
+ return this;
6609
+ }
6610
+ /**
6611
+ * By default a subject's layer nests under its nearest DOM-ancestor layer
6612
+ * (`view-transition-group: contain`), so the ancestor's clip/transform/opacity
6613
+ * apply to it through the transition - mirroring how the DOM actually paints,
6614
+ * and letting a wrapper crop its child for the whole morph rather than only
6615
+ * once the live DOM takes back over. (Needs a browser that supports nested
6616
+ * view-transition groups; elsewhere it degrades to the flat default.)
6617
+ *
6618
+ * Call `.group(false)` to opt out: the layer stays flat and top-level, so it
6619
+ * animates independently and escapes an ancestor's clip - e.g. an element
6620
+ * that should lift out of a card and fly across, which nesting would clip.
6621
+ */
6622
+ group(enabled = true) {
6532
6623
  enabled
6533
- ? this.noCrop.delete(this.currentSubject)
6534
- : this.noCrop.add(this.currentSubject);
6624
+ ? this.flatGroups.delete(this.currentSubject)
6625
+ : this.flatGroups.add(this.currentSubject);
6535
6626
  return this;
6536
6627
  }
6537
6628
  /**