Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-popup-fade-easing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
68 changes: 54 additions & 14 deletions .claude/skills/mosaic/references/motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
21 changes: 17 additions & 4 deletions packages/ui/src/mosaic/components/combobox/combobox.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as stylex from '@stylexjs/stylex';

import {
colorVars,
durationVars,
easingVars,
fontFamilyVars,
fontWeightVars,
radiusVars,
Expand All @@ -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',
},
Expand Down
13 changes: 8 additions & 5 deletions packages/ui/src/mosaic/components/dialog/dialog.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'],
},
},

Expand All @@ -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'],
},
},
});
32 changes: 19 additions & 13 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as stylex from '@stylexjs/stylex';

import {
colorVars,
durationVars,
easingVars,
fontFamilyVars,
fontWeightVars,
radiusVars,
Expand All @@ -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.
Expand Down Expand Up @@ -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': {
Expand Down
12 changes: 4 additions & 8 deletions packages/ui/src/mosaic/components/popover/popover.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/mosaic/components/select/select.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))',
Expand Down
Loading