Let the navigation bar hide on a scroll down

Plan step 25 (navigation Missing): M3's "hides on scroll-down, reappears on
scroll-up" had no implementation. `<x-navigation-bar hide-on-scroll>` slides the
bar out on the default spatial spring — an instant swap under reduced motion,
which zeroes the duration token — and never while a snackbar, a bottom sheet or
a drawer is anchored to its edge; focus reaching the bar brings it back.
`<x-app-shell hide-bar-on-scroll>` picks it, and --material-bottom-bar follows
the bar down and up so a `fab` button and the snackbar keep their offsets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:36:57 +02:00
co-authored by Claude Fable 5.1
parent 4d00d87cd2
commit ce1cd6eec0
8 changed files with 167 additions and 11 deletions
@@ -718,7 +718,7 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
```
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
- Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page), `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the page's own bar, above the page and beside the rail), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`); `tall-bar` picks M3's 80px navigation bar over the 64px one.
- Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page), `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the page's own bar, above the page and beside the rail), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`); `tall-bar` picks M3's 80px navigation bar over the 64px one; `hide-bar-on-scroll` lets the bar leave the window while the page scrolls down.
- `banner` or `top`: M3's scaffold is bars, then rails, then panes. An application-wide bar — one search, one account menu, the same on every page — goes in `banner` and the rail starts under it; a bar that titles the page goes in `top`, beside the rail. Never both. A banner that pins itself to the top of the window says how tall it is (`style="--material-banner: 4rem"` on `<x-app-shell>`), so the rail sticks under it instead of behind it.
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`).
- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
@@ -739,7 +739,7 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
</div>
```
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-app-shell tall-bar>` picks it, and the bottom offset grows with it). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-app-shell tall-bar>` picks it, and the bottom offset grows with it), `hide-on-scroll` (M3's scrolling behaviour: the bar slides out on a scroll down and springs back on a scroll up, never before the first screenful and never while a snackbar, bottom sheet or drawer is on screen; focus reaching it brings it back. `<x-app-shell hide-bar-on-scroll>` picks it, and `--material-bottom-bar` goes down and comes back with the bar, so a `fab` button and the snackbar keep their distance from it). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
+25 -2
View File
@@ -43,8 +43,9 @@
* rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at
* the same 37.5rem, so a bar in a narrow column lays out by its own width.
*
* Everything here is in `@layer components` except the last block, which restyles a FAB the
* application put in the rail: that one has to beat a utility, and a layer never does.
* Everything here is in `@layer components` except the blocks after it, which reach past the
* component into what an application wrote — the bottom offset <x-app-shell> publishes, a FAB put
* in the rail: those have to beat a utility, and a layer never does.
*/
@custom-variant rail-collapsed {
@@ -116,6 +117,19 @@
min-height: 5rem;
}
/* "Scrolling: hides on scroll-down, reappears on scroll-up" (the reference's Navigation bar
§ Behaviour). It slides out of the window on the default spatial spring, which reduced
motion zeroes to an instant swap along with every other duration token. Whether it may hide
at all is resources/js/navigation.js's call: never while a bottom sheet, a drawer or a
snackbar is on screen, since those are anchored to the bar's edge. */
[data-navigation-bar][data-hide-on-scroll] {
transition: translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
[data-navigation-bar][data-hide-on-scroll][data-hidden] {
translate: 0 100%;
}
[data-navigation-bar-item] {
--navigation-layer: 0;
position: relative;
@@ -664,6 +678,15 @@
}
}
/* A bar that has slid off the bottom of the window is no longer there to clear, so the offset
* everything pinned to the bottom reads drops to the bottom safe area and whatever the application
* has docked on the bar — a `fab` button, the snackbar and the page's own bottom padding all follow
* it down and come back up with the bar. Unlayered on purpose: <x-app-shell> publishes the variable
* with a utility, and a rule in any layer loses to one. */
[data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) {
--material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));
}
/* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has
* (docs/reference/m3/components-navigation-selection-inputs.md § Navigation rail: "when nested
* within another component, such as the navigation rail, the FAB's resting elevation should be
+64
View File
@@ -15,6 +15,8 @@
*
* `materialNavigationRail` is one rail's view of the store for its `mode` — see
* resources/views/components/navigation-rail.blade.php.
*
* `materialNavigationBar` is `<x-navigation-bar hide-on-scroll>` — see the same file's sibling.
*/
import { from } from './breakpoints.js'
@@ -172,4 +174,66 @@ document.addEventListener('alpine:init', () => {
}
},
}))
window.Alpine.data('materialNavigationBar', () => ({
away: false,
last: 0,
frame: null,
// Set in init(), so a second bar in the same page scope cannot take the first one's.
schedule: null,
init() {
this.last = Math.max(window.scrollY, 0)
this.measure = this.measure.bind(this)
this.schedule = () => {
this.frame ??= requestAnimationFrame(this.measure)
}
window.addEventListener('scroll', this.schedule, { passive: true })
},
destroy() {
window.removeEventListener('scroll', this.schedule)
cancelAnimationFrame(this.frame)
},
/** Anything that reaches the bar — the keyboard, a screen reader's focus — brings it back. */
show() {
this.away = false
},
measure() {
this.frame = null
const at = Math.max(window.scrollY, 0)
const by = at - this.last
// Smaller than a finger's jitter, or the rubber band at either end of the page: not a
// direction yet, and the bar should not flicker while one is being decided.
if (Math.abs(by) < 8) {
return
}
this.last = at
// A bar that is not on screen at this width (the shell hides it from `medium`) has no
// scroll behaviour to have; one with something anchored to its edge keeps still, so
// the snackbar or sheet resting on it does not slide with it.
if (this.$root.getClientRects().length === 0 || anchored()) {
this.away = false
return
}
// Never before the first screenful: the bar has to be passed before it can be left.
this.away = by > 0 && at > this.$root.offsetHeight
},
}))
})
/**
* Whether something on screen is anchored to the bar's edge and would be dragged along with it: a
* snackbar (`<x-toast>`'s, which reads --material-bottom-bar), or a bottom sheet or drawer over the
* page. M3 lets those cover the bar; it is the bar leaving from under them that looks broken.
*/
const anchored = () => [...document.querySelectorAll('[data-toast], [role="dialog"]')].some((over) => over.getClientRects().length > 0)
@@ -55,7 +55,8 @@
itself. The rail is one element at every width, so what is in it is also in the modal rail a
phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded
rail's width; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom
offset every pinned thing reads follows it.
offset every pinned thing reads follows it; `hide-bar-on-scroll` lets the bar leave the window
while the page scrolls down, and `--material-bottom-bar` goes down and comes back with it.
`banner` or `top` is a decision about what the bar belongs to: an application-wide bar one
search, one account menu, the same on every page spans the window and the rail starts under
@@ -84,6 +85,7 @@
'label' => null,
'railWidth' => '16rem',
'tallBar' => false,
'hideBarOnScroll' => false,
])
@php
@@ -177,7 +179,7 @@
@if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
<x-livewire-material::navigation-bar :label="$label" :tall="$tallBar">
<x-livewire-material::navigation-bar :label="$label" :tall="$tallBar" :hide-on-scroll="$hideBarOnScroll">
@foreach ($barItems as $item)
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
@@ -20,11 +20,20 @@
vertical item layout at every width — icon over label, never side by side — for a bar whose
labels need the room. The short bar stays the default.
`hide-on-scroll` is M3's scrolling behaviour: the bar slides out of the window on a scroll
down and springs back on a scroll up, never before the first screenful has gone by, and never
while a snackbar, a bottom sheet or a drawer is on screen those are anchored to the bar's
edge and would slide with it. Focus reaching the bar brings it back, which is as close as the
web gets to M3's "never hide it while a screen reader is active". Inside `<x-app-shell>`
`--material-bottom-bar` follows the bar down and up, so a `fab` button, the snackbar and the
page's bottom padding keep their distance from it rather than from where it was.
It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
and hides it where a rail takes over. `<x-app-shell>` does both, and lifts the snackbar and a
`fab` button above it through `--material-bottom-bar`.
`label` names the landmark ("Main" by default); `tall` picks the 80px container.
`label` names the landmark ("Main" by default); `tall` picks the 80px container;
`hide-on-scroll` lets it leave the window while the page scrolls down.
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
@@ -36,9 +45,21 @@
@props([
'label' => null,
'tall' => false,
'hideOnScroll' => false,
])
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar @if ($tall) data-tall @endif {{ $attributes }}>
<nav
aria-label="{{ $label ?? __('Main') }}"
data-navigation-bar
@if ($tall) data-tall @endif
@if ($hideOnScroll)
data-hide-on-scroll
x-data="materialNavigationBar"
x-bind:data-hidden="away ? '' : null"
x-on:focusin="show()"
@endif
{{ $attributes }}
>
<div data-navigation-bar-items>
{{ $slot }}
</div>
@@ -30,6 +30,18 @@
</div>
</div>
<div class="max-w-[25rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">Hide on scroll: scroll the page down, then up</p>
<div class="flex h-56 flex-col overflow-hidden rounded-corner-lg border border-outline-variant bg-surface">
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The bar follows the window's scrolling, not this box's: scroll the showcase down and it leaves, scroll up and it springs back. It stays while a snackbar or a bottom sheet is on screen.</div>
<x-navigation-bar hide-on-scroll>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
</x-navigation-bar>
</div>
</div>
<div class="max-w-[50rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">Tall (80px): icon over label whatever the width</p>
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">