Skip to content

feat(ui): make button hover quick to arrive and slow to leave - #9298

Open
maxyinger wants to merge 15 commits into
mainfrom
max/button-hover-timing
Open

feat(ui): make button hover quick to arrive and slow to leave#9298
maxyinger wants to merge 15 commits into
mainfrom
max/button-hover-timing

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Button hover faded symmetrically — 0.1s in, 0.1s out. Only the press was
asymmetric. Hover now arrives at --cl-duration-fast (0.1s) and leaves at
--cl-duration-base (0.15s), so it is quick to acknowledge the pointer and
unhurried to let go. The press keeps its instant arrival, and everything stays
linear, since nothing here moves.

The asymmetry needs no doubled values. The duration an element carries in a state
governs the transition into that state, so one duration per state yields a
different in and out — the resting value becomes the hover-out rate, and also
covers release-from-press on a device with no hover at all.

:not(:active) on the hover branch is required rather than stylistic: inside
@media (hover: hover) it would otherwise outrank the bare :active and steal the
press's instant duration.

Also documents the rule in the mosaic skill's motion.md, scoped to isolated
controls
. Density inverts the advice — on a dense traversed collection a hover
fade cannot track the pointer, leaving several rows partly lit at once with the
brightest trailing the cursor, which defeats the highlight's only purpose and reads
as lag. Item ships 0s for that reason, so the note warns against porting a
button's hover timing onto rows.

Stacked on max/mosaic-popover — it builds on the motion.md introduced there.
Merge that first, or retarget this to main after it lands.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

alexcarpenter and others added 15 commits July 28, 2026 20:33
Reusable StyleX-styled popover built on the @clerk/headless popover
primitive: a flexible popup card with content + footer slots, enter/exit
transition driven off self data-attributes, and swingset docs.
# Conflicts:
#	packages/swingset/src/lib/registry.ts
# Conflicts:
#	packages/ui/src/mosaic/styles/index.ts
The popover now owns only what it means to float: trigger wiring, ARIA,
focus management, positioning, stacking, viewport clamps, and the
enter/exit transition. It paints no surface of its own, so the content
inside it — usually a Card — supplies the background, border, radius,
shadow and padding, and only one element ever draws a border.

Popover.Content and Popover.Footer are removed; they duplicated
Card.Content and Card.Footer. Popover.Arrow is removed as unused.

The popup gains a `size` variant (sm/md/lg), reflected as `data-size`.
`md` reproduces the width the legacy PopoverCard uses (theme.sizes.$94)
so popovers migrating onto Mosaic keep their footprint. It also wraps
long unbroken strings, which would otherwise push past that width.

The positioner is always role="dialog" but only gains aria-labelledby
once a Popover.Title mounts, so a popover with neither a Title nor a
label was an unnamed dialog. PopoverProps now accepts aria-label and
aria-labelledby, and a dev-only check warns when the rendered dialog has
neither. Both are spread conditionally: an explicit `undefined` would
delete the Title's aria-labelledby through the primitive's mergeProps.

Motion moves onto the theme tokens — `--cl-duration-base`, with
`linear` for the fade and `--cl-ease-default` for the scale. Under
prefers-reduced-motion only `transform` leaves the transition; the fade
still runs, since the movement is the concern.
The examples now compose a Card inside the popover, making that the
canonical shape so the docs supply the consistency the component no
longer enforces on its own. Adds placement and alignment demos, a
section on naming the dialog, and drops the removed Content, Footer,
and Arrow parts from the parts table.

The placement demos reserve vertical room around the trigger: without
it the flip middleware bounces a `top` popover back below the trigger
inside the short preview frame, so the demo would contradict its label.

The story moves onto the remapped Button API. The trigger render prop is
typed as Omit<React.HTMLAttributes<HTMLElement>, 'color'> — the native
`color` is a string and collides with Button's narrowed variant, which
is the same omission props.ts makes for the same reason.
The `cssVars` middleware already computed the anchor's center to derive
`--cl-transform-origin`, then discarded one axis: for top/bottom it pins Y to the
floating element's own edge, and for left/right it pins X. That makes it a hybrid
point rather than the anchor's center, so scaling about it grows the element from
its own edge instead of out of the trigger.

Emit the unmodified center as a second variable rather than changing the first.
`--cl-transform-origin` is consumed elsewhere with its edge semantics, so
redefining it would silently retarget those animations.

Unlike `--cl-transform-origin`, this one ignores the arrow: the goal is the
anchor's bounding box, not the visual connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--cl-ease-default` is Swift Out: front-loaded, carrying its endpoint ~2% past
target before settling. That is an arrival curve, and reusing it for an exit fails
in three ways. It spends 90% of its travel in the first three frames and then
crawls, so most of the run is perceptually static — reported as choppy or dropped
frames, though a profile correctly shows none. Its overshoot inverts into a wobble
past the target, with nothing left to settle into. And a departure has no reason to
decelerate.

