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
+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 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;
}