Full-screen menu quirks for mobile Safari

Published on November 13, 2022

Programming

For the client website I am currently working on, I had to implement a full-screen mobile menu. The one that's triggered by a "hamburger" icon at the top right page corner and reveals a few links to other site pages when expanded.

It appears to be trivial at first, and for the most part it is trivial indeed. But there's a couple of quirks that you may want to take into account when working on a component like this. Especially if you want to achieve the native-like experience on iOS.

DISCLAIMER. This article shows components implemented in React with JSX, JavaScript and plain CSS for simplicity. It must be easy to change the implementation to Angular, Vue, or any other framework. React is just the one I use the most and, hopefully, is easy to understand for the majority of we developers.

Trying the most naive implementation

Let's say we've got a simple Layout component implementation, something that goes along these lines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const Layout = (props) => {
  const [menuIsOpen, setMenuIsOpen] = React.useState(false)

  return (
    <>
      <Header>
        <Logo />
        <Menu isOpen={menuIsOpen} />
        <MenuHamburger onClick={() => setMenuIsOpen(!menuIsOpen)} />
      </Header>
      <Content />
      <Footer />
    </>
  )
}

When a user clicks on MenuHamburger, it opens up the menu by passing it the inversed value for its isOpen prop.

The Menu component implementation may go something like that:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const Menu = (props) => {
  return (
    <nav className={`menu ${props.isOpen ? 'menu_open' : ''}`}>
      <ul>
        <li><a href="#">Home</a></li>
        <li><a href="#">Services</a></li>
        <li><a href="#">Contact us</a></li>
      </ul>
    </nav>
  )
}

Most importantly, Menu renders a nav element, and on mobile devices this element should cover the entire screen. As well as blocking the scroll as long as it remains open, but that's an extra.

First take on styling

The first attempt to writing CSS could be something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
.menu {
  display: none;
  z-index: 10;
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
}

.menu_open {
  display: block;
}

Assuming we don't need smooth transitions between states, it's exactly the code that will suffice.

In a desktop browser's mobile device emulator it will even work almost perfectly, except that we are still able to scroll the page underneath the menu. Which is a bit annoying but maybe not a big deal at all?

Issues in Safari on a real iPhone

If we open it in Safari on iOS, we will observe a few annoying effects.

  1. When the menu is opened and we start scrolling it becomes less than 100vh in height showing an annoying gap betweet its background and the URL bar.
  2. Safari's navbar is shrinking and expanding as we scroll up and down, which looks weird and seems to be related to the changing menu height as well.

Body is filled with yellow so that you can easily spot the bug.

Apparently both are related to the Safari's URL bar, which conveniently changes its size as the user interacts with our page. Which is nice but totally breaks the layout and the feeling of our website being native to the iOS.

Let's try to fix it.

Scrolling programmatically

Since Safari changes the URL bar height as we scroll, what if we try scrolling using window.scrollTo() method? We will add the toggleOpen callback, which will still change the menuIsOpen state variable and also scroll the page to 100px vertically.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Layout = (props) => {
  const [menuIsOpen, setMenuIsOpen] = React.useState(false)

  const toggleOpen = useCallback(() => {
    if (menuIsOpen) {
      window.scrollTo(0, 100)
    } else {
      // TODO: restore previous scroll position
    }
    setMenuIsOpen(!menuIsOpen)
  }, [menuIsOpen])

  return (
    <>
      <Header>
        <Logo />
        <Menu isOpen={menuIsOpen} />
        <MenuHamburger onClick={toggleOpen} />
      </Header>
      {props.children}
      <Footer />
    </>
  )
}

Unfortunately, this doesn't work. We could change 100 to be 1 or even 1000 pixels but scrolling things with window.scrollTo() won't make it work. Safari isn't that stupid and won't allow you to control its navbar height programmatically, from a website's script.

Otherwise, imagine opening a site making fun of you by doing all sorts of nasty and annoying things to your browser witout at least some of your actions triggering it. Not good.

This behavior is similar to the one of the built-in popup window blockers, which only allow popup windows created with the window.open() method if they were triggered by a user interaction with the page, such as a button click. Makes sense.

Outdated, not working minimal-ui

When you search the internet, there's a bunch of ~10-year old articles mentioning the magic <meta type="viewport"> element value minimal-ui:

1
<meta name="viewport" content="width=..., minimal-ui">

