ExpandingIconMenuDemo.jsx
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'
import { SharpNavIcon } from './ExpandingIconMenuIcons.jsx'
import { useReducedMotion } from 'motion/react'
import './ExpandingIconMenuDemo.css'
const ITEMS = [
{ label: 'Home', icon: 'home', opticalRight: 3 },
{ label: 'Explore', icon: 'near_me', opticalRight: 2.5 },
{ label: 'Saved', icon: 'bookmark', opticalRight: 4 },
{ label: 'Account', icon: 'person', opticalRight: 3.333 },
]
/** Label-sized tracks keep the padding balanced as the centered row expands. */
export const ExpandingIconMenuDemo = forwardRef(function ExpandingIconMenuDemo(
{ thumbnail = false, duration = 360, reducedMotion = false }, ref,
) {
const systemReduced = useReducedMotion()
const reduce = Boolean(systemReduced || reducedMotion)
const stageRef = useRef(null)
const buttons = useRef([])
const labels = useRef([])
const [labelWidths, setLabelWidths] = useState([0, 0, 0, 0])
const timers = useRef([])
const [active, setActive] = useState(0)
const [focused, setFocused] = useState(null)
const [visible, setVisible] = useState(false)
const [pageVisible, setPageVisible] = useState(() => typeof document === 'undefined' || !document.hidden)
useLayoutEffect(() => {
const measure = () => {
const next = labels.current.map((label) => label?.getBoundingClientRect().width ?? 0)
setLabelWidths((current) => current.every((width, index) => Math.abs(width - next[index]) < 0.1) ? current : next)
}
measure()
// Text boxes retain their intrinsic width while clipped. This also catches
// font loading and the smaller type at the mobile container breakpoint.
const observer = new ResizeObserver(measure)
labels.current.forEach((label) => { if (label) observer.observe(label) })
return () => observer.disconnect()
}, [])
const stop = useCallback(() => {
timers.current.forEach(window.clearTimeout)
timers.current = []
}, [])
const reset = useCallback(() => { stop(); setActive(0) }, [stop])
const replay = useCallback(() => {
reset()
if (reduce) return
// A single demonstration; direct interaction takes over immediately.
const beat = Math.max(800, duration + 440)
;[1, 2, 3, 2, 1, 0].forEach((index, step) => {
timers.current.push(window.setTimeout(() => setActive(index), (step + 1) * beat))
})
}, [duration, reduce, reset])
useImperativeHandle(ref, () => ({ replay, reset }), [replay, reset])
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { threshold: 0.25 })
observer.observe(stage)
const onVisibility = () => setPageVisible(!document.hidden)
document.addEventListener('visibilitychange', onVisibility)
return () => {
observer.disconnect()
document.removeEventListener('visibilitychange', onVisibility)
stop()
}
}, [stop])
useEffect(() => {
if (thumbnail && visible && pageVisible && !reduce) replay()
else stop()
return stop
}, [thumbnail, visible, pageVisible, reduce, replay, stop])
function activate(index) { stop(); setActive(index) }
function onKeyDown(event) {
const focused = buttons.current.indexOf(document.activeElement)
const from = focused < 0 ? active : focused
let next
if (event.key === 'ArrowRight') next = (from + 1) % ITEMS.length
else if (event.key === 'ArrowLeft') next = (from + ITEMS.length - 1) % ITEMS.length
else if (event.key === 'Home' || event.key === 'Escape') next = 0
else if (event.key === 'End') next = ITEMS.length - 1
else return
event.preventDefault()
event.stopPropagation()
activate(next)
buttons.current[next]?.focus({ preventScroll: true })
}
const timing = Math.min(600, Math.max(180, duration))
return (
<div ref={stageRef} className={`expanding-icon-stage${thumbnail ? ' expanding-icon-stage--thumbnail' : ''}`}
data-reduced-motion={reduce} style={{ '--icon-menu-duration': `${timing}ms`, '--icon-menu-label-duration': `${timing * 0.55}ms`, '--icon-menu-label-delay': `${timing * 0.14}ms` }}>
<div className="expanding-icon-menu" data-active={active} role="toolbar" aria-label="Navigation preview" aria-orientation="horizontal" onKeyDown={onKeyDown}
style={Object.fromEntries(labelWidths.map((width, index) => [`--icon-menu-reveal-${index}`, `${width + 8 + ITEMS[index].opticalRight}px`]))}
onPointerDownCapture={stop} onFocusCapture={stop}
onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(null) }}>
{ITEMS.map(({ label, icon, opticalRight }, index) => (
<button key={label} ref={(element) => { buttons.current[index] = element }} type="button"
className="expanding-icon-menu__button" data-expanded={active === index}
style={{ '--icon-menu-optical-right': `${opticalRight}px` }}
aria-label={label} aria-pressed={active === index} tabIndex={(focused ?? active) === index ? 0 : -1}
onPointerEnter={(event) => { if (event.pointerType !== 'touch') activate(index) }}
onFocus={() => { setFocused(index); activate(index) }} onClick={() => activate(index)}>
<SharpNavIcon name={icon} className="expanding-icon-menu__icon" />
<span ref={(element) => { labels.current[index] = element }} className="expanding-icon-menu__label" aria-hidden="true">{label}</span>
</button>
))}
</div>
</div>
)
})