Building a PWA with Sveltekit

Building a PWA with Sveltekit

Do you feel the same way about the App Store as I do? That – from a web developer’s perspective – it feels like an overly complicated, locked-in mess. I’ll do my best to avoid it.

Still, I often have the need to create something that delivers a native mobile experience. But I want it to be open and use the web platform! Not App Store and spend days setting up signing certificates, keys, waiting for approval, and so on.

Sure, you can build a mobile app with web tech and then use something like Capacitor or Tauri, but I still have to do the dreaded App store dance.

So the only remaining hope is a progressive web app (PWA) – defined by MDN as:

Like a website, a PWA can run on multiple platforms and devices from a single codebase. Like a platform-specific app, it can be installed on the device, can operate while offline and in the background, and can integrate with the device and with other installed apps.

PWA’s have so much potential! But sadly is being held back, mainly by Apple who doesn’t want any threats to their app monopoly. Understandable? Maybe a tiny bit. But at the same time Apple feels like the type of company who should help and celebrate innovation. And in my opinion, the App store and unrestricted PWA’s would be able to co-exist just fine.

Despite this – or maybe because of this - Every now and then, I start to build a PWA to see what is possible. Prior attempts have always ended in some kind of disappointment. But since last time (early 2023), quite a lot has happened!

CSS has been on a Victory parade lately, with an abundance of new useful things, and many parts of it have made it all the way to iOS. View Transitions is one of them, to make smooth, native app-like transitions. Another exciting thing, while not entirely new, iOS Safari can since iOS 16.4 receive push notifications!

Mobile web push notifications felt like utopia for a long time. This was very close to be a short-lived happiness though, since Apple tried to kill it (in EU), claiming it wasn’t possible to support anymore. But massive pushback followed, and they didn’t have any choice but to back down on their plans.

So what does it take to come somewhat close to a native app, but only with the use of web tech? In this blog post, I will try to summarize what I learned along the way, and pitfalls to avoid, in the – still – somewhat messy world of PWA’s. This will mostly apply to Safari and iOS. Android is another story, but probably an easier one.

In my case I use Sveltekit, and the first thing you want to do after setting up a new project is to add all the PWA specific things. There are quite a few PWA starterkits out there if you wanna take a shortcut.

First up is to add a service worker, which acts as proxy server to handle network requests inside your app. It also opens up for offline capabilities. But even if you need that or not, it improves performance by caching css and js to speed up your app. Furthermore, a service worker is required to make your app handle push notifications. Don’t worry, it may sound complex, but once it’s there, you don’t really have to think about it more.

In addition, your app also requires a manifest.json file – a JSON file that describes your web app. It should include the name of your app, icons, theme colors, and behavior when installed on a device. It ensures your PWA is properly recognized and integrated into your operating system, to behave close to a native app.

Many properties that were traditionally defined using HTML meta tags can now be specified in the manifest.json. However, since Apple’s PWA support is lagging a bit behind, some properties like apple-mobile-web-app-capable and apple-mobile-web-app-status-bar-style must still be defined using tags 😒.

The display property is particularly important. Set it to standalone for the most native-like appearance, and to play along well with other properties, such as theme_color.

The last critical thing is that you need to serve your app over https (except for localhost in your dev environment). Https should be used anyway, PWA or not, but in this case, it is a requirement.

Let’s move on to more code-specific topics and some tricks you can utilize to actually blur the line between a native app and your web app in a PWA costume.

Your app probably has some forms and input fields that the user will interact with. If not – lucky you! The virtual keyboard and how it effects your UI, is probably the first thing that will drive you nuts.

In my app, there’s an input field that is fixed to the bottom of the screen with position: sticky. I started with a grid container with three children, something like this:

<div class="grid h-dvh grid-rows-[auto,1fr,auto]">
	<header>My app</header>
	<main class="overflow-y-scroll">Scrollable main content area</main>
	<footer>
		<input type="text" />
	</footer>
</div>

Here we have a full-height container height: 100dvh;. The header and footer adapts in height to its content, and the main element takes up all the remaining space. overflow-y:scroll means that it is a scrollable container should the content overflow.

