Slide one shared grape pill between mobile nav items
CI / ci (pull_request) Successful in 1m32s
CI / ci (push) Successful in 1m31s

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:
2026-10-03 18:44:44 -04:00
co-authored by Claude Opus 5.5
parent 998658b42c
commit 004a81b854
5 changed files with 342 additions and 28 deletions
+11 -7
View File
@@ -74,20 +74,24 @@ describe('AppShell', () => {
expect(bar.borderRadius).toBe('var(--radius-pill)')
})
it('shows the active mobile item as icon and label on a grape pill, inactive items as neutral icons', () => {
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
renderAt('/discover', MOBILE)
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
expect(pills).toHaveLength(1)
const pill = declaredStyle(pills[0]!)
expect(pill.position).toBe('absolute')
expect(pill.top).toBe('10px')
expect(pill.height).toBe('42px')
expect(pill.background).toBe('var(--color-accent)')
expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex))
const active = screen.getByRole('link', { name: 'Discover' })
const pill = active.firstElementChild!
expect(pill).toHaveClass(bottomBarStyles.active!)
expect(declaredStyle(pill).height).toBe('42px')
expect(declaredStyle(pill).background).toBe('var(--color-accent)')
expect(declaredStyle(pill).color).toBe('var(--on-accent)')
expect(declaredStyle(active).color).toBe('var(--on-accent)')
expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!)
expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px')
const inactive = screen.getByRole('link', { name: 'Plan' })
expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)')
expect(inactive.firstElementChild).not.toHaveClass(bottomBarStyles.active!)
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
})
+22 -12
View File
@@ -13,28 +13,38 @@
background: var(--color-text);
}
/* The whole bar height is the tap target; the 42 px pill sits inside it. */
/* The one grape pill, shared by every item: BottomBar sets its left and width
to the active item's and slides it between items. */
.pill {
position: absolute;
top: 10px;
height: 42px;
z-index: 0;
border-radius: var(--radius-pill);
background: var(--color-accent);
pointer-events: none;
}
/* The whole bar height is the tap target; the 42 px pill sits behind it.
Items stay above the pill. */
.item {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 62px;
min-width: 44px;
padding: 0 13px;
box-sizing: border-box;
color: var(--color-neutral-400);
text-decoration: none;
white-space: nowrap;
transition: color .2s;
}
.pill {
display: flex;
align-items: center;
gap: 6px;
height: 42px;
padding: 0 13px;
border-radius: var(--radius-pill);
}
.active {
background: var(--color-accent);
.item[aria-current='page'] {
color: var(--on-accent);
}
+194
View File
@@ -0,0 +1,194 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import App from '../App.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css'
const MOBILE = 390
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
// jsdom does no layout, so lay the bar out here: items sit side by side from
// 6 px in, inactive items 48 px wide and the active one (icon + label) 110 px.
function layOut(element: HTMLElement): { left: number; width: number } {
const bar = element.parentElement!
const items = Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
let left = 6
for (const item of items) {
const width = item.getAttribute('aria-current') === 'page' ? 110 : 48
if (item === element) return { left, width }
left += width
}
return { left: 0, width: 0 }
}
let animate: Mock<Element['animate']>
beforeEach(() => {
// jsdom has no Web Animations API.
animate = vi.fn<Element['animate']>()
Element.prototype.animate = animate
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) {
return layOut(this).left
})
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
return layOut(this).width
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(Element.prototype, 'animate')
Reflect.deleteProperty(window, 'matchMedia')
})
function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
)
}
function nav() {
return screen.getByRole('navigation', { name: 'Primary' })
}
function pill(): HTMLElement {
return nav().querySelector<HTMLElement>(`.${styles.pill}`)!
}
function link(name: string): HTMLElement {
return within(nav()).getByRole('link', { name })
}
function animationsOf(element: Element) {
return animate.mock.contexts
.map((target, i) => ({ target, args: animate.mock.calls[i]! }))
.filter(({ target }) => target === element)
.map(({ args }) => ({ keyframes: args[0], options: args[1] as KeyframeAnimationOptions }))
}
describe('BottomBar', () => {
it('places the pill on the active item without animating on first render', () => {
renderAt('/plan')
expect(pill().style.left).toBe('54px')
expect(pill().style.width).toBe('110px')
expect(pill()).toBeVisible()
expect(animate).not.toHaveBeenCalled()
})
it('hides the pill when no item is active', () => {
renderAt('/onboarding')
expect(pill()).not.toBeVisible()
})
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
renderAt('/')
fireEvent.click(link('Quick'))
expect(animationsOf(pill())).toEqual([
{
keyframes: [
{ left: '6px', width: '110px' },
{ left: '150px', width: '110px' },
],
options: { duration: 420, easing: SPRING },
},
])
expect(pill().style.left).toBe('150px')
expect(pill().style.width).toBe('110px')
})
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
renderAt('/')
fireEvent.click(link('Quick'))
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
// and Quick grows in place; Settings ends where it started.
for (const name of ['Plan', 'Discover']) {
expect(animationsOf(link(name))).toEqual([
{
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
options: { duration: 420, easing: SPRING },
},
])
}
expect(animationsOf(link('Quick'))).toEqual([
{
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
options: { duration: 420, easing: SPRING },
},
])
expect(animationsOf(link('Home'))).toEqual([])
expect(animationsOf(link('Settings'))).toEqual([])
})
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
renderAt('/')
fireEvent.click(link('Discover'))
const label = within(link('Discover')).getByText('Discover')
expect(label).toHaveClass(styles.label!)
expect(animationsOf(label)).toEqual([
{
keyframes: [
{ opacity: 0, transform: 'translateX(-4px)' },
{ opacity: 1, transform: 'none' },
],
options: { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' },
},
])
})
it('does not animate when the active item stays the same', () => {
renderAt('/plan')
fireEvent.click(link('Plan'))
expect(animate).not.toHaveBeenCalled()
})
it('jumps the pill to the new item with no animation under reduced motion', () => {
renderAt('/', true)
fireEvent.click(link('Settings'))
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
expect(animate).not.toHaveBeenCalled()
expect(pill().style.left).toBe('198px')
expect(pill().style.width).toBe('110px')
})
it('re-places the pill when the viewport resizes', () => {
renderAt('/discover')
expect(pill().style.left).toBe('102px')
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
fireEvent(window, new Event('resize'))
expect(pill().style.left).toBe('120px')
expect(pill().style.width).toBe('96px')
expect(animate).not.toHaveBeenCalled()
})
it('re-places the pill once the web fonts are ready', async () => {
let fontsReady!: () => void
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
try {
renderAt('/discover')
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
fontsReady()
await ready
expect(pill().style.width).toBe('124px')
} finally {
Reflect.deleteProperty(document, 'fonts')
}
})
it('crossfades the icon colour with a .2s colour transition', () => {
renderAt('/')
for (const item of within(nav()).getAllByRole('link')) {
expect(declaredStyle(item).transition).toBe('color .2s')
}
})
})
+109 -5
View File
@@ -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
}
+6 -4
View File
@@ -3,14 +3,16 @@ import { act } from '@testing-library/react'
type Listener = (event: MediaQueryListEvent) => void
// Replaces window.matchMedia with one that answers min-width / max-width
// queries for a pretend viewport width (jsdom has no matchMedia and no
// layout). Returns a function that resizes the pretend viewport and notifies
// every query whose answer changed.
export function mockViewport(initialWidth: number): (width: number) => void {
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
// function that resizes the pretend viewport and notifies every query whose
// answer changed.
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
let width = initialWidth
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
function evaluate(query: string): boolean {
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)