Introducing visualViewport

Jake Archibald
Jake Archibald

What if I told you, there's more than one viewport.

BRRRRAAAAAAAMMMMMMMMMM

And the viewport you're using right now, is actually a viewport within a viewport.

BRRRRAAAAAAAMMMMMMMMMM

And sometimes, the data the DOM gives you, refers to one of those viewport and not the other.

BRRRRAAAAM… wait what?

It's true, take a look:

Layout viewport vs visual viewport

The video above shows a web page being scrolled and pinch-zoomed, along with a mini-map on the right showing the position of viewports within the page.

Things are pretty straight forward during regular scrolling. The green area represents the layout viewport, which position: fixed items stick to.

Things get weird when pinch-zooming is introduced. The red box represents the visual viewport, which is the part of the page we can actually see. This viewport can move around while position: fixed elements remain where they were, attached to the layout viewport. If we pan at a boundary of the layout viewport, it drags the layout viewport along with it.

Improving compatibility

Unfortunately web APIs are inconsistent in terms of which viewport they refer to, and they're also inconsistent across browsers.

For instance, element.getBoundingClientRect().y returns the offset within the layout viewport. That's cool, but we often want the position within the page, so we write:

element.getBoundingClientRect().y + window.scrollY

However, many browsers use the visual viewport for window.scrollY, meaning the above code breaks when the user pinch-zooms.

Chrome 61 changes window.scrollY to refer to the layout viewport instead, meaning the above code works even when pinch-zoomed. In fact, browsers are slowly changing all positional properties to refer to the layout viewport.

With the exception of one new property…

Exposing the visual viewport to script

A new API exposes the visual viewport as window.visualViewport. It's a draft spec, with cross-browser approval, and it's landing in Chrome 61.

console.log(window.visualViewport.width);

Here's what window.visualViewport gives us:

visualViewport properties
offsetLeft Distance between the left edge of the visual viewport, and the layout viewport, in CSS pixels.
offsetTop Distance between the top edge of the visual viewport, and the layout viewport, in CSS pixels.
pageLeft Distance between the left edge of the visual viewport, and the left boundary of the document, in CSS pixels.
pageTop Distance between the top edge of the visual viewport, and the top boundary of the document, in CSS pixels.
width Width of the visual viewport in CSS pixels.
height Height of the visual viewport in CSS pixels.
scale The scale applied by pinch-zooming. If content is twice the size due to zooming, this would return 2. This is not affected by devicePixelRatio.

There are also a couple of events:

window.visualViewport.addEventListener('resize', listener);
visualViewport events
resize Fired when width, height, or scale changes.
scroll Fired when offsetLeft or offsetTop changes.

Demo

The video at the start of this article was created using visualViewport, check it out in Chrome 61+. It uses visualViewport to make the mini-map stick to the top-right of the visual viewport, and applies an inverse scale so it always appears the same size, despite pinch-zooming.

Gotchas

Events only fire when the visual viewport changes

It feels like an obvious thing to state, but it caught me out when I first played with visualViewport.

If the layout viewport resizes but the visual viewport doesn't, you don't get a resize event. However, it's unusual for the layout viewport to resize without the visual viewport also changing width/height.

The real gotcha is scrolling. If scrolling occurs, but the visual viewport remains static relative to the layout viewport, you don't get a scroll event on visualViewport, and this is really common. During regular document scrolling, the visual viewport stays locked to the top-left of the layout viewport, so scroll does not fire on visualViewport.

If you're wanting to hear about all changes to the visual viewport, including pageTop and pageLeft, you'll have to listen to the window's scroll event too:

visualViewport.addEventListener('scroll', update);
visualViewport.addEventListener('resize', update);
window.addEventListener('scroll', update);

Avoid duplicating work with multiple listeners

Similar to listening to scroll & resize on the window, you're likely to call some kind of "update" function as a result. However, it's common for many of these events to happen at the same time. If the user resizes the window, it'll trigger resize, but quite often scroll too. To improve performance, avoid handling the change multiple times:

// Add listeners
visualViewport.addEventListener('scroll', update);
visualViewport.addEventListener('resize', update);
addEventListener('scroll', update);

let pendingUpdate = false;

function update() {
    // If we're already going to handle an update, return
    if (pendingUpdate) return;

    pendingUpdate = true;

    // Use requestAnimationFrame so the update happens before next render
    requestAnimationFrame(() => {
    pendingUpdate = false;

    // Handle update here
    });
}

I've filed a spec issue for this, as I think there may be a better way, such as a single update event.

Event handlers don't work

Due to a Chrome bug, this does not work:

Don't

Buggy – uses an event handler

visualViewport.onscroll = () => console.log('scroll!');

Instead:

Do

Works – uses an event listener

visualViewport.addEventListener('scroll', () => console.log('scroll'));

Offset values are rounded

I think (well, I hope) this is another Chrome bug.

offsetLeft and offsetTop are rounded, which is pretty inaccurate once the user has zoomed in. You can see the issues with this during the demo – if the user zooms in and pans slowly, the mini-map snaps between unzoomed pixels.

The event rate is slow

Like other resize and scroll events, these no not fire every frame, especially on mobile. You can see this during the demo – once you pinch zoom, the mini-map has trouble staying locked to the viewport.

Accessibility

In the demo I used visualViewport to counteract the user's pinch-zoom. It makes sense for this particular demo, but you should think carefully before doing anything that overrides the user's desire to zoom in.

visualViewport can be used to improve accessibility. For instance, if the user is zooming in, you may choose to hide decorative position: fixed items, to get them out of the user's way. But again, be careful you're not hiding something the user is trying to get a closer look at.

You could consider posting to an analytics service when the user zooms in. This could help you identify pages that users are having difficulty with at the default zoom level.

visualViewport.addEventListener('resize', () => {
    if (visualViewport.scale > 1) {
    // Post data to analytics service
    }
});

And that's it! visualViewport is a nice little API which solves compatibility issues along the way.