content-visiblity: auto; vs. jumpy scrollbars, a solution

As warned in content-visibility: the new CSS property that boosts your rendering performance you need to be careful with applying content-visibility: auto; on each and every element as the scrollbar might get jumpy.

This is because elements will be rendered as they scroll into the viewport and will be hidden as they scroll out of the viewport, thereby affecting the height of the rendered page, and thus also affecting the scrollbar.

ℹ️ Apart from a jumpy scrollbar it can also negatively affect accessibility when you include headings and landmark elements inside of regions styled with content-visibility: auto;. See Content-visibility and Accessible Semantics for details.

Now, thanks to infinite scroll we are β€” or at least I am β€” kind of used to the thumb part of the scrollbar shrinking and jumping back up a bit on the scrollbar track as you scroll down. What we’re not used to is the thumb part jump forwards on the scroll track as you scroll down. This is because elements that slide out of the viewport will no long be rendered β€” as that’s what content-visibility: auto; does β€” and the scrollbar is (by default) only calculated against the rendered elements.


Elements can become non-rendered elements as they scroll out of the viewport,
thanks to content-visibility: auto; doing its thing.

To cater for this jumpy behavior you should use contain-intrinsic-size so space for an element is reserved when it’s not being rendered. However, it is not always possible to know a box its dimensions in advance. Looking for a way to automatically reserve space for previously rendered elements, Alex Russel created a little script for it.

One challenge with naive application of content-visibility, though, is the way that it removes elements from the rendered tree once they leave the viewport — particularly as you scroll downward. If the scroll position depends on elements above the currently viewable content “accordion scrollbars” can dance gleefully as content-visibility: auto does its thing.

In a first version of the script he applied content-visibility: visible on each element from the moment it had appeared on screen. To detect this an IntersectionObserver is used. While this does prevent the scrollbar thumb from jumping forwards as you scroll down, it will make the page slow again as that content remains rendered (even though it’s off-screen).

A second version of the script takes a different approach and calculates the contain-intrinsic-size to apply based on the element’s dimensions. That way elements that passed by once now have a correct contain-intrinsic-size set, and can safely be hidden again as content-visibility: auto does its job.

let spaced = new WeakMap();
let reserveSpace = (el, rect) => {
    let old = spaced.get(el);
    // Set intrinsic size to prevent jumping.
    if (!old || rectNotEQ(old, rect)) {
        spaced.set(el, rect);
        el.attributeStyleMap.set(
        "contain-intrinsic-size",
        `${rect.width}px ${rect.height}px`
        );
    }
};

Additionally he also added a ResizeObserver to cater for resize events.

Resize-Resilient `content-visiblity` Fixes →

πŸ€” Clever script indeed, yet I cannot help but think: this should be possible without the needs for this extra script. What if a value like contain-intrinsic-size: auto; would be allowed, and do exactly as the script Alex built does?

UPDATE: I’ve created an issue on GitHub that proposes contain-intrinsic-size: auto;. Let’s see where this goes …

useInView – A React Hook to work with IntersectionObserver

The react-intersection-observer package is an easy way to work with the Intersection Observer API in React. It comes with both a Hooks, render props and plain children implementation.

import React from 'react'
import { useInView } from 'react-intersection-observer'

const Component = () => {
  const [ref, inView, entry] = useInView({
    /* Optional options */
    threshold: 0,
  })

  return (
    <div ref={ref}>
      <h2>{`Header inside viewport ${inView}.`}</h2>
    </div>
  )
}

Installation per NPM/Yarn:

npm install react-intersection-observer --save

react-intersection-observer

Smooth Scrolling Sticky ScrollSpy Navigation

Yesterday evening I was working on some documentation pages. The page layout is quite classic, as it consists of a content pane on the left and a sidebar navigation on the right. Looking for a way to make the page less dull I decided to add a few small things to it:

  1. Smooth Scrolling when clicking internal links
  2. A Sticky Navigation, so that the sidebar navigation always stays in view
  3. A β€œScrollSpy” to update the active state of the navigation

