Slide one shared grape pill between mobile nav items
The mobile bottom bar now draws a single absolutely positioned grape pill (top 10 px, height 42 px) behind its items and implements handoff Motion section 1: on a tab change it slides the pill from the old item's left and width to the new one's over 420 ms on the --ease-spring curve, FLIPs every moved item with the same timing, and fades the new label in (220 ms ease-out after 120 ms). Icon colour crossfades via transition color .2s. Under prefers-reduced-motion the pill jumps with no animation. The pill is re-placed on resize and once document.fonts.ready resolves. Each item keeps the full 62 px bar height as its tap target. Maestro task d340ae00-3c2a-48cf-9ac6-35e0b8ed5a7c Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+109
-5
@@ -1,23 +1,127 @@
|
||||
import { NavLink } from 'react-router'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
|
||||
// The handoff's Motion §1 timing. SPRING is the --ease-spring token's value.
|
||||
const SLIDE_MS = 420
|
||||
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
|
||||
const LABEL_MS = 220
|
||||
const LABEL_DELAY_MS = 120
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
// Where the pill and every item sat when the pill was last placed, as layout
|
||||
// offsets within the bar (offsets ignore transforms, so a running FLIP does
|
||||
// not skew them).
|
||||
type Geometry = {
|
||||
active: HTMLElement | null
|
||||
pill: { left: number; width: number }
|
||||
itemLefts: number[]
|
||||
}
|
||||
|
||||
// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive
|
||||
// items show only their icon; the active item adds its label on a grape pill.
|
||||
// items show only their icon; the active item adds its label. One shared
|
||||
// grape pill sits behind the items and slides to the active one.
|
||||
export function BottomBar() {
|
||||
const barRef = useRef<HTMLElement>(null)
|
||||
const pillRef = useRef<HTMLSpanElement>(null)
|
||||
const placed = useRef<Geometry | null>(null)
|
||||
const { pathname } = useLocation()
|
||||
|
||||
// After each navigation: if the active item changed, move the pill to it and,
|
||||
// motion allowed, slide the pill, FLIP the items and fade the label in.
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current!
|
||||
const pill = pillRef.current!
|
||||
const active = activeItem(bar)
|
||||
const before = placed.current
|
||||
if (before && before.active === active) return
|
||||
placed.current = place(bar, pill, active)
|
||||
if (!before || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return
|
||||
|
||||
const after = placed.current
|
||||
if (before.active) {
|
||||
pill.animate(
|
||||
[
|
||||
{ left: `${before.pill.left}px`, width: `${before.pill.width}px` },
|
||||
{ left: `${after.pill.left}px`, width: `${after.pill.width}px` },
|
||||
],
|
||||
{ duration: SLIDE_MS, easing: SPRING },
|
||||
)
|
||||
}
|
||||
items(bar).forEach((item, i) => {
|
||||
const dx = before.itemLefts[i]! - after.itemLefts[i]!
|
||||
if (dx === 0) return
|
||||
item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], {
|
||||
duration: SLIDE_MS,
|
||||
easing: SPRING,
|
||||
})
|
||||
})
|
||||
active.querySelector(`.${styles.label}`)!.animate(
|
||||
[
|
||||
{ opacity: 0, transform: 'translateX(-4px)' },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
],
|
||||
{ duration: LABEL_MS, delay: LABEL_DELAY_MS, easing: 'ease-out', fill: 'backwards' },
|
||||
)
|
||||
}, [pathname])
|
||||
|
||||
// Item widths change with the viewport and once the web fonts arrive, so
|
||||
// re-place the pill (without animating) on both. A failed font load
|
||||
// rejects fonts.ready and is left to surface as an unhandled rejection.
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current!
|
||||
const pill = pillRef.current!
|
||||
let mounted = true
|
||||
const replace = () => {
|
||||
if (mounted) placed.current = place(bar, pill, activeItem(bar))
|
||||
}
|
||||
window.addEventListener('resize', replace)
|
||||
// jsdom has no document.fonts; every browser the app targets does.
|
||||
if (document.fonts) void document.fonts.ready.then(replace)
|
||||
return () => {
|
||||
mounted = false
|
||||
window.removeEventListener('resize', replace)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav aria-label="Primary" className={styles.bar}>
|
||||
<nav ref={barRef} aria-label="Primary" className={styles.bar}>
|
||||
<span ref={pillRef} className={styles.pill} aria-hidden="true" />
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
|
||||
<>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
<span className={isActive ? styles.label : styles.hiddenLabel}>{item.label}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function items(bar: HTMLElement): HTMLElement[] {
|
||||
return Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
|
||||
}
|
||||
|
||||
// NavLink's aria-current is the one record of which item is active.
|
||||
function activeItem(bar: HTMLElement): HTMLElement | null {
|
||||
return bar.querySelector<HTMLElement>(`.${styles.item}[aria-current="page"]`)
|
||||
}
|
||||
|
||||
// Puts the pill on the active item (hidden when no item is active) and
|
||||
// returns the resulting geometry.
|
||||
function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): Geometry {
|
||||
const geometry = {
|
||||
active,
|
||||
pill: { left: active?.offsetLeft ?? 0, width: active?.offsetWidth ?? 0 },
|
||||
itemLefts: items(bar).map((item) => item.offsetLeft),
|
||||
}
|
||||
pill.hidden = !active
|
||||
pill.style.left = `${geometry.pill.left}px`
|
||||
pill.style.width = `${geometry.pill.width}px`
|
||||
return geometry
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user