Draw the FAB without Tailwind

Plan step 36. <x-fab> renders data-md-fab with data-md-size,
data-md-color, data-md-variant, data-md-extended and, collapsing on
scroll, data-md-collapse-on-scroll with a bound data-md-collapsed; it
passes its glyph's size to <x-icon>. fab.css draws FabBaseline/Medium/
LargeTokens and ExtendedFab*Tokens per size, the container and filled
colours, elevation 3 and 4 on hover, the state layer and focus ring,
and the collapse morph that leaves actions.css.

data-fab becomes data-md-fab, also in navigation.css (the rail flattens
a nested FAB) and toolbar.css (a docked toolbar's FAB), with their tests.
The actions browser test gains the owed collapse-on-scroll test.

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 15:06:45 +02:00
co-authored by Claude Opus 5
parent 2a4dcead3d
commit 1cf4f16c95
11 changed files with 357 additions and 146 deletions
+1
View File
@@ -19,6 +19,7 @@
@import './components/group.css';
@import './components/button-group.css';
@import './components/split-button.css';
@import './components/fab.css';
/* Inputs, selection and data */
-62
View File
@@ -84,65 +84,3 @@
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;
}
+207
View File
@@ -0,0 +1,207 @@
/*
* <x-fab>: M3 Expressive's floating action button and extended FAB.
*
* FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens (androidx Compose Material 3, Apache-2.0),
* per `data-md-size`:
*
* size FAB corner glyph extended: min width gap padding type
* sm 56px lg (16px) 24px 80px 8px 16px title-md
* md 80px lg-increased 28px 80px 16px 26px title-lg
* lg 96px xl (28px) 32px 96px 20px 28px headline-sm
*
* A FAB and its extended form share the height and the corner. `data-md-color` is `primary`,
* `secondary` or `tertiary`, drawn in its container (`data-md-variant="container"`) or in the
* colour itself (`filled`), at elevation 3, 4 under a hovering pointer, the shadow and colours on
* the fast effects spring. The state layer and focus ring are the declarations of the foundation's
* `md-state-layer` and `md-focus-ring`. `data-md-fab` on the root is also the hook a place uses to
* draw a nested FAB its own way: a navigation rail or a docked toolbar flattens it to elevation 0.
*
* `data-md-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 — when switching between FAB↔extended FAB, shape changes, the
* icon moves left, and the text label fades in/out" (resources/js/fab.js sets
* `data-md-collapsed`). `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,
* while the label fades on the effects spring. The collapsed width is the FAB's own size. The
* extended padding already centres the glyph at `sm` (16 + 24 + 16) 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. The
* label stays in the accessibility tree — clipped and transparent, never `display: none` — so the
* collapsed FAB keeps the name the extended one had. Under reduced motion tokens/motion.css takes
* every duration to zero, so it swaps.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './tooltip.css';
@layer material.components {
[data-md-fab] {
--md-fab-color: var(--md-sys-color-primary);
--md-fab-on-color: var(--md-sys-color-on-primary);
--md-fab-container: var(--md-sys-color-primary-container);
--md-fab-on-container: var(--md-sys-color-on-primary-container);
position: relative;
isolation: isolate;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
block-size: var(--md-fab-size);
border-radius: var(--md-fab-corner);
background-color: var(--md-fab-container);
color: var(--md-fab-on-container);
box-shadow: var(--md-sys-elevation-3);
white-space: nowrap;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
outline: none;
transition-property: box-shadow, background-color, color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover {
box-shadow: var(--md-sys-elevation-4);
}
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:focus-visible::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
}
[data-md-fab][data-md-size='sm'] {
--md-fab-size: 56px;
--md-fab-corner: var(--md-sys-shape-corner-lg);
--md-fab-extended-min: 80px;
}
[data-md-fab][data-md-size='md'] {
--md-fab-size: 80px;
--md-fab-corner: var(--md-sys-shape-corner-lg-increased);
--md-fab-extended-min: 80px;
}
[data-md-fab][data-md-size='lg'] {
--md-fab-size: 96px;
--md-fab-corner: var(--md-sys-shape-corner-xl);
--md-fab-extended-min: 96px;
}
[data-md-fab][data-md-color='secondary'] {
--md-fab-color: var(--md-sys-color-secondary);
--md-fab-on-color: var(--md-sys-color-on-secondary);
--md-fab-container: var(--md-sys-color-secondary-container);
--md-fab-on-container: var(--md-sys-color-on-secondary-container);
}
[data-md-fab][data-md-color='tertiary'] {
--md-fab-color: var(--md-sys-color-tertiary);
--md-fab-on-color: var(--md-sys-color-on-tertiary);
--md-fab-container: var(--md-sys-color-tertiary-container);
--md-fab-on-container: var(--md-sys-color-on-tertiary-container);
}
[data-md-fab][data-md-variant='filled'] {
background-color: var(--md-fab-color);
color: var(--md-fab-on-color);
}
[data-md-fab]:not([data-md-extended]) {
inline-size: var(--md-fab-size);
}
[data-md-fab][data-md-extended] {
min-inline-size: var(--md-fab-extended-min);
}
[data-md-fab][data-md-extended][data-md-size='sm'] {
gap: var(--md-sys-measurement-space100);
padding-inline: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-fab][data-md-extended][data-md-size='md'] {
gap: var(--md-sys-measurement-space200);
padding-inline: 26px;
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
font-variation-settings: normal;
}
[data-md-fab][data-md-extended][data-md-size='lg'] {
gap: 20px;
padding-inline: 28px;
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
font-variation-settings: normal;
}
/* The extended FAB that collapses to a FAB while the page scrolls down. */
[data-md-fab][data-md-collapse-on-scroll] {
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-md-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);
& > span {
overflow: hidden;
white-space: nowrap;
}
}
[data-md-fab][data-md-collapse-on-scroll][data-md-collapsed] {
min-inline-size: var(--md-fab-size);
gap: 0;
& [data-md-fab-label] {
grid-template-columns: 0fr;
opacity: 0;
}
}
[data-md-fab][data-md-collapse-on-scroll][data-md-size='lg'][data-md-collapsed] {
padding-inline: var(--md-sys-measurement-space400);
}
}
+4 -4
View File
@@ -806,8 +806,8 @@
* within another component, such as the navigation rail, the FAB's resting elevation should be
* level 0"). Unlayered on purpose: the FAB draws its shadow with a utility, and a rule in any
* layer loses to a utility — the same reason toolbar.css gives for its vibrant recolouring. */
[data-navigation-rail-header] [data-fab],
[data-navigation-rail-header] [data-fab]:hover {
[data-navigation-rail-header] [data-md-fab],
[data-navigation-rail-header] [data-md-fab]:hover {
box-shadow: none;
}
@@ -821,7 +821,7 @@
* same reason as the rule above: `gap-2` and `min-w-20` are utilities, and a layer would lose to
* them. The cap is there because a transition needs a length to travel to; a rail FAB's label is a
* word or two. */
[data-navigation-rail-header] [data-fab] > span {
[data-navigation-rail-header] [data-md-fab] > span {
max-width: 16rem;
overflow: hidden;
white-space: nowrap;
@@ -835,7 +835,7 @@
}
}
[data-navigation-rail-header] [data-fab] {
[data-navigation-rail-header] [data-md-fab] {
@variant rail-collapsed {
min-width: 0;
aspect-ratio: 1;
+2 -2
View File
@@ -89,8 +89,8 @@
margin-inline-start: auto;
}
[data-toolbar-fab] [data-fab],
[data-toolbar-fab] [data-fab]:hover {
[data-toolbar-fab] [data-md-fab],
[data-toolbar-fab] [data-md-fab]:hover {
box-shadow: none;
}