These three additions make the page more delightful, and best of all is: they’re really easy to implement! In this post I’ll lay out the details.

~

The result

First things first, here’s a recording of the end result so that you get an idea of what I’m talking about:

~

The Markup

The markup is really basic:

  • A main element surrounds our content div and nav.
  • Each piece of content is wrapped in a section which gets an id attribute. The sidebar navigation then links to its id
<main>
	<div>
		<h1>Smooth Scrolling Sticky ScrollSpy Navigation</h1>
		<section id="introduction">
			<h2>Introduction</h2>
			<p>…</p>
		</section>
		<section id="request-response">
			<h2>Request &amp; Response</h2>
			<p>…</p>
		</section>
		<section id="authentication">
			<h2>Authentication</h2>
			<p>…</p>
		</section>
		…
		<section id="filters">
			<h2>Filters</h2>
			<p>…</p>
		</section>
	</div>
	<nav class="section-nav">
		<ol>
			<li><a href="#introduction">Introduction</a></li>
			<li><a href="#request-response">Request &amp; Response</a></li>
			<li><a href="#authentication">Authentication</a></li>
		…
			<li class=""><a href="#filters">Filters</a></li>
		</ol>
	</nav>
</main>

Sprinkle some CSS on top to lay everything out – using CSS Grid here – and you have a fully working – albeit dull – page:

See the Pen
Smooth Scrolling Sticky ScrollSpy Navigation (base layer)
by Bramus (@bramus)
on CodePen.

~

1. Smooth Scrolling

Enabling smooth scrolling is really easy, it you can enable it using a single line of CSS:

html {
	scroll-behavior: smooth;
}

😱 Yes, that’s it!

In the demo embedded below, click any of the links in the nav and see how smooth it scrolls:

See the Pen
Smooth Scrolling Sticky ScrollSpy Navigation (base layer)
by Bramus (@bramus)
on CodePen.

For browsers that don’t support this you could add this JS fallback:

// Smooth scrolling for browsers that don't support CSS smooth scrolling
if (window.getComputedStyle(document.documentElement).scrollBehavior !== 'smooth') {
    document.querySelectorAll('a[href^="#"]').forEach(internalLink => {
        const targetElement = document.querySelector(internalLink.getAttribute('href'));
        if (targetElement) {
            internalLink.addEventListener('click', (e) => {
                targetElement.scrollIntoView({
                    behavior: 'smooth',
                });
                e.preventDefault();
            });
        }
    });
}

However, browser’s that don’t support scroll-behavior: smooth; also don’t support behavior: "smooth" for Element#scrollIntoView, so there’s not real advantage to adding this JS fallback.

~

2. Sticky Navigation

To make the navigation stay in place as you scroll we can rely on position: sticky;. As with Smooth Scrolling, this is a really simple CSS addition:

main > nav {
	position: sticky;
	top: 2rem;
	align-self: start;
}

πŸ’β€β™‚οΈ Since we’re using CSS Grid to lay out the children of <main>, adding align-self: start; to <nav> is an important one here. If we would omit it, the nav element would be as high as the enclosing main element. If that were the case, then nav would never be able to stick.

In the demo embedded below, click any of the links in the nav and see how the nav now also stays in view while the rest of the page scrolls:

See the Pen
Smooth Scrolling Sticky ScrollSpy Navigation (base layer + smooth scrolling + sticky nav)
by Bramus (@bramus)
on CodePen.

~

πŸ‘‹ Like what you see so far? Follow @bramusblog on Twitter or subscribe to the RSS feed to stay up-to-date.

~

3. ScrollSpy with IntersectionObserver

