In this post, I will raise some problem when designing a nav bar (menu bar) in a web app, and provide solutions to solve each problem. I will use sample code in a react application. The idea is totally same as is any other framework.

When designing a web app for PC screen, there is usually noticeable space to display menu bar permanently. However, this is not true for most application when the screen size shrinks to fit mobile device. Typical approach is collapsing menu bar and toggle it only when needed via user interaction.

Solution 1: use internal state to control navbar visibility.

const [open, setOpen] = useState(false)
return <OutsideClick onOutsideClick={()=>void (open && setOpen(false))}>
    <div className={[styles.menu, open && styles.active].filter(Boolean).join(' ')}>
        <Link to="/">Home</Link>
        <Link to="/login">Login</Link>
	</div>
	<button type="button" onClick={()=>setOpen(o => !o)}/>Toggle menu</button>
</OutsideClick>

Problem: javascript code does not work in AMP page

Solution 2: add AMP support

via amp-bind

const [open, setOpen] = useState(false)
const isAmp = useIsAmp()
return <OutsideClick onOutsideClick={()=>void (open && setOpen(false))}>
    <div className={[styles.menu, open && styles.active].filter(Boolean).join(' ')}
    {...isAmp && {
        'data-amp-bind-class': `['${styles.menu}', open && '${styles.active}'].filter(x => !!x).join(' ')`
    }}>
        <Link to="/">Home</Link>
        <Link to="/login">Login</Link>
	</div>
	<button type="button"
			{...isAmp && {on: 'tap:AMP.setState({open: !open})'}}
			onClick={()=>setOpen(o => !o)}/>Toggle menu</button>
</OutsideClick>

Do not forget to include amp-bind script in head section of the html page.

Some css optimization library (e.g. purify-css) prune a class name if it does not appear in document structure. Hence, you should take care and add the dynamic class name to whitelist setting appropriately.

purifyCss(body, cssContent, {whitelist: [styles.active]})

Problem 1: SEO support. Google does not recognize javascript and it will never expand our menu

Problem 2: the menu will not open, interacts to user operation as long as hydration has not finished

Solution 3 (final): add SEO support, interact with user regardless hydration process

The idea is simple: instead of storing menu status as an internal state, store it in URL query. i.e. migrate the source of truth from internal state to query.

import queryString from 'query-string'

const NoClickLink = ({to, ampProps, ...props}: ComponentProps<typeof Link> & {ampProps: {[key: string]: string}}) => {
  const amp = useAmp()
  return amp ? <a href="#" {...props} {...ampProps}/> : <Link to={to} {...props}/>
}
NoClickLink.displayName = 'NoClickLink'


const MenusBar = () => {
	const query = useQuery()
    const open = query.menu === null
    const {history, location} = useRouter()
    const isAmp = useIsAmp()
    return <OutsideClick onOutsideClick={()=>void (open && history.push({...location, search: queryString.stringify({...query, menu: undefined})})}>
        <div className={[styles.menu, open && styles.active].filter(Boolean).join(' ')}
        {...isAmp && {
            'data-amp-bind-class': `['${styles.menu}', open && '${styles.active}'].filter(x => !!x).join(' ')`
        }}>
            <Link to="/">Home</Link>
            <Link to="/login">Login</Link>
        </div>
        <NoClickLink
                ampProps={{on: 'tap:AMP.setState({open: !open})'}}
                to={{...location, search: queryString.stringify({...query, menu: open ? undefined : null})}}
                />Toggle menu</button>
    </OutsideClick>
}
  • The reason that NoClickLink component is created is that if to prop of the Link component is set to "#", it will render an anchor <a> tag with full url as of the current page (instead of <a href='#'>).

Thus, we have to use <a> directly in amp page. Otherwise, page will be navigated when user clicks the toggle button in AMP page.

  • One more advantage of this solution is that it also closes the menu when navigating to a new page via clicking menu from the menus bar. Because the navigation also reset query parameter in URL.
    With solution 1 and solution 2, the internal state keeps unchanged if the navigation does not cause re-rendering. Which adds more complication to the current code to control whether the url changed
//this code is not neccesary in method 3. Only used in method 1, and 2
const {location: {path, search, hash}} = useRouter()
const fullPath = path + search + hash
const prevFullPath  = usePrev(fullPath)
useEffect(() => void(fullPath !== prevFullPath && open && setOpen(false)), [fullPath, prevFullPath, open])