Thinking differently about progressive enhancement

Some people think that progressive enhancement is more work and means designing for the least capable browser—at the cost of making better experiences for the latest browsers.

But this isn't true. Progressive enhancement is often less work and enables us to design the best experiences, using cutting-edge JavaScript without harming users who use older browsers.

Starting with a problem

Most web interfaces contain text, images, links, videos and forms. None of this needs JavaScript, so we can avoid the complexity that.

To do this, we can render semantic HTML from the server which results in a really fast, standards compliant and accessible experience in all browsers.

If you want, you can pick a nice templating library (like Nunjucks) and a CSS preprocessor (like SASS), because these technological choices only affect developers—not users.

Richer interfaces

With server-rendered HTML covering the vast majority of things people need, we can look at ways to enhance the experience using JavaScript.

That could mean using progressive disclosure to reveal content in menus, tabs and accordions.

Equally it could mean something more complex like letting users annotate a web page and having their comments pushed to collaborators in real time—just like Google Docs.

Using JavaScript APIs

Using JavaScript to do this stuff might sound difficult without a framework, but in reality there's only a handful of methods you need to know about for most things:

  • Find elements: el.getElementById, el.querySelector[All], el.getElementsByClassName
  • Change element attributes/properties: el.setAttribute, el.getAttribute, el.classlist.*, el.propName =
  • Listen to events like click, drag, submit and viewport width changes: el.addEventListener, window.matchMedia
  • Modify elements: el.innerHTML, el.appendChild, el.removeChild, document.createElement
  • Make AJAX calls: XMLHttpRequest
  • Native helpers: Function.prototype.bind, Array.prototype.forEach, Array.prototype.map

Some of these things work “everywhere”, like el.innerHTML and some of these things work in a few modern browsers like window.matchMedia.

But don't worry about individual browsers. The web is a continuum and so we can approach this the same way regardless of the browser or API.

Feature detection

Instead of thinking in terms of browsers, we should focus on the methods our component needs to work.

To make sure our component works, we need to make sure the methods it depends on work. If they do, they can be used safely without the risk of breaking the experience in browsers that lack support. In other words, the component will degrade gracefully.

Let's create a basic component that shows content when a button is clicked.

Firstly, we need to render HTML on the server which works for everyone.

<div class="stuff">
  <div class="stuff-content">
    <p>Stuff to show/hide</p>
  </div>
</div>

Secondly, we need create and inject a button using JavaScript:

// Create button
var button = document.createElement('button');
button.type = "button"
button.textContent = "Show content";

// Inject button
var container = document.querySelector('.stuff');
var content = document.querySelector('.stuff-content');
container.insertBefore(button, content);

Finally, we need to listen to the button's click event so that the class name and button state are changed in order to reveal the content.

button.addEventListener('click', function() {
  if(content.classList.contains('hidden')) {
    content.classList.remove('hidden');
    button.textContent = "Hide content";
  } else {
    content.classList.add('hidden');
    button.textContent = "Show content";
  }
}, false);

This code works in browsers that support the methods. But if the user's browser doesn't support just one of them, the component will break. So we need to feature detect all of them first.

To do this reliably we should use two small functions from Peter Michaux's article, Feature Detection: State of the Art Browser Scripting:

function isHostMethod() {
  var objectMethod = object[method];
  var type = typeof objectMethod;
  return type == 'function' || type == 'object' && null !== objectMethod || type == 'unknown';
}

function isHostObjectProperty() {
  var objectProperty = object[property];
  return typeof objectProperty == 'object' || typeof objectProperty == 'function' && null !== objectProperty;
}

Once you have these, they can be used like this:

if(isHostObjectProperty(document.body, 'textContent')
  && isHostMethod(document, 'createElement')
  && isHostMethod(document, 'querySelector')
  && isHostMethod(document.body, 'insertBefore')
  && isHostMethod(document.body, 'addEventListener')
  && isHostObjectProperty(document.body, 'classList')) {
  // put the rest of the code inside here.
}

If a browser doesn’t support any of the APIs, the user always sees the content. The code doesn't break for anyone. That’s a form of inclusive design.

Libraries are useful

What if we have lots of components in our application? And what if these components use the same APIs? Who wants to write the same, long-winded feature detection code over and over? Not me.

A collection of functions can help reduce the repetition. And a collection of functions is known as a library. With a library in place our code can look more like this:

if(lib.hasFeatures(
  'setText',
  'querySelector',
  'createElement',
  'prependElement',
  'addEventListener',
  'addClass',
  'hasClass',
  'removeClass')) {
  var button = lib.createElement('button', {
    type: 'button', text: 'Show content'
  });
  var container = lib.querySelector('.stuff');
  var content = lib.querySelector('.stuff-content');
  lib.prependElement(button, content);
  lib.addEventListener(button, 'click', function() {
    if(lib.hasClass(content, 'hidden')) {
      lib.removeClass(content, 'hidden');
      lib.setText(button, 'Hide content');
    } else {
      lib.addClass(content, 'hidden');
      lib.setText(button, 'Show content');
    }
  });
}

Read more about this approach in Cross Browser Widgets. Written in 2008, and yet still perfectly applicable today. That shows just how robust and future-proof this technique is.

And such a library only weighs a few kilobytes. Cutting-edge, performant and inclusive all at the same time—whether the browser is new or old.

And it turns out this approach is really useful for developers as much as it is for end users.

Less checking caniuse.com. Less testing in multiple browsers. Less worrying about excluding users. Less worrying about using older technology. Less worry about wrangling with [insert popular framework here]. Less worrying about new browsers. No more polyfills and their unavoidable caveats. No more worrying about whether JavaScript is available or not.