From 7c0fd5dfb291fb7a96669e09fe078a196095db0d Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 16 Sep 2026 14:28:47 -0600 Subject: [PATCH 1/4] refactor(ui): ease Mosaic popup fades and align menu/combobox motion Opacity enters on `--cl-ease-enter` and leaves on `--cl-ease-exit` across the Popover, Select, Menu, Combobox and Dialog popups, replacing `linear`. The Dialog's backdrop scrims stay linear. Menu and Combobox popups move off their hardcoded `150ms`/`ease-out` onto the duration and easing tokens the other popups use, and reduced motion now drops the scale rather than the whole transition. Menu items swap their background instantly on hover, matching Select. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-popup-fade-easing.md | 2 ++ .../components/combobox/combobox.styles.ts | 22 ++++++++++--- .../mosaic/components/dialog/dialog.styles.ts | 13 +++++--- .../src/mosaic/components/menu/menu.styles.ts | 33 +++++++++++-------- .../components/popover/popover.styles.ts | 14 ++++---- .../mosaic/components/select/select.styles.ts | 4 +-- 6 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 .changeset/mosaic-popup-fade-easing.md 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/packages/ui/src/mosaic/components/combobox/combobox.styles.ts b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts index 64236e7bcbc..2f3d64b1022 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,29 @@ export const styles = stylex.create({ default: 1, ':where([data-ending-style], [data-starting-style])': 0, }, + // Reduced motion drops the scale VALUE as well as its transition: left in, it would + // apply instantly and snap the popup out of 96% on the way out. 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..3391487c3da 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,33 @@ 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, }, + // Reduced motion drops the scale VALUE as well as its transition: left in, it would + // apply instantly and snap the popup out of 96% on the way out. 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 +109,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..aac0cc018ce 100644 --- a/packages/ui/src/mosaic/components/popover/popover.styles.ts +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -73,15 +73,13 @@ 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, in the order of `transitionProperty`. The fade takes the plain enter and + // exit curves; 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. 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))', From ce3132b50ed1cbfa7a44e207d1cd0a4560a20ee9 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 16 Sep 2026 14:32:55 -0600 Subject: [PATCH 2/4] docs(repo): update the Mosaic motion reference for the eased fades Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/mosaic/references/motion.md | 57 ++++++++++++++++------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md index c2c5d5e8eba..2bfd064bbb6 100644 --- a/.claude/skills/mosaic/references/motion.md +++ b/.claude/skills/mosaic/references/motion.md @@ -5,20 +5,41 @@ 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 small surface feel snappy rather than merely fast. But +that 2% is a fraction of the delta, so the same curve overshoots by a different +amount depending on what it is applied to, and what the eye judges is the absolute +distance. A 200px popup scaling from 0.96 passes its target by under a pixel and +reads as a settle. A full-width dialog does the same thing by tens of pixels and +reads as playful — the surface arriving past its inset and correcting. That is +where `--cl-ease-enter` came from: the dialogs, where the default had too much +play in 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 +84,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 +112,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. From ca7d3144a8e672ebb1e69038c1a7fefbbdbd625c Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 16 Sep 2026 15:02:58 -0600 Subject: [PATCH 3/4] docs(repo): correct the overshoot arithmetic and trim the motion comments Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/mosaic/references/motion.md | 25 +++++++++++++------ .../components/combobox/combobox.styles.ts | 3 +-- .../src/mosaic/components/menu/menu.styles.ts | 3 +-- .../components/popover/popover.styles.ts | 6 ++--- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md index 2bfd064bbb6..689b6cc87fa 100644 --- a/.claude/skills/mosaic/references/motion.md +++ b/.claude/skills/mosaic/references/motion.md @@ -23,14 +23,23 @@ 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 small surface feel snappy rather than merely fast. But -that 2% is a fraction of the delta, so the same curve overshoots by a different -amount depending on what it is applied to, and what the eye judges is the absolute -distance. A 200px popup scaling from 0.96 passes its target by under a pixel and -reads as a settle. A full-width dialog does the same thing by tens of pixels and -reads as playful — the surface arriving past its inset and correcting. That is -where `--cl-ease-enter` came from: the dialogs, where the default had too much -play in it. +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 its overshoot lands sub-pixel +however large the surface — `--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 diff --git a/packages/ui/src/mosaic/components/combobox/combobox.styles.ts b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts index 2f3d64b1022..c60fa5b9d1c 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.styles.ts +++ b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts @@ -27,8 +27,7 @@ export const styles = stylex.create({ default: 1, ':where([data-ending-style], [data-starting-style])': 0, }, - // Reduced motion drops the scale VALUE as well as its transition: left in, it would - // apply instantly and snap the popup out of 96% on the way out. + // 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, diff --git a/packages/ui/src/mosaic/components/menu/menu.styles.ts b/packages/ui/src/mosaic/components/menu/menu.styles.ts index 3391487c3da..9c8ca11b06f 100644 --- a/packages/ui/src/mosaic/components/menu/menu.styles.ts +++ b/packages/ui/src/mosaic/components/menu/menu.styles.ts @@ -32,8 +32,7 @@ export const popup = stylex.create({ default: 1, ':where([data-starting-style], [data-ending-style])': 0, }, - // Reduced motion drops the scale VALUE as well as its transition: left in, it would - // apply instantly and snap the popup out of 96% on the way out. + // The reduced-motion scale value goes too, or the exit snaps out of 96%. scale: { default: 1, ':where([data-starting-style], [data-ending-style])': 0.96, diff --git a/packages/ui/src/mosaic/components/popover/popover.styles.ts b/packages/ui/src/mosaic/components/popover/popover.styles.ts index aac0cc018ce..c0b4fd25059 100644 --- a/packages/ui/src/mosaic/components/popover/popover.styles.ts +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -73,10 +73,8 @@ export const styles = stylex.create({ default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity', }, - // Positional, in the order of `transitionProperty`. The fade takes the plain enter and - // exit curves; 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: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, ':where([data-ending-style])': easingVars['--cl-ease-exit'], From 01c3e134533468c0d2b0a6073cbec17c5e3f1446 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 16 Sep 2026 15:28:30 -0600 Subject: [PATCH 4/4] docs(repo): bound the scale-overshoot claim to a pixel threshold Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/mosaic/references/motion.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md index 689b6cc87fa..7a1504090ed 100644 --- a/.claude/skills/mosaic/references/motion.md +++ b/.claude/skills/mosaic/references/motion.md @@ -34,12 +34,14 @@ pixels, and the arithmetic is worth doing rather than eyeballing: the overshoot | 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 its overshoot lands sub-pixel -however large the surface — `--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. +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