Turning a React app into an installable PWA with offline detection, service workers and theming.
Recently I decided to take the dive into making my web app progressive. Some of the benefits are excellent caching, sped up page load times and the ability for a user to install it “natively”.
There are definitely some gotchas and other interesting tidbits which I’ll also be covering below.
I’m using React, so I’ll assume you are too. If you want to jump in to the code, it’s all in the mixmello GitHub repo.
Let’s get started!
Setting Up Service Workers
Create-react-app provides us a couple of excellent service worker files to help us get started. They automatically configure lots of useful things like caching your webpack output. They’ll pretty much contain everything we need for our PWA.
You can get these files by running npx create-react-app my-app --template cra-template-pwa
.
This will give you two files you can move into your project, serviceWorkerRegistration.js
and service-worker.js
. Add these into /src
of your project (or use the new project provided by the command). I'm not going to deep dive into these files today as they are extremely well documented via comments.
Now we actually need to register our service worker on launch. In your app index
file, import the service worker.
import { register as registerServiceWorker } from './serviceWorkerRegistration';
Now simply run the function with registerServiceWorker();
.
A finished index file should look something like this:
import React from 'react';
import ReactDOM from 'react-dom';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import App from './App';ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);registerServiceWorker();
Service workers will only register/run in a production build, unless specifically enabled (see create-react-app documentation in the extras section below). This is because hot-reloading and service worker caching don’t mix very well! This means you won’t see the service worker running in Dev tools > Application > Service Workers
.
Offline Detection & UI/UX
Offline detection is not specifically a service worker/PWA feature, however, PWAs are ‘offline first’, meaning it’s a good idea to have code to handle offline/online state.
In my application, I decided to add a little bubble that comes down from the top of the screen and block the page. See what it looks like below (might take a few seconds to load):
To make a good user & developer experience for this feature -
- It should be a higher order component we can wrap round our whole app, for single responsibility and no code duplication
- It should prevent the user from scrolling when open
- It should be able to detect when the app is online/offline in real time
- It should be clear what’s happening
The Component
Let’s make a new folder, Offline
. Where you put it is up to you. In my app, it's in src/common/components
. I'm using SCSS, but you can continue to use whatever framework your app is using.
Create 3 new files, index.js
, Offline.js
and _offline.scss
.
index.js
provides the default export for our component:
export { default } from './Offline';
Offline.js
is our main component. The component is comprised of two main bits of functionality. 1) The window event handlers to handle network state changes and 2) the actual JSX/HTML itself. Here I'm using React 17 and hooks but you could retrofit this to a class component if needed.
Let’s start building!
export default function Offline({ children }) {
return (
<>
<div className="offline" />
{children}
</>
);
}
We’ve instantiated a new component and rendered it inside a fragment, because we don’t want to add an additional layer/container above our app’s children.
import cx from 'classnames';
import './_offline.scss';export default function Offline({ children }) {
return (
<>
<div className="offline" />
<div className={cx('offline__overlay')} />
{children}
</>
);
}
Now we have our styles import and an overlay div that will fade out the background. I’m using a library called classnames
to chain classes but you don't have to use it. Later on, we'll conditionally change the overlay styles bases on our online/offline state.
import cx from 'classnames';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';export default function Offline({ children }) {
return (
<>
<div className="offline">
<div className="offline__content">
<OfflineLogo />
<div className="offline__text">
<Text subHeading className="mt-0 mb-5">You're not online</Text>
<Text className="mt-0 mb-0">Check your internet connection.</Text>
</div>
</div>
<div className={cx('offline__overlay')} />
{children}
</>
);
}
Now we’re adding some content to our little offline bubble. Text
is a component wrapper for text elements like <p>
. I've created a dedicated SVG logo for offline, but you can use whatever you like in it's place. The mt-x
helper classes are for margin which I cover in my other article here.
import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';export default function Offline({ children }) {
const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
const previousOnline = usePrevious(online); useEffect(() => {
window.addEventListener('online', setOnline);
window.addEventListener('offline', setOffline); return () => {
window.removeEventListener('online', setOnline);
window.removeEventListener('offline', setOffline);
};
}, []); return (
<>
<div className="offline">
<div className="offline__content">
<OfflineLogo />
<div className="offline__text">
<Text subHeading className="mt-0 mb-5">You're not online</Text>
<Text className="mt-0 mb-0">Check your internet connection.</Text>
</div>
</div>
<div className={cx('offline__overlay')} />
{children}
</>
);
}
We’ve added the logic that makes it do something! We have two state variables, online
which will reflect our network state (boolean) and previousOnline
which allows us to prevent the overlay appearing on first load which we'll set up shortly.
The useEffect
hook only runs once (on first render) and sets up our window event listeners. The function that's returned will be run on page unload and will clear those same listeners. useBooleanState
is a hook provided by webrix and is a simple convenience hook for boolean manipulation.
import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';export default function Offline({ children }) {
const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
const previousOnline = usePrevious(online); useEffect(() => {
window.addEventListener('online', setOnline);
window.addEventListener('offline', setOffline); return () => {
window.removeEventListener('online', setOnline);
window.removeEventListener('offline', setOffline);
};
}, []); return (
<>
<div
className={cx(
'offline',
'animate__animated',
'animate__faster', // This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
'animate__${online ? 'slideOutUp' : 'slideInDown'}'
)}
style={previousOnline === online && online ? { display: 'none' } : void 0}
>
<div className="offline__content">
<OfflineLogo />
<div className="offline__text">
<Text subHeading className="mt-0 mb-5">You're not online</Text>
<Text className="mt-0 mb-0">Check your internet connection.</Text>
</div>
</div>
<div className={cx('offline__overlay', { 'offline__overlay--visible': !online })} />
{children}
</>
);
}
Now we’ll actually use our online
variable to do some cool stuff! Firstly, we're adding a conditional class to our overlay, which we'll style later.
Next, we’re making it a bit more shiny with animation! I’ve used animate.css to make the bubble slide in and out of the screen. It provides us some animation classnames we can use.
Finally, we’ve added a conditional style to our container, to cover the initial load when we’re connected. This prevents the bubble from appearing and immediately sliding out of view.
import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';export default function Offline({ children }) {
const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
const previousOnline = usePrevious(online); useEffect(() => {
if (!online) { return void disableBodyScroll(document.body); } enableBodyScroll(document.body);
}, [online]); useEffect(() => {
window.addEventListener('online', setOnline);
window.addEventListener('offline', setOffline); return () => {
window.removeEventListener('online', setOnline);
window.removeEventListener('offline', setOffline);
};
}, []); return (
<>
<div
className={cx(
'offline',
'animate__animated',
'animate__faster', // This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
'animate__${online ? 'slideOutUp' : 'slideInDown'}'
)}
style={previousOnline === online && online ? { display: 'none' } : void 0}
>
<div className="offline__content">
<OfflineLogo />
<div className="offline__text">
<Text subHeading className="mt-0 mb-5">You're not online</Text>
<Text className="mt-0 mb-0">Check your internet connection.</Text>
</div>
</div>
<div className={cx('offline__overlay', { 'offline__overlay--visible': !online })} />
{children}
</>
);
}
Last but not least, let’s lock scrolling. Remember the earlier requirement? When the overlay and bubble are open the user shouldn’t be able to scroll in the background. For this, we use a library called body-scroll-lock
and simply toggle the lock in our new useEffect
hook.
The Styling
Styling in SCSS is pretty simple. Here’s how we can get the result above:
@import 'vars';.offline {
position: fixed;
top: 0;
z-index: 4;
left: calc(50% - 200px);
width: 400px;
padding-top: 40px; @media only screen and (max-width: $mobile-width) {
padding-top: 20px;
} @media only screen and (max-width: 500px) {
padding-top: 20px;
width: calc(100% - 40px);
left: 20px;
} &__content {
padding: 15px 20px;
background: white;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px; > svg {
height: 50px;
width: auto;
margin-right: 20px;
}
} &__overlay {
position: fixed;
z-index: 3;
background: rgba(0, 0, 0, 0.8);
top: 0;
left: 0;
width: 100vw;
height: 100vh;
opacity: 0;
transition: opacity 0.5s ease-in-out;
pointer-events: none; &--visible {
opacity: 1;
pointer-events: unset;
}
}
}
Parts worth talking about are:
- Hardcoded
right %
, instead oftranslate
.animate.css
uses transforms to animate, so we need a different approach to center it horizontally. @import 'vars'
- this is just a file full of SCSS variables. The media query variable is just a pixel value.padding: top
instead of an actualtop
value -animate.css
usestransform: translateY(-100%)
on the container when sliding it out. If we use a top value, the component won't slide completely out of view. If we give it padding instead, we are making the component larger and therefore will all slide out, but still have the gap from the top of the screen.
Using It In Our App
You can use the component wherever you want, but I recommend as high as possible. In mine, it’s in the app index
file:
ReactDOM.render(
<React.StrictMode>
<Offline>
<App />
</Offline>
</React.StrictMode>,
document.getElementById('root')
);
Icons & Splash Screens
Manifest.json
The manifest file is used to tell platforms how we want our PWA to behave. create-react-app
creates a manifest.json
file automatically for us, in the public
folder.
{
"short_name": "name",
"name": "name",
"description": "description",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": ".",
"display": "standalone",
"background_color": "#fff"
}
short_name
- the title that's displayed on smaller areas, such as on home screens
name
- the full title of the app
description
- app description
icons
- these are icons used on an android home screen or for PWA desktop apps on desktop.These are not used on iOS PWAs (see gotchas below)
start_url
- entry point to your application. For standard React apps, this will be root, or .
display
- how should your app be displayed within a PWA container? standalone
will render full screen and give a more native experience
background_color
- loading screen background colour (such as on a splash screen). This is not the background colour of your app when loaded.
theme_color
- this dictates the color of the status bar at the top of the app, however I choose to just use the theme <meta>
tag in index.html
as I can dynamically change it (see themes below).
For my app, I took my app’s logo and turned it into a macOS-esque rounded icon, such as:
Full breakdown of the manifest.json
file can be found here. Your index.html
file should link to this manifest, with a line similar to <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
.
iOS & Gotchas
iOS still doesn’t handle PWAs very well. Your manifest file will be pretty much ignored, other than to tell iOS you support PWAs. PWAs are only supported via Safari.
iOS does not support transparency on icons. It’ll render a black background behind your icon if it’s a png. You should make special icons for iOS, with a coloured background (mine’s white), which looks like:
To use it, we’ll need the link <link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/ios-touch-icon.png">
in our index.html
file.
Splash Screens
To show a splash screen on iOS when the app’s loading, you’ll need a series of html code lines in index.html
. Unfortunately, you'll need a different sized image per supported resolution:
<link href="%PUBLIC_URL%/splash/iphone5_splash.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphone6_splash.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphoneplus_splash.png" media="(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonex_splash.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonexr_splash.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonexsmax_splash.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipad_splash.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro1_splash.png" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro3_splash.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro2_splash.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
Themes & Theme Colours
As mentioned before, we’ll control theme through index.html
and not using manifest.json
. Find out more about theme-color
and what it looks like in action, here.
Static Theme Colour
Static theme colours are easy. Simply include this line in your index.html
file. <meta name="theme-color" content="#ffffff" />
. create-react-app
provides this by default.
Dynamic Theme Colour
In your app, you might have different page colours. For example, in my app, the homepage is green, but the rest are white. I wanted the theme-color to change based on where I was. When a Modal window opens, the theme-color becomes black.
For this, you’ll need a library called react-helmet
. Helmet allows us to modify the <head>
of our document from within our components. Sweet!
To do this, simply include the <Helmet>
element in any of your components:
<Helmet><meta name="theme-color" content="#000000" /></Helmet>
We can actually extend the Offline.js
component we built earlier to make the status bar black:
<div
className={cx(
'offline',
'animate__animated',
'animate__faster', // This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
'animate__${online ? 'slideOutUp' : 'slideInDown'}'
)}
style={previousOnline === online && online ? { display: 'none' } : void 0}
> // The line below changes the theme dynamically, but only when we're offline
{!online && <Helmet><meta name="theme-color" content="#000000" /></Helmet>} <div className="offline__content">
<OfflineLogo />
<div className="offline__text">
<Text subHeading className="mt-0 mb-5">You're not online</Text>
<Text className="mt-0 mb-0">Check your internet connection.</Text>
</div>
</div>
</div>