In Quad is deliberately the gentlest of the in-family. An exit covers a small
distance over few frames, so a sharper curve (In Quart, In Circ) leaves half of
them below the threshold of visible change and reproduces the same stall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The popup had no transform-origin, so it grew from its own center and read as
appearing beside the trigger rather than out of it. It now scales about
`--cl-anchor-origin`, the trigger's bounding-box center. A pixel-precise point is
what makes this hold: keyword origins anchor to the popup's own box, so they drift
off the trigger as soon as `shift()` or `flip()` moves it.

Start scale goes 0.98 -> 0.94. Travel is `(1 - startScale) x distance(origin, popup
center)`, so origin and start scale only work as a pair — at 0.98 the effect is
about 2px and invisible.

The enter and exit now differ in duration and curve. The exit is shorter, because
an arrival earns a moment to settle and a dismissal should get out of the way, and
takes `--cl-ease-exit`. Entering, opacity is given the shorter duration so it lands
opaque as the scale reaches full size, leaving the settle to play at full strength
instead of through a fade; leaving, both share one duration so the shape does not
finish ahead of the fade and leave a motionless rectangle behind.

Reduced motion drops the scale itself, not just its transition. Removing
`transform` from `transitionProperty` stops it animating but not changing, so the
0.94 was still applied — instantly. Entering that is invisible (it happens at
`opacity: 0`), but exiting it snapped to 94% at full opacity and then faded. The
override is restated inside the media query rather than added as a sibling key:
both would otherwise compile to the same specificity and the tiebreak would be
source order, which `@stylexjs/sort-keys` reorders on autofix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A button that opens a popover returned to its resting fill the moment the pointer
left, so nothing on the trigger showed that the surface it controls is still open.
`[data-open]` now takes the same pressed fill as `:active` across every
filled/outline/ghost cell. `link` opts out — it reads as text, not a control.

Styling only: the disclosure primitives already set `data-open` on the trigger, and
a plain button never carries it.

`[data-open]` is excluded from hover for the same reason `:active` is. StyleX gives
at-rules extra priority, so a `@media (hover: hover)` `:hover` outranks it, and
moving the pointer over an open trigger would otherwise lift it back to the lighter
hover step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `references/motion.md`, covering the rules that came out of the popover
enter/exit work: curves have a direction and an arrival curve must not be reused
for a departure, the three axes of enter/exit asymmetry, the dead-frame test for
choosing a curve against a given delta and duration, the two transform-origin
variables and the geometry that decides how far an element travels, and the
reduced-motion trap where gating `transitionProperty` alone leaves the value change
behind. Includes the measurements each rule came from, plus a rAF sampler for
checking a transition — reading the CSS will not tell you that one stalls.

`stylex.md` gains a pointer to it, a DON'T against reusing `--cl-ease-default` for
exits, and the disclosure-trigger pattern beside the `:hover`/`:active` cascade
rule it extends. Its worked example was refreshed: it still showed a literal
`150ms`, the pre-0.94 start scale, and a reduced-motion branch that killed the fade
rather than the transform, contradicting the house rule a section above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records ~6px as the house target for how far an anchored surface travels as it
opens, and the rule for hitting it: travel is `(1 - s) x distance(origin, center)`,
so a taller surface pushes its own center further from the trigger and the same
start scale overshoots. Solve for the scale rather than fixing it.

Notes the three limits — a floor around 0.90 so a surface close to its origin does
not get an attention-drawing zoom to manufacture the target, that travel is
measured at the center by convention since points move in proportion to their own
distance from the origin, and that content-driven height makes any static scale an
estimate for a typical size rather than an exact normalization.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hover faded symmetrically at `fast` in both directions. Only the press was
asymmetric. Hover now enters at `fast` and leaves at `base`, so it is quick to
acknowledge the pointer and unhurried to let go, while the press keeps its instant
arrival.

The asymmetry needs no doubled values: the duration an element carries in a state
governs the transition into that state, so declaring one duration per state gives a
different in and out. The resting value becomes the hover-out rate, and also covers
release-from-press on a device with no hover.

`:not(:active)` on the hover branch is required, not stylistic — inside
`@media (hover: hover)` it would otherwise outrank the bare `:active` and steal the
press's instant duration.

Documents the rule in the mosaic skill's motion.md, along with `linear` for anything
that only recolors, and — importantly — scopes it to isolated controls. Density
inverts the advice: on a dense traversed collection a hover fade cannot track the
pointer, so several rows sit partly lit at once with the brightest trailing the
cursor, which destroys the highlight's only job and reads as lag. `Item` ships `0s`
for that reason, so the note warns against porting a button's hover timing onto
rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: af13e9d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview Jul 30, 2026 11:50pm
swingset Ready Ready Preview Jul 30, 2026 11:50pm

Request Review

Base automatically changed from max/mosaic-popover to main July 31, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants