Every foundations, styles and components page of m3.material.io (238, from the sitemap) extracted into docs/reference/m3, five audit reports with 142 findings in docs/audits/m3-alignment, and the 2.0.0 plan in docs/plans/material-3-alignment.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
49 KiB
Audit: navigation
Scope: app-bar, toolbar, tabs/tab, navigation-bar(-item), navigation-rail(-item|-section),
app-shell, section-nav, account-menu, theme-toggle, scheme-picker, their CSS/JS, the showcase
usage and the matching SKILL.md sections.
Sources abbreviated below: RCB = docs/reference/m3/reference-components-b.md; RF =
docs/reference/m3/reference-foundations.md; raw/ = docs/reference/m3/raw/<page>.md; tok/ =
docs/reference/m3/tokens/<File>.kt (androidx-main). Anything fetched live is named with its URL.
Summary
The component-level work is unusually faithful: every numeric token I checked for the app bar
(64/112/136/120/152dp heights, title-large → headline-medium → display-small, 4dp edge spacing), the
navigation bar (64dp, 56×32 indicator, 40dp horizontal pill, 16dp indicator spaces, and even Compose's
calculateCenteredContentHorizontalPadding formula reproduced as calc(10% * (count + 3))), the rail
(96dp collapsed, 220–360dp expanded, 44dp top space, 40dp header space, 56dp expanded item, modal =
surface-container + elevation 2 + CornerLarge inner edge) and both toolbars (64dp, 16dp/4–32dp docked,
8dp/4dp + CornerFull floating) matches the androidx token files exactly. The catalogue is also right
about Expressive: the deprecated bottom app bar and navigation drawer are absent, the flexible app bars
and the flexible navigation bar are what is implemented, and the expanded rail correctly plays the
drawer's role. The real problems are three: the adaptive breakpoint map is Tailwind's, not M3's — sm
640 stands in for 600 and, much worse, lg 1024 stands in for 840, so every window from 840 to 1023px is
denied the standard expanded rail and gets a modal that re-closes on every navigation; two hand-rolled
buttons (theme-toggle, account-menu) are 40×40 with no 48px target, below M3's stated minimum, while
the library's own <x-button> and the rail's menu button both do it correctly; and a few placement
rules are simply not wired up — a place="bottom" toolbar sits on top of the app shell's navigation
bar, a rail-nested FAB keeps elevation 3 where M3 says level 0, and theme-toggle mode="picker"
hand-rolls the Expressive-deprecated segmented button even though the library already ships its
successor (<x-group>).
Findings
N-01 · theme-toggle, account-menu · 40×40 controls with no 48px interaction target
- Severity: must-fix
- M3 says: "Touch target minimum 48 × 48dp"; "Web default target size: at least 48 × 48 CSS pixels"; and, precisely for this case, "The size of state layers is 40dp while the interactive target size is 48dp" (RF §Accessibility Values, §States/state-layer mechanics). RF §Layout/Density repeats: "Interaction targets must stay ≥48×48dp even when the visible element is scaled down."
- Library does:
resources/views/components/theme-toggle.blade.php:65rendersinline-flex size-10(40px) with no target extension;resources/views/components/account-menu.blade.php:41rendersinline-flex size-10 … overflow-hidden(40px), likewise none. The library already knows the fix: the icon button appliesafter:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2atxs/sm(resources/views/components/button.blade.php:171-172, comment "Below 48px the touch target reaches past the button, as M3 requires"), and the rail's menu button repeats it atresources/views/components/navigation-rail.blade.php:108(after:size-12). - Fix: add the same
after:pseudo-target to both class lists. Foraccount-menu,overflow-hiddenon the button would clip the pseudo-element — move the clipping to the<img>(class="size-full rounded-corner-full object-cover") and dropoverflow-hiddenfrom the button. - Effort: S
- Breaks API? no
N-02 · toolbar, app-shell · a place="bottom" toolbar sits on top of the navigation bar
- Severity: must-fix
- M3 says: "Docked toolbar and navigation bar occupy the same screen position — never show both simultaneously" (RCB §Toolbars/Behaviour; raw/components_toolbars_guidelines.md). For the floating case: "FAB, when present, floats above the nav bar, right-aligned — must never overlap/cover it" (RCB §Navigation Bar/Behaviour), and the toolbar occupies the same rail region as a FAB (raw/foundations_layout_scaffold_rails.md: "On mobile, a toolbar can float in the rail region" — the rail region sits above the navigation bar, item 1: "A toolbar sits above the navigation bar").
- Library does:
resources/css/components/toolbar.css:75-81pins the toolbar atbottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))— it ignores--material-bottom-bar, the variable the shell sets for exactly this purpose (resources/views/components/app-shell.blade.php:88). The library's FAB (resources/views/components/button.blade.php:177,max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)]) and the snackbar (resources/views/components/toast.blade.php:36) both do use it. A dockedvariant="docked" place="bottom"toolbar (toolbar.css:91-96,bottom: 0) lands squarely on the navigation bar with no warning anywhere in the code or in SKILL.md:754-764. - Fix: in
toolbar.css, change[data-toolbar-place="bottom"]'sbottomtocalc(var(--material-bottom-bar, 0px) + 1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))(mirroring the FAB/toast rule, which already folds the safe area into--material-bottom-bar), and for[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"]bottom: var(--material-bottom-bar, 0px). Add one line to SKILL.md's<x-toolbar>section: a docked toolbar and a navigation bar must never be on screen together — show the bar on primary pages, the toolbar on secondary/contextual ones. - Effort: S
- Breaks API? no
N-03 · navigation-rail · a FAB nested in the rail keeps elevation 3
- Severity: must-fix
- M3 says: "When nested within another component, such as the navigation rail, the FAB's resting elevation should be level 0" (raw/components_navigation-rail_guidelines.md:213; RCB §Navigation Rail/Behaviour: "the site explicitly states this contrasts with a standalone FAB's normal elevation").
- Library does:
resources/views/components/fab.blade.php:49always appliesshadow-elevation-3 hover:shadow-elevation-4;resources/css/components/navigation.csscontains no rule for a FAB inside a rail (grep forfabin it returns nothing). The documented pattern (navigation-rail.blade.php:7-9, SKILL.md:717-719,showcase/shell.blade.php:48-49) puts a FAB in theheaderslot, so every documented use is at elevation 3. - Fix: the FAB has no attribute hook today (
fab.blade.php:65renders<{{ $tag }} {{ $attributes }}>with classes only), so adddata-fabto its merged attributes, then add an unlayered rule tonavigation.css—shadow-elevation-3is a Tailwind utility, so a@layer componentsrule would lose, exactly astoolbar.css:10-12already explains for its vibrant recolouring:[data-navigation-rail-header] [data-fab], [data-navigation-rail-header] [data-fab]:hover { box-shadow: none; }. - Effort: S
- Breaks API? no
N-04 · theme-toggle · mode="picker" renders the Expressive-deprecated segmented button
- Severity: must-fix
- M3 says: segmented buttons "are deprecated in the expressive update"; "connected button groups should
replace the baseline segmented button" (
docs/reference/m3/reference-components-a.md:343-345, :224, :257). - Library does:
resources/views/components/theme-toggle.blade.php:22-48hand-rolls one — a 40px (h-10) outlined stadium (rounded-corner-full border border-outline) whose segments share dividing borders (not-first:border-s) and whose selected segment isbg-secondary-containerwith a check icon. That is theOutlinedSegmentedButtonTokensdesign (40dp,CornerFull, 1dp outline, label-large), point-for-point. Its own header comment says so: "picker: M3's segmented buttons for the three choices". Meanwhile the library ships the successor twice over:resources/views/components/group.blade.php:1-2("An M3 Expressive connected button group — the successor of the segmented button", native radios, arrow-key movement, selected segment rounds fully) andresources/views/components/button-group.blade.php(connected). The showcase puts the deprecated control in its own chrome atshowcase/layout.blade.php:148. - Fix: re-implement
mode="picker"on top of<x-group>with:options="[['id' => 'light', 'name' => __('Light'), 'icon' => 'light_mode'], …]"and anx-model-ish binding onto$store.theme.choice(a smallx-datawrapper writing$store.theme.set()on change). The selected-segment shape morph, the radiogroup semantics and the arrow keys then come for free — which also resolves N-17. - Effort: M
- Breaks API? no (the
mode="picker"prop name and thedata-theme-toggle="picker"/data-theme-optionhooks can be kept; the internal markup changes, so any app CSS targeting the inner buttons would move)
N-05 · tabs · every panel is visible until Alpine initialises
- Severity: must-fix
- M3 says: tabs show one destination at a time; the active indicator plus a single visible panel is the whole mechanism (RCB §Tabs/Anatomy, /Behaviour). Exposing all panels also breaks the ARIA tabs contract — only the panel for the selected tab may be rendered/visible.
- Library does:
resources/views/components/tab.blade.php:10hides the panel withx-showonly, and there is nox-cloakor server-sidehidden/style="display:none".[x-cloak]{display:none!important}is defined (resources/css/tokens/state.css:68-70) but unused here. Until Alpine boots, all panels stack vertically (the showcase's 4-tab example renders all four bodies), and a screen reader reaching the page early sees fourrole="tabpanel"regions at once. The same line bindsx-bind:id, soaria-controls="…-panel"on every tab button (tabs.blade.php:65) dangles until Alpine runs. - Fix: in
tab.blade.php, render the id server-side the way the tab buttons already do (pass the$iddown, or reusetabsIdvia a@aware/slot prop) and addx-cloakplus a server-rendered@if ($name !== $initial) style="display: none" @endif— or simplest: keepx-show, addx-cloak, and add a CSS rule that un-cloaks the initially selected panel. Any of these removes both the flash and the danglingaria-controls. - Effort: S
- Breaks API? no
N-06 · app-shell, navigation-rail · lg (1024px) stands in for M3's expanded breakpoint (840dp)
- Severity: should-fix
- M3 says: breakpoints are Compact <600, Medium 600–839, Expanded 840–1199, Large 1200–1599, Extra-large ≥1600 (RF §Layout/Breakpoints). For Expanded the recommended navigation is "Modal or standard expanded navigation rail", and the swap table gives Expanded a "Standard expanded navigation rail" (RF §Layout/Breakpoints tables). The rail page: "Expanded → extra-large → use a rail (never a bar); choose Standard vs. Modal based on space and destination count" (RCB §Navigation Rail/Behaviour).
- Library does: the threshold is Tailwind's
lg= 64rem = 1024px, in three places that must agree —resources/js/navigation.js:16(const WIDE = '(min-width: 64rem)'),resources/css/components/navigation.css:51and:57(therail-collapsedvariant) and:184-188(the adaptive rail's 96px layout width). Below it,materialNavigationRail.modalis true (navigation.js:116-118) andexpandedignores$store.rail.collapsed(:124-130). What a user sees at 840–1023px (a landscape tablet, a half-screen desktop window, a 12.9" iPad): a 96px collapsed rail; pressing its menu button opens the expanded rail as a modal over a scrim with a focus trap;document.addEventListener('livewire:navigating', … hide())(navigation.js:86) closes it on every destination click, so the rail must be re-opened for each navigation; and the remembered$store.rail= expanded preference is silently ignored. M3 lists a standard (in-layout) expanded rail for this whole band. - Fix: change
WIDEto'(min-width: 52.5rem)'(840px) and the three64remmedia queries innavigation.cssto52.5rem. If 840px feels too narrow for a 256px rail beside content, expose the threshold instead of hard-coding it: a--material-rail-expanded-atcustom property or awide-atprop on<x-app-shell>/<x-navigation-rail mode="adaptive">, defaulting to 840px. Update SKILL.md:665 and :735, which both say "tolg" / "fromlg". - Effort: M
- Breaks API? no (unless an app relies on the 1024px switch point visually)
N-07 · app-shell, navigation-bar · sm (640px) stands in for M3's compact/medium boundary (600dp)
- Severity: should-fix
- M3 says: Compact is "Under 600dp"; Medium is "600–839dp" (RF §Layout/Breakpoints). The navigation bar is for "compact (<600dp) and medium (600–839dp)" (RCB §Navigation Bar/Behaviour); at Medium the swap table gives "Collapsed navigation rail" as the primary navigation (RF §Layout/Breakpoints).
- Library does: two different numbers for the same boundary. The bar's own item layout switches at the
right place —
@container (width >= 37.5rem)= 600px (navigation.css:123,:465,:519,:525,:555,:562), correct and matchingShortNavigationBarArrangement.Centered. The shell, however, switches atsm= 40rem = 640px:app-shell.blade.php:142(sm:hiddenon the bar wrapper),:87(sm:flex),:88and:136(the--material-bottom-barpadding), andnavigation.css:190-194/:235-279(the adaptive rail is width 0 anddisplay:nonebelow 40rem). What a user sees at 600–639px: the navigation bar is still the only navigation, but it has already flipped to the medium horizontal (icon-beside-label, centred) configuration — so the Expressive medium bar exists in a 40px-wide window band and then disappears entirely at 640px, where the collapsed rail takes over. The two switch points should be the same number. - Fix: replace the
sm:/max-sm:utilities inapp-shell.blade.php(lines 87, 88, 136, 142) with arbitrary variants at 600px (max-[37.5rem]:/min-[37.5rem]:), or define acompact/mediumcustom variant in the theme so the whole package has one name for 600px; change the two40remmedia queries innavigation.cssto37.5rem. Also update the documented wrapper innavigation-bar.blade.php:4and SKILL.md:700-707, which teachsm:hidden. - Effort: M
- Breaks API? no (the documented
sm:hiddenwrapper idiom changes in the docs)
N-08 · navigation-bar-item · the horizontal (medium) item label is label-large, not label-medium
- Severity: should-fix
- M3 says:
NavigationBarTokens.LabelTextFont = TypographyKeyTokens.LabelMedium(tok/NavigationBarTokens.kt). There is no per-layout label font:NavigationBarHorizontalItemTokensdefines onlyActiveIndicatorHeight/LeadingSpace/TrailingSpace/IconSize, and Compose'sShortNavigationBarItempasseslabelTextStyle = NavigationBarTokens.LabelTextFont.valuefor both icon positions (androidxShortNavigationBar.kt:220, fetched from https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ShortNavigationBar.kt). - Library does:
resources/css/components/navigation.css:147-148setsfont: var(--md-sys-typescale-label-lg)inside@container (width >= 37.5rem). (The rail's horizontal item is label-large —NavigationRailHorizontalItemTokens.LabelTextFont = LabelLarge— which is probably where this came from; the nav bar has no such token.) - Fix:
navigation.css:147-148→ keeplabel-md(delete the two lines; the base[data-navigation-pill]rule at:105-106already sets label-medium). - Effort: S
- Breaks API? no
N-09 · app-bar · the search variant's container does not follow the 312dp / 50% growth rule
- Severity: should-fix
- M3 says: "The search container of the search app bar should fill 100% of the space between leading and trailing app bar elements until it reaches 312dp. Then, it should only grow further to fill 50% of that space" (raw/components_app-bars_guidelines.md:454; RCB §Top App Bar/Behaviour).
- Library does:
resources/css/components/app-bar.css:133-137—[data-app-bar-search] { flex: 1 1 0%; }, i.e. 100% of the available space at every width. On a 1600px window the search bar spans ~1500px. The showcase works around it by hand (showcase/layout.blade.php:60,class="mx-auto w-full max-w-2xl"), which is evidence the component's own behaviour is wrong, andmax-w-2xl(672px) is not the M3 curve either. - Fix: in
app-bar.css, give the search slot the M3 curve:[data-app-bar-search] { flex: 1 1 0%; max-width: calc(19.5rem + (100% - 19.5rem) / 2); margin-inline: auto; }(19.5rem = 312px; below 312pxmax-widthnever binds so it still fills 100%). Then drop the manualmax-w-2xlin the showcase layout. - Effort: S
- Breaks API? no
N-10 · tabs · scrollable tabs have no 52dp leading offset
- Severity: should-fix
- M3 says: "When using scrollable tabs, the first visible tab should be offset by 52dp from the left side of the device for both web and mobile"; "Do — Offset the first scrollable tab 52dp from the leading edge so it's clear that more content is available" (raw/components_tabs_guidelines.md:150, :157; RCB §Tabs/Variants).
- Library does:
resources/css/components/tabs.css:50-52only setsflex: noneon the tabs when[data-scrollable]is present; there is no leading padding anywhere on[data-tabs-bar]. The showcase's scrollable example (showcase/sections/bars.blade.php:101-105) starts "January" flush against the container edge, so nothing signals that the set scrolls. - Fix:
tabs.css, add[data-tabs-bar][data-scrollable] { padding-inline-start: 3.25rem; }(52px) — logical property, so RTL mirrors for free. - Effort: S
- Breaks API? no
N-11 · app-bar · the centred headline uses fixed 56px insets and collides with two trailing actions
- Severity: should-fix
- M3 says: the centred configuration is the Expressive replacement for the center-aligned bar ("Merged into small. Use centered-text configuration.", RCB §Top App Bar/Status), and trailing elements are "up to 2 icon buttons after headline" (RCB §Top App Bar/Behaviour). The headline must not be truncated (raw/components_app-bars_guidelines.md:337).
- Library does:
resources/css/components/app-bar.css:122-130absolutely positions the centred headline withinset-inline: 3.5rem— a hard-coded 56px on each side, enough for exactly one 48px icon button. With the M3-sanctioned two trailing buttons (≈96px plus the 4px row padding), the headline box extends ~40px underneath them; a long title ellipsises behind the buttons instead of before them. Nothing in the code adapts the inset to what thenavigation/actionsslots actually contain. - Fix: replace the absolute positioning with a three-column grid on the row for the centred variant —
[data-app-bar][data-variant="center"] [data-app-bar-row] { display: grid; grid-template-columns: 1fr auto 1fr; }with the leading slot in column 1, the headline (centred,justify-self: center,min-width: 0) in column 2 and the trailing slot in column 3 (justify-self: end) — the headline then shrinks against whatever is actually beside it. Failing that, measure the two slots inapp-bar.js(which already runs aResizeObserver) and publish--app-bar-lead/--app-bar-trail. - Effort: M
- Breaks API? no
N-12 · toolbar · a vertical floating toolbar placed at end uses a 16px margin, M3 wants ≥24dp
- Severity: should-fix
- M3 says: "Horizontal toolbars should have a minimum 16dp margin from the edge of the window"; "Vertical toolbars should have a minimum 24dp margin" / "Maintain at least a 24dp margin for vertical toolbars" (raw/components_toolbars_guidelines.md:334, :346, :350; RCB §Toolbars/Specs).
- Library does:
resources/css/components/toolbar.css:83-89—[data-toolbar-place="end"] { inset-inline-end: calc(1rem + var(--material-safe-right, …)); }, 16px for every toolbar placed at the end edge, andplace="end"is documented as the placement "for a vertical one" (toolbar.blade.php:6). - Fix:
toolbar.css, split the rule:[data-toolbar][data-vertical][data-toolbar-place="end"], [data-toolbar-group][data-vertical][data-toolbar-place="end"] { inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right))); }(24px) while horizontal placements keep 16px. - Effort: S
- Breaks API? no
N-13 · toolbar · standard (non-filled, non-tonal) buttons inside a standard toolbar are on-surface-variant, M3 says Primary
- Severity: should-fix (with a caveat — see both readings)
- M3 says: standard toolbar colour list, verbatim: "1. Surface container / 2. Filled button (Primary, On primary) / 3. Toggle tonal button (Secondary container, On secondary container) / 4. Standard button (Primary)" (raw/components_toolbars_specs.md:228-234; RCB §Toolbars/Specs). The vibrant list gives "Standard button (On primary container)" (:253) — which the library does honour.
- Library does:
resources/css/components/toolbar.css:49-61recolours buttons only in the vibrant case. In a standard toolbar the icon buttons keep the library's default icon-button ink,text-on-surface-variant(resources/views/components/button.blade.php:116$quietInk, :123 for icon-only text buttons) — the container's owncolor: var(--md-sys-color-on-surface-variant)(toolbar.css:28,:37) cannot reach them because they carry a utility. The other reading: M3's own icon-button spec gives an unselected standard icon buttonon-surface-variant, and the toolbar colour list is annotated from an image showing text/standard buttons rather than icon buttons. If the library treats the toolbar list as applying to label buttons only, its current choice is defensible. I lean to the finding because the same list's vibrant row is implemented, so the two rows are being read inconsistently. - Fix: if adopting the spec, add next to the vibrant rules (same unlayered block):
[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) { color: var(--md-sys-color-primary); }. If keeping the current behaviour, say so intoolbar.css's header comment with the reason, so the next reader does not re-litigate it. - Effort: S
- Breaks API? no
N-14 · app-shell · the app bar sits inside the content pane, beside the rail, not across the window
- Severity: should-fix (structural; see both readings)
- M3 says: the scaffold is bars → rails → panes, in that nesting order. "Bars can frame the page"; "Rails create the perimeter space surrounding panes" (raw/foundations_layout_scaffold_overview.md:54-58); "Rails are the next level in layout after bars, filling the perimeter space surrounding panes"; "Rails occupy the spaces immediately adjacent to bars" (raw/foundations_layout_scaffold_rails.md:56, :64). And for the bar itself: "Container always spans 100% of window width at its default height" (RCB §Top App Bar/Behaviour).
- Library does:
resources/views/components/app-shell.blade.php:97-139puts<x-navigation-rail>as a flex sibling before a column that contains{{ $top }}and<main>. Fromsmthe app bar therefore starts 96px (or 256px) in from the window's leading edge and is a pane-level bar, not a page-level one. There is no slot that renders above the rail, so an application cannot opt into the M3 arrangement without abandoning<x-app-shell>. The other reading:NavigationSuiteScaffoldin Compose nests the whole content — top bar included — beside the rail, and M3's canonical-layout imagery shows both arrangements. If the library regards the app bar as a pane bar, the current structure is consistent; it is still worth a documented decision rather than an accident of markup order. - Fix: add a
window-top(orbanner) slot to<x-app-shell>rendered as the first child of[data-app-shell]with the shell root becomingflex flex-coland the rail+content row nested inside, so an application can choose either. Document which slot means which in SKILL.md:663-695. - Effort: M
- Breaks API? no (additive slot)
N-15 · account-menu · the avatar trigger has no state layer (no hover or pressed state)
- Severity: should-fix
- M3 says: the six states are Enabled, Disabled, Hover, Focused, Pressed, Dragged, and "States have two visual indicators to ensure accessibility"; the state layer's colour equals the content's "on" colour, at 8% hover / 10% focus / 10% pressed (RF §Interaction/States).
- Library does:
resources/views/components/account-menu.blade.php:41—focus-ring inline-flex size-10 … rounded-corner-full bg-primary-container … text-on-primary-container. It has the focus ring but nostate-layer, so there is no hover and no pressed feedback at all. Every comparable trigger in the library (the rail menu button atnavigation-rail.blade.php:108, the theme toggle attheme-toggle.blade.php:65, every<x-button>) carriesstate-layer. - Fix: add
state-layerto the class list. Note the ordering constraint fromresources/css/tokens/state.css:13-27(the utility setsposition: relative; isolation: isolateand paints a::beforeatz-index: -1) — withoverflow-hiddenremoved per N-01 this composes cleanly. - Effort: S
- Breaks API? no
N-16 · section-nav · secondary tabs wrapped onto a 3/4-column grid break the tab bar's divider and indicator
- Severity: should-fix
- M3 says: "Container always spans full width, divided into equal sections; bottom-edge divider separates it from content below" (RCB §Tabs/Behaviour). M3's stated answer to "too many tabs" is scrollable tabs — "scrollable allows longer labels and more tabs" (RCB §Tabs/Variants) — and its accessibility page explicitly blesses them: "Horizontal scrolling tabs meet accessibility requirements because they need to increase in width to respond to label text without affecting the layout" (raw/components_tabs_accessibility.md:95).
- Library does:
resources/views/components/section-nav.blade.php:31-35, :60reuses[data-tabs-bar][data-variant="secondary"]but appliessm:grid sm:grid-cols-3/sm:grid-cols-4, so from 5 items the "tab bar" becomes two or more rows.tabs.css:19puts the 1px outline-variant divider on the bar, so only the last row gets one and the upper rows' 2px active indicators float in mid-air against nothing.[data-tab] { min-width: 5.625rem }(tabs.css:36) also fightsgrid-cols-4in a narrow column. - Fix: either keep
sm:flexwith[data-scrollable]semantics (flex: none+ the 52dp offset from N-10), which is what M3 prescribes and what the library already implements for<x-tabs scrollable>; or, if the grid is kept deliberately, move the divider to each row ([data-section-nav] [data-tabs-bar] > li { border-bottom: 1px solid var(--md-sys-color-outline-variant); }with the bar's own border removed) and drop themin-widthinside the grid. See also "Deliberate deviations" — the stated reason for the grid does not survive M3's text. - Effort: M
- Breaks API? no
N-17 · theme-toggle · the picker's radiogroup keyboard handling is incomplete
- Severity: should-fix
- M3 says: a single-select segmented control's role is "Radiogroup"
(
docs/reference/m3/reference-components-a.md:392); RF §Interaction/Inputs and the per-component accessibility pages defer to the APG pattern, in which a radio group handles Up/Down as well as Left/Right and wraps from last to first. - Library does:
resources/views/components/theme-toggle.blade.php:38-39handles onlykeydown.arrow-right/keydown.arrow-left, via$el.nextElementSibling/previousElementSibling, so there is no wrap (Right on "System" and Left on "Light" do nothing), no Up/Down, and no Home/End.aria-checked="false"is also rendered on all three server-side (:33), so before Alpine boots the group reports nothing checked. - Fix: fixed by N-04 —
<x-group>uses native radios in a fieldset, where the browser supplies wrap, both axes and the roving tab stop. If N-04 is not taken, addarrow-down/arrow-up, wrap withnextElementSibling ?? firstElementChild, and render the initialaria-checkedfrom the server default. - Effort: S (as part of N-04)
- Breaks API? no
N-18 · tabs · the primary active indicator is missing its 2dp side inset
- Severity: should-fix
- M3 says: "Primary tab active indicators are additionally inset 2dp on each side" (RCB §Tabs/Specs, from tabs/specs page text); "Active indicator minimum length 24dp".
- Library does:
resources/css/components/tabs.css:119-129—inset-inline: 0on[data-tab-indicator], which for a primary tab is scoped to[data-tab-content](min-width: 1.5rem= the 24dp minimum ✓,tabs.css:108). The 3px height, the3px 3px 0 0corners and the 2px/square secondary variant are all correct; only the inset is absent. - Fix:
tabs.css, in the primary case:[data-tabs-bar]:not([data-variant="secondary"]) [data-tab-indicator] { inset-inline: 2px; }. - Effort: S
- Breaks API? no
N-19 · navigation-bar-item · the state layer uses on-surface where the rail uses on-secondary-container
- Severity: should-fix
- M3 says: "The state layer's color equals the content's 'on' color" (RF §Interaction/States,
state-layer mechanics). For navigation items androidx is explicit and uses one colour for active and
inactive:
ItemActiveHoveredStateLayer = ItemActiveFocusedStateLayer = ItemActivePressedStateLayer = ItemInactiveHoveredStateLayer = ItemInactiveFocusedStateLayer = ItemInactivePressedStateLayer = OnSecondaryContainer(tok/NavigationRailColorTokens.kt).NavigationBarTokens.ktships no state-layer tokens, so the rail's are the only stated ones for a navigation item. - Library does: two different answers for the same kind of item. The rail is right —
navigation.css:544-552paints both::beforelayers withvar(--md-sys-color-on-secondary-container). The navigation bar is not:navigation.css:504-517leavesbackground-color: var(--md-sys-color-on-surface)for[data-navigation-bar-item]'s indicator and pill layers. On an active bar item the pill is secondary-container, so the hover layer is an on-surface wash over secondary-container rather than the on-secondary-container the content uses. - Fix:
navigation.css, add[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before { background-color: var(--md-sys-color-on-secondary-container); }after the shared rule, matching the rail's treatment two blocks below. - Effort: S
- Breaks API? no
N-20 · tabs · the focus ring is drawn inside the tab (outline-offset: -3px) instead of M3's 2px out
- Severity: nice-to-have
- M3 says: Foundations calls the indicator only "ring-like" and gives no number (RF §Interaction/States:
"No thickness, offset, or color token is specified anywhere in Foundations"), but the library has picked
one convention for the whole package —
focus-ring= 3px secondary atoutline-offset: 2px(resources/css/tokens/state.css:46-53), and the navigation bar/rail items follow it (navigation.css:554-583). - Library does:
resources/css/components/tabs.css:81-85usesoutline-offset: -3px, drawing the ring inside the tab's own box. (The likely reason isoverflow-x: autoon the bar clipping an outward ring; the file says nothing about it.) - Fix: either keep it and record the reason in
tabs.css's header comment, or give the barpadding-block: 3px/scroll-padding-inline: 3pxroom and switch tooutline-offset: 2pxso tabs match every other focusable thing in the library. - Effort: S
- Breaks API? no
N-21 · tabs · aria-current="page" gets the indicator but not the active label colour in the primary variant
- Severity: nice-to-have
- M3 says: primary tabs' active label is Primary; secondary tabs' is On surface (RCB §Tabs/Specs).
- Library does:
resources/css/components/tabs.css:87-93— the active colour rule for the primary variant matches only[aria-selected="true"], while the secondary rule matches:is([aria-selected="true"], [aria-current="page"])and the indicator rule (:141) matches both. So a link-based tab bar (the patternsection-navuses,section-nav.blade.php:65-70) built withvariant="primary"would show the indicator but keep the label on-surface-variant. - Fix:
tabs.css:87→[data-tab]:is([aria-selected="true"], [aria-current="page"]). - Effort: S
- Breaks API? no
N-22 · navigation-rail · no vertical divider and no way to turn the container fill off
- Severity: nice-to-have
- M3 says: "Optional vertical divider separates rail from content, placed on the content-adjacent edge"; "container fill can be turned off (transparent) as long as items keep ≥3:1 contrast"; "If a layout scrolls horizontally, the rail can scroll off-screen or remain fixed. To distinguish that content is scrolling underneath the rail, use a divider or add elevation to the rail" (RCB §Navigation Rail/Behaviour; raw/components_navigation-rail_guidelines.md:456).
- Library does:
navigation.css:196-215always paintsbackground-color: var(--md-sys-color-surface)and draws no divider; there is no prop for either (navigation-rail.blade.php:60-65has onlymode,label,width,menu). - Fix: add a
dividerboolean prop renderingborder-inline-end: 1px solid var(--md-sys-color-outline-variant)on[data-navigation-rail-panel], and afillprop (defaulttrue) that setsbackground-color: transparentwhen false. - Effort: S
- Breaks API? no (additive props)
N-23 · navigation-rail · the FAB → extended FAB transition is a display swap, not a morph
- Severity: nice-to-have
- M3 says: "Rail transitions from collapsed↔expanded should animate the FAB into/out of an Extended FAB" (RCB §Navigation Rail/Behaviour).
- Library does: the documented pattern is two FABs swapped by
display(navigation-rail.blade.php:42-43, SKILL.md:737,showcase/shell.blade.php:48-49) — the collapsed one appears at once while the rail's width is still springing. The rail's own width is animated on the spatial spring (navigation.css:171), so the mismatch is visible. - Fix: a single
<x-fab>whose label is width-animated (grid-template-columns: 0fr → 1fron the label span,ease-spatial-default) inside therail-collapsedvariant would morph instead of swap; or at minimum mention the limitation in SKILL.md. - Effort: M
- Breaks API? no
N-24 · navigation-rail · mode="collapsible" has no width floor of its own
- Severity: nice-to-have
- M3 says: "Compact → don't use a standard rail, use a navigation bar" (RCB §Navigation Rail/Behaviour).
- Library does: only
mode="adaptive"is width-aware (navigation.css:184-194). Acollapsibleorexpandedrail keepsclamp(13.75rem, 16rem, 22.5rem)at 360px too — two-thirds of a phone screen. The docs point applications at<x-app-shell>for adaptivity, and the showcase only usescollapsibleinside a bounded demo box, so nothing in-repo hits it; an application wiring a bare<x-navigation-rail mode="collapsible">into a page layout will. - Fix: a one-line guard in
navigation.css—@media (width < 37.5rem) { [data-navigation-rail='collapsible'], [data-navigation-rail='expanded'] { width: 6rem; } }plus the matchingrail-collapsedvariant arm — or an explicit sentence in SKILL.md:735 that these two modes are fixed-width by design and belong behind amin-[37.5rem]:wrapper. - Effort: S
- Breaks API? no
Deliberate deviations
- Expanded rail items are full-width pills, not label-hugging —
navigation-rail-item.blade.php:18-19: "Compose's expanded item hugs its label; the package draws the full-width pill, which leaves room for the count at the end." Holds up. M3 says exactly this is allowed: the indicator "hugs the label text by default (can be overridden to fill the full container width, resembling the old navigation drawer style); target area always spans the full rail width" (RCB §Navigation Rail/Behaviour). - No drop shadow on a scrolled app bar, colour change only —
app-bar.css:12,:59-61. Holds up, and is the right reading. androidx hasAppBarTokens.OnScrollContainerElevation = Level2, but the site is explicit: "On scroll: No drop shadow, instead a color fill creates separation from content" (raw/components_app-bars_overview.md:137). - Medium/large flexible height keyed on whether a subtitle is present —
app-bar.css:33-47maps medium 112/136 and large 120/152 to without/with subtitle. Holds up: those are exactlyContainerHeight/LargeContainerHeightin tok/AppBarMediumFlexibleTokens.kt and tok/AppBarLargeFlexibleTokens.kt, and Compose's flexible app bars pick the taller one when a subtitle is supplied. - Rail container is
surface, modal issurface-container—navigation.css:210,:229. Holds up: tok/NavigationRailCollapsedTokens.ktContainerColor = Surface; tok/NavigationRailExpandedTokens.ktModalContainerColor = SurfaceContainer,ModalContainerElevation = Level2,ModalContainerShape = CornerLarge— all three reproduced exactly. (The site's colour list says "Surface container (optional container fill)"; androidx is the more specific source and the library follows it.) - Every toolbar control stays in the Tab order rather than a roving tabindex —
toolbar.js:4-5: "Every control stays in the Tab order, so a control added by a Livewire render is reachable without any bookkeeping." Holds up against M3 (though not against APG's toolbar pattern): M3's own accessibility page says "Initial focus lands on the first interactive element; Tab (or Arrows) moves between all others" (RCB §Toolbars/Accessibility). - Section nav wraps onto a grid instead of scrolling —
section-nav.blade.php:15-16: "tabs that scroll hid the last sections on a tablet." Does not hold up. M3 prescribes scrollable tabs for exactly this case and its accessibility page defends them explicitly ("Horizontal scrolling tabs meet accessibility requirements…", raw/components_tabs_accessibility.md:95), while the grid breaks the tab bar's divider and strands the indicators (N-16). If the grid is kept for product reasons, it should stop presenting itself as[data-tabs-bar]. - Small app-bar titles truncate with an ellipsis —
app-bar.css:104-110. M3 says "Don't truncate the headline text" and "Don't wrap text in a small app bar" (raw/components_app-bars_guidelines.md:337, :347); its actual answer is "If headline text is long, use a medium flexible or large flexible app bar" (:342). Truncation is the only remaining web-sane behaviour, so this is a reasonable forced choice — but it is undocumented. Worth one line in the header comment pointing authors atvariant="medium".
Aligned
- App bar: 64 / 112 / 136 / 120 / 152px heights; title-large + label-medium (small), headline-medium +
label-large (medium flexible), display-small + title-medium (large flexible); 4px leading/trailing space;
0 gap between icon buttons; 16px title inset without a leading button; surface → surface-container on
scroll; on-surface title / on-surface-variant subtitle and trailing icons / on-surface leading icon;
square container. The pure-CSS collapse (negative sticky
top+ sticky inner row) is elegant and produces no layout shift; the flexible variants' row headline isaria-hiddenso the real<h1>is never announced twice. - Navigation bar: 64px, surface-container, square, items 0 apart, 56×32
CornerFullindicator, 4px icon–label space, 6px item vertical padding, 40px horizontal indicator with 16px leading/trailing, 24px icons, on-secondary-container active icon, secondary-container indicator, secondary active label (vertical) / on-secondary-container (horizontal), on-surface-variant inactive — all correct. The medium arrangement reproducescalculateCenteredContentHorizontalPadding(items occupy10 × (count + 3)% of the bar) exactly, and it switches on a container query at 600px, so a bar in a narrow column keeps the compact layout. - Navigation rail: 96px collapsed,
clamp(220px, width, 360px)expanded, 44px top space, 40px header space (32 + 8), 4px between collapsed items, 64px collapsed item, 56px expanded item, 8px/4px icon–label spaces, 56×32 collapsed indicator,CornerFull. Modal = fixed, surface-container, elevation 2,CornerLargeon the inner edge, 32% scrim,x-trap.inert.noscroll, Escape, scrim click, focus returned to the menu button, closed onlivewire:navigating. Badges move from the icon (collapsed) to the label end (expanded), which is M3's stated rule. State layers use on-secondary-container per NavigationRailColorTokens. - Catalogue vs. Expressive: no bottom app bar, no navigation drawer, no baseline medium/large app bar, no baseline navigation bar — all four correctly skipped, and the expanded rail (standard + modal) is what stands in for the drawer, exactly as M3 Expressive directs.
- Tabs: 48px (64px stacked), 24px icons, 1px outline-variant divider, 3px primary indicator with
3px 3px 0 0corners, 2px square secondary indicator, 24px minimum indicator length, 8px icon–label gap, title-small labels, primary/on-surface active colours, 8%/10% state layers, 38% disabled. Real tablist semantics, arrow keys + Home/End, disabled tabs skipped,aria-selected/tabindexrendered on the server, and the indicator moves in a view transition on the spatial spring with a reduced-motion escape. - Toolbars: docked 64px / square / surface-container / 16px ends / 4–32px between items (as a
clamp); floating 64px /CornerFull/ surface-container / 8px ends / 4px between; vibrant = primary-container with on-primary-container ink and a surface-container + on-surface selected button — all matching tok/DockedToolbarTokens.kt and tok/FloatingToolbarTokens.kt.role="toolbar"+aria-orientation+ RTL-aware arrow keys. - Motion: all durations collapse to 0 under
prefers-reduced-motion(resources/css/tokens/motion.css:63-75); spatial springs for size/position, effects easing for colour and opacity, throughout this group. - Safe areas: the app bar pads the top inset, the navigation bar and rail the bottom inset, the docked toolbar the bottom inset — matching M3's safety-region rule.
- Links vs. buttons: navigation items are real
<a>s witharia-current="page"(plus the filled icon and the indicator — the two-indicator rule) and degrade to<button>without alink; the section nav uses links witharia-current, not fake tabs.
Missing
M3 variants/configurations in this group with no implementation at all:
- Navigation bar, tall container (80dp) —
NavigationBarTokens.TallContainerHeight = 80.0.dp(tok/NavigationBarTokens.kt). Only the 64px short bar exists (navigation.css:76); no prop selects the tall one. - Navigation bar, hide-on-scroll — "Scrolling: hides on scroll-down, reappears on scroll-up — but never hide it while a screen reader is active" (RCB §Navigation Bar/Behaviour). Not implemented and not mentioned.
- Navigation rail, narrow collapsed width (80dp) —
NavigationRailCollapsedTokens.NarrowContainerWidth = 80.0.dp. Only 96px (navigation.css:174). - Navigation rail, centre alignment — "Alignment: top or center; on tablets prefer center alignment for reach. Menu icon and FAB are always top-aligned" (RCB §Navigation Rail/Behaviour). The rail is top-aligned only.
- Navigation rail, "hide when collapsed" as an explicit configuration — it is in M3's configuration
table ("Expanded behavior | Hide when collapsed | — | Available"). The library gets it implicitly below
sminmode="adaptive", but there is no mode that hides a rail in an immersive wide layout. - Docked toolbar with a FAB — M3's configuration table lists "Other elements | With FAB" for both
toolbars;
toolbar.blade.php:26gates thefabslot on$variant === 'floating'. - App bar, trailing-action overflow at small widths — "trailing actions collapse into an overflow menu at smaller breakpoints and reappear at larger ones" (RCB §Top App Bar/Behaviour). Not implemented (and M3 also tells you not to put an overflow menu in the app bar in the first place, so this is genuinely optional).
- Toolbar, large-screen treatments — "On web/large screens it can gain rounded corners and use dividers to organize many actions"; "medium+ breakpoints allow centering all elements or centering one key action while pinning others to the edges" (RCB §Toolbars/Behaviour). The toolbar has no breakpoint behaviour at all.
Correctly absent (deprecated in M3 Expressive, do not add): bottom app bar, navigation drawer,
baseline medium/large top app bar, baseline navigation bar. theme-toggle mode="picker" is the one place
a deprecated design slipped back in — see N-04.
Breakpoint map
M3's classes: Compact <600, Medium 600–839, Expanded 840–1199, Large 1200–1599, Extra-large ≥1600
(RF §Layout/Breakpoints). Tailwind's: sm 640, md 768, lg 1024, xl 1280, 2xl 1536.
| Component / behaviour | Library breakpoint used | M3 class it stands in for | Gap |
|---|---|---|---|
app-shell — bottom navigation bar shown; rail hidden, modal-only |
max-sm < 640px (app-shell.blade.php:142, :87-88, :136; navigation.css:190-194, :235-279) |
Compact (<600dp) | +40px. 600–639px is M3 Medium but is driven as Compact |
navigation-bar — item layout vertical → horizontal |
container 600px (navigation.css:123, :465, :519, :525, :555, :562) |
Medium (600dp) | exact — and a container query, so it also works in a narrow column |
app-shell — collapsed rail appears (96px), bar disappears |
sm ≥ 640px |
Medium (600–839dp) | +40px late; disagrees with the line above, so the horizontal bar layout only exists in a 40px band |
app-shell / navigation-rail mode="adaptive" — rail becomes a standard, in-layout collapsible rail; menu button toggles instead of opening a modal |
lg ≥ 1024px (js/navigation.js:16; navigation.css:51, :57, :184) |
Expanded (≥840dp) | +184px. 840–1023px gets a collapsed rail whose expansion is modal, scrimmed, focus-trapped and re-closed by every wire:navigate; the remembered "expanded" preference is ignored |
app-shell — content region max-lg:overflow-x-clip |
lg 1024px (app-shell.blade.php:136) |
Expanded (840dp) | +184px (a clipping backstop, no M3 rule attached) |
section-nav — menu picker → secondary-tab row |
sm 640px (section-nav.blade.php:40, :59) |
Compact/Medium boundary (600dp) | +40px |
section-nav — 3/4-column grid → single row |
xl 1280px (section-nav.blade.php:33-34) |
Large (1200dp) | +80px |
| showcase chrome — theme picker → cycle button | md 768px (showcase/layout.blade.php:148-149) |
none (no M3 rule) | n/a — showcase only |
app-bar, toolbar, tabs |
none | Medium+ adaptive guidance (toolbar centring/rounded corners, app-bar action overflow) | not implemented (see Missing) |
What a user actually sees in the three bands the brief asks about
- 600–639px (M3 Medium; e.g. a small tablet in portrait, a resized desktop window): no navigation rail
at all — it is
width: 0; display: none. The bottom navigation bar is still pinned, but it has already flipped to the Expressive medium configuration (icon beside label in a 40px secondary-container pill, items grouped in the middle at10 × (count + 3)% of the width, label-large instead of label-medium per N-08). Any destination marked'bar' => falseis reachable only by an application-supplied menu button calling$store.rail.show(). At 640px the bar vanishes outright and a 96px collapsed rail appears — a jump, at the wrong number. - 840–1023px (M3 Expanded; a landscape tablet, a half-screen desktop browser): a 96px collapsed rail
with icon-over-label items. The menu button does not expand it in place —
materialNavigationRail.modalis still true, so it opens the 256px rail over the page on a 32% scrim with a focus trap, andlivewire:navigatingcloses it again, so choosing a destination means re-opening the rail every time.$store.rail.collapsed === false(the visitor's remembered choice, applied to<html data-rail>before first paint) has no effect here. M3 lists a standard expanded rail for this whole band. - 1200–1279px (M3 Large): no mismatch —
lg(1024px) is long past, so the rail is already the standard collapsible one, expanded by default and remembered in localStorage, which is what M3 wants at Large. The only thing that changes at 1280px issection-nav's grid → row switch (N-07'sxlrow above), which is 80px later than M3's 1200dp Large boundary. Nothing in the group does anything different at 1600px (Extra-large), where M3 suggests considering a third pane.