Fade and shrink popovers out in every engine, Firefox included

Menus, submenus, tooltips, rich tooltips and the FAB menu held their
exit with `transition-behavior: allow-discrete` on `display` and
`overlay`. Firefox transitions neither (MDN browser-compat-data,
`display.is_transitionable`: Chrome 117, Safari 18, Firefox none), so
every one of them vanished on its first frame there. A script cannot
hold a popover open instead: `beforetoggle` is not cancellable on the
way out, and the browser's own light dismiss (Escape, a press outside)
never asks.

resources/js/popover-exit.js: a popover marked `data-md-popover-exit`
closes for real at once — focus, aria-expanded and toggle stay the
browser's — and a copy taken in `beforetoggle`, while it is still drawn,
stands in for the exit. The copy is decoration: a manual popover in the
top layer (closing no other popover), inert, aria-hidden, without ids or
nested popovers, `x-ignore`d so Alpine starts nothing in it, pinned to
the popover's box with its resolved colours. It is shown with its
transitions off, so `@starting-style` does not replay the entry, then
marked `data-md-popover-closing`, which each stylesheet turns into its
closed values (`:popover-open:not([data-md-popover-closing])`, and the
FAB menu's items' sink), so it moves on the component's own tokens. It
is removed once the longest of them has run, and opening the popover
again takes it away. Under reduced motion every duration is zero and no
copy is made. `display`, `overlay` and `allow-discrete` leave the
transitions, so Chrome and Safari take the same path.

Browser tests in Chrome, Firefox and Safari slow the motion tokens so a
round trip still finds the exit on screen: a menu after Escape and after
a press outside (the real menu closed and focus back on its button, the
copy inert, fading, with no Alpine state, and gone after), a reopen
part-way through, reduced motion, a submenu while its menu stays open, a
tooltip, the FAB menu's items part-way down their sink, and a persistent
rich tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 20:24:23 +02:00
co-authored by Claude Opus 5
parent 684d60efc8
commit 7db522826b
18 changed files with 331 additions and 60 deletions
+6 -11
View File
@@ -8,10 +8,9 @@
* (foundation/interaction.css); its 56px already meets M3's target, so it needs no
* `md-touch-target`.
*
* The entry is `@starting-style` on the item itself; the exit needs the state to be readable
* while the popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *`
* (fab-menu.css) drives from the popover's own open state — the popover keeps `display: flex` for
* the length of its own discrete transition, so the item has somewhere to animate to (ACT-33).
* The entry is `@starting-style` on the item itself; the exit is the popover's exit copy
* (resources/js/popover-exit.js), whose items fab-menu.css sets to the closed values, so the item
* has somewhere to animate to (ACT-33).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@@ -42,13 +41,9 @@
user-select: none;
translate: 0 0;
opacity: 1;
transition-property: translate, opacity, display, overlay;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default), linear, linear;
transition-behavior: allow-discrete;
transition-property: translate, opacity;
transition-duration: var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default);
@starting-style {
translate: 0 var(--md-sys-measurement-space100);
+9 -11
View File
@@ -12,13 +12,13 @@
* button, which stays fixed (ACT-32).
*
* The items (fab-menu-item.css) rise into place as the list opens and sink back as it closes: the
* entry is `@starting-style` on the item itself; the exit needs the state to be readable while the
* popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *` is — the
* popover keeps `display: flex` for the length of its own discrete transition, so the items have
* somewhere to animate to (ACT-33). The Tailwind-era rule sat outside the layers to beat the
* item's utilities; inside the layer it still beats fab-menu-item.css's resting `translate: 0 0`
* and `opacity: 1`, on specificity — an attribute and a pseudo-class against the item's one
* attribute — whichever order the two files are bundled in.
* entry is `@starting-style` on the item itself; the exit is the popover's exit copy
* (`data-md-popover-exit`, resources/js/popover-exit.js), which stays on screen after the popover
* has closed, so the items have somewhere to animate to (ACT-33) in every engine, Firefox included.
* `[data-md-fab-menu-popover]:not(:popover-open) > *` and the copy's `[data-md-popover-closing] > *`
* are the closed values; inside the layer they beat fab-menu-item.css's resting `translate: 0 0`
* and `opacity: 1` on specificity — two attributes, or an attribute and a pseudo-class, against the
* item's one attribute — whichever order the two files are bundled in.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@@ -112,9 +112,6 @@
border-width: 0;
background-color: transparent;
padding: var(--md-sys-measurement-space100);
transition-property: display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-behavior: allow-discrete;
&:popover-open {
display: flex;
@@ -149,7 +146,8 @@
position-area: bottom span-right;
}
[data-md-fab-menu-popover]:not(:popover-open) > * {
[data-md-fab-menu-popover]:not(:popover-open) > *,
[data-md-fab-menu-popover][data-md-popover-closing] > * {
translate: 0 var(--md-sys-measurement-space100);
opacity: 0;
}
+4 -8
View File
@@ -165,15 +165,11 @@
position-try-fallbacks: flip-inline;
opacity: 0;
scale: 0.95;
transition-property: opacity, scale, display, overlay;
transition-duration:
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function:
var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear;
transition-behavior: allow-discrete;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast);
&:popover-open {
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
scale: 1;
}
+4 -8
View File
@@ -70,15 +70,11 @@
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 0;
scale: 0.95;
transition-property: opacity, scale, display, overlay;
transition-duration:
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function:
var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear;
transition-behavior: allow-discrete;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast);
&:popover-open {
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
scale: 1;
}
+6 -6
View File
@@ -10,9 +10,10 @@
*
* Placed by CSS anchor positioning on `data-md-side`, corner-to-corner so the 312px bubble has
* room to spread past a narrow trigger; `position-try-fallbacks` flips it when the window has no
* room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring,
* `allow-discrete` keeping `display` and `overlay` alive for the fade out and `@starting-style`
* giving the fade in a start.
* room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring:
* `@starting-style` gives the fade in a start, and the fade out is its exit copy's
* (`data-md-popover-exit`, resources/js/popover-exit.js), which `data-md-popover-closing` turns
* back to the closed opacity.
*
* resources/js/rich-tooltip.js shows and hides the bubble and keeps the trigger described with
* `aria-describedby` — and, while `persistent`, `aria-haspopup`/`aria-expanded` (ACT-22) — none of
@@ -41,12 +42,11 @@
text-align: start;
white-space: normal;
opacity: 0;
transition-property: opacity, display, overlay;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
&:popover-open {
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
@starting-style {
+6 -6
View File
@@ -8,9 +8,10 @@
* text in `inverse-on-surface`, 8px either side and 4px above and below, 200px wide at most, 4px
* from its anchor. It never takes the pointer, so it can stand over the control it names.
*
* It fades in and out on the fast effects spring; `allow-discrete` keeps `display` and `overlay`
* alive until the fade out has been seen, and `@starting-style` gives the fade in a start.
* resources/js/tooltip.js shows and hides it.
* It fades in and out on the fast effects spring: `@starting-style` gives the fade in a start, and
* the fade out is its exit copy's (`data-md-popover-exit`, resources/js/popover-exit.js), which
* `data-md-popover-closing` turns back to the closed opacity. resources/js/tooltip.js shows and
* hides it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@@ -38,12 +39,11 @@
white-space: normal;
pointer-events: none;
opacity: 0;
transition-property: opacity, display, overlay;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
&:popover-open {
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
@starting-style {