TL;DR: Try out the ShadowReader, and browse the source code.

I’m happy to introduce the ShadowReader, a (fairly) production-ready, vanilla-JS implementation of the PWAMP pattern combining AMP and PWA. I’ve posted some high level details and motivation why I’ve built it on the AMP blog, but here are some highlights:

  • The entire app (minus fonts, loaded data and AMP) weights less than 10kb
  • We can keep the shell lean and simple, as AMP manages article rendering
  • Using your AMP pages as data source means you don’t have to start from scratch when building your PWA

Show me the money magic/demo/code!

Instead of a simplistic view with fake articles, the app uses real AMP pages from The Guardian (thanks to the Guardian for allowing me to use them as example!), and I didn’t cut corners:

  • Fully accessible (including keyboard and screen reader navigation)
  • SEO-friendly, with URL-based navigation
  • Skeleton UI for perceived performance
  • FLIP-based animations for actual performance
  • Comes with polyfill to support other browsers (Safari should work fine, Edge support is coming)

The completed application source code along with build instructions is available at ampproject/amp-publisher-sample/amp-pwa-reader. To follow the upcoming tutorials, I’m creating step-based branches that only contain the stuff needed at start (first branch for next week’s step #1).

And browse to https://amp.cards on your phone, install it on your homescreen etc. to play with the app. If you find bugs, of which I’m sure there are many, file them here.

Navigating accessibility

To be screen-reader and keyboard friendly, in addition to generally sensible markup (buttons, links etc.) and using friendly browser events (click), the app goes further in three areas:

  1. Managing focus state
  2. Managing focusable elements
  3. Managing screen reader roles

Managing the focus state is required to improve keyboard-based navigation, the hamburger button, menu items and cards have a special focus styles – try tabbing through the interface. When opening the sidebar menu, the app shifts the focus on the first menu item, when closing it again, it restores focus to the first card in the main view.

If that was all, you could tab into the menu items even if the menu isn’t open – not great. To combat that, we need to manage the focusable elements at any given time. When you close the menu, the app adds a tabindex of -1 to all menu items, effectively disallowing tabbing into them, and opening the menu again removes the tabindex attribute. This way, the menu is actually invisible to the keyboard if not visible.

Since the thumbnail image in the cards is an actual <img> tag (to animate it effectively, more later), a screen reader reads it out. However we don’t have a meaningful alt-text for the image, and it doesn’t improve screen reader navigation. To solve it, all thumbnails have a role="presentation" attribute, which hides the image from screen readers.

URL-based navigation with and without pushState

As a serverless SPA (short for single page application), all links to the app go to the same index.html file. By default, any navigation within the app would not result in an updated URL, which makes sharing impossible, as well as crawling and indexing the content as search engine.

To solve this, the app uses a combination of the History API and good old URL parsing:

  • When navigating categories and clicking on articles, we change the URL via history.pushState
  • Using the popstate event, we restore to either the relevant article or category view

That’s not enough though, as a “cold start”, e.g. without any history state, won’t produce a popstate and won’t give you a history.state object to work with. In that case, we have to fall back to URL parsing, so the URL needs to contain enough information to restore the view. To see how it’s done, check out the History class, the initialization logic and the popstate handling.

All combined, it means that URL-based navigation simply works, including back buttons and cold starts.

View-dependent skeleton UIs

The default skeleton UI shipped with the app is the card view, which means that loading an URL that contains an article would first show the card view skeleton UI, then the article. Of course, if the skeleton UI is totally different from the layout of the actual loaded content, there’s no perceived load improvement, making it unless. So how do we solve it?

The ShadowReader has a tiny bit of state-parsing code – just enough to understand if the URL is loading the card view or article view – right below the <body> element:

<body>
<!-- Load critical state-parsing JS to show the right skeleton UI at the right time -->
<script>
/^\/[^\/]+\/[^\/]+\/[^\/]+/.test(location.pathname) && document.body.classList.add('sr-show-article-skeleton');
</script>

This means that before you even get to a flash of unstyled or irrelevant content, and before all other JS is loaded, the right skeleton is shown to the user.

FLIP-based card transitions

In theory, styling the cards is straight forward: A containing element, a background image with background-size set to contain, a h2 and an optional <p> below it. What’s coolest, animating the element to a different size could be done with just one line of CSS, as the CSS contain magic would automatically recenter the image at the right location. And indeed, that was how my first prototype I showed at Google I/O functioned. There was just this tiny problem. The performance.

Animating any properties short of transform and opacity is ultimately going to give you a hard time, as these animations can’t be GPU accelerated, and this means that while visually correct, the card animations were quite sluggish on mobile devices, usually in the 15-20 fps range. To fix this, we have to use Paul Lewis’ FLIP technique, which sadly makes our code way more complicated than it should be:

  • The image is back to being a simple <img> and we need to contain it manually via JS
  • The h2 and <p> is in another container next to the image
  • We animate the card by changing the transform of it to the final, zoomed in size, which skews the other elements inside
  • At the same time, we un-skew the inner elements by animating them in reverse, with a counter-transform

I’m doing it from scratch with help from Web Animations, but you could use Paul Lewis’ flip.js helper library to make your life a little easier.

API-less design: RSS feed-based navigation

To drive the shell navigation, I needed the get category-based data to fill the card views. In your own production environment, you’d ideally create a lean, JSON-based category feed for this. I didn’t have that, and needed to access The Guardian’s data from the outside. The Guardian has RSS feeds for every site category, so I built a FeedReader class that queries YQL with the relevant RSS feed URL, returning a CORS-enabled JSON feed with all the right info. Neat!

Global UI configuration via CSS variables

CSS Variables are used in the app to globally control the easing and duration of animations. But not all animations are CSS-driven, the FLIP animation is done via Web Animations in JS, so what now?

Easy, we add a ES6 getter to the relevant class that imports the variables from CSS, like so:

get cssVariables() {

  if (!this._cssVariables) {
    let htmlStyles = window.getComputedStyle(document.querySelector("html"));
    this._cssVariables = {
      animationSpeedIn: parseFloat(htmlStyles.getPropertyValue("--animation-speed-in")) * 1000,
      animationSpeedOut: parseFloat(htmlStyles.getPropertyValue("--animation-speed-in")) * 1000,
      easing: htmlStyles.getPropertyValue("--animation-easing")
    };
  }

  return this._cssVariables;
}

Now we can reference them anywhere by calling something like this.cssVariables.animationSpeedIn.

Drag- and slidable sidebar

Those who know my past work know I’ve spent plenty of time with drag & drop on the web. For the ShadowReader, I wrote a DragObserver class that is a streamlined version of jQuery UI’s mouse widget, written in a future-proof way using pointer events.

As a component, this doesn’t do drag & drop itself, but allows you to build drag interactions – in my case, I wanted to support being able to “drag in” the menu by swiping. Here’s how it’s done:

this.dragObserver = new DragObserver(document, { axis: 'x' });
var wasOpen = false;
var delta = 0;

this.dragObserver.bind('start', () => {
  wasOpen = document.body.classList.contains('sr-nav-shown');
  this.element.classList.add('sr-disable-transitions');
});

this.dragObserver.bind('move', (position) => {
  delta = position.x;
  let x = Math.max(-200, Math.min(position.x, 200) - (wasOpen ? 0 : 200));
  this.element.style.transform = 'translateX(' + x + 'px)';
});

this.dragObserver.bind('stop', () => {
  this.element.classList.remove('sr-disable-transitions');
  this.element.style.transform = '';
  if (Math.abs(delta) > 70) {
    this[wasOpen ? 'hide' : 'show']();
  }
});

The axis option makes the events only trigger when dragging left or right. When starting a drag, we first check whether the menu was already open, then add a class to the nav that disables transitions during drag, as they would interfere. During the drag itself, the observer is firing move events, giving us the relative position delta. Whether it was shown or not, we constrain it to its natural boundaries, then set a transform to actually move it along. Finally, when releasing the mouse or finger, we re-enable transitions, unset our manual transform, and depending on the current position we either call hide() or show, which sets the relevant class and triggers the transition to ‘snap’ into place.

Effective view initialization with lazy re-connection to other views

When the app realizes it needs to load an article based on the URL state, it initially skips populating the card based view to load the article right away. But if that’s the case, what happens if you click the back button in the UI?

To handle this case, the app is lazy loading the relevant card view right after the article is loaded, then attempts to reconnect the article with the relevant card retroactively. This results in the back button to simply work as expected: Animating back to the relevant card.

Delegating to AMP to render articles

And of course, the real magic happens when fetching the AMP pages via XMLHttpRequest as XML doc, then sanitizing it and handing it over to Shadow AMP to render. Walk through the Article class and read our docs on how to connect AMP and PWA to learn more about how it’s done.


Phew! There’s much more to be found in the code, and I recommend to spend a little time with it. It comes with plenty of commentary and is vanilla JS for a reason, so that you don’t have to spend time learning a new framework. To actually follow along with the upcoming tutorial, subscribe via RSS to this blog or follow me on Twitter.

Finally, many thanks to The Guardian for not only allowing me to their data for this sample, but also helping me get some finishing design touches in, Rob for his great accessibility review and everyone else you helped alpha test!