Skip to content

feat(studio): menus and popovers paint instantly - #4268

Merged
miguel-heygen merged 2 commits into
mainfrom
feat/studio-instant-menus
Sep 22, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
feat/studio-instant-menus

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Opacity

Clicked the menu in the running app, on the fixture, and read computed opacity from requestAnimationFrame. Before is main. After is this head.

Tree t+0 Later
Before, main, 180ms 0 0.805 at t+60
After, this head, 120ms from 0.8 0.8 1 at t+120

Before

t+30 on main. The menu is still translucent.

Before t+30

After

t+30 on this head. The menu is already readable.

After t+30

Menus and popovers open and close with no fade. Hover colour is unchanged.
@miguel-heygen
miguel-heygen marked this pull request as ready for review September 22, 2026 04:36

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The change is one token value, but the token is shared, so the review is mostly about who inherits the zero — and the answer is exactly the two components the comment names.

Who consumes --duration-open

One class, one place: the duration-open utility on popupSurface (Menu.tsx:36). popupSurface in turn has exactly two users — menuPopup in the same file, and Popover.tsx:66. Nothing else in the repo reads the token or the utility, and the other four duration tokens are untouched. So "Menus and popovers share this for open and close" is exact rather than approximate.

One thing that follows from that and isn't in the body: the token compiles into :root, :host of the published dist/styles.css, and popupSurface is exported from the package index. Anyone consuming @hyperframes/studio who uses the duration-open utility or var(--duration-open) inherits the new value. Nothing in-repo does, and the deprecated JS preset mirrors colours only (no duration entries), so there's no drift to chase — but it is a public token value change if this versions.

The close path

The comment is right that the token drives close too (data-[ending-style]:opacity-0), and Base UI holds the unmount until the transition settles — which is the shape where a zero duration could plausibly strand a popup. It doesn't, and the reason is that 0 ms here is not a new configuration. The duration-* utility already zeroes itself under reduced motion. Compiled from studio.css at this head:

.duration-open {
  transition-duration: var(--duration-open);
  @media (prefers-reduced-motion: reduce) {
    transition-duration: 0ms;
  }
}

Same class, same property, same value — so every prefers-reduced-motion: reduce user has been getting an instant open and close on this element in production already. This PR moves everyone onto a path that has been shipping.

The five close assertions in Menu.test.tsx (129, 203, 259, 273, 299) all stay green, but I'd not lean on them: they run in happy-dom, which doesn't run transitions, so they'd pass at any duration. They confirm the logic, not the timing. The reduced-motion precedent is what's actually load-bearing.

The new test discriminates

Set the token back to main's 180ms and it is the only thing that fails — expected '180ms' to be '0ms' — with the other 11 in the file unaffected. Green at 0ms. So it goes red on the code it guards.

The reduced-motion guard isn't weakened

Worth checking, because a token change can quietly make a regex vacuous. --value(--duration-*) emits var(--duration-open) rather than inlining the literal, so the only literal 0ms in that rule is still inside the media query, and Menu.test.tsx's /…prefers-reduced-motion: reduce[\s\S]*?transition-duration: 0ms/ still requires the media query to be there. It now describes two identical states instead of a difference, so it guards the structure rather than a behaviour — but it still fails if someone drops the reduced-motion half.

Base and board

main moved one commit since the merge base (#4267's merge), touching zero files under packages/studio. 97 tests across 12 files in src/styles and src/components/ui pass at 4df46530.

Notes, none blocking

  • The opacity table covers open only. Close shares the token and the comment says so — a close row would make the evidence match the change's actual reach.
  • ease-out-quint on popupSurface is inert at 0 ms. Keeping it is the right call if the intent is that overriding --duration-open restores the old feel in one line; just noting it's now decorative until someone does.

— Rames

Menus, popovers, and dropdowns start at opacity 0.8 and scale 0.97, then settle in 120ms. They close in 90ms. Tooltips use the same shape in 100ms.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

The open is no longer 0ms. Menus, popovers, and dropdowns start at opacity 0.8 and scale 0.97, settle in 120ms ease-out, and close in 90ms ease-in. Tooltips use the same shape in 100ms. The 0ms approval was on 4df4653; this needs a look on 6e69b06.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 6e69b065e. Approving again.

This is a much larger change than the head I stamped — 4df46530 was one token over two files, this is +51/-12 over six, and it turns a one-off class string into a shared floatingMotion() helper that Select and Tooltip now use too. Those two carried no transition at all before, so the blast radius went from two floating surfaces to four. Worth saying out loud since the title still reads like a one-liner.

What I checked at the compiled level

data-[ending-style]:duration-close resolves to .data-\[ending-style\]\:duration-close[data-ending-style], a class-plus-attribute selector, so it outranks the plain .duration-open on exit — the 90 ms close actually wins. Each of duration-open, duration-close and duration-tooltip carries the prefers-reduced-motion zero, so the new tokens inherit that half rather than opting out of it. --ease-in resolves too: it isn't in theme.css, but Tailwind's default theme ships it, so the exit easing is real and not a dropped declaration.

11 test files / 96 tests pass in src/styles and src/components/ui. (My earlier review said 97 across 12 — that count included a scratch probe file of my own. 96 is the real board, and it was 96 at the previous head too.)

The finding: the enter scale doesn't animate

There is exactly one transition-property rule in the whole compiled sheet:

.transition-\[opacity\,transform\] { transition-property: opacity,transform; }

And Tailwind v4 compiles both the old and the new scale to the standalone scale property, not to transform:

.scale-95 { scale: var(--tw-scale-x) var(--tw-scale-y); }
.data-\[starting-style\]\:\[scale\:var\(--popup-enter-scale\)\][data-starting-style] { scale: var(--popup-enter-scale); }

scale is an independent animatable property; transitioning transform does not cover it. So --popup-enter-scale: 0.97 jumps to 1 on the frame the attribute drops, while opacity eases 0.8 → 1 over 120 ms. The eased part of the enter is the opacity only.

Two things about that. It is not a regressionscale-95 had the same problem on main, so nothing got worse. And if the tokens exist to guarantee a visible first frame, which is what the comment says ("Never enter from scale 0 or opacity 0"), then the goal is met either way: 0.97 and 0.8 are both visible at t+0, which is exactly what your table shows.

But the PR also adds ease-out-quint over a named --popup-enter-scale, which reads like an intended scale-in. If that's the intent, the fix is one word in the transition list — transition-[opacity,scale,transform] — and it's worth doing here rather than leaving a design token that names a value nothing eases.

Smaller things

  • The exit lost its scale. data-[ending-style]:scale-95 is gone; the close now sets opacity only. Given the above it was never animating anyway, so this removes an instant 5% shrink — probably an improvement, but it isn't in the body.
  • Nothing pins the new wiring. The tests added assert the declared token values in theme.css; no test asserts that Select or Tooltip received floatingMotion, and Select has no test file at all. Menu.test.tsx still pins duration-open on the menu popup, so Menu is covered and the three others aren't. One classList assertion apiece on Select and Tooltip would pin the part of this change that actually moved.
  • The title, "menus and popovers paint instantly", is now wrong twice over: it's a 120 ms fade rather than instant, and it covers dropdowns and tooltips as well. The body was updated for the new head; the title wasn't.

— Rames

@miguel-heygen
miguel-heygen merged commit 867787b into main Sep 22, 2026
61 of 95 checks passed
@miguel-heygen
miguel-heygen deleted the feat/studio-instant-menus branch September 22, 2026 05:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants