Progressive Web Apps are web applications that take advantage of new web technologies to act and feel like a native app. Progressive web apps work for every user regardless of the browser choice, they are reliable, fast and secure. However, building a Progressive Web App from scratch with such requirements can be tedious and complex. As we will see, thanks to web components, a set of W3C standards, most of the complex logic can be encapsulated in a component allowing you to build better and faster progressive web apps.

What is the Polymer App Toolbox?

The Polymer App Toolbox is a collection of web components, tools and templates for building Progressive Web Apps. The App Toolbox includes components for layout, routing, localization and storage. It also offers a very handy command line tool which we can use to scaffold, serve and build our app, among other things.

What are we going to be building?

In this codelab, we're going to build an app to browse and watch shows from the Chrome Developer Channel using Polymer 1.0.

Try it out

The App toolbox will allow us to:

  • Architect a component-based web app using Polymer and Web Components.
  • Build the responsive layout using the app-layout components.
  • Set up the routes using app-route.
  • Configure the manifest.json.
  • Deploy service workers for offline caching.
  • Build the app bundle for production with the Polymer CLI.

What you'll learn

What you'll need

Download the Code

Click the following button to download all the code for this codelab:

Download source code

Unpack the zip file. You will see a folder (pwa-from-scratch-master), which contains one folder for each step of this codelab as a reference. We will begin the codelab by changing the current directory to pwa-from-scratch-master/work in the terminal:

cd pwa-from-scratch-master/work

Installing Bower

We will use bower to install elements and other dependencies. You can run npm install if you haven't installed bower yet:

npm install -g bower

Installing the Polymer CLI

The Polymer CLI is a command line interface for Polymer projects. It will allow us to start a new project from a template, serve the app for development and build it for production. To install the Polymer CLI, you can run npm install:

npm install -g polymer-cli

For this codelab, we will need version 0.16.x or newer. To verify the installed version, you can run:

polymer --version

Make sure your current directory is pwa-from-scratch-master/work, then let's create a new project by running:

polymer init

The CLI will ask you to choose a template. In our case, we would like to build an app, so we select application: A blank application template.

Next, we will enter the app name, the main element name and a brief description of the app. We are going to call the app Chrome Developer Channel, the main element name will be show-app and the description could be Shows for web developers.

In this case, the element show-app will contain the app shell, which will be responsible for routing and will contain parts of the navigation UI.

Once the names are entered, the Polymer CLI creates a new project and installs the bower dependencies. If we open the current working directory with our code editor of choice, we will see the structure of the app:

|── README.md
|── bower.json
|── bower_components
|── index.html
|── manifest.json
|── images
|    └── chrome-logo.svg
|── src
|    └── show-app
|         └── show-app.html
└── test
     └── show-app
          └── show-app_test.html

The CLI generated a README.md, index.html, manifest.json, bower.json, a directory named bower_components for the external dependencies and a directory named src where our app's elements will live. As we can see, the main element show-app.html has already been created for us.

Registering service worker

Service workers will allow the app to work offline. A script service-worker.js will be automatically generated by the CLI when we build the app for production in the last step. We can start by opening the index.html file and adding the following script to the head element:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', function() {
      navigator.serviceWorker.register('/service-worker.js');
    });
  }
</script>


Setting the styles

Next, let's start to style the app by adding an internal stylesheet to the head element:

<style>
  body {
    margin: 0;
    background-color: black;
    background-image: linear-gradient(to bottom, rgb(0, 0, 0) 0%, rgb(50, 50, 50) 50%, rgb(0, 0, 0) 100%);
    background-attachment: fixed;
    font-family: 'Roboto', 'Noto', sans-serif;
  }
</style>

As you can see we have set the background, margin and font family for the body element of the web app. A few comments about the styles:

From now on, we can view the app in the browser by simply running:

polymer serve --open

By default, the CLI uses port 8080. If it's already in use, you can change it by specifying the -p flag followed by the port number. For example: polymer serve -p 3000 --open.

The --open flag opens a new tab in your default browser. The URL should be http://localhost:port-number/; http://localhost:8080/ if using the default port.

Congrats! You have successfully set up the app. We are ready to start working on the app shell in the next step!

