diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md
index 3dbe379f..59d0600d 100644
--- a/resources/boost/skills/livewire-material-development/SKILL.md
+++ b/resources/boost/skills/livewire-material-development/SKILL.md
@@ -321,6 +321,8 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
`` — `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 (`
` — 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` (`
`) 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.
+
### `
`, ``
```blade
diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css
index 4018d2d3..0f96f924 100644
--- a/resources/css/components/actions.css
+++ b/resources/css/components/actions.css
@@ -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 (``, 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 FAB↔extended 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;
+}
diff --git a/resources/js/fab.js b/resources/js/fab.js
new file mode 100644
index 00000000..b4578d45
--- /dev/null
+++ b/resources/js/fab.js
@@ -0,0 +1,74 @@
+/**
+ * `materialFab`: `` — 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())
+ },
+ }))
+})
diff --git a/resources/js/material.js b/resources/js/material.js
index aa6a83f5..2b780e2a 100644
--- a/resources/js/material.js
+++ b/resources/js/material.js
@@ -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'
diff --git a/resources/views/components/fab.blade.php b/resources/views/components/fab.blade.php
index 647336f1..c9aefaea 100644
--- a/resources/views/components/fab.blade.php
+++ b/resources/views/components/fab.blade.php
@@ -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 @@
@endif
- @if ($extended)
+ @if ($collapsing)
+ {{ $label ?? $slot }}
+ @elseif ($extended)
{{ $label ?? $slot }}
@endif
diff --git a/resources/views/showcase/sections/buttons.blade.php b/resources/views/showcase/sections/buttons.blade.php
index 7bf4248a..84f77be6 100644
--- a/resources/views/showcase/sections/buttons.blade.php
+++ b/resources/views/showcase/sections/buttons.blade.php
@@ -155,6 +155,11 @@
BLADE,
+ 'An extended FAB that collapses on scroll (scroll the page)' => <<<'BLADE'
+
+
+
+ BLADE,
'FAB menu' => <<<'BLADE'
diff --git a/tests/Feature/Components/FabTest.php b/tests/Feature/Components/FabTest.php
index eada234f..50a3e82c 100644
--- a/tests/Feature/Components/FabTest.php
+++ b/tests/Feature/Components/FabTest.php
@@ -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('');
+
+ expect($html)
+ ->toContain('x-data="materialFab"')
+ ->toContain('x-bind:data-collapsed="collapsed ? '' : null"')
+ ->toContain('data-fab-collapsible')
+ ->toContain('data-fab-size="lg"')
+ // The label is clipped, never removed, so the collapsed FAB keeps its name.
+ ->toContain('New share');
+
+ // A plain FAB has nothing to collapse, and an extended FAB without a glyph would collapse to nothing.
+ expect((string) $this->blade(''))->not->toContain('materialFab')
+ ->and((string) $this->blade(''))->not->toContain('materialFab')
+ ->and((string) $this->blade(''))->not->toContain('materialFab')->toContain('New share');
+});
+
it('marks its root for the places that draw a nested FAB their own way', function () {
expect((string) $this->blade(''))->toContain('data-fab');
});