From c26a867931a8abd66e043b6d4ecc0608e20a4d10 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Mon, 3 Aug 2026 12:20:25 -0600 Subject: [PATCH 1/2] feat(ui): make button hover land instantly and fade out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hover faded symmetrically at `fast` in both directions. It now arrives instantly and leaves at `base`, matching the press, which already landed instantly. The arrival is instant because a hover or a press is confirmation of something the pointer just did, and confirmation cannot lag. Only the exit is a judgement call: on an isolated control 0.15s costs nothing and takes the edge off, whereas across a traversed collection the same fade strings a wake of dimming rows out behind a fast sweep. Buttons get the exit; `Item` keeps none. Hover and press now carrying the same duration is what removes the machinery. The earlier split needed a `@media (hover: hover)` wrapper and a `:not(:active)` guard purely to stop the hover branch outranking the press; both are gone, and no hover-media guard is needed either, since a `:hover` that sticks after a tap only means the color already landed. `Icon` gets the timing handed to it through `--_cl-icon-duration`, alongside the `--_cl-icon-color` it already reads, since transitions do not inherit and an icon on its own duration would trail the button it sits in. Its standalone default drops from `fast` to `instant`: the arrival never varies, so any non-zero default was wrong for every container at once, and instant also leaves a traversed collection correct in both directions without opting into anything. The link variants' underline follows the same timing. `text-decoration-line` is a keyword and cannot tween, so toggling it snapped the underline away on exit while every other property faded. The line is now always drawn and only `text-decoration-color` moves, which paints nothing at rest, never participates in layout, and keeps the change a color so it inherits the shared timing unaltered. Documents the rules in the mosaic skill's motion.md: `linear` for anything that only recolors, an arrival that never varies, an exit decided by traversal rather than by element type, the three cases that look like this and are not, and the two traps — transitions not inheriting, and keywords not tweening. Corrects the duration-token comment, which still assigned hover to `fast`. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/mosaic/references/motion.md | 163 +++++++++++++++++- .../mosaic/components/button/button.styles.ts | 30 +++- .../src/mosaic/components/icon/icon.styles.ts | 5 +- packages/ui/src/mosaic/tokens.stylex.ts | 7 +- 4 files changed, 191 insertions(+), 14 deletions(-) diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md index 6ac66dc32fa..c2c5d5e8eba 100644 --- a/.claude/skills/mosaic/references/motion.md +++ b/.claude/skills/mosaic/references/motion.md @@ -7,9 +7,9 @@ rather than eyeball it. | Token | Value | For | | ----------------------- | --------------------------------------- | --------------------------- | -| `--cl-duration-instant` | `0s` | press feedback | -| `--cl-duration-fast` | `0.1s` | exits, hover | -| `--cl-duration-base` | `0.15s` | entrances | +| `--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) | @@ -85,6 +85,163 @@ opacity-at-overshoot-peak from 0.78 to 1.00. **Exits land together.** Do _not_ split them going out. Matching durations are 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: +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. + +**The arrival is always `0s`. Only the exit is a judgement call**, and what decides +it is whether a pointer traverses the element on its way somewhere else: + +| the highlight sits on… | in | out | +| ------------------------------------------------------------------ | ---- | ------- | +| an isolated control — button, card, standalone target | `0s` | `0.15s` | +| a traversed collection — menu item, list/table row, palette result | `0s` | `0s` | + +**Why the arrival never varies:** it follows from **who caused the change**. You +moved the pointer, so the highlight is confirmation of your own act, and any +duration on it is latency between doing and being told. It is the same reason a +press lands instantly. + +**Why the exit does vary:** leaving carries no information, so on an isolated +control 0.15s costs nothing and takes the hard edge off. Across a collection that +same fade becomes a comet trail — a wake of dimming rows strung out behind a fast +sweep, which is the arrival's ambiguity re-introduced from the other side. Rows +leave instantly for the same reason they arrive instantly. + +Note the axis is traversal, not element type. A button in a toolbar that the +pointer sweeps across follows the collection row, not the button row. + +**The mechanic:** the duration an element carries in a given state governs the +transition _into_ that state. So the asymmetry falls out of one declaration per +state — no doubled values, no JS: + +```ts +transitionProperty: 'background-color, border-color, color, opacity', +transitionTimingFunction: 'linear', +transitionDuration: { + default: durationVars['--cl-duration-base'], // 0.15s — leaving hover or press + ':enabled:active': durationVars['--cl-duration-instant'], + ':enabled:hover': durationVars['--cl-duration-instant'], +}, +``` + +The bare `:hover` is safe here only because hover and press carry the same value: +both match during a press, so which one wins does not matter. Give them different +durations and the hover branch needs `:not(:active)`, since the two are equal +specificity and the winner comes down to how StyleX orders them. + +Keep the duration itself outside `@media (hover: hover)` either way. A duration is +inert on its own — it times an appearance, and if that appearance is media-guarded +then nothing transitions on a touch device regardless. Wrapping it buys nothing and +costs the `:not(:active)` guard, because the at-rule doubles the class and outranks +`:active` (see `stylex.md`). + +Worked examples: `button.styles.ts` for the isolated control, `item.styles.ts` for +the traversed collection — which declares no `transition` at all, since both of its +durations are `0s`. + +### Children need the timing handed to them + +**Transitions do not inherit.** A child that recolors along with its container — a +`Button`'s `Icon`, anything reading a `--_cl-*` color the parent branches per state — +animates on _its own_ `transition-duration`, not the parent's. Give the parent one +timing and the child another and the child visibly trails it; at `0s` in, a child +still on `0.1s` reads as the icon lagging the button by a tenth of a second. + +Hand the duration down the same way the color goes down, so one declaration governs +both and they cannot drift apart: + +```ts +// container: alongside `--_cl-icon-color`, on the same conditions +'--_cl-icon-duration': { default: base, ':enabled:active': instant, ':enabled:hover': instant }, + +// child: read it, defaulting to instant +transitionDuration: `var(--_cl-icon-duration, ${durationVars['--cl-duration-instant']})`, +``` + +**The child's default is `instant`, not a middling fade.** The arrival never varies, +so any non-zero default is wrong for every container at once; the exit is the only +contextual half, and a container that wants one opts in through the var. That also +makes the standalone default correct for a traversed collection with no work — a row +gets `0s` both ways by doing nothing. The `transitionProperty` and +`transitionTimingFunction` still have to be declared even though the default duration +makes them inert, since they are what the var has to animate once a container sets it. + +### A keyword cannot tween — reach for its color-valued sibling + +Some properties that read as visual are discrete keywords, so they flip between +frames no matter what duration you set. `text-decoration-line` is the one that bites: +toggling `none` → `underline` on hover gives an instant arrival, which is right by +accident, and an instant exit, which is not. + +Draw the thing permanently and animate the color instead: + +```ts +textDecorationColor: { default: 'transparent', ':enabled:hover': 'currentColor' }, +textDecorationLine: 'underline', +// and add `text-decoration-color` to the shared `transitionProperty` +``` + +It costs nothing — a transparent decoration paints nothing and never participates in +layout — and it keeps the change a **color**, so the rule above applies unaltered +rather than needing a curve. Verified in Chrome and Safari; where a browser declines +to interpolate it, the failure is graceful, since it snaps exactly as it does today. + +Prefer this to the other animatable decoration properties. +`text-decoration-thickness` from `0` renders unreliably at sub-pixel values, and +`text-underline-offset` makes the underline _slide_, which is movement — that breaks +the "nothing moves" premise the linear curve rests on. + +### Three things that look like this and are not + +- **An element arriving** — tooltip, popover, dropdown, anything that mounts on + hover. That is an entrance, not a state change; it gets a real duration and a + curve. The rest of this file applies instead. +- **System-driven changes** — going disabled, a loading dim, a validation color. + Instant only reads as confirmation when the user just acted; when the system + acted it reads as a flash. Keep those symmetric and on the duration scale. +- **Anything moving alongside the color** — a sliding thumb, a drawing check. The + color has to take the movement's duration or the two desync. "Nothing moves" is + the premise that licenses both the linear curve and the instant arrival. + +### Why the arrival must be `0s`, seen most clearly in dense collections + +Menu items, list and table rows, command-palette results — anywhere a pointer +crosses many targets on its way somewhere — is where a non-zero fade-in stops being +a question of taste. The highlight's job there is to answer _which row am I on_, and +a transition makes it unable to. Mosaic `Item` rows are 52px, so at ordinary pointer +speeds a 100ms fade leaves several rows partly lit at once, the brightest of them +trailing behind the cursor: + +| pointer speed | ms/row | rows mid-transition | +| ------------- | ------ | ------------------- | +| 300 px/s | 173 | 0.6 | +| 600 px/s | 87 | 1.2 | +| 900 px/s | 58 | 1.7 | +| 1200 px/s | 43 | 2.3 | +| 2000 px/s | 26 | 3.8 | + +Users report this as lag, and they are describing it accurately — the highlight is +behind the pointer. It is a legibility failure, not a matter of polish, and no +duration short enough to fix it is long enough to be worth having. Instant tracking +is also what platform menus have always done. + +An isolated button never fails this visibly, but the arrival is the same rule either +way; there is no button-versus-row split on the way in. + +The same arithmetic sizes the comet trail: a 0.15s exit is a longer fade than the +0.1s modelled above, so it strings out proportionally more rows behind the pointer. +That is why the exit collapses to `0s` here even though it stays at 0.15s on a +button. + +`Item` declares no `transition` at all, which is `0s` in both directions and is +correct on both counts — instant arrival, and no exit to trail the pointer. Leave it +that way; do not "improve" it by porting a button's 0.15s exit onto rows. + ## Small deltas constrain the curve (the dead-frame test) A transition's usable curves depend on how much it actually moves. A scale delta diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 37a7bbfebbf..0a8239c21d4 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -65,6 +65,13 @@ const iconFadedOnNegative = `color-mix(in oklab, ${colorVars['--cl-color-negativ export const styles = stylex.create({ base: { + // Handed to `Icon`, which needs its own copy: transitions don't inherit, so without this + // the icon would still be catching up 0.1s after the button itself has landed. + '--_cl-icon-duration': { + default: durationVars['--cl-duration-base'], + ':enabled:active': durationVars['--cl-duration-instant'], + ':enabled:hover': durationVars['--cl-duration-instant'], + }, borderColor: 'transparent', borderRadius: radiusVars['--cl-radius-control'], borderStyle: 'solid', @@ -86,13 +93,15 @@ export const styles = stylex.create({ fontWeight: fontWeightVars['--cl-font-medium'], justifyContent: 'center', outlineOffset: '2px', - // The press reads as contact, not a fade, so it lands instantly. Release falls back to - // `fast` — `:active` stops matching as the color heads back. Instant press, soft settle. + // The duration a state carries governs the transition INTO it, so one declaration per + // state gives an instant arrival and a 0.15s settle out. Instant because a hover or a + // press confirms something the user just did, and confirmation cannot lag; see `motion.md`. transitionDuration: { - default: durationVars['--cl-duration-fast'], + default: durationVars['--cl-duration-base'], ':enabled:active': durationVars['--cl-duration-instant'], + ':enabled:hover': durationVars['--cl-duration-instant'], }, - transitionProperty: 'background-color, border-color, color, opacity', + transitionProperty: 'background-color, border-color, color, opacity, text-decoration-color', // Linear, not `--cl-ease-default`: nothing here moves. An ease on already non-uniform // color interpolation just drags the midpoint, and the house curve's overshoot would // extrapolate past the target color. @@ -361,6 +370,10 @@ export const variants = stylex.create({ // link opts out of the box the size axis sets — it reads as text, not a control. Per-side // zeros for the same reason `shapeSquare` uses them. + // + // The underline is always drawn and only its color moves: `text-decoration-line` is a keyword, + // so toggling it cannot tween and the exit would snap where every other property fades. A + // transparent decoration paints nothing and never participates in layout. 'link-primary': { '--_cl-icon-color': { default: iconFadedNeutral, @@ -373,7 +386,8 @@ export const variants = stylex.create({ color: colorVars['--cl-color-primary'], paddingInlineEnd: 0, paddingInlineStart: 0, - textDecorationLine: { default: 'none', ':enabled:hover': 'underline' }, + textDecorationColor: { default: 'transparent', ':enabled:hover': 'currentColor' }, + textDecorationLine: 'underline', textUnderlineOffset: '2px', height: 'auto', }, @@ -389,7 +403,8 @@ export const variants = stylex.create({ color: colorVars['--cl-color-neutral-foreground'], paddingInlineEnd: 0, paddingInlineStart: 0, - textDecorationLine: { default: 'none', ':enabled:hover': 'underline' }, + textDecorationColor: { default: 'transparent', ':enabled:hover': 'currentColor' }, + textDecorationLine: 'underline', textUnderlineOffset: '2px', height: 'auto', }, @@ -405,7 +420,8 @@ export const variants = stylex.create({ color: colorVars['--cl-color-negative'], paddingInlineEnd: 0, paddingInlineStart: 0, - textDecorationLine: { default: 'none', ':enabled:hover': 'underline' }, + textDecorationColor: { default: 'transparent', ':enabled:hover': 'currentColor' }, + textDecorationLine: 'underline', textUnderlineOffset: '2px', height: 'auto', }, diff --git a/packages/ui/src/mosaic/components/icon/icon.styles.ts b/packages/ui/src/mosaic/components/icon/icon.styles.ts index 806dc4d274d..1ed40b76011 100644 --- a/packages/ui/src/mosaic/components/icon/icon.styles.ts +++ b/packages/ui/src/mosaic/components/icon/icon.styles.ts @@ -10,7 +10,10 @@ export const styles = stylex.create({ display: 'inline-block', flexShrink: 0, // Transitions don't inherit, so the container's own color transition doesn't animate this. - transitionDuration: durationVars['--cl-duration-fast'], + // The default is `instant` because the arrival never varies (see `motion.md`); only the exit + // is contextual, so a container that wants one hands its timing down the same way it hands + // down `--_cl-icon-color` (see `button.styles.ts`). + transitionDuration: `var(--_cl-icon-duration, ${durationVars['--cl-duration-instant']})`, transitionProperty: 'color', transitionTimingFunction: 'linear', }, diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 09371e11f8b..ff76e47c4b3 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -255,9 +255,10 @@ export const fontWeightVars = stylex.defineVars(fontWeightDefaults); // Read as "how direct is this feedback": the more a change is the answer to // something the pointer just did, the shorter it runs. `instant` is for the state // that has to feel like contact rather than a fade — a press landing, a highlight -// appearing under the cursor; `fast` for hover and other pointer-driven state; -// `base` for that state decaying once the pointer leaves, which reads better a -// little slower than it arrived; `slow`/`slower` for changes the pointer didn't +// appearing under the cursor, hover included; `fast` for exits and other short +// pointer-driven change; `base` for that state decaying once the pointer leaves, +// which reads better a little slower than it arrived; `slow`/`slower` for changes +// the pointer didn't // cause directly, like a panel or overlay resolving. // // Durations are not gated on `prefers-reduced-motion`. That signal is about From ef12fb4856106113e6f2d58607f0f2f625e4c182 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Mon, 3 Aug 2026 12:20:26 -0600 Subject: [PATCH 2/2] chore(repo): add empty changeset for the button hover timing change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mosaic is pre-release, so its changes take empty changesets — matching `mosaic-item`, `mosaic-button-variants`, and `mosaic-popover`. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/button-hover-timing.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/button-hover-timing.md diff --git a/.changeset/button-hover-timing.md b/.changeset/button-hover-timing.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/button-hover-timing.md @@ -0,0 +1,2 @@ +--- +---