Fixing the iOS Toolbar Overlap Issue with CSS Viewport Units

A new CSS feature can fix a mobile layout issue that’s annoyed web developers for years.

One of the most exciting aspects of web development in recent years has been the continued growth and improvement of CSS. Flexbox and grid revolutionized how we build webpage layouts. Custom properties (aka, CSS variables) opened up new possibilities for theming. Newer selectors like :has(), :is(), and :where() have lead to more powerful and concise CSS. Container queries are now a reality and looking ahead, native CSS nesting is on its way (decisions concerning its syntax notwithstanding).

But all of these new and upcoming features means that CSS is becoming increasingly complicated. Inevitably, then, some new features might fall through the cracks and get overlooked. (Which is why I’m of the opinion that “full-time CSS engineer” ought to be a thing, but that’s a topic for another post.) Speaking personally, I’m still pretty ignorant concerning the benefits of newer color formats like hwb() and lch(). Which brings me to viewport units.

Put simply, viewport units allow you to size a page’s elements relative to the size of the browser’s viewport, which contains everything that is currently visible on a webpage. (The viewport is basically the browser window minus any UI elements like the navigation and search bar.)

Consider this very simple example:

header {
    height: 100vh;
}

The vh stands for “viewport height,” so an element set to 100vh will be 100% of the viewport’s height. If that element’s height is set to 50vh, then it’ll be 50% of the viewport’s height, and so on. The 100vh is often used when you want an element to fill up the entire browser window. For instance, I use this technique on special features (like my recent David Zindell post) to make their headers fill up the entire viewport with a splashy image. This gives them some extra visual oomph that sets them apart from my “normal” posts.

Not All Viewports Are the Same

iOS Safari Overlap
Figure A: Safari’s bottom bar overlaps the very bottom of the post header, covering up the rest of the text

This approach works well except for one pretty prominent scenario. If you’re viewing such a layout in Safari on an iOS device, that 100vh element fills up the viewport, but its bottom portion is then covered by a toolbar that includes the next/previous navigation and other controls. (See Figure A.)

Note: Although I’m focusing on iOS Safari, this issue also occurs in iOS Chrome. It doesn’t occur in other iOS browsers like Brave, DuckDuckGo, Firefox, and Opera. (More on that in a moment.) I haven’t tested this in any Android browsers.

In other words, Safari doesn’t seem to take its own UI into consideration when drawing its viewport. Thus, a 100vh element doesn’t behave the way it seems like it should, i.e., filling up the space between the URL bar and the bottom toolbar. (Remember that a browser viewport is the browser window minus any UI elements.)

There are, of course, reasons for why Apple opted for this approach. And reading the developer’s explanation — the viewport’s height changes dynamically because any toolbars disappear or minimize when you scroll — they seem perfectly valid. But that doesn’t mean I liked how it looked. It was hard to believe that this was still an issue the Year of Our Lord 2023.

Various Google searches returned different solutions, including some JavaScript-based workarounds. Using JavaScript to fix visual layout issues, however, always feels hack-y to me. Call me old-fashioned, but I like to keep my CSS and JavaScript nice and separate, and reserved for those things that they do best (e.g., CSS for layout, JavaScript for interactivity).

That aforelinked article also pointed me to Matt Smith’s article about -webkit-fill-available, which seemed promising at first. Unfortunately, it wasn’t applicable to my situation. I didn’t want the post header to simply fill up the entire available space because I also needed to take into account the height of my site’s header, which contains the logo, nav, and search.

Here’s what my original CSS looked like:

.post-header {
     min-height: calc(100vh - 6rem);
}

The site header is 6 rems high, so I use the calc function to subtract that from the 100vh to dynamically calculate the post header’s new height. But, as pointed out before, iOS doesn’t respond to 100vh the way you might think it would. What I really needed was a new type of CSS unit — and fortunately, I found it.

New Viewport Units

