Collapse an extended FAB to a FAB while the page scrolls

Plan step 22, actions.md § Missing (FAB to extended FAB scroll collapse):
M3 has an extended FAB collapse to a FAB on scroll-down and re-extend on
scroll-up, and `<x-button fab>` only swapped on window width.

`<x-fab collapse-on-scroll>` (extended, with an icon) takes a
`materialFab` flag from the new resources/js/fab.js, which reads the
window's scroll once a frame, ignores moves under 8px and extends again
near the top. The morph is CSS in actions.css: the label sits in a grid
track that closes to zero, the gap and minimum width follow on the
default spatial spring, and the label fades on the effects one; reduced
motion swaps outright through the motion tokens. The label is clipped, not
removed, so the collapsed FAB keeps its accessible name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 07:15:56 +02:00
co-authored by Claude Opus 5
parent 44ae9ecea4
commit 7e4e7b44c6
7 changed files with 179 additions and 2 deletions
@@ -321,6 +321,8 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
`<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
`collapse-on-scroll` on an extended FAB with an `icon` (`<x-fab icon="edit" label="Compose" collapse-on-scroll />`) is M3's scroll behaviour: it shrinks to the FAB of its size while the window scrolls down and extends again on scroll-up or at the top of the page. The width morphs on the spatial spring and the label fades; under reduced motion it swaps outright. The label stays in the page, clipped, so the collapsed FAB keeps its accessible name. It watches the window, so it is for a FAB pinned over a scrolling page, not one inside a scrolling pane.
### `<x-fab-menu>`, `<x-fab-menu-item>`
```blade
+63 -1
View File
@@ -1,6 +1,6 @@
/*
* The transitions an action runs between its states buttons, connected segments, menu items,
* the FAB menu's trigger.
* the FAB menu's trigger, and an extended FAB collapsing to a FAB on scroll.
*
* M3 has two spring styles and they are not interchangeable: spatial moves things (position,
* size, rotation, rounded corners) and overshoots by 9% on the way, effects changes how things
@@ -84,3 +84,65 @@
translate: 0 0.5rem;
opacity: 0;
}
/*
* An extended FAB that collapses to a FAB while the page scrolls down (`<x-fab
* collapse-on-scroll>`, resources/js/fab.js sets `data-collapsed`). M3's extended FAB page: it
* "can collapse to a FAB on scroll-down and re-expand to extended on scroll-up when switching
* between FABextended FAB, shape changes, the icon moves left, and the text label fades in/out".
*
* `width: auto` cannot transition, so the label sits in a one-column grid whose track closes to
* zero; that track is what animates, and the button lays itself out around it every frame. The
* gap and the minimum width go with it, all on the default spatial spring the size of morph
* `state-transition-default` is for while the label fades on the effects spring. The collapsed
* sizes are the FAB's own (FabBaseline/Medium/LargeTokens: 56/80/96px). The extended FAB's
* padding already centres the glyph at `sm` (16px + 24px + 16px) and `md` (26 + 28 + 26); at
* `lg` its 28px is 4px short of the 32px that centres a 32px glyph in 96px, so that one moves.
* Corners do not change: each size's FAB and extended FAB share one.
*
* The transition list repeats the FAB's own shadow and colour transitions, which this rule
* replaces. Under reduced motion tokens/motion.css takes every duration to zero, so it swaps.
*/
[data-fab-collapsible] {
--fab-collapsed-size: 3.5rem;
transition-property: min-inline-size, padding-inline, gap, box-shadow, background-color, color;
transition-duration:
var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
}
[data-fab-collapsible][data-fab-size='md'] { --fab-collapsed-size: 5rem; }
[data-fab-collapsible][data-fab-size='lg'] { --fab-collapsed-size: 6rem; }
[data-fab-label] {
display: grid;
grid-template-columns: 1fr;
transition-property: grid-template-columns, opacity;
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default);
}
[data-fab-label] > span {
overflow: hidden;
white-space: nowrap;
}
[data-fab-collapsible][data-collapsed] {
min-inline-size: var(--fab-collapsed-size);
gap: 0;
}
[data-fab-collapsible][data-fab-size='lg'][data-collapsed] {
padding-inline: 2rem;
}
/* The label stays in the accessibility tree clipped and transparent, never `display: none` so
the collapsed FAB keeps the name the extended one had. */
[data-fab-collapsible][data-collapsed] [data-fab-label] {
grid-template-columns: 0fr;
opacity: 0;
}
+74
View File
@@ -0,0 +1,74 @@
/**
* `materialFab`: `<x-fab collapse-on-scroll>` M3's extended FAB that collapses to a FAB while the
* page scrolls down and extends again on the way back up, or once the page is at the top.
*
* Only the flag lives here. The morph itself is CSS (resources/css/components/actions.css), which
* is how it stays on the spatial spring and how reduced motion makes it instant without a second
* path through this file.
*
* It watches the window, which is what a FAB pinned to the corner of the page scrolls against. A
* FAB inside a scrolling pane of its own is not this.
*/
// Scrolling is noisy — a wheel's own wobble, a rubber-band bounce at either end — and a FAB that
// flipped on every pixel would never be still. A move has to be worth this much to count as a
// direction, and one worth less is kept and added to the next.
const STEP_PX = 8
// Within this much of the top the FAB is extended whichever way the page was last going: M3
// re-expands it "at the bottom of the view", which on a web page is where the page begins.
const TOP_PX = 24
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialFab', () => ({
collapsed: false,
lastY: 0,
ticking: false,
listeners: [],
init() {
this.lastY = Math.max(window.scrollY, 0)
this.listen(window, 'scroll', () => this.queue(), { passive: true })
},
/** One reading a frame: `scroll` fires far more often than anything can be drawn. */
queue() {
if (this.ticking) {
return
}
this.ticking = true
requestAnimationFrame(() => {
this.ticking = false
this.measure()
})
},
measure() {
const y = Math.max(window.scrollY, 0)
const moved = y - this.lastY
if (y <= TOP_PX) {
this.collapsed = false
} else if (moved > STEP_PX) {
this.collapsed = true
} else if (moved < -STEP_PX) {
this.collapsed = false
}
if (Math.abs(moved) > STEP_PX || y <= TOP_PX) {
this.lastY = y
}
},
listen(target, type, handler, options) {
target.addEventListener(type, handler, options)
this.listeners.push(() => target.removeEventListener(type, handler, options))
},
destroy() {
this.listeners.forEach((remove) => remove())
},
}))
})
+1
View File
@@ -11,6 +11,7 @@ import './theme.js'
import './figure.js'
import './tooltip.js'
import './menu.js'
import './fab.js'
import './snackbar.js'
import './rich-tooltip.js'
import './progress.js'
+17 -1
View File
@@ -20,6 +20,14 @@
`data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
rail flattens a nested FAB to elevation 0.
`collapse-on-scroll` is M3's extended FAB that "can collapse to a FAB on scroll-down and
re-expand to extended on scroll-up": while the window scrolls down it shrinks to the FAB of
its size, and it extends again when the page scrolls back up or reaches the top. The label
closes and fades while the width follows it on the spatial spring (resources/css/components/
actions.css); under reduced motion the two swap outright. The label stays in the page,
clipped rather than removed, so the collapsed FAB keeps its accessible name. It needs an
`icon` a FAB with no glyph is no FAB and does nothing on a plain FAB.
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
(androidx Compose Material 3, Apache-2.0). --}}
@@ -33,12 +41,14 @@
'external' => false,
'tooltip' => null,
'type' => 'button',
'collapseOnScroll' => false,
])
@php
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm';
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$extended = filled($label) || $slot->isNotEmpty();
$collapsing = $collapseOnScroll && $extended && filled($icon);
$isLink = filled($link);
$colours = $variant === 'filled'
@@ -68,6 +78,10 @@
'type' => $isLink ? null : $type,
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
'data-fab' => true,
'x-data' => $collapsing ? 'materialFab' : null,
'x-bind:data-collapsed' => $collapsing ? "collapsed ? '' : null" : null,
'data-fab-collapsible' => $collapsing ? true : null,
'data-fab-size' => $collapsing ? $size : null,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
@@ -78,7 +92,9 @@
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
@endif
@if ($extended)
@if ($collapsing)
<span data-fab-label><span>{{ $label ?? $slot }}</span></span>
@elseif ($extended)
<span>{{ $label ?? $slot }}</span>
@endif
@@ -155,6 +155,11 @@
<x-fab icon="add" label="New share" />
<x-fab icon="upload" label="Upload" size="md" variant="filled" />
BLADE,
'An extended FAB that collapses on scroll (scroll the page)' => <<<'BLADE'
<x-fab icon="edit" label="Compose" collapse-on-scroll />
<x-fab icon="upload" label="Upload" size="md" color="secondary" collapse-on-scroll />
<x-fab icon="add" label="New share" size="lg" color="tertiary" collapse-on-scroll />
BLADE,
'FAB menu' => <<<'BLADE'
<div class="flex h-72 w-full items-end justify-end">
<x-fab-menu label="New">
+17
View File
@@ -41,6 +41,23 @@ it('gives the extended FAB M3\'s gaps and its 80px minimum width', function (str
'lg' => ['lg', 'h-24 min-w-24 gap-5'],
]);
it('collapses an extended FAB to a FAB on scroll when asked', function () {
$html = (string) $this->blade('<x-fab icon="add" label="New share" size="lg" collapse-on-scroll />');
expect($html)
->toContain('x-data="materialFab"')
->toContain('x-bind:data-collapsed="collapsed ? &#039;&#039; : null"')
->toContain('data-fab-collapsible')
->toContain('data-fab-size="lg"')
// The label is clipped, never removed, so the collapsed FAB keeps its name.
->toContain('<span data-fab-label><span>New share</span></span>');
// A plain FAB has nothing to collapse, and an extended FAB without a glyph would collapse to nothing.
expect((string) $this->blade('<x-fab icon="add" aria-label="New" collapse-on-scroll />'))->not->toContain('materialFab')
->and((string) $this->blade('<x-fab label="New share" collapse-on-scroll />'))->not->toContain('materialFab')
->and((string) $this->blade('<x-fab icon="add" label="New share" />'))->not->toContain('materialFab')->toContain('<span>New share</span>');
});
it('marks its root for the places that draw a nested FAB their own way', function () {
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
});