feat(ui): make button hover quick to arrive and slow to leave - #9298
Open
maxyinger wants to merge 15 commits into
Open
feat(ui): make button hover quick to arrive and slow to leave#9298maxyinger wants to merge 15 commits into
maxyinger wants to merge 15 commits into
Conversation
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 detectedLatest commit: af13e9d The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen 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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
alexcarpenter
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 andunhurried 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:activeand steal thepress's instant duration.
Also documents the rule in the mosaic skill's
motion.md, scoped to isolatedcontrols. 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.
Itemships0sfor that reason, so the note warns against porting abutton's hover timing onto rows.
Stacked on
max/mosaic-popover— it builds on themotion.mdintroduced there.Merge that first, or retarget this to
mainafter it lands.Checklist
pnpm testruns as expected.pnpm buildruns as expected.Type of change