The app shell will contain a header and will have logic to switch pages based on a given route. Because the app shell sits in the front line of the critical rendering path, we want to make it as light as possible. Furthermore, one technique we can use to reduce the overhead is to lazily load resources such as pages or elements that aren't necessary for the first meaningful paint; that is, displaying relevant content to a user action as fast as possible.

Installing the dependencies

To start working on the app shell you will need to install a few dependencies using bower:

bower install PolymerElements/app-layout#^0.10.0 PolymerElements/app-route#^0.9.0 PolymerElements/iron-flex-layout#^1.0.0 PolymerElements/iron-pages#^1.0.0 PolymerElements/paper-icon-button#^1.0.0 --save

Copying the seed element

Open src/show-app/show-app.html and paste the content from the seed element pwa-from-scratch-master/resouces/show-app.html or run:

cp ../resources/show-app.html src/show-app/show-app.html

The seed element contains the CSS, so we don't need to worry about writing all the CSS rules for the show-app element.

Adding the imports

Open src/show-app/show-app.html and import the dependencies into the <show-app> element (below the first import):

<link rel="import" href="../../bower_components/app-layout/app-header/app-header.html">
<link rel="import" href="../../bower_components/app-layout/app-toolbar/app-toolbar.html">
<link rel="import" href="../../bower_components/app-route/app-location.html">
<link rel="import" href="../../bower_components/app-route/app-route.html">
<link rel="import" href="../../bower_components/iron-flex-layout/iron-flex-layout.html">
<link rel="import" href="../../bower_components/iron-pages/iron-pages.html">
<link rel="import" href="../../bower_components/paper-icon-button/paper-icon-button.html">

Coding

Adding the routes

We will use app-route, an element that enables declarative, self-describing routing for a web app. To start adding the routes to our app, we insert the app-location and app-route elements to the template of the show-app element:

<app-location route="{{route}}"></app-location>
<app-route
    route="{{route}}"
    pattern="/:page"
    data="{{routeData}}"
    tail="{{subroute}}"></app-route>

The app-location produces a route value. Then, the route.path property is matched by comparing it to the pattern property /:page. If the pattern property matches route.path, the app-route will set or update its data property with an object whose properties correspond to the parameters in pattern. For example, if the route is /video, the routeData.page property will equal video. For more about app-route, check the elements catalog.

Adding the header

Next, we are going to start working on the header. App-layout gives us app-header and app-toolbar, which we can use to display the logo and the app name. Below the app-route line, add the following snippet:

<app-header condenses reveals threshold="1">
  <app-toolbar>
    <paper-icon-button id="leftItem"></paper-icon-button>
    <a href="/" title="Developer Channel">
      <!-- svg logo -->
      <h1 class="title">Developer Channel</h1>
    </a>
  </app-toolbar>
</app-header>

As we can see, the app-header element has the attributes condenses and reveals which will allow the header to slide in and out as the user scrolls the main content. It also has the threshold attribute, which will allow us to animate the Chrome logo when the user has scrolled at least one pixel:

If you look at the CSS that came with the seed element, you will see the selector [threshold-triggered], which we use to fire the animation. The threshold-triggered attribute is added or removed by app-header depending on whether the user has scrolled the distance specified in the threshold attribute. In our case, that distance is one pixel.

Next, we will inline the SVG for the Chrome logo by adding the content of the file images/chrome-logo.svg below the comment <!-- svg logo -->.

Finally, we will add the class chrome-logo to the svg element:

<svg class="chrome-logo" xmlns="http://www.w3.org/2000/svg" viewBox="15.5 15.5 224.5 224.5">
....
</svg>

Adding the pages

To add the pages, we can insert the iron-pages element below the app-header element:

<iron-pages id="pages"
    role="main"
    selected="[[routeData.page]]"
    selected-attribute="active"
    attr-for-selected="name"
    fallback-selection="show">
  <show-list-page name="show"  
      subroute="[[subroute]]"></show-list-page>
  <show-video-page name="video"   
      subroute="[[subroute]]"></show-video-page>
</iron-pages>

The iron-pages element is used to select one of its children as the visible page. Notice that the selected page is determined by app-route since we are binding to routeData.page. The selected-attribute will allow us to set the active attribute to the page element that is currently selected (we will see why this is useful later). The page elements are selected by the name attribute, and the fallback selection is show.

Inside iron-pages, we have two elements that will be used for the two pages of our app.

  1. show-list-page will show the list of videos.
  2. show-video-page will let users watch a video.

With the current structure, the app shell will handle the following routes:

  1. /shows/* renders <show-list-page>
  2. /video/* renders <show-video-page>

Anything else will fallback to <show-list-page> thanks to the fallback-selection attribute we set in iron-pages. Notice that the subroutes are passed down to the corresponding element via the subroute attribute. For example, if the user visits shows/polycast, it would be <show-list-page> responsibility to figure out how to show videos from the polycast show.

Configuring the toolbar

For this app, we will need to configure the toolbar depending on the current active page or the screen size. For example, in small screens we would like to show a hamburger menu in the list page, so users can tap on it to get to the main navigation. On the other hand, we would like to show a back button in the video page to allow users go back to the list page regardless of the screen size.

However, the toolbar lives in the app shell; that is, outside the pages. Therefore, we need a general mechanism to communicate what goes in the toolbar from the active page. Custom Events allow us to do exactly that; send a message from an element to any of its ancestors listening to a given event.

In the app, the active page can fire an event, e.g. setup-toolbar that tells the app shell to change the icon and the onclick action on behalf of the page. To do that, we are going to insert this code into the element's definition of show-app:

listeners: {
  'setup-toolbar': '_setupToolbar'
},

_setupToolbar: function(e) {
  this.$.leftItem.onclick = e.detail.leftItemClickAction;
  if (e.detail.leftItemIcon) {
    this.$.leftItem.setAttribute('icon', e.detail.leftItemIcon);
  } else {
    this.$.leftItem.removeAttribute('icon');
  }
}

The event's detail is used to pass the configuration and it will contain the following properties:

Let's refresh http://localhost:8080 to see all the progress we have made so far:


Icons

As we saw in the previous step we will use two icons in the toolbar: menu and arrow-back. We start by installing iron-iconset-svg:

bower install PolymerElements/iron-iconset-svg#^1.0.0 --save

Next, we create a file to add the icons:

touch src/show-app/show-icons.html

Open that file with your editor of choice and paste the following code:

<link rel="import" href="../../bower_components/iron-iconset-svg/iron-iconset-svg.html">

<iron-iconset-svg name="icons" size="24">
<svg><defs>
<g id="menu"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></g>
<g id="arrow-back"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></g>
</defs></svg>
</iron-iconset-svg>

This SVG contains both the menu and arrow-back icons. Save it, and we have got the icons using iron-iconset-svg.

Pages

We are going to copy the seed elements from the resources folder:

cp ../resources/show-list-page.html src/show-app/show-list-page.html
cp ../resources/show-video-page.html src/show-app/show-video-page.html


Putting it all together

Let's open the app shell in src/show-app.html and add the imports to the files we just created:

<link rel="import" href="show-icons.html">
<link rel="import" href="show-list-page.html">
<link rel="import" href="show-video-page.html">

The list page allows users to browse videos from the Chrome Developer Channel and filter them by show name. To get the video list, we will use the YouTube Data API, which will allow us to query the latest videos from the channel. Take a look at the response body from the search endpoint.

As you can see in the payload, the results are grouped by pages, each up to 20 videos. The payload also provides a token nextPageToken which we will use to retrieve the next page.

Given those first 20 videos, the app will render them as soon as the route / or /show/* is active. Next, we will load the next page of videos when the user scrolls near the end of the list. In other words, we will implement an infinite list of videos using two very handy Polymer elements: iron-list and iron-scroll-threshold.

Installing the dependencies

bower install PolymerElements/iron-ajax#^1.0.0 PolymerElements/iron-list#^1.0.0 PolymerElements/iron-scroll-threshold#^1.0.0 PolymerElements/iron-selector#^1.0.0 PolymerElements/iron-media-query#^1.0.0 --save

Coding

Adding the dependencies

Now we can add the dependencies we just imported along with a few others that will be required in show-list-page:

<link rel="import" href="../../bower_components/app-layout/app-drawer/app-drawer.html">
<link rel="import" href="../../bower_components/iron-flex-layout/iron-flex-layout.html">
<link rel="import" href="../../bower_components/iron-ajax/iron-ajax.html">
<link rel="import" href="../../bower_components/iron-list/iron-list.html">
<link rel="import" href="../../bower_components/iron-scroll-threshold/iron-scroll-threshold.html">
<link rel="import" href="../../bower_components/iron-selector/iron-selector.html">
<link rel="import" href="../../bower_components/iron-media-query/iron-media-query.html">

Getting the videos with iron-ajax

We will use iron-ajax to get the videos from the YouTube Data API, so add the following code to the HTML of the element's template (anywhere in the <template>):

<iron-ajax
        auto
url="https://www.googleapis.com/youtube/v3/search?part=snippet&q=[[show.q]]&channelId=UCnUYZLuoy1rq1aVMwx4aTzw&order=date&maxResults=20&pageToken=[[pageToken]]&key=AIzaSyA053VCd78p0v2Pica01W_ljGUSAfzl0vA"
        handle-as="json"
        on-response="_onXhrResponse"></iron-ajax>

Notice that the URL is dynamically computed based on the values of show.q and pageToken. Because we are using the auto property, any changes to show.q or pageToken will result in a new network request. Finally, the function _onXhrResponse will handle the response from the YouTube API to push the videos to the list.

Adding the responsive layout

Let's add the following properties to the properties block:

/* whether the user is on a small screen device*/
smallScreen: Boolean,
/* whether the drawer is opened or not */
openedDrawer: {
  type: Boolean,
  value: true
},
/* whether the drawer is always visible */
persistentDrawer: {
  type: Boolean,
  value: true
 },
