Collapsing menu bar in mobile with SEO support
In this post, I will raise some problems when designing a navbar (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 the same as is any other framework.
When designing a web app for a PC screen, there is usually noticeable space to display the menu bar permanently. However, this is not true for most application when the screen size shrinks to fit mobile devices. A typical approach is collapsing menu bar and toggle it only when needed via user interaction.
Solution 1: use the 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 on 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 the head section of the HTML page.
Some css optimization libraries (e.g. purify-css) prune a class name if it does not appear in the document structure. Hence, you should take care and add the dynamic class name to the 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 with user operation as long as hydration has not finished
Solution 3 (final): add SEO support, interact with user regardless of the hydration process
The idea is simple: instead of storing menu status as an internal state, store it in a 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 ifto
prop of theLink
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, the 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])