Thanks to the almighty IntersectionObserver we can implement a ScrollSpy. Basically we use it to watch all section["id"] elements. If they are intersecting, we add the .active class to any link that links to it. For styling purposes we don’t add .active to the link itself, but to its surrounding li element.

In code, that becomes this little snippet:

window.addEventListener('DOMContentLoaded', () => {

	const observer = new IntersectionObserver(entries => {
		entries.forEach(entry => {
			const id = entry.target.getAttribute('id');
			if (entry.intersectionRatio > 0) {
				document.querySelector(`nav li a[href="#${id}"]`).parentElement.classList.add('active');
			} else {
				document.querySelector(`nav li a[href="#${id}"]`).parentElement.classList.remove('active');
			}
		});
	});

	// Track all sections that have an `id` applied
	document.querySelectorAll('section[id]').forEach((section) => {
		observer.observe(section);
	});
	
});

πŸ’‘ To make the transition to and from .active not too abrupt, add a little blob of CSS to ease things:

.section-nav a {
    transition: all 100ms ease-in-out;
}

~

Complete Demo

Putting everything together, we end up with this:

See the Pen
Smooth Scrolling Sticky ScrollSpy Navigation
by Bramus (@bramus)
on CodePen.

Delightful, no? 😊

~

πŸ’‘ If you’re also looking for more inspiration to make your interfaces more delightful, be sure to check Hakim El Hattab’s β€œBuilding Better Interfaces” talk. Recommended stuff!

Did this help you out? Like what you see?
Thank me with a coffee.

I don't do this for profit but a small one-time donation would surely put a smile on my face. Thanks!

β˜•οΈ Buy me a Coffee (€3)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.

New WebKit Features in Safari 12.1

Jonathan Davis – Web Technologies Evangelist for Apple – has done a writeup on the new features that have landed in Safari 12.1, which is included with macOS Mojave 10.14.4 and iOS 12.2.

This release delivers web platform features that improve website integration with the operating system, new real-time communication capabilities, more compatible encrypted media support, and features that help developers reduce the need for polyfills.

Whilst Dark Mode most likely is the most touted feature, I’m especially excited to see Intersection Observer land as it allows you to do nifty stuff such as lazily load images, triggering events for position: sticky;, prefetch pages for links that are in the viewport, etc.

Supporting <input type="color" /> looks handy, yet (on iOS) I’d also like to be able to input a color hex code myself.

The <datalist> element also forms a nice addition, but the implementation (on iOS) needs some more work imho: the triangle on the right of the input makes me think it’s a dropdown. Upon tapping the input I get to see three of the defined options. Since I am under the impression that this control is a dropdown, I’m also left with the impression that I can only choose one of those three options that are initially presented … confusing.

Not mentioned in the post, but mentioned in the release notes, is the fact that Safari will now implicitly add rel="noopener" onto links that have target="_blank" set.

New WebKit Features in Safari 12.1 →
Safari 12.1 Release Notes →

πŸ€” Why should you care about rel="noopener"? Mathias Bynens – who else – has got you covered.

quicklink – Prefetch links (during idle time) based on what is in the user’s viewport

Great little piece of JavaScript which prefetches links, but only when the browser is idle (and when the user is on a “fast” connection). Uses the aforementioned Intersection Observer to detect which links are in-view.

Install it per NPM and call its quicklink(); method – for example after the DOM has loaded – to initialize the script.

Amongst ignore patterns, it’s also possible to define the origins which are allowed to be preloaded.

quicklink Source (GitHub) →

Sticky Events – Events for position: sticky;

Sticky Events is a library that can listen for events on elements that have position: sticky; applied. It’s an abstraction built on top of the IntersectionObserver, and provides one with three types of events:

  • StickyEvent.CHANGE: Fired when an element becomes stuck or unstuck
  • StickyEvent.STUCK: Fired only when an element becomes stuck
  • StickyEvent.UNSTUCK: Fired only when an element becomes unstuck

Usage is quite simple: set up it once, and then add event listeners to the elements:

