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.
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
When a user clicks on
MenuHamburger, it opens up the menu by passing it the inversed value for its
Menu component implementation may go something like that:
1 2 3 4 5 6 7 8 9 10 11
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
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.
- When the menu is opened and we start scrolling it becomes less than
100vhin height showing an annoying gap betweet its background and the URL bar.
- 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.
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
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
When you search the internet, there's a bunch of ~10-year old articles mentioning the magic
<meta type="viewport"> element value
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
And righly so, if we think about websites controlling browser's behavior and appearance for a minute.
Setting the content height to
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
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
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
1 2 3 4
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
toggleOpen method will now become this:
1 2 3 4 5 6 7 8 9 10 11
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
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.
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
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.