Thinking differently about progressive enhancement

Some people think 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 progressive enhancement is often less work and lets us to create the best experience, using cutting-edge JavaScript without harming people who use older browsers.

Starting with a problem

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

To do this, we can server render semantic HTML. This results in a fast, standards compliant, accessible experience in all browsers.

If you want, you can pick a nice templating library (I like Nunjucks) and a CSS preprocessor—these technological choices improve the developer experience without negatively impacting users.

Richer interfaces

With server-rendered HTML covering the vast majority of things people need, lets 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 shared with collaborators in real time—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:

  • 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 about browsers, we should focus on the methods the component needs to work.

To do this, we need to make sure these methods 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 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 method, the component will break. So we need to feature detect all of them first.

To do this we should use the following functions described in 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.

Even though it's written in 2008 it's perfectly applicable today which shows just how robust and future proof this technique is.

Such a library weighs in at just a few kilobytes. Cutting-edge, performant and inclusive all at the same time. Nice.

And this really helps developers too.

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.