This all works very well until you tap the input to give it focus, which brings up the virtual keyboard. Still no problem at this point. But, when your typing business has finished and the keyboard closes, you are left with a gap between main and footer. WTF? The screen does not really go back its previous state.

The only remedy I have found that doesn’t involve weird hacks is to simply get rid of height: 100dvh for the container and overflow-y: scroll for the main element. Then just use the good old body for the scrolling part. And with the help of position:sticky for both header and footer, you are good to go. This acutually feels like a cleaner solution anyway, but before we had position: sticky you were forced to use position: fixed that has its own set of drawbacks.

But sticky isn’t without its own flaws. In my specific case I have an input field in the footer. When it receives focus, Safari tries to scroll to it, even if it’s not needed – a sticky element already is in-view, duh!

The result is that the page is scrolled all the way to the bottom, and our scroll position is lost. So we have to manually save the scroll position before the keyboard is launched, and then restore it when the keyboard is hidden again.

For this we can use a quite new browser API called the Visual Viewport API. In the docs it’s described like this:

The visual viewport is the visual portion of a screen excluding on-screen keyboards, areas outside of a pinch-zoom area, or any other on-screen artifact that doesn’t scale with the dimensions of a page.

Perfect in our case! We can use the resize event of window.visualViewport to figure of if the keyboard is visible or not.

The code looks like this:

let y_pos = 0
let keyboard_is_open = false

window.addEventListener('scroll', () => {
	if (!keyboard_is_open) {
		y_pos = window.scrollY
	}
})

if ('visualViewport' in window) {
	const default_height = window.visualViewport.height
	window.visualViewport.addEventListener('resize', () => {
		keyboard_is_open = default_height > window.visualViewport.height
		if (!keyboard_is_open) {
			window.scrollTo({
				top: y_pos,
				left: 0,
				behavior: 'smooth'
			})
		}
	})
}

Let’s break it down a bit. First we add an event listener that’s saves the scroll position. As a sidenote, I would much rather have used the scrollend event here, which only fires when the scrolling has stopped, and not repeatedly through out the scroll. But Safari has no support for it yet, bummer.

We then add another listener for the resize event of the visualViewport. In the callback, figure out if the keyboard was opened or closed by comparing the original viewport height to the current one. If the keyboard was closed - scroll the window to the last saved position.

That’s it - a bit hacky, but it’s necassary to not ruin the user experience.

Another thing to tackle is the annoying behavior of text sometimes being selected by mistake when the user interacts with the screen. In my case, I use the press event, which is like a tap but without releasing your finger. In that case, it’s almost certain that text is selected accidentally.

I handled this by - in addition to press - using the pressdown and pressup events.

On pressdown I add the class pressing to the body, and on pressup i simply remove it. Then in my css, I have the following selector:

body.pressing {
	user-select: none;
	-webkit-user-select: none;
	touch-action: none;
}

So when the class of pressing is present on the <body> no text is selectable. In addition to that I also disable contextmenus.

Simple, but still quite nifty. Here is the complete code (in Svelte):

<script>
function handleContextMenu(event: MouseEvent) {
  event.preventDefault() // Disable context menu for pressable elements
}

function onpressdown() {
  document.body.classList.add('pressing')
  document.addEventListener('contextmenu', handleContextMenu)
}

function onpressup() {
  document.body.classList.remove('pressing')
  document.removeEventListener('contextmenu', handleContextMenu)
}
</script>

<button
  onpress={() => {
    doSomething()
  }}
  {onpressdown}
  {onpressup}
>
  Button text
</button>

It might be tempting to just use user-select: none; from start. But that’s not very accessible, and chances are your users will be upset when they realize they won’t be able to copy any text at all.

At this point, my PWA feels quite good and has a clearly native feel to it. The only thing left, that I can’t seem to get around, is to make link opens in a PWA instead of in the browser.

Let’s say you click a link in iMessage that goes to a PWA (installed on your homescreen). You want the link to trigger this app to open, but that is not the case. The link opens in a new window in Safari instead. This sadly seems to be a current shortcoming without workarounds, and I just hope that this gets implemented soon in iOS. It actually works this way in MacOS, so maybe it’s not a total mirage.

So there it is, the current state of PWAs from my point of view, and some things I learned while trying to build one. Let’s revisit this soon again! Maybe in a year or so.