As of iOS 7 (yes, I know it's laughable to mention it in 2022), it made the browser shrink its auxiliary panels to the minimally possible height to let the site get the most screen space. But just about a year later, with the iOS 8 release of 2014, Apple removed the minimal-ui support.

And righly so, if we think about websites controlling browser's behavior and appearance for a minute.

Setting the content height to 100vh

We cannot shrink the navbar in mobile Safari programmatically.

But since Safari shows it in two variants, the full-height and smaller one, might there be a way to keep it expanded all the time? What if the page had only limited content so that the scroll won't appear and, hopefully, make keep Safari's navbar intact?

Something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
  <head>
    <title>Full-height test</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <style>
      body {
        padding: 0;
        margin: 0;
        width: 100vw;
        height: 100vh;
        display: flex;
        justify-content: center;
        align-items: center;
        font-family: sans-serif;
      }
    </style>
  </head>

  <body>
    Full-screen
  </body>
</html>

So apparently, as we open this page in Safari, we see that the navbar is initially expanded with the page not fitting fully into the viewport. As we scroll down completely, it shrinks amd appears to be not expandable back. At least on my iPhone XS at the moment of writing this article. But we may still click the URL bar to expand it.

It turns out, height: 100vh is not in fact 100% of the current viewport height. Instead, it's 100% of the possibly available viewport height, which only becomes available when the URL bar is shrunk. Ok, Safari, makes sense.

The working solution for making Safari keep its navbar expanded at all times is setting the body height to 100% instead of 100vh. You can check it on by opening this page.

No matter how hard we try to scroll, the navbar won't shrink due to height: 100% instead of 100vh. This is approximately what we want to achieve with the browser's navbar behavior. Now let's combine it with the desired page behavior.

The working implementation

An important addition is to make body fixated in place and/or add overflow: hidden to it. This will prevent the content of the page, if there's too much of it, from sticking out of the element boundaries causing overflow and scrollbars to appear:

1
2
3
4
5
6
body.menu_open {
  position: fixed;
  overflow: hidden;
  width: 100%;
  height: 100%;
}

Note that I am using positon: fixed to make it bulletproof. Perhaps position: fixed isn't strictly required to achieve the result and overflow: hidden combined with the height: 100% is enough. But I'll keep it as is since it bears little to no overhead in terms of the behavior and overall complexity of the solution.

We should now take the menu_open class and toggle it on the body element conditioned upon the menuIsOpen flag.

1
2
3
4
const toggleOpen = useCallback(() => {
  document.body.classList.toggle('menu_open', !menuIsOpen)
  setMenuIsOpen(!menuIsOpen)
}, [menuIsOpen])

Let's check the page in mobile Safari to ensure that it works. And it does indeed! No matter if we've scrolled before opening the menu, once we do open it, body is fixed and there's no weird navbar resizes. It stays full-height.

The only missing detail is that we actually lose previous scroll position, which is unfortunately reset by the browser as we change the body styles to be fixed and therefore having no scroll.

Retaining scroll position

To preserve the scroll position, when the menu is opened, we need to save window.scrollY. When the menu is closed, we will scroll back using the window.scrollTo(x, y) method.

And as we simply want to keep a mutable variable without triggering component re-renders in React, we will use a ref with useRef. Our toggleOpen method will now become this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const prevScroll = useRef(0)
const toggleOpen = useCallback(() => {
  if (!menuIsOpen) {
    prevScroll.current = window.scrollY
  }
  document.body.classList.toggle("menu_open", !menuIsOpen)
  if (menuIsOpen) {
    window.scrollTo(0, prevScroll.current)
  }
  setMenuIsOpen(!menuIsOpen)
}, [menuIsOpen])

Note that since we're using the current menuIsOpen state variable before changing it, we use the inverse !menuIsOpen conditional to save the scroll as we will subsequently open it. And of course, the order of invocation matters. If we try to save the current scroll position after toggling the menu_open class it will have already changed to 0.

Check it in Safari to have a proof that the solution works. Once open, the menu takes the entire available viewport height and disables the annoying jumping of the Safari's URL bar.

One remaining annoying behavior in this particular example is that it's visible through the semi-transparent menu background: rgba(255, 255, 255, .9) that the scroll position changes back and forth. I kept it this way since I wanted the example to show the behavior explicitly.

Usually the menu's background will be fully opaque in real projects though. But if you need the scroll position to remain visibly intact, you'll have to apply some sorcery of scrolling the body's content by the appropriate number of pixels and then restoring it back to 0 when the menu is closed.

Triggering scroll events

One important detail we have to keep in mind as we employ this solution in real projects is that calling the window.scrollTo method triggers scroll events.

It as well may affect IntersectionObservers if you use any, but I'll keep it to your discretion to make sure it doesn't cause any undesired side-effects.

Read next