/* the list of shows */
shows: {
  type: Array,
  value: function() {
     return [
       { name: 'All', subroute: 'all', q: '' },
       { name: 'Polycasts', subroute: 'polycast', q: 'polycasts' },
       { name: 'Totally Tooling Tips', subroute: 'tips', q: 'totally tooling' },
       { name: 'A11ycasts', subroute: 'a11ycasts', q: 'a11ycasts' },
       { name: 'Supercharged', subroute: 'supercharged', q: 'supercharged' }
     ];
  }
},

Now we can start working on the navigation drawer. The drawer will contain the list of shows users can filter videos by. For this codelab, we will use app-drawer, an element part of app-layout that displays a responsive navigation drawer.

<app-drawer
    opened="{{openedDrawer}}"
    persistent="[[persistentDrawer]]">
  <ul class="nav" role="navigation">
    <h2>Shows</h2>
    <iron-selector selected="[[show.subroute]]" attr-for-selected="name" selected-class="active">
      <template is="dom-repeat" items="[[shows]]" as="show">
        <li name="[[show.subroute]]"><a href="/shows/[[show.subroute]]">[[show.name]]</a></li>
      </template>
    </iron-selector>
  </ul>
</app-drawer>

Notice that we have a few bindings that we haven't defined yet. We will soon see how everything comes together.

Next, we will detect small screen sizes to set up the layout correctly using the iron-media-query element.

<iron-media-query
    query="max-width: 1280px"
    query-matches="{{smallScreen}}"></iron-media-query>

Adding the infinite list

The infinite list let us efficiently add more videos when the next page from the YouTube API has been fetched. Polymer has iron-list, an element which ensures users have a smooth scrolling experience even on a long list of videos. In this app, iron-list will be in grid mode and it will use the main document as the scroll target:

<iron-list id="list" scroll-target="document" items="[[items]]" grid>
  <template>
    <div class="grid-item">
      <a href="/video/[[item.id.videoId]]" style$="background-image:url([[item.snippet.thumbnails.high.url]]);">
        <div class="title">[[item.snippet.title]]</div>
      </a>
    </div>
  </template>
</iron-list> 

Next, iron-scroll-threshold will let us set a scroll position threshold, so the function _onLowerThreshold will be called when lower-threshold is reached. That is, when the user reaches the end of the scrollable content, the function _onLowerThreshold will update the token (pageToken) to fetch the next page.

Lastly, we add a loading indicator that will show a message when a new page of videos is being fetched from the YouTube API.

<iron-scroll-threshold
    id="scrollThreshold"
    scroll-target="document"
    lower-threshold="0"
    on-lower-threshold="_onLowerThreshold"></iron-scroll-threshold>

<div class="loading-indicator" off$="[[lastPage]]">More videos to come...</div>

Next we add a computed property to the properties block so we can determine which show is currently active. We will get the show name from the subroute property that was passed down from the app shell.

subroute: Object,
show: {
  type: Object,
  computed: '_getActiveShow(shows, subroute)',
  observer: '_showDidChange'
},

Notice that the show property also has an observer. In that observer, we will refresh the list of videos and close the navigation drawer.

_getActiveShow: function(shows, subroute) {
   var key = subroute.path ? subroute.path.substr(1) : 'all';
   var res = shows.filter(function(show) { return show.subroute == key; });
   return res[0] ? res[0] : shows[0];
},
_showDidChange: function() {
  this.pageToken = '';
  this.lastPage = false;
  this.openedDrawer = this.persistentDrawer;
}

Updating the toolbar

In the app shell we added a listener to the setup-toolbar event, so we can set the configuration from the list page. Also, it's worth remembering that the active property is set by iron-pages in the app shell to the currently active page. We can use that property to configure the toolbar. When active = true, the list page is currently active.

Let's add the active property to the properties block:

active: Boolean,

The purpose of the hamburger menu is to open the navigation drawer, but in large screen devices the navigation drawer is persistent or visible the entire time. For that reason, we can check whether the user is on a small screen device in order to make the hamburger menu icon visible. Let's include an observer to the active and smallScreen properties by adding the following code the element's definition:

observers: [
  '_updateToolbar(active, smallScreen)'
],
 _updateToolbar: function(active, smallScreen) {
  if (active) {
    this.fire('setup-toolbar', {
      leftItemIcon: smallScreen ? 'menu' : '',
      leftItemClickAction: function() { this.openedDrawer = true; }.bind(this)
    });
  }
  this.persistentDrawer = !smallScreen;
  this.openedDrawer = !smallScreen;
},

Updating the list

We have already added iron-ajax to the element's template. Now, we add the remaining properties to the properties block:

items: {
  type: Array,
  value: function() {
    return [];
  }
},
nextPageToken: String,
pageToken: {
  type: String,
  value: ''
},
lastPage: Boolean,
currentShow: String,

Next, the function that will handle the response bits from iron-ajax:

_onXhrResponse: function(e) {
  var response = e.detail.response;
  var items = response.items;
  if (items.length === 0) {
    this.lastPage = true;
  } else {
    if (this.currentShow != this.show) {
      this.set('items', []);
    }
    this.totalResults = response.pageInfo.totalResults;
    if (!response.nextPageToken) {
      this.lastPage = true;
    } else {
      this.nextPageToken = response.nextPageToken;
    }
    this.currentShow = this.show;
    items.forEach(function(item) {
      this.push('items', item);
    }, this);
  }
  this.$.scrollThreshold.clearTriggers();
},

Lastly, the function that will update the page token when the user reached the end of the scrollable list:

_onLowerThreshold: function() {
  this.pageToken = this.nextPageToken;
},

If you refresh localhost:8080, you will see the list page in action:

Congrats! The app starts to look real.

We are about to start working on the video page. This page is much simpler than the list page, so keep up the good work!

The video page will let users watch videos from the Chrome developer channel using the YouTube player. In this codelab, we will use the google-youtube element to display the video player.

Installing the google-youtube element

bower install GoogleWebComponents/google-youtube#^1.0.0 --save

Coding

Adding the imports

Let's open src/show-app/show-video-page.html and add the HTML import for the google-youtube element we just downloaded:

<link rel="import" href="../../bower_components/google-youtube/google-youtube.html">

Adding the HTML

Next we add the HTML needed to display the video. Add the following HTML snippet to the element's template (anywhere inside <template>):

<div class="video-frame">
  <google-youtube
     width="100%"
     height="calc(100vh - 64px)"
     video-id="[[videoId]]"
     rel="0"
     start="5"
     autoplay="1"></google-youtube>
</div>

Notice that google-youtube receives a videoId property. We will get the video's id from the subroute that was passed down from the app shell (the show-app element) in step 4.

Extracting the video id

To determine the videoId we will use a computed property, whose value depends on the subroute property:

properties: {
  subroute: Object,

  videoId: {
    type: String,
    computed: '_getVideoId(subroute)'
  },
}

Next, we add the _getVideoId function to the element's definition to extract the video id from the subroute:

_getVideoId: function(subroute) {
  return subroute && subroute.path ? subroute.path.substr(1) : '';
},

Nicely done! We have completed the routing portion of the video page. In the last step, we will configure the toolbar using the setup-toolbar event we discussed earlier.

Updating the toolbar

Let's update the toolbar when the active property has changed. To do that we add the property to the properties block of the element's definition:

active: {
  type: Boolean,
  observer: '_activeDidChange'
},

Lasly we add the function _activeDidChange to the element's definition where we will change the icon to arrow-back and the onclick action to go back in history:

_activeDidChange: function(active) {
  if (active) {
    this.fire('setup-toolbar', {
      leftItemIcon: 'arrow-back',
      leftItemClickAction: function() { window.history.back(); }
    });
  }
},

Now if you refresh localhost:8080 and click on a video, you can see the video playing and the back button we just set up.

Now, we can enjoy the next episode of Polycast from the comfort of our own web app!

The build step consists in generating a production-ready build of the app. This process includes minifying the HTML, CSS, and JS of the application dependencies, and generating a service worker to pre-cache dependencies.

polymer.json

This is a file that lives at the top-level of the project and contains the build configuration. We start by creating an empty file:

touch polymer.json

Next we add the configuration to the polymer.json file to indicate the main entrypoint, the app shell and a few more:

{
  "entrypoint": "index.html",
  "shell": "src/show-app/show-app.html",
  "fragments": [
    "src/show-app/show-list-page.html",
    "src/show-app/show-video-page.html"
  ],
  "sourceGlobs": [
   "src/**/*",
   "bower.json",
   "app.yaml"
  ],
  "includeDependencies": [
    "manifest.json",
    "images/*",
    "bower_components/webcomponentsjs/webcomponents-lite.js"
  ]
}

sw-precache-config.js

sw-precache-config.js is a file that stores the configuration for the service worker. Configuration properties such as static files, navigation fallbacks and the network strategies are just a few of the options that are supported. For more information, you can check the docs for sw-precache or complete the codelab dedicated to sw-precache.

Let's start by creating the emptyfile:

touch sw-precache-config.js

And then we copy the following configuration:

module.exports = {
  staticFileGlobs: [
    '/index.html',
    '/manifest.json',
    '/bower_components/webcomponentsjs/webcomponents-lite.js'
  ],
  navigateFallback: '/index.html',
  navigateFallbackWhitelist: [/^(?!.*\.html$|\/data\/).*/],
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/i\.ytimg\.com/,
      handler: 'fastest',
      options: {
        cache: {
          maxEntries: 100,
          name: 'yt-images-cache'
        }
      }
    },
    {
      urlPattern: /^https:\/\/www\.googleapis\.com\/youtube\/v3\/search/,
      handler: 'networkFirst',
      options: {
        cache: {
          maxEntries: 100,
          name: 'yt-data-cache'
        }
      }
    }
  ]
};

In this file the configuration goes as follows:

Using the CLI

Now, we are ready to build the app by running the following command:

polymer build

If it was built successfully, stop any previously run polymer serve and serve the bundled build:

polymer serve build/bundled

Now, if you visit http://localhost:8080, you will be looking at the built version. This means a few things:

Clearing the caches

The service worker has cached the app shell and its dependencies which means you need to clear the browser caches when making changes to the build files. To do that, open DevTools while on localhost:8080, go the Application panel / Clear storage, mark "Unregister service worker", all the caches and press Clear site data.

Now that we have the production-ready build, we can deploy it to our cloud provider of preference. For this codelab, this app has been deployed to Google App Engine https://pwa-codelab.appspot.com