Progressive enhancement: The best of both webs


In this post, I explain how to implement progressive enhancement in a website. Progressive enhancment means starting with static, or unchanging content, and then progressively adding "enhanced" and interactive content with JavaScript as the user's device and preferences permit.

What's "enhanced" content? It's content that can only be viewed if your browser has certain features or capabilities. For example, this very paragraph. You're only seeing this because your browser is running the JavaScript that made this visible. What makes it enhanced, though? Well it's not enhanced yet, but you can enhance it by clicking here: .


In the beginning God—sorry, I mean Tim Berners-Lee—created the World Wide Web.

It started as a simple, but revolutionary, idea: a way to share hypertext—or text that can link to other text—over the interent.

The hypertext is what we now know as HTML (Hyper-text Markup Language), the original and still most widely used document type on the web. Soon after came CSS (Cascading Style Sheets) for formatting and styling HTML to make it more presentable for humans.

In the early years, the web was static. That means that the content of a page didn't update after it was first loaded. It was just a document with text, images, and links to other documents with text and images. Imagine that: no annoying pop-ups, no videos flashing in your face, no devious scripts running in the background collecting all your data.

Maybe we should've kept it that way.

But then JavaScript was invented in the mid 1990's as a scripting language for the web, and everything changed.

Now web pages are dynamic: the web page you load initially can be updated as you use it. This allows for all sorts of great things we now take for granted: sending messages back-and-forth on social media, animating elements of the web page, playing browser games, controlling playback of media, redirecting, storing data on the user's device, and more.

But it also came with some downsides: we got pop-up boxes and alerts, slow page loads while scripts were running in the background, and companies collecting and saving data on their servers about us—often just to personalize ads and get us to buy stuff.

We can't go back and undo JavaScript: It's fully penetrated the web, with upwards of 98.7% of all websites using it as of 2023. And we wouldn't want to, even taking all the downsides into account. JavaScript has unlocked incredible web applications like interactive data visualization with tools like D3, 3D graphics with WebGL, WebSockets, and much more.

But maybe we can have it both ways. Maybe we can preserve the simplicity of the early static web, but then, where possible, add a little JavaScript, unobtrusively and always optional, to ehance content where it makes sense to.

This is the art of progressive enhancement: Provide a baseline of essential content and functionality to as many users as possible, while delivering the best possible experience only to users with browsers that can run all the required code.

Starting with static

Even if we don't relinquish JavaScript, it can still make sense to create sites that first and foremost function as static sites. That is, sites that are perfectly coherent and navigable without JavaScript.

This is pretty easy to do: just create a basic HTML page that contains content people might find interesting, useful, or enjoyable. You can use a plain vanilla Vite app, or a web framework that supports static site generation like NextJS, Hugo, Gatsby,SvelteKit and others.

Within static content—text, forms, icons, images—you can also prioritize text and navigation elements first, then load other, larger media types. This helps give the user the basic content and context first, and prevents large media items from slowing down the initial page load. You can do this by lazy loading non-critical resources, which sets them to load only once they're needed and aren't blocking more important content from loading. For example, this can be done by setting the loading attribute on an img element to "lazy". You can also take advantage of the aspect-ratio CSS property so loading the image later doesn't cause any layout shifts.

Making a static-first site isn't just a throwback to the web's early days. It helps make your content as broadly accessible as possible today.

Despite being nearly universal, JavaScript isn't always accessible. For one, the JavaScript ecosystem suffers from a lot of bloat, and as a result, bundles sizes can get huge, eating bandwidth and slowing page load times. This is especially true for mobile users with intermittent connections (think someone trying to access a site from a subway car).

JavaScript can also be compute-heavy depending on what it's doing, and while new devices are getting more powerful every year thanks to Moore's law, there are still billions of internet devices in use that have lower storage and compute-capity. You don't want these users to bounce, or miss important content, because because your page is too slow.

Of course, if you're building a game or an application that requires JavaScript for core functionality, then creating a static-first site might be impossible. But for things like landing pages, marketing pages, or blogs (like this!)—sites that are essentially just static content—it makes sense to have a static site on first load that everyone can access and use, and that doesn't require JavaScript. If you want to test that a site is static-first, just disable JavaScript in your brower's preferences, reload the page, and see if it's still usable.