import { observeStickyEvents, StickyEvent } from "sticky-events";

// Add listeners to all `.sticky-events` elements on the page
observeStickyEvents();

// Events are dispatched on elements with the `.sticky-events` class
const stickies = Array.from(document.querySelectorAll('.sticky-events'));

stickies.forEach((sticky) => {
  sticky.addEventListener(StickyEvent.CHANGE, (event) => {
    sticky.classList.toggle('bg-dark', event.detail.isSticky);
  });

  sticky.addEventListener(StickyEvent.STUCK, (event) => {
    console.log('stuck');
  });

  sticky.addEventListener(StickyEvent.UNSTUCK, (event) => {
    console.log('unstuck');
  });
});

Sticky Events – Events for position: sticky;

Scroll to the future – CSS and JavaScript features that make navigating around a single page smooth, beautiful and less resource-hungry.

Very in-depth article on Evil Martians’ team blog on scrolling:

We have scrolled to the bottom of modern web specifications to take you on a whirlwind tour of latest CSS and JavaScript features that make navigating around a single page smooth, beautiful and less resource-hungry.

Subjects tackled are styling of scrollbars, position: sticky, IntersectionObserver, Smooth Scrolling, the overscroll-behavior property, etc.

I especially like this part of the closing notes (next to the plea for Progressive Enhancement):

Maybe even now, while you were scrolling through this article, another browser has shipped support for a property that will make your life easier, and your bundle size smaller.

Scroll to the future →

Lazy Loading images with IntersectionObserver

Smashing Magazine has an extensive article on using the aforementioned IntersectionObserver to lazy load image assets on your page.

The article first explains the difference between a regular Event and an Observer, before diving into the IntersectionObserver.

const images = document.querySelectorAll('[data-src]');
const config = {
  rootMargin: '0px 0px 50px 0px',
  threshold: 0
};
let loaded = 0;

let observer = new IntersectionObserver(function (entries, self) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // console.log(`Image ${entry.target.src} is in the viewport!`);
      preloadImage(entry.target);
      // Stop watching and load the image
      self.unobserve(entry.target);
    }
  });
}, config);

images.forEach(image => {
  observer.observe(image);
});

Now You See Me: How To Defer, Lazy-Load And Act With IntersectionObserver

Scrollama – Scrollytelling with IntersectionObserver

Scrollama is a modern & lightweight JavaScript library for scrollytelling using IntersectionObserver in favor of scroll events.

The code that accompanies the markup pictured above:

// instantiate the scrollama
const scroller = scrollama();

// setup the instance, pass callback functions
scroller
  .setup({
    container: '.scroll', // wrapping container
    step: '.scroll__text .step', // all steps
    graphic: '.scroll__graphic', // the sticky element
    offset: 0.5, // optional, default = 0.5
  })
  .onStepEnter(handleStepEnter)
  .onStepExit(handleStepExit)
  .onContainerEnter(handleContainerEnter)
  .onContainerExit(handleContainerExit);

Scrollama →

Using Intersection Observers

With the Intersection Observer coming to Firefox, a nice article covering it appeared on Mozilla Hacks.

The IntersectionObserver interface of the Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport.

To use it, create a new instance of IntersectionObserver, and then let it observe a given element (target):

let observer = new IntersectionObserver((entries, observer) => { /* … */});
observer.observe(target); // <-- Element to watch

Here's a demo pen:

See the Pen Hello IntersectionObserver by Dan Callahan (@callahad) on CodePen.

To not watch the target's relation to the viewport, but to another element, use the root option.

let observer = new IntersectionObserver((entries, observer) => { /* … */}, {
   root: parentElement,
});
observer.observe(target); // <-- Element to watch

Works in Edge 15, Chrome 51, and soon Firefox 55. For browsers that don't support it you can use a polyfill.

Intersection Observer comes to Firefox →
IntersectionObserver Polyfill →