Skip to content

Instantly share code, notes, and snippets.

@claus
Last active August 29, 2024 02:10
Show Gist options
  • Save claus/622a938d21d80f367251dc2eaaa1b2a9 to your computer and use it in GitHub Desktop.
Save claus/622a938d21d80f367251dc2eaaa1b2a9 to your computer and use it in GitHub Desktop.

Mobile Safari's 100% Height Dilemma

Whether you're developing a web application with native-ish UI, or just a simple modal popup overlay that covers the viewport, when it comes to making things work on iDevices in Mobile Safari, you're in for a decent amount of pain and suffering. Making something "100% height" is not as easy as it seems.

This post is a collection of Mobile Safari's gotchas and quirks on that topic, some with solutions and fixes, some without, in good parts pulled from various sources across the internets, to have it all in one place. Things discussed here apply to iOS8, iOS9 and iOS10.

The Disappearing Browser Chrome

Screen real estate on smartphones is limited, so Mobile Safari collapses the browser chrome (address bar and optional tab bar at the top, and tool bar at the bottom) when the user scrolls down. When you want to make something span exactly the height of the viewport, or pin something to the bottom of the screen, this can get tricky because the viewport changes size (or not, as we see later).

Additional care must be taken when text input controls are involved. The virtual keyboard will pop up when a text input is focused, shifting elements up (so they are guaranteed to stay in view), potentially messing with the layout when certain conditions are met.

Full Viewport Overlays

To create an overlay that covers the entire viewport, you'd apply CSS like the following to the element:

.overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%; // or right: 0;
  height: 100%; // or bottom: 0;
}

Make sure that no ancestor element of .overlay have a transform applied to them (a simple translateX(0) is sufficient). From CSS Transforms Module:

Any computed value other than none for the transform results in the creation of both a stacking context and a containing block. The object acts as a containing block for fixed positioned descendants.

So if positioning turns out not to be relative to the viewport, you probably have a transform applied on an ancestor, and your element is positioned relative to that ancestor's bounds.

% vs vh

Why not use 100vh you ask?

vh stands for "Viewport Height", one vh unit reflects 1/100th of the total height of the browser's viewport. An element with 100vh applied is supposed to have the same height as the viewport, independent of heights applied to parent elements. The perfect way to force full viewport height on an element you might think.

Not in Mobile Safari though, where vh is always relative to the viewport size with collapsed browser chrome, no matter if the browser chrome is actually collapsed or fully visible, so an element with height: 100vh will be too big when the chrome is fully visible.

Giving the element a height: 100% fixes this (the computed height will adjust to viewport size changes due to toggled browser chrome etc), and bottom: 0 works just as well.

Quirks

Now we could almost wrap up here if there weren't any quirks, and of course there are.

Reflow And Resize Events

On iPhone in landscape mode, with no tab bar visible, things start getting a little weird. On page load, Mobile Safari starts with an incorrect window.innerHeight value (e.g., 414 on a 6 Plus, which is the full viewport height, same as if height: 100vh was used). After the first render with this incorrect height, window.innerHeight is set to the correct value (e.g., 370 on a 6 Plus), a reflow happens and a resize event is fired. Collapsing and expanding the browser chrome via scrolling also triggers reflows and resize events.

On iPhone in landscape mode, with the tab bar visible (only "Plus" models), things are hopelessly broken. It starts with the same incorrect window.innerHeight value, but no reflow happens and no resize event is fired. Collapsing and expanding the browser chrome via scrolling does not trigger reflows or resize events either. window.innerHeight is updated with the correct values though. Additionally, the touch areas of interactive elements (buttons and such) are offset vertically by a seemingly random amount. See this Stackoverflow question and Webkit bug #153056. The only way to get around this is querying window.innerHeight repeatedly (e.g., on touch events), and setting an explicit px height on the element.

Note that this does not happen on iPhone in portrait orientation, or on iPad in both orientations: Everything works as it should, window.innerHeight has the correct value from the start, reflows happen and resize events are fired.

Text Input and the Virtual Keyboard

If you have a text field in your fixed positioned element, and it is positioned at or near the bottom, when it's focused and the keyboard pops up, the scrollable ancestor scrolls all the way down for some reason. This StackOverflow answer shows how to fix it.

Hidden Treasures

Last but not least, if you have a tappable element (like a button) at the very bottom of the viewport, and the browser chrome is hidden, you're in for a surprise: In portrait mode, taps on the area where the bottom tool bar would be trigger the browser chrome to show (just like taps on the collapsed address bar), so your button will initially not be tappable. The only solution to that is to force browser chrome to always be visible. See this article for details.

Prevent body from scrolling

Normally, setting overflow: hidden on the body element prevents it from scrolling. That doesn't work in Mobile Safari though (see Webkit bug #153852).

The only way to prevent body from scrolling via CSS is to set position: fixed on body, which results in some undesirable side effects:

  • If the page was scrolled down previously, it jumps back to the top.
  • In portrait mode, if the browser chrome was previously collapsed, it expands, and can not be toggled by vertical swipes.
  • In landscape mode, the browser chrome stays in its previous state, but can be toggled by vertical swipes.

This can mostly be remedied by setting top to -scrollTop (plus potentially some delta adjusting for the jump caused by the expanding browser chrome), and afterwards resetting scrollTop to its original value. But bugs in landscape mode with visible tab bar outlined above still apply, and make this non trivial in that particular configuration.

Alternatives

  1. Keep site content in a container component:

    body {
      position: fixed;
    }
    
    .container {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      overflow: auto;
      -webkit-overflow-scrolling: touch;
    }

    This forces the browser chrome to be always visible and never collapse. -webkit-overflow-scrolling: touch is applied to enable rubber band scrolling on that element. Set overflow: hidden on .container to disable scrolling.

  2. If the overlay does not need any scroll- or other gestures, blocking the touchmove event on the overlay via preventDefault() does the trick:

    [...document.querySelectorAll('.overlay')].forEach(overlay => 
      overlay.addEventListener('touchmove', event => {
        event.preventDefault();
      })
    );

Also read the article Six things I learnt about iOS Safari's rubber band scrolling, which explains quite well how to block overscroll amongst other things.

@jjaskulis
Copy link

jjaskulis commented Jun 4, 2021

Good resource on the quirks of iOS! Wish I would have found this earlier, would have have saved me from a few new grey hairs.
It might be useful to know about window.visualViewport, which allows to get the actual size of viewport when the keyboard is open. Very helpful in some layouts, e.g. when you don't want software keyboard to cover some elements and update the layout.

@claus
Copy link
Author

claus commented Jun 4, 2021

@jjaskulis Yes there's visualViewport now, which is helpful indeed.

BTW this article is about 100 years old in tech years, so i don't even know if everything still applies (i think most does, though).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment