Creating Custom Viewport Units Instead of Using vh and vw

Hero image for Creating Custom Viewport Units Instead of Using vh and vw. Image by Josh Calabrese.
Hero image for 'Creating Custom Viewport Units Instead of Using vh and vw.' Image by Josh Calabrese.

I've written about using CSS viewport units (specifically vw and vh) in the past. However, there still remain two big weaknesses when it comes to using these (and vh in particular, as we'll get to in a minute):

  • Even all these years later, crossbrowser compatibility remains a concern. Admittedly support gets better with every passing day, but when you have the likes of Microsoft Edge, which didn't fully support them until the end of 2017, you have a potential problem.
  • A quirk in the way these values are calculated. Both vh and vw are calculated to the 'largest possible viewport', which includes things like address bars and virtual keyboards (vertically) and scrollbars (horizontally), meaning your '100%' element will sit beneath these things.

I use a solution (which I can't take much personal credit for) that resolves both of these issues with a little JavaScript.


100vh !== 100% of visible height

This is something you will no doubt come across every time you develop something that takes up the fullscreen. As soon as you load it up on an iPhone (or Android, or any physical mobile device), you'll find that your element is actually overflowing the viewport and falling behind things like the URL bar.

I touched on this briefly in the introduction above, but essentially, how viewport units are calculated can feel counterintuitive and not as useful as they might first seem. 1vh is not the same as 1% of the visible screen height (nor is 1vw the same as 1% of the visible screen width).

This is because these devices calculate 100vh as a percentage of the 'largest possible viewport' rather than what's actually visible at that time. It means that things like the URL bar that appears at the bottom of Safari and Chrome, overlap your 100vh height element, and your 100vw width content will fall behind browser elements like the scrollbars.

These values do not change when the browser elements (like the URL bar, virtual keyboard, or scrollbars) are shown and hidden.

Here's an example from a project I've been working on:

A white background with two side-by-side iPhone screenshots displaying the new Virgin Holidays navigation and demonstrating the issue where 100vh results in content folding off the bottom of the screen beneath the URL bar.

This is the new flyout navigation for Virgin Atlantic Holidays, where the main panel is expected to be the full height of the screen, with a set of imagebased location panels positioned off the bottom edge.

On the left, that navigation panel is set to height: 100vh and as you can see those bottom image panels are sitting down behind the URL bar. On the right is how it is intended to look and how it does look with a little additional JavaScript help.


The Solution: A Custom CSS Property and a Little JavaScript

The solution to this is relatively simple and robust. Instead of using viewport units, we create a dynamic custom CSS property that represents 1% of the visible width and/or height of the current viewport, and combine that with calc when used in our stylesheets. This way, we get realtime layout adjustments as the CSS properties change.

Handily, this also provides consistent behaviour across browsers, removing some of the crossbrowser compatibility issues I discussed earlier on.

Implementation: JavaScript

Every time I've implemented this, it's been in React, but there's no reason vanilla JavaScript couldn't be used to the same effect:

  • Create a Resize Handler

    : the crux of this implementation is responding to window resize events. Fortunately, both iOS Safari and Android Chrome trigger a resize event on window when browser elements change. I wrap this in a useCallback hook to make sure that the function isn't recreated unnecessarily.
  • Debounce it

    : In the interest of improving performance further, I also debounce the handler and avoid excessive recalculations during rapid viewport changes (like resizing the browser window). This is optional because the downside of debouncing like this is that there will be a delay between the resize event itself and the dimensions updating.
  • Create the Custom Property/Properties

    : The resize handler function measures the current viewport, divides it by 100 (to get 1%), and then assigns it as a custom CSS property to the html element.
  • Use it in the CSS

Here's what my code looks like:

const handleResize = useCallback(  debounce(() => {    if (document && window) {      const documentEl = document.documentElement;      const { innerHeight, innerWidth } = window;      documentEl.style.setProperty('--calculated-vh', `${innerHeight / 100}px`);      documentEl.style.setProperty('--calculated-vw', `${innerWidth / 100}px`);    }  }, 250),  []);

I've included both vw and vh calculations here, but it's fair to say that for the most part, most of my issues relate to heights rather than widths, so you may not find it necessary to include both.

It's also worth mentioning that unlike assigning multiple 'normal' CSS style properties to an element, which you can do with Object.assign, you have to assign custom CSS variables one at a time because they aren't part of the CSSOM and need individual getters and setters.

In order to trigger this listener, you use a 'run once effect' (with an empty dependency array: []) to firstly trigger the function onmount so that the CSS variables can be set, and then start and stop the listener during the wider application lifecycle:

useEffect(() => {  if (document) {    handleResize();    window.addEventListener('resize', handleResize);    return () => {      window.removeEventListener('resize', handleResize);    };  }}, []);

In listening to the window.resize event, we can also take advantage of the 'phantom' resize events that iOS triggers each time an item of browser chrome is shown or hidden.


Using this in your CSS

Using these custom properties in your CSS is as simple as creating a calc to multiply the custom value by the number of them you want to use. For example:

// the calculated equivalent of 50vh:calc(var(--calculated-vh) * 50); // the calculated equivalent of 100vh:calc(var(--calculated-vh) * 100);// the calculated equivalent of 10vw:calc(var(--calculated-vw) * 10); 

So, if you wanted an element that was 100% the width of the viewport and 25% of the height, it would look something like this:

.element{  width: calc(var(--calculated-vw) * 50);  height: calc(var(--calculated-vh) * 25)}

Using Viewport Units as Fallbacks

As part of making sure this is as robust as possible, it is important that we consider what happens if the visitor either:

  1. Has a browser that doesn't support calc;
  2. Doesn't have the custom properties in their DOM.

There are any number of reasons why either might occur: a thirdparty script might scrub the html style object for example, or your resize handler might simply not mount.

Either way, what you don't want is to have a situation where all your calculated dimensions suddenly collapse to zero just because the custom properties don't exist.

It may seem counterintuitive considering everything we've discussed until now, but the answer is simple: use vw and vh as fallback, like this:

.element {  width: 50vw;  // if this following declaration fails, then the browser will fall back to the one above  width: calc(var(--calculated-vw) * 50);  height: 25vh;  // the same is true here too  height: calc(var(--calculated-vh) * 25);}

This just follows basic CSS inheritance principles where we define the same property twice (e.g., height). The browser will apply the last one first because it appears further down the CSS file, and then revert back to the previous declaration for the same property if need be.

This also has the added benefit of capturing any rogue visitors who have JavaScript disabled although, from my more recent experience, the only time anybody visits one of my projects without JavaScript is when I or my QA team are testing to make sure it works without JavaScript...!


Benefits and Drawbacks

This has become a bit of a meandering article now, and many of these points have already been touchedupon above (some of them more than once), but nevertheless it's worth quickly rounding up the benefits and disadvantages of using this approach:

Benefits

  • Dynamic Adjustment:

    with this approach, our layout will dynamically react to changes in the viewport, in a way that we're simply unable to achieve with regular viewport units. You can also tie the calculation into other emitted events should you need to trigger recalculation outside of the regular window resize event.
  • CrossBrowser Consistency:

    here, we get a unified solution across different browsers and devices rather than the slightly muddled differences in different browser implementations.
  • Integration with JavaScript:

    We can use this for further JavaScriptbased logic in our application since we are already listening for the resize event.

Drawbacks

  • Performance:

    As I've mentioned above, I used debounce to avoid running those big fulldocument recalculations on every resize event. This helps to reduce performance issues that might otherwise be introduced by the feature, but does also mean that our custom properties don't update instantaneously. What you get is a payoff between potential performance impacts from the calculations, and not having a snappy response to user resize events.
  • JavaScript Dependency:

    Again, I mentioned this just a couple of paragraphs above, but you need to be aware of potential issues that might occur if your JavaScript doesn't work for any reason. Use fallbacks in your CSS, otherwise your layouts may collapse altogether.

Wrapping up

It is annoying that vw and vh don't actually behave as you might hope; that they calculate off the maximum viewport, rather than what is being displayed to our users at that time. However, this approach offers a robust solution which has the added benefit of also smoothingout some of the crossbrowser inconsistencies you would come across in the vast mobile device landscape.


Categories:

  1. Cross‑Browser Compatibility
  2. CSS
  3. Front‑End Development
  4. JavaScript
  5. Responsive Development