How I Built a Dark Mode PWA without JS Libraries in 24 Hours

mpuckett profile image Michael Puckett ・5 min read


I decided to give my Hacker News reading experience a facelift.

First and foremost, I wanted Dark Mode!

Second, I wanted to be able to "install" it on my iPhone's homescreen, so that it runs in its own process, and not in Safari. (Dev.to does this natively, kudos!)

I also wanted to build a project over break that would let me explore new web standards. I wanted to commit to using the latest tools of the native web platform, so I wouldn't use any JS libraries or create a build process. I also wouldn't worry about any browsers other than the ones I use every day -- latest Safari and Chromium.

Before I started, I also got the idea to make it a little more functional for myself, so that it loads to the top comment along with the headline.

Finally, I wanted to timebox it to 24 hours.

Step #1: Loading Data

This was the easy part. The Hacker News API has an endpoint that provides JSON data of the stories. No authorization, no setup, just load the data.

Since I wasn't limited by browser support, I could safely use fetch, Promises, and async/await:

const storyIDs = await fetch(`https://hacker-news.firebaseio.com/v0/topstories.json`).then(res => res.json())

const stories = await Promise.all(storyIDs.slice(0, 25).map(id => fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).then(res => res.json())))

Step #2: Templating and Dynamic Data

Each of the loaded stories would be rendered as an instance of a web component.

There are basically 3 types of data to consider when you use a web component:

  • Named slots
  • Custom properties
  • Custom attributes

I ended up not having a need for custom attributes.

Let's start by looking at the template for a top-story element:

    <article class="top-story">
      <span class="top-story-submitter">
        <slot name="by"></slot>
      <div class="top-story-content">
        <a class="top-story-main" href="">
          <h3 class="top-story-headline">
            <slot name="title"></slot>
        <slot name="top-comment"></slot>

I'm using named slots where I want the dynamic content to go. This will be on the Shadow DOM side.

Anything in the Light DOM side with a matching slot attribute will be injected into the rendered template.

So for the dynamic data, I needed to convert each JSON data property received from the API into an HTML element with a slot attribute. I'm adding the JSON data to the web component as custom properties, then letting setting those properties trigger the creation of the elements with a slot attribute.

  stories.forEach(story => {
    if (story) { // can be null
      const element = window.document.createElement('top-story')
      Object.assign(element, story)

Object.assign here is setting these directly on the element, so we can set those up to be custom properties that react to changes.

In the web component, I have a helper function to do the property conversion to slots, and I have a setter for each of the properties:

window.customElements.define('top-story', class extends HTMLElement {
  constructor() {
  setSlot(slot, value) {
    if (!this.querySelector(`[slot="${slot}"]`)) {
      const element = window.document.createElement('data')
      element.setAttribute('slot', slot)
    this.querySelector(`[slot="${slot}"]`).innerHTML = value
  set text(value) {
    this.setSlot('text', value)

Now, if I change the data on the component, the slot will also update on the Light DOM side, which will update in place in the rendered Shadow DOM.

I can also use the setters to do other kinds of work. I want to embed another web component for the Top Comment inside this one, so I won't use my setSlot helper function. Instead, in the setter, I set up that component the same way I set up this one. This is also where I updated the href attributes on the links.

Step #3: Code Splitting / Imports

Typically I use webpack for converting my projects to ES5 and concatenating into a single JS file.

Here I'm using native JS imports to add the split-up files. Add that to the fact that the base markup is in its own web component, and my HTML file ends up being pretty light:

    <link rel="stylesheet" href="./styles.css">
    <script type="module">
      import './imports/fetcher.js'
      import './imports/AppScreenTemplate.js'
      import './imports/AppScreen.js'
      import './imports/TopCommentTemplate.js'
      import './imports/TopComment.js'
      import './imports/TopStoryTemplate.js'
      import './imports/TopStory.js'

Step #4: Dark Mode

Although I always use Dark Mode, I wanted to use the native CSS media query that detects Dark Mode in the system settings, in case someone else was used to Light Mode instead:

  @media (prefers-color-scheme: dark) {
    body {
      background: black;
      color: white;

Step #5: PWA Installation

One of the most important aspects of all this was to make Hacker News run like a native app, in its own window and not in Safari. That way my scroll state would be preserved.

This is actually pretty simple for iOS:

  <meta name="apple-mobile-web-app-capable" content="yes" />

To make this more compliant with other browsers, including Chromium Edge, which I have been using, I also added a manifest.json file:

  "name": "Hacker News PWA",
  "short_name": "HN",
  "theme_color": "#CD00D8",
  "background_color": "#000000",
  "display": "standalone",
  "orientation": "portrait",
  "scope": "/",
  "start_url": "/",
  "icons": [{
    "src": "/icons/icon-512x512.png",
    "type" : "image/png",
    "sizes": "512x512"

Challenge #1: Dates!

I ended up removing all dates from the project for now. I'm used to using a library such as moment.js or date-fns, and the native functions would sometimes show undefined or have other problems! I think for the final product, if I continue with it, I will pull in one of those libraries.

Challenge #2: Time Constraints

I had planned on having the comments (and possibly even the story if iframe embed is supported) show up in a modal drawer that overlays the rest of the content. This might still happen, but it's outside the 24-hour timebox.

It also isn't quite a full-fledged PWA with service workers. I need to do some work on automatically refreshing content.


I had a great time working on this, and I have started using it whenever I want to check Hacker News. You might enjoy it too.

Install it as an "Add to Homescreen" app from Safari:




Final Result:

Final Result


markdown guide

what about the service worker? is it not required or you didn't include it in this article? I am new to PWA world, thus, curious about it.


There's no service workers... yet. So maybe it doesn't count as a PWA. Sorry if that's misleading! I'm new to PWAs as well.

My goal was to get a fullscreen web app with its own window/process, which for this case doesn't require a service worker.

I would like to do background refresh next. I believe that would require a service worker.


Thanks for posting this excellent article Michael.