Nav pill slide animation (#39)
CI / ci (push) Successful in 1m29s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #39.
This commit is contained in:
2026-10-03 18:52:57 -04:00
committed by approver_agent
parent 998658b42c
commit c899f07b1f
5 changed files with 455 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);
}
+282
View File
@@ -0,0 +1,282 @@
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 }
}
// jsdom paints nothing either, so bounding boxes come from the laid-out
// offsets with the bar at the viewport's left edge. A test mid-slide sets
// where the pill is drawn and how far each item is still translated.
let drawnPill: { left: number; width: number } | null
let translated: Map<Element, number>
function box(left: number, width: number): DOMRect {
return { left, width, x: left, right: left + width, top: 0, y: 0, bottom: 0, height: 0 } as DOMRect
}
let animate: Mock<Element['animate']>
beforeEach(() => {
drawnPill = null
translated = new Map()
// jsdom has no Web Animations API.
animate = vi.fn<Element['animate']>(() => ({ cancel: vi.fn() }) as unknown as Animation)
Element.prototype.animate = animate
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (!(this instanceof HTMLElement)) return box(0, 0)
if (this.classList.contains(styles.pill!)) {
return drawnPill
? box(drawnPill.left, drawnPill.width)
: box(parseFloat(this.style.left), parseFloat(this.style.width))
}
if (this.classList.contains(styles.item!)) {
return box(this.offsetLeft + (translated.get(this) ?? 0), this.offsetWidth)
}
return box(0, 0)
})
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 runningOn(element: Element): Animation[] {
return animate.mock.contexts
.map((target, i) => ({ target, animation: animate.mock.results[i]!.value as Animation }))
.filter(({ target }) => target === element)
.map(({ animation }) => animation)
}
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('starts a second slide from where the first one is on screen, not from its destination', () => {
renderAt('/')
fireEvent.click(link('Quick'))
const [firstPill] = runningOn(pill())
const firstPlan = runningOn(link('Plan'))[0]!
// Midway: the pill is drawn at 78 px on its way from 6 to 150, and Plan
// still carries 31 of its 62 px FLIP translation.
drawnPill = { left: 78, width: 110 }
translated.set(link('Plan'), 31)
animate.mockClear()
fireEvent.click(link('Settings'))
expect(firstPill!.cancel).toHaveBeenCalled()
expect(firstPlan.cancel).toHaveBeenCalled()
expect(animationsOf(pill())).toEqual([
{
keyframes: [
{ left: '78px', width: '110px' },
{ left: '198px', width: '110px' },
],
options: { duration: 420, easing: SPRING },
},
])
// Plan's layout does not move between Quick and Settings, so it only
// finishes the 31 px it had left. Settings moves 62 px left.
expect(animationsOf(link('Plan'))).toEqual([
{
keyframes: [{ transform: 'translateX(31px)' }, { transform: 'translateX(0)' }],
options: { duration: 420, easing: SPRING },
},
])
expect(animationsOf(link('Settings'))).toEqual([
{
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
options: { duration: 420, easing: SPRING },
},
])
})
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
renderAt('/')
fireEvent.click(link('Quick'))
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
expect(slide).toHaveLength(4)
animate.mockClear()
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(140)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
fireEvent(window, new Event('resize'))
for (const animation of slide) expect(animation.cancel).toHaveBeenCalled()
expect(animate).not.toHaveBeenCalled()
expect(pill().style.left).toBe('140px')
expect(pill().style.width).toBe('96px')
})
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')
}
})
})
+134 -5
View File
@@ -1,23 +1,152 @@
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)'
// The active item and every item's layout offset within the bar when the pill
// was last placed (offsets ignore transforms, so a running FLIP does not skew
// them).
type Geometry = {
active: HTMLElement | null
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)
// The pill slide and item FLIPs currently running.
const sliding = useRef<Animation[]>([])
const stopSlide = () => {
sliding.current.forEach((animation) => animation.cancel())
sliding.current = []
}
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. A
// slide still running is measured where it is on screen, then stopped, so
// the next one starts from there instead of from its destination.
useLayoutEffect(() => {
const bar = barRef.current!
const pill = pillRef.current!
const active = activeItem(bar)
const before = placed.current
if (before && before.active === active) return
const from = before && onScreen(bar, pill, before)
stopSlide()
placed.current = place(bar, pill, active)
if (!before || !from || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return
const after = placed.current
if (before.active) {
sliding.current.push(
pill.animate(
[
{ left: `${from.pill.left}px`, width: `${from.pill.width}px` },
{ left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` },
],
{ duration: SLIDE_MS, easing: SPRING },
),
)
}
items(bar).forEach((item, i) => {
const dx = from.itemLefts[i]! - after.itemLefts[i]!
if (dx === 0) return
sliding.current.push(
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
// stop any running slide and 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) return
stopSlide()
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"]`)
}
// Where the pill and items are on screen right now, as offsets within the
// bar. Bounding boxes include a running slide: the pill's box is its animated
// left and width, and an item's box is its current layout plus whatever FLIP
// translation remains, which applies to the layout it had when last placed.
function onScreen(bar: HTMLElement, pill: HTMLElement, before: Geometry) {
const origin = bar.getBoundingClientRect().left + bar.clientLeft
const pillBox = pill.getBoundingClientRect()
return {
pill: { left: pillBox.left - origin, width: pillBox.width },
itemLefts: items(bar).map(
(item, i) => before.itemLefts[i]! + item.getBoundingClientRect().left - origin - item.offsetLeft,
),
}
}
// 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 {
pill.hidden = !active
pill.style.left = `${active?.offsetLeft ?? 0}px`
pill.style.width = `${active?.offsetWidth ?? 0}px`
return { active, itemLefts: items(bar).map((item) => item.offsetLeft) }
}