Oct 12, 2022

The Perfect Full-Height Element

Creating full-height HTML elements isn’t as easy as it might seem. The main challenge is the way mobile browsers handle viewport units. The ideal scenario would be to set an element’s height to 100vh and be done with it. That works on desktop, but on mobile, browsers hide their toolbars on scroll and change their viewport size. So browsers have a decision to make here: should 100vh include the height of the toolbars or not? I’d like 100vh to exclude the toolbars because it’s the value I use when I don’t want elements to be cut off at the bottom. However, the Webkit team decided that 100vh would represent the larger viewport size:

The base problem is this: the visible area changes dynamically as you scroll. If we update the CSS viewport height accordingly, we need to update the layout during the scroll. Not only that looks like shit, but doing that at 60 FPS is practically impossible in most pages (60 FPS is the baseline framerate on iOS). It is hard to show you the “looks like shit” part, but imagine as you scroll, the contents moves and what you want on screen is continuously shifting.

Dynamically updating the height was not working, we had a few choices: drop viewport units on iOS, match the document size like before iOS 8, use the small view size, use the large view size.

From the data we had, using the larger view size was the best compromise. Most website using viewport units were looking great most of the time.

This is what it looks like on an iPhone 13 Pro, there’s a 100vh element with text positioned at its bottom. When the toolbar is visible, it covers the text:

What solutions are already out there?

I’ll start with the prior art, these are solutions that can work but they all have flaws which is why I’m sharing my technique.

Setting the height to 100%

For simple layouts, you can set the height to 100% from the html element all the way down to the element that should fill the screen. This works on all browsers but I wouldn’t use it for anything but the simplest web page. The main reason is that passing height 100% all the way down to your element is not flexible and can cause other layout issues.

While passing height 100% is a pure CSS solution, it’s not an ideal one for the web apps I tend to build.

Using the new viewport units – large, small, and dynamic

Viewport units similar to vw and vh that are based on shown or hidden browser UI states to address shortcomings of the original units. Currently defined as the lvh, lvw, svh, svw, dvh, dvw units.

This will eventually be the correct solution; however, right now, browser support is terrible. At the time of writing this article, Oct. 2022, I don’t recommend using these units.

Using window.innerHeight

Unfortunately, CSS has failed us. It’s time to pull out our JavaScript toolbox. Most solutions I have found on this subject rely on window.innerHeight and window.addEventListener('resize', ...). It’s a good solution, but it has a little flaw if used by itself. Whenever the toolbar disappears or reappears, the window.innerHeight value changes and causes an abrupt page jump when the height adjusts itself.

Take a look:

Improving window.innerHeight

We want to use window.innerHeight without the jump that it causes when toolbars resize the viewport. We can’t just ignore resize events because they’re important for screen rotation or window resizes on desktop. There is a solution. It lies in the fact that 100vh remains constant despite the browser UI changing. We can use that to detect actual page resizes and ignore ones caused by a toolbar resize.

First, we need an element in our HTML that will serve as a measuring stick. It can be placed anywhere in the DOM:

<body>
  <div id="measuring-stick"></div>
</body>

With the following CSS:

#measuring-stick {
  position: absolute;
  width: 1px;
  top: 0;
  left: -1px;
  height: 100vh;
}

Next, we’ll use the measuring stick element to detect if the browser height has actually changed or not. We know that 100vh stays constant when the toolbar resizes, so, on a resize event, we can check if the measuringStick’s height changed. It it stayed the same, we know the window resize event was triggered by the toolbar and we can ignore it. Otherwise, we have to actually recalculate our height. Here I’m using a technique that sets a CSS variable named --vh, but you could also adapt this script and directly set your element’s height to window.innerHeight. You may also want to throttle the resize handler event which I won’t cover in this article.

// Reference value
let browserHeight_previousValue = null;

const setVh = () => {
  const measuringStickHeight = document.querySelector("#measuring-stick").clientHeight;
  const browserHeight_hasChanged = measuringStickHeight !== browserHeight_previousValue;

  // Quit if the measuringStick's height didn't change
  if (!browserHeight_hasChanged) return;

  // Set our CSS variable
  document.querySelector(":root").style.setProperty("--vh", window.innerHeight / 100 + "px");

  // Update our reference value
  browserHeight_previousValue = measuringStickHeight;
};

window.addEventListener("resize", setVh);
window.addEventListener("load", setVh);

Finally we can set the height on our full-height elements like this:

<div style="height: calc(100 * var(--vh);"></div>

Here’s the final result, notice how it’s smooth and there are no jumps when the toolbar hides or appears:

As a bonus because I’m feeling generous, here it is as a React hook:

import { useLayoutEffect, useRef } from "react";

const use_setVh = () => {
  const browserHeight_previousValue = useRef(null);

  useLayoutEffect(() => {
    const setVh = () => {
      const measuringStickHeight = document.querySelector("#measuring-stick").clientHeight;
      const browserHeight_hasChanged = measuringStickHeight !== browserHeight_previousValue.current;

      if (browserHeight_hasChanged) {
        browserHeight_previousValue.current = measuringStickHeight;
        document.querySelector(":root").style.setProperty("--vh", window.innerHeight / 100 + "px");
      }
    };

    window.addEventListener("resize", setVh);
    setVh();

    return () => {
      window.removeEventListener("resize", setVh);
    };
  }, []);
};

export default use_setVh;

As mentionned before, you may want to throttle the resize handler event. I hope you found this useful!