Back in November, Google’s Web.dev blog covered three new viewport units: the “large,” “small,” and “dynamic” viewport units. These units were created specifically to work with viewports whose size might change due to dynamic toolbars — which was the exact problem I was facing.

  • The “large” viewport units assume that any dynamic toolbars (e.g., Safari’s bottom bar) are retracted and hidden, and calculate the viewport’s size accordingly. (This is akin to Safari’s aforementioned default behavior.)
  • The “small” viewport units assume that any dynamic toolbars are expanded and visible, and calculates the viewport’s size accordingly.
  • The “dynamic” viewport units sit in-between the “large” and “small” units, and react automatically to the dynamic toolbar’s behavior.

At first glance, a “dynamic” viewport unit seemed like the solution. After all, who doesn’t like a web design that automatically responds, all on its own, to a given situation? With that thought in mind, I updated my CSS:

.post-header {
     min-height: calc(100vh - 6rem);
}

@supports (height: 100dvh) {

     .post-header {
          min-height: calc(100dvh - 6rem);
     }

}

In addition to the original selector, I added a feature query via @supports that basically says if the browser recognizes and supports the height: 100dvh declaration, then run the following CSS. (This is an example of progressive enhancement, i.e., starting with the basics and then adding on more advanced code that modern browsers will recognize.) That CSS is virtually identical to my original CSS, except I’m now using 100dvh instead of 100vh. (The dvh stands for “dynamic viewport height.”)

The first time I loaded the page, the problem seemed to be solved: the post header now filled up the space between Safari’s toolbars without anything cut off or hidden. But then I scrolled a little bit.

When you scroll down in iOS, the browser’s toolbars disappear or reduce in size, thus increasing the height of the browser’s viewport. Conversely, scrolling back to the top causes the toolbars to reappear or return to their original size, thus decreasing the viewport’s height. This behavior caused some distracting (IMO) changes to the post header: the background image expanded while the text shifted down in response to the additional height.

Interestingly, this “dynamic” approach is the behavior employed by the iOS versions of Brave, DuckDuckGo, Firefox, and Opera. In other words, toolbar overlap appears to be a non-issue for them, at least as far as Opus is concerned.

So after giving it some more thought, I replaced 100dvh with 100svh — i.e., the “small” viewport height — which assumes that any toolbars are always expanded.

Here’s my final code:

.post-header {
     min-height: calc(100vh - 6rem);
}

@supports (height: 100svh) {

     .post-header {
          min-height: calc(100svh - 6rem);
     }

}
iOS Safari Overlap Fix
Figure B: After switching to a “small” viewport unit, the entire post header is now visible.

You can see the results — that is, the entire post header — in Figure B. Upon scrolling, the post header doesn’t take advantage of the increased viewport height, so it’s not a truly “full-height” element. However, it doesn’t have any weird shifting, either, but looks the same all the time. And I always prefer such stability in my web designs.

For what it’s worth, Firefox, Brave, et al. ignore the 100svh setting altogether, and instead, always stick with the “dynamic” handling of the viewport and post header heights. That’s a little frustrating, but since they represent a relatively minuscule amount of Opus’ overall traffic, I’m not going to sweat it.

Final Thoughts

Along with the aforementioned color formats, viewport units are one of those aspects of CSS that has always felt rather abstract to me. (Then again, I still have trouble wrapping my mind around how srcset works, and that’s used all the time for responsive images.) The problems they seek to address have often seemed rather niche to me, compared to the issues that I’m trying to solve 95% of the time.

Of course, now I have to eat a little crow because I found myself in just such a “niche” situation. Which is to say, I’m glad that really smart people have spent time thinking through these situations, rarefied as they might seem, to find and propose potential solutions.

I’m also glad that browser makers are quick to implement them; browser support for these new viewport units is pretty good, with Opera being the only major holdout. (Which means that I’ll probably remove the @supports feature query in the not-too-distant future and use the 100svh as the default CSS.)

Finally, while Safari’s behavior was initially frustrating, I do believe they made the better choice concerning how to handle dynamic toolbars and viewport heights now that I’ve seen how Firefox et al. handle them. I’d rather have part of the design covered up by default (but fixable, if needed, with the right CSS) then see the page rearrange itself as you scroll. The latter behavior is unexpected and thus distracting, two things that can create a poorer user experience — which is something I try to avoid in every aspect of my designs.