diff --git a/.changeset/mosaic-popup-fade-easing.md b/.changeset/mosaic-popup-fade-easing.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-popup-fade-easing.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md index c2c5d5e8eba..7a1504090ed 100644 --- a/.claude/skills/mosaic/references/motion.md +++ b/.claude/skills/mosaic/references/motion.md @@ -5,20 +5,52 @@ Token semantics live in `packages/ui/src/mosaic/tokens.stylex.ts`, above how-to layer: the rules that decide a transition's shape, and how to check one rather than eyeball it. -| Token | Value | For | -| ----------------------- | --------------------------------------- | --------------------------- | -| `--cl-duration-instant` | `0s` | hover and press arrival | -| `--cl-duration-fast` | `0.1s` | exits | -| `--cl-duration-base` | `0.15s` | entrances, hover exit | -| `--cl-duration-slow` | `0.25s` | larger surfaces | -| `--cl-duration-slower` | `0.35s` | — | -| `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) | -| `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) | +| Token | Value | For | +| ----------------------- | --------------------------------------- | -------------------------------- | +| `--cl-duration-instant` | `0s` | hover and press arrival | +| `--cl-duration-fast` | `0.1s` | exits | +| `--cl-duration-base` | `0.15s` | entrances, hover exit | +| `--cl-duration-slow` | `0.25s` | larger surfaces | +| `--cl-duration-slower` | `0.35s` | — | +| `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) | +| `--cl-ease-enter` | `cubic-bezier(0, 0, 0.2, 1)` | arrivals that must not overshoot | +| `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) | Named curves come from [easing.dev](https://www.easing.dev) (Lochie Axon's Easing Graphs). Take one from there rather than inventing a bezier, so the catalog stays the shared vocabulary. +## `--cl-ease-default` vs `--cl-ease-enter`: count the overshoot in pixels + +`--cl-ease-default` is the default, and should stay that way — its ~2% pass past +the target is what makes a surface feel snappy rather than merely fast. What +decides whether that pass is charm or play is how far it actually travels in +pixels, and the arithmetic is worth doing rather than eyeballing: the overshoot is +2% of the **delta**, not of the element. + +| what moves | travel | overshoot | +| --------------------------------------- | ------ | --------- | +| popover, `scale(0.94 → 1)` at 320px | 19px | 0.4px | +| dialog card, `scale(0.98 → 1)` at 600px | 12px | 0.3px | +| sheet, `translate` its own 640px height | 640px | 15px | + +A scale delta is a few percent of the element, so the pass reaches a whole pixel +only on something far larger than it is applied to: ~725px of width at a 6% delta, +~2,175px at 2%. Menu, Select and Combobox cap at `18rem`, and a Popover would have +to be stretched most of the way across the viewport, so `--cl-ease-default` is +right for all of them. It is travel-based motion that crosses the threshold: a +sheet passes its inset by ~15px and visibly corrects. That is where +`--cl-ease-enter` came from, and the dialog surfaces now take it for their scale +too, as a house choice at that size rather than because the arithmetic demands it. + +So the axis is not the element's type but the size of its overshoot. Work out what +2% of the travel actually is; once it is enough pixels to notice as a bounce, take +`--cl-ease-enter`, which decelerates the same way without the pass-through. + +Opacity is the degenerate case and always takes `--cl-ease-enter`: there is nothing +past `1` to overshoot into, so the pass is clamped away and only its cost — the +slower approach to full opacity — is left. + ## A curve has a direction — don't run the entrance curve backwards The single most common motion bug in this codebase. `--cl-ease-default` is @@ -63,12 +95,16 @@ transitionDuration: { ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-default']}`, - ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], }, ``` -Both lists are **positional against `transitionProperty`** (`opacity, transform`). +The entrance list is **positional against `transitionProperty`** (`opacity, +transform`) — the fade on `--cl-ease-enter`, the geometry on the overshoot. The +exit collapses to one value because both properties want the same curve; write the +list out only when a slot genuinely differs, as the dialog's sheet does for its +slide. ## Asymmetry, in three places @@ -87,8 +123,12 @@ what stop an exit reading as a lingering ghost. ## Color and state changes (hover, press) -A state change that only recolors — background, border, text, opacity — takes -**`linear`, always**. Nothing moves, so there is nothing for an ease to sell: +A state change on an element that is already there and stays there — background, +border, text, or an opacity that only dims it — takes **`linear`, always**. This is +the counterpart to the section above, not an exception to it: a fade that carries a +surface into or out of existence is an entrance, and takes the entrance and exit +curves. A hover is not, and neither is a scrim, which dims the page rather than +arriving on it. Nothing moves, so there is nothing for an ease to sell: color interpolation is already perceptually non-uniform, an ease on top just drags the midpoint, and `--cl-ease-default`'s overshoot would extrapolate past the target color. Reserve the curves for geometry. diff --git a/packages/ui/src/mosaic/components/combobox/combobox.styles.ts b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts index 64236e7bcbc..c60fa5b9d1c 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.styles.ts +++ b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts @@ -2,6 +2,8 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, + durationVars, + easingVars, fontFamilyVars, fontWeightVars, radiusVars, @@ -25,17 +27,28 @@ export const styles = stylex.create({ default: 1, ':where([data-ending-style], [data-starting-style])': 0, }, + // The reduced-motion scale value goes too, or the exit snaps out of 96%. scale: { default: 1, ':where([data-ending-style], [data-starting-style])': 0.96, + '@media (prefers-reduced-motion: reduce)': { + default: 1, + ':where([data-ending-style], [data-starting-style])': 1, + }, }, transformOrigin: 'var(--cl-transform-origin)', transitionDuration: { - default: '150ms', - '@media (prefers-reduced-motion: reduce)': '0.01ms', + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: { + default: 'opacity, scale', + '@media (prefers-reduced-motion: reduce)': 'opacity', + }, + transitionTimingFunction: { + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], }, - transitionProperty: 'opacity, scale', - transitionTimingFunction: 'ease-out', maxHeight: 'var(--cl-available-height)', minWidth: '12.5rem', }, diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 10d9ab3d461..0b3fbe758df 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -717,14 +717,14 @@ export const popupMotion = stylex.create({ // `--cl-ease-default` because a surface this size should land rather than settle — Swift Out's // ~2% overshoot reads as the sheet arriving past its inset and correcting. transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}`, + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}`, // Positional against `transitionProperty`, so the third slot is `translate` — the sheet's // slide, and the only one that departs from `--cl-ease-exit`. Set on the PLAIN // `[data-ending-style]` branch rather than behind a media query on purpose: `translate` is // unset above the phone band, so the slot is inert there, and a media-scoped branch would // have to out-rank a plain sibling on the same property — the fight documented on // `translate` below. - ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}, ${SHEET_EXIT_EASE}`, + ':where([data-ending-style])': `${easingVars['--cl-ease-exit']}, ${easingVars['--cl-ease-exit']}, ${SHEET_EXIT_EASE}`, }, /** * The sheet's slide rides the independent `translate` property, NOT `transform` — and it @@ -779,8 +779,8 @@ export const popupMotion = stylex.create({ '@media (prefers-reduced-motion: reduce)': 'opacity', }, transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-enter']}`, - ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], }, }, @@ -803,6 +803,9 @@ export const popupMotion = stylex.create({ ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, transitionProperty: 'opacity', - transitionTimingFunction: 'linear', + transitionTimingFunction: { + default: easingVars['--cl-ease-enter'], + ':where([data-ending-style])': easingVars['--cl-ease-exit'], + }, }, }); diff --git a/packages/ui/src/mosaic/components/menu/menu.styles.ts b/packages/ui/src/mosaic/components/menu/menu.styles.ts index 55a15e1ef15..9c8ca11b06f 100644 --- a/packages/ui/src/mosaic/components/menu/menu.styles.ts +++ b/packages/ui/src/mosaic/components/menu/menu.styles.ts @@ -2,6 +2,8 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, + durationVars, + easingVars, fontFamilyVars, fontWeightVars, radiusVars, @@ -28,23 +30,32 @@ export const popup = stylex.create({ color: colorVars['--cl-color-foreground'], opacity: { default: 1, - ':is([data-ending-style])': 0, - ':is([data-starting-style])': 0, + ':where([data-starting-style], [data-ending-style])': 0, }, + // The reduced-motion scale value goes too, or the exit snaps out of 96%. scale: { default: 1, - ':is([data-ending-style])': 0.96, - ':is([data-starting-style])': 0.96, + ':where([data-starting-style], [data-ending-style])': 0.96, + '@media (prefers-reduced-motion: reduce)': { + default: 1, + ':where([data-starting-style], [data-ending-style])': 1, + }, }, // `--cl-transform-origin` is set on the positioner by the headless `cssVars` // middleware, so the popup scales out of the edge nearest its trigger. transformOrigin: 'var(--cl-transform-origin)', transitionDuration: { - default: '150ms', - '@media (prefers-reduced-motion: reduce)': '0.01ms', + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: { + default: 'opacity, scale', + '@media (prefers-reduced-motion: reduce)': 'opacity', + }, + transitionTimingFunction: { + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], }, - transitionProperty: 'opacity, scale', - transitionTimingFunction: 'ease-out', maxHeight: 'var(--cl-available-height)', // Capped, or the popup grows to whatever its longest item says and `Menu.Label` never // reaches the width it has to truncate at. @@ -97,11 +108,6 @@ export const item = stylex.create({ outlineOffset: 0, position: 'relative', textAlign: 'start', - transitionDuration: { - default: '150ms', - '@media (prefers-reduced-motion: reduce)': '0.01ms', - }, - transitionProperty: 'background-color', height: space['8'], width: '100%', '::before': { diff --git a/packages/ui/src/mosaic/components/popover/popover.styles.ts b/packages/ui/src/mosaic/components/popover/popover.styles.ts index 2703d2fb53d..c0b4fd25059 100644 --- a/packages/ui/src/mosaic/components/popover/popover.styles.ts +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -73,15 +73,11 @@ export const styles = stylex.create({ default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity', }, - // Positional, in the order of `transitionProperty`. Opacity takes `linear` — - // its interpolation is already perceptually non-uniform, and the overshoot in - // `--cl-ease-default` would extrapolate past the target for no gain. The scale - // is what moves, so it gets the overshoot on the way in and accelerates away on - // the way out; running the entrance curve backwards stalls the exit for most of - // its duration and turns the overshoot into a wobble past the target. + // Positional against `transitionProperty`; the entrance curve run backwards would + // stall the exit and turn the overshoot into a wobble. transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-default']}`, - ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], }, maxHeight: 'min(80dvh, 36rem)', maxWidth: 'calc(100vw - 2rem)', diff --git a/packages/ui/src/mosaic/components/select/select.styles.ts b/packages/ui/src/mosaic/components/select/select.styles.ts index 6207989a7a4..7cc23529156 100644 --- a/packages/ui/src/mosaic/components/select/select.styles.ts +++ b/packages/ui/src/mosaic/components/select/select.styles.ts @@ -65,8 +65,8 @@ export const popup = stylex.create({ '@media (prefers-reduced-motion: reduce)': 'opacity', }, transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-default']}`, - ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], }, maxHeight: 'var(--cl-available-height)', maxWidth: 'min(18rem, calc(100vw - 2rem))',