Some brave souls have even tried exclusively using the web without JavaScript with some mixed results. If others tire of the slowness and visual bombardment of JavaScript sites, and start browsing more without JavaScript, you want your site available and fully functional for them, too.

Progressive enhancement

Once you have a static site that loads fast and is usable on most internet devices, then you can enhance it and take advantage of all the dynamism JavaScript provides. This is the second part progressive enhancement: once the basic page is loaded, you progressively add features, like animation or graphics or more interactivity, for users with the browsers and bandwidth that can support them.

You can do this by having a callback function that runs once the DOM (aka the page) is first rendered with static content. This callback function could then start loading and revealing the interactive content. You could also gently animate the "enhanced" content into view so there aren't any jerky layout shifts and the page updates naturally with the new content (although you could limit the animation for those without a reduced motion preference in their browser/OS settings).

If a user doesn't have JavaScript, then the callback function obviously wouldn't run, and they would just keep viewing the static site.

In a web framework like Svelte, this callback function that runs once the page is first loaded is called onMount. So in the onMount function you could load the JavaScript-only content and then set a flag for it to be revealed.

For example, say you have a ThreeJS scene that is incorporated into a blog post (like this one). In the onMount function you could check to make sure the device supports WebGL. Then, once you know it's capable, you could load the required code for creating the scene (e.g. import ThreeJS), then run the code to render the scene, then set a flag to reveal the canvas element that it's being rendered on.

That could look like the code below, where the flag to set the ThreeJS canvas as visible is called threeSceneActive and is set to false at first:

let threeSceneActive = false;

onMount(() => {
    if (window.WebGLRenderingContext) {

        const THREE = await import('three');

        renderThreeScene();

        threeSceneActive = true;
    }

});

Then in the HTML you could wrap the canvas element in an if block so it's only visible when the threeSceneActive variable is true, which it won't be unless and until the ThreeJS scene has loaded successfully. In Svelte, the syntax for creating an if block looks like {#if condition} <conditionalHTML/>; {/if}.

You could also add an animation directive to the element that is being conditionally revealed, so it gently slides into view. In Svelte that's as simple as adding in:fly="{{y: 50, duration: 500}}" to the element which will make it move up 50 pixels over half a second when it's revealed.

The HTML for the conditional content, then, would look like this:


{#if threeSceneActive}

<canvas id="webGLCanvas" in:fly="{{y: 50, duration: 500}}"></canvas>;

{/if}

Loading the page progressively like this has benefits even for those that do have full capability to view the enhanced content. They get the base text content quickly, without being blocked by any heavy JavaScript imports. Even if there's a graphic that's important to the article, it still makes sense to give the user the text content first to give context around the graphic. It's not really hurting their experience that the graphic might be slightly delayed, especially if they're getting the text content sooner as a result.

Progressive enhancement in practice: a simple blog site

Here's how I applied the principles of progressive enhancement to this very blog site, and how you can, too. If you want, try disabling JavaScript in your browser preferences and refresh this page—you should see roughly the same site. Then try enabling JavaScript and refreshing the page again—you might see some "enhanced", though not strictly necessary, content come into view.

  • Each blog page has a pre-rendered static version that is just pure HTML and CSS, which everyone sees on the initial page load. Images are "lazy" loaded so they don't create bottlenecks.
  • All content that is only available if the device has JavaScript is wrapped in a conditional if statement (e.g. if (device.hasJavaScript) {[JavaScript only content]}). If something might not make sense without that content, then there can be an else block showing a message or other content explaining what it is the user isn't able to see so they're not confused.
    • The non-JavaScript version could also show something different: if you have an interactive demo, for example, that relies on JavaScript, then non-JavaScript users could see a gif simulating the demo which transitions to the actual demo once JavaScript is registered.
  • Where it makes sense, I animate the dynamic content into view. For example, the settings and light/dark mode buttons in the nav bar only work for JavaScript-enabled devices, so those fade into view once the page registers your have JavaScript. JavaScript-only elements that are part of the article also animate gently up into view once JavaScript is registered.
    • I use the in:fly or in:fade animation directives that come with Svelte, but there's plenty of other ways to animate with different frameworks

That's all it takes to create a progressively enhanced site. Hopefully you didn't find it too static or boring!