<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Grant Forrest</title>
    <description>The latest articles on DEV Community by Grant Forrest (@atype).</description>
    <link>https://dev.to/atype</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F170539%2Fc641f4a5-f8f2-428d-a357-b012d419a4a7.jpg</url>
      <title>DEV Community: Grant Forrest</title>
      <link>https://dev.to/atype</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/atype"/>
    <language>en</language>
    <item>
      <title>CSS-only auto spatial hierarchy with container style queries</title>
      <dc:creator>Grant Forrest</dc:creator>
      <pubDate>Wed, 16 Jul 2025 13:34:17 +0000</pubDate>
      <link>https://dev.to/atype/css-only-auto-spatial-hierarchy-with-container-style-queries-237h</link>
      <guid>https://dev.to/atype/css-only-auto-spatial-hierarchy-with-container-style-queries-237h</guid>
      <description>&lt;p&gt;I wasn't particularly interested in the new CSS &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_size_and_style_queries#container_style_queries" rel="noopener noreferrer"&gt;container style queries&lt;/a&gt; at first -- I mean, we already have inheritance and attribute queries, right?&lt;/p&gt;

&lt;p&gt;But then in the shower this morning the pieces started to fall together for how this feature could finally catch one of my CSS white whales: proportional nesting values!&lt;/p&gt;

&lt;h2&gt;
  
  
  The law of spatial hierarchy
&lt;/h2&gt;

&lt;p&gt;If you've ever worked with a designer or studied visual design at all, you know about hierarchy. One of the common rules of spatial hierarchy is that related elements should be closer to one another, and unrelated ones should be further away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5syflzb5myx1dp8xib14.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5syflzb5myx1dp8xib14.png" alt="A sketch of a simple signup form with a name and email field. There are visual annotations for separation between the fields themselves and the labels from the inputs. Labels are much closer to inputs than fields are to one another, demonstrating visual grouping through spacing."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the above wireframe, I've highlighted the spatial hierarchy principle in action: form labels and grouped buttons are much closer to one another than the fields are.&lt;/p&gt;

&lt;p&gt;For this reason, designs will often use progressively smaller spacing values as container nesting increases. For example, the fields in a form may be spaced with &lt;code&gt;--space-md&lt;/code&gt;, while the label and input are spaced with &lt;code&gt;--space-sm&lt;/code&gt;, visually "grouping" them together.&lt;/p&gt;

&lt;p&gt;It's generally up to developers to pay attention to spacing sizes and implement them appropriately. But this manual effort can only go so far. One of the problems with flattening systematic rules into coded values is you lose 'contextuality.' Understanding of context is moved from the system to the implementer (designer / developer), so the computer (which is good at consistent logic) cannot help us remain consistent and logical anymore.&lt;/p&gt;

&lt;p&gt;I'll follow this up with a more in-depth thoughts later on, but first let's dive into what didn't work before &lt;code&gt;@container style&lt;/code&gt; and what works now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using conditionals to power proportional nested values
&lt;/h2&gt;

&lt;p&gt;So, fundamentally, what we want to do is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a box is nested within another box, its spacing values (gap, padding) should be proportional to the parent&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What didn't work before
&lt;/h3&gt;

&lt;p&gt;Like I said, this has been a white whale for a bit. I've attempted this kind of thing before, but it never worked due to circular property references. Consider the following CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.box&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--spacing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Theoretically&lt;/em&gt; this expresses our rule, and in an imperative programming language this would work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;spacing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;spacing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;spacing&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// just fine!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But in CSS this &lt;em&gt;doesn't&lt;/em&gt; work, because this is a property declaration, not an assignment. The value of &lt;code&gt;calc(var(--spacing)...&lt;/code&gt; used to "assign" &lt;code&gt;--spacing&lt;/code&gt; isn't referencing the parent scope; it's referencing &lt;em&gt;our&lt;/em&gt; &lt;code&gt;--spacing&lt;/code&gt;: a circular reference. If you try this and open your inspector, you will see that the end result is &lt;code&gt;--spacing&lt;/code&gt; is not defined at all.&lt;/p&gt;

&lt;p&gt;And for a long time, that was a dead end. No amount of clever tricks I could think of (e.g. having two different variables) would violate this circular logic. It seemed mathematically impossible with the way CSS is designed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What works now
&lt;/h3&gt;

&lt;p&gt;Leveraging &lt;code&gt;@container style()&lt;/code&gt;, we can indeed get this working with a clever trick.&lt;/p&gt;

&lt;p&gt;Let's jump right to the demo, and then I'll explain.&lt;/p&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/a-type/embed/vENOdYB?height=600&amp;amp;default-tab=html,result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Don't just look at the result -- notice the &lt;em&gt;markup&lt;/em&gt; that accomplished it. That's the cool part!&lt;/p&gt;

&lt;h4&gt;
  
  
  How it works
&lt;/h4&gt;

&lt;p&gt;To start off, I'm establishing a system of custom properties to "smuggle" an inherited value past the circular reference problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--ctx-mode&lt;/code&gt;: A generic &lt;code&gt;1/2&lt;/code&gt; state value which tells us which "mode" the current nested element is in. Keep reading.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--ctx-nest-1&lt;/code&gt;: "Mode 1" nesting variable. To avoid circular referencing, this computes itself from &lt;code&gt;--ctx-nest-2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--ctx-nest-2&lt;/code&gt;: "Mode 2" nesting variable. To avoid circular referencing, this computes itself from &lt;code&gt;--ctx-nest-1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, I'd tried something like this before, but the problem is that to make this work fully automatically with no other JS or markup required, you'd have to apply CSS like so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.box&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--ctx-nest-1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-nest-2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--ctx-nest-2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-nest-1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;... thus arriving at a circular reference again. Why? Because if we refuse to force developers to treat nested elements any differently (like, applying a &lt;code&gt;mode-1&lt;/code&gt; or &lt;code&gt;mode-2&lt;/code&gt; class), both of these properties &lt;em&gt;need&lt;/em&gt; to be defined on every element, and we had no way of saying "alternate nesting elements" to only apply one at a time. Normal CSS nesting selectors like &lt;code&gt;.box .box {&lt;/code&gt; also wouldn't work, because the same rules will be applied to &lt;em&gt;every&lt;/em&gt; nesting element, not &lt;em&gt;every other&lt;/em&gt; alternating nested element.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is all recursive stuff and kind of hard to think about, so don't worry too much if my explanations aren't sufficient... It does work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Breaking it down
&lt;/h2&gt;

&lt;p&gt;We can use &lt;code&gt;@container style(--ctx-mode: ...)&lt;/code&gt; to solve this problem by creating two style queries which do the alternation for us. They are mirror images of each other, so I'll just break down the first one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@container style(--ctx-mode: 1) {
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;For parents with mode: 1&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  .box {
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Targeting a child &lt;code&gt;box&lt;/code&gt; class&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    --ctx-mode: 2;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Flip mode to 2 (we are nesting now)&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    --ctx-nest-2: calc(var(--ctx-nest-1) / 2);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Compute the new nesting multiplier from our parent's (who is mode 1, since we are mode 2)&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    --ctx-nest: var(--ctx-nest-2);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Assign our common-use property from the active mode's value&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Then we add the inverse CSS for &lt;code&gt;style(--ctx-mode: 2)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now nested boxes will have alternating &lt;code&gt;--ctx-nest&lt;/code&gt; and values which gradually get smaller! Behind the scenes, this is what it looks like.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Box 1: &lt;code&gt;--ctx-nest: 1&lt;/code&gt;, &lt;code&gt;--ctx-nest-1: 1;&lt;/code&gt; (default value, no &lt;code&gt;@container style&lt;/code&gt; query matches)

&lt;ul&gt;
&lt;li&gt;Box 2: &lt;code&gt;--ctx-nest: 0.5&lt;/code&gt;, &lt;code&gt;--ctx-nest-2: 0.5;&lt;/code&gt;, &lt;code&gt;--ctx-nest-1: 1;&lt;/code&gt; (inherited)

&lt;ul&gt;
&lt;li&gt;Box 3: &lt;code&gt;--ctx-nest: 0.25&lt;/code&gt;, &lt;code&gt;--ctx-nest-1: 0.25;&lt;/code&gt;, &lt;code&gt;--ctx-nest-2: 0.5;&lt;/code&gt; (inherited)&lt;/li&gt;
&lt;li&gt;...&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;At each level, we alternately subdivide either &lt;code&gt;--ctx-nest-1&lt;/code&gt; or &lt;code&gt;--ctx-nest-2&lt;/code&gt; depending on the flip-flop mode, then apply the subdivided value to a common &lt;code&gt;--ctx-nest&lt;/code&gt; property that can be safely used by the element's styling without worrying about the rest of the logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;p&gt;My example above demonstrates the principle, but how you apply it may depend on what you want to use proportional nesting for, and in particular, how you want to modify the value as it nests. Usage also gets more convenient if you utilize encapsulated components, either via a framework or Custom Elements, since you can move the main nesting logic into its own class applied within the component instead of having to apply it to every nested element.&lt;/p&gt;

&lt;p&gt;Anyways, the core concept can be captured in the following CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.nesting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;/* Initial defaults, applied to highest first matching element */&lt;/span&gt;
    &lt;span class="py"&gt;--ctx-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--ctx-nest-1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c"&gt;/*
        NOTE: you do not want to define --ctx-nest-2 here. The trick relies on
        it not being defined for every other element
    */&lt;/span&gt;
    &lt;span class="py"&gt;--ctx-nest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@container&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;.nesting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="py"&gt;--ctx-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="py"&gt;--ctx-nest-2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-nest-1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="py"&gt;--ctx-nest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-nest-2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@container&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;.nesting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="py"&gt;--ctx-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="py"&gt;--ctx-nest-1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-nest-2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="py"&gt;--ctx-nest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-nest-1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you apply the &lt;code&gt;nesting&lt;/code&gt; class to your nestable elements, they will compute the correct nested value (&lt;code&gt;1&lt;/code&gt;, &lt;code&gt;0.5&lt;/code&gt;, &lt;code&gt;0.25&lt;/code&gt;, ...) according to the hierarchy and provide it on &lt;code&gt;--ctx-nest&lt;/code&gt; for you to use in any calculations you choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further ideas
&lt;/h2&gt;

&lt;p&gt;With the basics settled, some improvements come to mind:&lt;/p&gt;

&lt;h3&gt;
  
  
  Resetting nesting context
&lt;/h3&gt;

&lt;p&gt;You don't &lt;em&gt;always&lt;/em&gt; want to treat nested containers as spatial hierarchy. It's convenient to have it as the rule, but when you want to make an exception, you could have a class like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.nesting-reset&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--ctx-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--ctx-nest-1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--ctx-nest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ctx-nest-1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Semantically it might make sense to reset the nesting context for elements which are visually separate from the flow of the page already, like bordered cards. It's also important if you're using popovers or other surfaces which have their own visual hierarchy context.&lt;/p&gt;

&lt;p&gt;You could use the same principle to override the nesting value starting point of a subtree by setting &lt;code&gt;--ctx-nest-1: 0.5&lt;/code&gt; or whatever, if you want to hardcode a particular section to start out with tighter spacing than the global default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Variable nesting factor
&lt;/h3&gt;

&lt;p&gt;Instead of hardcoding &lt;code&gt;/ 2&lt;/code&gt; as the nesting division factor, we could make it a custom property.&lt;/p&gt;

&lt;p&gt;Now you could either configure this globally (like a design system token) or override it on-demand at a particular element.&lt;/p&gt;

&lt;p&gt;This also lets you be a little more flexible with how you adjust the nesting factor instead of enforcing the same division everywhere. One can imagine utility classes like &lt;code&gt;.nesting-tight&lt;/code&gt; or &lt;code&gt;.nesting-loose&lt;/code&gt;, for example, which adjust the &lt;code&gt;--ctx-nest-factor&lt;/code&gt; property used to compute nested values and increase or reduce the subdivision factor.&lt;/p&gt;

&lt;p&gt;You can decide whether to configure this property to be inheritable or not -- I'm not sure which behavior is preferable at the moment, but I'm inclined to make it non-inheritable, personally. This would mean you can selectively tighten the nesting up for one element and any further children will resume the normal pattern. But again, not sure yet, inheritance could be good too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safeguards
&lt;/h3&gt;

&lt;p&gt;The best systems are resilient, and I can think of at least one way this spatial hierarchy could fail: too much nesting! Pretty quickly, you'll probably start hitting sub-1-pixel gaps, especially if you use a larger division factor like &lt;code&gt;1/4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It would probably be a good idea to use &lt;code&gt;max()&lt;/code&gt; to ensure a minimum value for &lt;code&gt;--ctx-nest&lt;/code&gt; before assigning it. After a certain level of nesting, this would make the hierarchy simply 'turn off' and create uniform spacing. While not "systematic" this is perfectly reasonable behavior as you reach 1-2px. Having &lt;em&gt;some&lt;/em&gt; gap is important. Of course, you may want to evaluate why such deep nesting is occurring and whether the system needs to accommodate that more explicitly, or if the nesting itself is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate, namespaced nesting contexts
&lt;/h2&gt;

&lt;p&gt;If you want to have multiple independently tracked nesting values, you could namespace your properties and copy the same structure, i.e. &lt;code&gt;--ctx-foo-nest-mode&lt;/code&gt;/&lt;code&gt;--ctx-foo-nest-1&lt;/code&gt;/&lt;code&gt;.nesting-foo&lt;/code&gt; etc. This would let you repeat this nesting proportional value pattern for different semantic uses without overlapping.&lt;/p&gt;

&lt;p&gt;For example, if you wanted to do a nesting color gradient with one context, while having nested spacing be independent from it and on a different multiplier scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  A more concrete demo, and more "why"
&lt;/h2&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/a-type/embed/YPyXjKp?height=600&amp;amp;default-tab=html,result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The UI expressed in the demo above is not particularly exciting (or pretty), but what I really find compelling is the markup. Especially if you imagine component encapsulation which automatically applies the &lt;code&gt;nesting&lt;/code&gt; class for you.&lt;/p&gt;

&lt;p&gt;The point here is: as a developer, I no longer have to personally manage spacing sizes. I am free from the tyranny of &lt;code&gt;gap-sm&lt;/code&gt;, &lt;code&gt;gap-md&lt;/code&gt;, &lt;code&gt;gap-lg&lt;/code&gt;, and so on. I can simply say: &lt;code&gt;gap&lt;/code&gt;, and let the system decide how much to apply based on my hierarchy.&lt;/p&gt;

&lt;p&gt;This also means if I encapsulate a portion of the UI, such as the signup form, into a reusable component, that component will adapt its spacing hierarchy to its container wherever it is used. You won't ever have the problem of the gap between label and input accidentally being the same as the gap between the form and its sibling, for example, even if you drop it in somewhere the original designers didn't anticipate. Hierarchy is encoded into the system.&lt;/p&gt;

&lt;p&gt;As a designer, maybe (just maybe) this lets you encode good design rules into the product without relying on every frontend developer fully understanding their usage and implementing them faithfully. Again... maybe! Reality will probably be more tricky.&lt;/p&gt;

&lt;p&gt;This is all a bit arbitrary right now, but I believe this could potentially be a very powerful tool if refined into a coherent design system. But in the end, perhaps design is not as predictable and systematic as one hopes. Either way, I'm glad to finally have realized this CSS dream.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>designsystem</category>
    </item>
    <item>
      <title>A comprehensive guide to making your web app feel native</title>
      <dc:creator>Grant Forrest</dc:creator>
      <pubDate>Tue, 15 Aug 2023 04:00:00 +0000</pubDate>
      <link>https://dev.to/atype/a-comprehensive-guide-to-making-your-web-app-feel-native-3bl9</link>
      <guid>https://dev.to/atype/a-comprehensive-guide-to-making-your-web-app-feel-native-3bl9</guid>
      <description>&lt;p&gt;When it comes to apps, the web always feels a little second-class.&lt;/p&gt;

&lt;p&gt;Sure, we’ve had some advancements in the web platform which make it more capable of powering app-like experiences. Greater device access, “install” options on phones, share targets, and the like have all made web apps feel more native. But these advances on their own won’t convince a user that your web-based product is on par with a native app. More work goes into thoughtfully designing that experience before the lines start to blur. That’s what I’ll be covering here.&lt;/p&gt;

&lt;p&gt;But first, for the native elitists… yeah, I know a single-threaded browser view has some hard limits in terms of raw ability to compete with an optimized, native app. But for relatively simple apps on most phones, with a little effort it can be hard for most users to tell the difference. Real-world experience is what matters here, not theoretical technical differences.&lt;/p&gt;

&lt;p&gt;Also, this isn’t the kind of post where I’m going to give you a bunch of code to copy and try. Most of this stuff is highly dependent on how your app works. What I’m going over here are &lt;em&gt;principles&lt;/em&gt;, the implementation is up to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  It starts with a PWA
&lt;/h2&gt;

&lt;p&gt;This almost goes without saying, but you need a PWA (Portable Web App) to get things started. That means you need two things: a service worker, and a manifest.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Service Worker
&lt;/h3&gt;

&lt;p&gt;The service worker doesn’t have to be particularly fancy to get started, but in order to really nail the native feeling, we’re going to go the extra mile and &lt;em&gt;precache our application for offline use.&lt;/em&gt; All HTML, JS, and (optionally) media assets can be downloaded once, then loaded from disk instead of network for future launches. That’ll make loads much faster for repeated use, and even (if your application supports it… more later) let the user use your app while their data connection is spotty.&lt;/p&gt;

&lt;h4&gt;
  
  
  Precaching client files and offline support
&lt;/h4&gt;

&lt;p&gt;I’ll note that you &lt;em&gt;can&lt;/em&gt; have a service worker which just has an “offline” page that shows up whenever the user isn’t able to connect, but this is an article about making your app feel native, and native apps don’t do that. So we can’t stop there.&lt;/p&gt;

&lt;p&gt;Luckily, there are plenty of great tools like Workbox which help us deal with service worker precaching without having to be an expert on service workers. And there are even integrations with builders like Webpack or Vite that will automatically detect our app assets and generate a “precache manifest” for us! I highly recommend this. In fact, I use these exclusively, so I can’t even really explain to you how to do it at a lower level. A little lazy of me as an author, I know, but my goal is to make functional apps, not know all the minute inner workings for their own sake. Tools like Workbox or &lt;code&gt;vite-plugin-pwa&lt;/code&gt; haven’t let me down.&lt;/p&gt;

&lt;p&gt;The important thing to know at a high level is that we need to gather up a list of every URL that our app needs to request (whether it be an HTML, JS, CSS, or asset file) that we want to be available offline, and register those in our service worker.&lt;/p&gt;

&lt;p&gt;When those change, which always happens when you change your app code or swap out an image, we re-generate that list. This changes the actual code of our service worker, because it’s using that list (often just an inline array of strings) within its own code. The browser notices the service worker code is different, and from there we can pre-fetch those new assets, cache them, and prepare to update the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  App updates
&lt;/h3&gt;

&lt;p&gt;This is a big one that folks getting started in PWAs underestimate. Probably because this is one of the biggest ways PWAs differ from a traditional web-based experience. Instead of the user always loading the latest version of your client files after you push an update, they’ll still be on the older version the next time they launch the app, since we’ve &lt;em&gt;precached&lt;/em&gt; those files.&lt;/p&gt;

&lt;p&gt;It’s only after the app loads up on the old, precached version, that it will fetch the new version in the background. This often takes a second or more. Then, you have a chance to manually initiate an update of the app in your code. That looks like this, usually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In your service worker file:&lt;/span&gt;

&lt;span class="c1"&gt;// This allows the web app to trigger skipWaiting via&lt;/span&gt;
&lt;span class="c1"&gt;// registration.waiting.postMessage({type: 'SKIP_WAITING'})&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SKIP_WAITING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skipWaiting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We add an event handler in the service worker file to listen for messages from the main client. If the client sends a &lt;code&gt;SKIP_WAITING&lt;/code&gt; message, that means “don’t wait for the right time to update the app code; let’s update it now!” If you don’t do this, even refreshing will still stick with the old, precached version of your app.&lt;/p&gt;

&lt;p&gt;On the client, we do this when we’re ready to update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tell the service worker to install immediately so we're ready with the new app&lt;/span&gt;
&lt;span class="c1"&gt;// on next page load&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateApp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SKIP_WAITING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// listen for the install, reload the page when ready&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;refreshing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;controllerchange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refreshing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;refreshing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this means for you, practically, is that you have to decide how to get the user on your new app version, and how urgent that is. There are a few common strategies, but for all of them I’ll be using this helper function to listen for incoming updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onUpdateReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/service-worker.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updatefound&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;installing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onstatechange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// No arrow function because 'this' is needed.&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;installed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;First install for this service worker.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nf"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you’re using a service worker helper library like Workbox (which I highly recommend doing), there may be versions of these helpers already available to you. Read the docs!&lt;/p&gt;

&lt;h4&gt;
  
  
  Update strategies
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Update immediately when available.&lt;/strong&gt; This refreshes the page without user input. It might be seamless enough if your app is very small, but as the download size becomes larger, the delay from launch to unexpected refresh gets long enough to become actively disruptive. I don’t recommend it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;onUpdateReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateApp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Show an update prompt when the update is available.&lt;/strong&gt; This might be the most common approach, and you’ll see it often in more mature web apps. Usually takes the form of a small, dismissible popup in the corner with a call to action to update and refresh.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;onUpdateReady&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showInstallPrompt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;onInstallPromptClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateApp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Wait for navigation and update.&lt;/strong&gt; I don’t actually know if many people besides me do this, but basically I wait for the user to click a link, and if an update is ready to install, I interrupt the normal navigation and do an update and reload. In my app, a navigation event is a reasonable time to briefly interrupt the user which won’t be frustrating or disorienting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;updateAvailable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;onUpdateReady&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;updateAvailable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// in each link you want to trigger updates...&lt;/span&gt;
&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;updateApp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Wait for next launch to update.&lt;/strong&gt; If there’s really no rush to update, you can always wait until the user closes the app completely. The browser will automatically launch the latest version when they come back. No code needed for this one, but keep in mind that it’s up to the browser how it will decide when the app is ‘closed’ and ready to update; it may keep tabs in memory long after the browser app has been backgrounded.&lt;/p&gt;

&lt;h4&gt;
  
  
  Be careful with server-side features
&lt;/h4&gt;

&lt;p&gt;One big reason you may want the user to update sooner rather than later is if your app relies on server-side APIs or realtime features. You might push a change to how these features work which is incompatible with older client versions. With PWA, even though the new version is downloaded and ready to install, the user won’t know there’s an update until you tell them. If you don’t tell them, but the client starts breaking due to server-side incompatibility, the user will assume your app is broken.&lt;/p&gt;

&lt;p&gt;If your app relies on server-side features which could be incompatible during a version change, I’d recommend one or both of these strategies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always support backwards-compatibility for a certain time period.&lt;/strong&gt; Give your users some time to update the app via the methods above. Keep in mind that users who don’t update in this time period may still experience problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide on an error code which indicates an update is required.&lt;/strong&gt; You can standardize some error code in your API payloads which your client will see and recognize that it needs to prompt the user to update ASAP. This can trigger a mandatory modal which initiates the update process, or just reload the page outright.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;Can I just say, for a moment, before we move on—despite how much harder this update process is for a beginner to grok, I think it’s really cool that we get &lt;strong&gt;this much control&lt;/strong&gt; over how our apps update on the web. We don’t even have to wait until the user closes the app to load in new code! While often PWA update implementations are clunky, with a bit of work I think you can create a great experience.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Manifest
&lt;/h3&gt;

&lt;p&gt;The manifest gives us nice integration features with the host OS and browser. We can specify a color for the browser chrome (&lt;code&gt;theme_color&lt;/code&gt;) and add nice icons for different occasions.&lt;/p&gt;

&lt;h4&gt;
  
  
  Icons
&lt;/h4&gt;

&lt;p&gt;Different sizes of icons are important to look good in a variety of contexts. Not much to say about these, and if you’ve ever followed a PWA tutorial you already know about them. I recommend using &lt;a href="https://www.pwabuilder.com/imageGenerator" rel="noopener noreferrer"&gt;a tool to generate these from a source icon&lt;/a&gt; rather than making them manually.&lt;/p&gt;

&lt;h4&gt;
  
  
  Maskable icon
&lt;/h4&gt;

&lt;p&gt;It’s important to add a maskable icon if you want your app’s homescreen icon to look good, especially on Android. Normal icons will get shoved inside a white circle in Android’s launcher, which makes it really obvious which apps are PWAs, and generally looks bad. But the maskable icon doesn’t!&lt;/p&gt;

&lt;p&gt;Your maskable icon should have ample space around the outside so that it can adapt to different icon shapes. It must also have a solid background.&lt;/p&gt;

&lt;p&gt;For more specifics, you should definitely &lt;a href="https://web.dev/maskable-icon/" rel="noopener noreferrer"&gt;check out the documentation.&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Install screenshots
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgfor.rest%2Fimages%2Fblog%2Fpwa-native%2Finstall-screenshots.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgfor.rest%2Fimages%2Fblog%2Fpwa-native%2Finstall-screenshots.png" alt="A screenshot of a PWA install prompt which has several app screenshots as part of the install page"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here’s a hidden gem for PWAs (well, Android PWAs… opened in Chrome…): you can set up fancy, App-Store-like screenshot previews of your app that show up right in the PWA install prompt! Although the use case is very platform-limited, Chrome users will get a really slick install experience if you add some screenshots to your manifest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;screenshots:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;src:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'images/screenshots/list.png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'image/png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;sizes:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;1170&lt;/span&gt;&lt;span class="err"&gt;x&lt;/span&gt;&lt;span class="mi"&gt;2532&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;src:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'images/screenshots/recipe_overview.png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'image/png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;sizes:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;1170&lt;/span&gt;&lt;span class="err"&gt;x&lt;/span&gt;&lt;span class="mi"&gt;2532&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;src:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'images/screenshots/cooking.png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'image/png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;sizes:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;1170&lt;/span&gt;&lt;span class="err"&gt;x&lt;/span&gt;&lt;span class="mi"&gt;2532&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  System color scheme
&lt;/h2&gt;

&lt;p&gt;Users expect native apps to integrate well with the OS, and one way to fulfill that on the web is to inherit your light/dark mode configuration from the device.&lt;/p&gt;

&lt;p&gt;What that means in practice is utilizing the &lt;code&gt;@media(prefers-colors-scheme: dark)&lt;/code&gt; and &lt;code&gt;@media(prefers-color-scheme: light)&lt;/code&gt; media queries when defining your theme styles. Beyond that it will depend on how you’re implementing dark/light themes.&lt;/p&gt;

&lt;p&gt;For my apps I utilize CSS vars for my theme, so what I actually end up doing is creating one block for each of those queries and defining all of the theme color vars. &lt;strong&gt;Then&lt;/strong&gt;, to let users override the system theme in app settings, I &lt;strong&gt;also&lt;/strong&gt; define a second set of blocks which define the opposite theme colors when the &lt;code&gt;.override-dark&lt;/code&gt; or &lt;code&gt;.override-light&lt;/code&gt; class is applied to the &lt;code&gt;html&lt;/code&gt; element. This makes theme overriding as simple as storing user config in &lt;code&gt;localStorage&lt;/code&gt; (or a cookie to use server-side rendering) and setting an &lt;code&gt;.override-dark&lt;/code&gt; or &lt;code&gt;.override-light&lt;/code&gt; class on &lt;code&gt;html&lt;/code&gt; if the user has indicated a preference.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/**
 * Default light theme (if you default dark, use a media query)
 * here and remove the one on the dark theme in the next block
 */&lt;/span&gt;
&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;white&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;black&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c"&gt;/** etc */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/** Default dark theme */&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--color-paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;black&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--color-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;white&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/** User override: prefers dark, but system is light */&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.override-dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--color-paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;black&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--color-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;white&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/** User override: prefers light, but system is dark */&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.override-light&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--color-paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;white&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--color-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;black&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You might be clever enough to make this more succinct, but I just created some processor functions to do this for me, which works fine for my ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smart loading
&lt;/h2&gt;

&lt;p&gt;All apps need to load data. But native apps often are loading it from the local filesystem, not a server somewhere else. One of the subtle indications to a user that they’re using a browser is how things get loaded: blank white screens for page transitions, and a multitude of spinners in single-page apps. How can web apps compete in loading UX?&lt;/p&gt;

&lt;h3&gt;
  
  
  Start using local data
&lt;/h3&gt;

&lt;p&gt;This will heavily depend on your use case, but it &lt;em&gt;is&lt;/em&gt; possible to load your data from the local device in a web app. Even if the source of truth is on your server!&lt;/p&gt;

&lt;p&gt;There are two main ways to store local device data on the web - the Storage APIs, and IndexedDB.&lt;/p&gt;

&lt;p&gt;You may be familiar with the Storage APIs from using &lt;code&gt;localStorage&lt;/code&gt; . There’s also its less permanent cousin, &lt;code&gt;sessionStorage&lt;/code&gt;. People &lt;em&gt;do&lt;/em&gt; use these tools to store complex data (like using &lt;code&gt;JSON.stringify&lt;/code&gt;), but they’re really meant as simple key-value stores. I think mostly folks use them for complicated data because IndexedDB is so intimidating to get started with. I get it, but eventually serializing and deserializing data out of &lt;code&gt;localStorage&lt;/code&gt; is going to be too much overhead.&lt;/p&gt;

&lt;p&gt;I’d honestly recommend reading the docs for IndexedDB. If you’re comfortable with how callbacks work, I find it’s not so much effort to get the boilerplate out of the way and wrap it up in a custom API which conforms to your personal needs.&lt;/p&gt;

&lt;p&gt;That said, there are some great ways to wrap IndexedDB to make it easier to work with. Since I’m writing this article I’ll toss in &lt;a href="https://verdant.dev" rel="noopener noreferrer"&gt;my local-first framework Verdant&lt;/a&gt;, which can be used without the sync features as a standalone IndexedDB-powered database with a type-safe schema and deployable data migrations. For a lighter-weight approach, maybe check out &lt;a href="https://dexie.org/" rel="noopener noreferrer"&gt;Dexie&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;However, you don’t have to rely &lt;em&gt;only&lt;/em&gt; on local data to use these solutions. If your app data all lives in the cloud, you can still cache that data (or a relevant subset) locally on-device using web storage tools, so it’s ready instantly (and offline!) whenever the user opens the app. Many existing app state management systems, like Redux and Zustand, have plug-in systems to “persist” data for you easily. They’ll load data from disk to resume the last state, and then if network is available, they’ll repopulate it once the fetching is complete.&lt;/p&gt;

&lt;h4&gt;
  
  
  Side note: how about local-first?
&lt;/h4&gt;

&lt;p&gt;Something web developers take for granted is the idea that there's a server out there with a database, and that's where user data lives. But servers cost money to keep running, which incentivises you as an app provider to try to monetize your users with stuff like ads.&lt;/p&gt;

&lt;p&gt;And while there's definitely no shortage of ad-funded apps on mobile marketplaces, they can actually be more sustainable business-wise because they don't assume data needs to live on a server. For a simple app that's only managing a user's personal data, like a grocery list, why not &lt;em&gt;exclusively&lt;/em&gt; use IndexedDB?&lt;/p&gt;

&lt;p&gt;This is a growing area of focus in some web communities, and it's called local-first. &lt;a href="https://verdant.dev" rel="noopener noreferrer"&gt;Verdant&lt;/a&gt; is a full local-first framework, including upgrading to multi-device sync and multiplayer with a server when you're ready. &lt;a href="https://dev.to/blog/sustainable-software-should-be-simpler"&gt;I wrote a bit on it elsewhere&lt;/a&gt;, and there's an &lt;a href="https://localfirstweb.dev" rel="noopener noreferrer"&gt;emerging community&lt;/a&gt; around local-first more broadly. Check it out!&lt;/p&gt;

&lt;h3&gt;
  
  
  Delay transitions until data is loaded
&lt;/h3&gt;

&lt;p&gt;It’s not all about load times, though, in practice. It matters &lt;em&gt;how&lt;/em&gt; you load—how you represent the loading state to users. Try opening the Settings on your phone and tapping one of the options (maybe something with a decent amount of loading to do — like the list of all your installed apps). Notice what you &lt;em&gt;don’t&lt;/em&gt; see (at least on most recent phones). It’s a spinner.&lt;/p&gt;

&lt;p&gt;What I see, on Android, is the option I tapped plays a brief animation to indicate that it registered my intent. Then, for a few milliseconds, nothing happens… until the page I navigated to animates into view, fully loaded.&lt;/p&gt;

&lt;p&gt;This is a big thing web developers get wrong, thanks in part to a sort of spinner / “skeleton” obsession among common web learning material. Everyone loves making a fancy loading spinner, but the hard truth is, if done well, the user should almost never see it.&lt;/p&gt;

&lt;p&gt;The key is, instead of immediately updating the interface before the data to render it is ready, you instead want to establish this “native” feeling pattern for your interactions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Immediately and clearly register user intent (with a visual state change)&lt;/li&gt;
&lt;li&gt;Keep the current UI intact while you load in the background—including allowing interactions&lt;/li&gt;
&lt;li&gt;Only transition when the next view is ready&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, that’s an ideal world case, but there are a few addendums for extraordinary circumstances:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the delay in loading is more than a few seconds, show &lt;em&gt;another&lt;/em&gt; affordance to assure the user you’re working on their action&lt;/li&gt;
&lt;li&gt;If the action the user took implies an immediate change of location, go ahead and transition and show a spinner (I’ll cover those cases in a bit)&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Registering intent
&lt;/h4&gt;

&lt;p&gt;I think in part this kind of loading flow isn’t taught because it relies on some design sense, not just technical ability. You have to step back and understand what the user is trying to do, not just what the software is capable of.&lt;/p&gt;

&lt;p&gt;Which also means I can’t give you a code sample and banket advice here. What you have to do is think about what this action means to your user. Things like, “I selected a settings menu option, so I want to see that sub-menu,” or, “I entered a new search term, I expect to see different results.” The end-goal of that action (the sub-menu, the new results) is something you can’t do yet (hence the loading). So what can you do in the meantime, besides showing a new blank page with a spinner, to assure the user that they did successfully execute their action? Maybe change the background color of the button, or animate the border of the search bar. It’s fun to get creative with this, and these are the details that really count toward making your app feel high-quality.&lt;/p&gt;

&lt;h4&gt;
  
  
  Parallel loading
&lt;/h4&gt;

&lt;p&gt;In the meantime, while you’re showing the initial affordance, you want to immediately begin loading the next step. For a well-architected web app, this often means loading a code-split bundle of code which powers the next page. There’s probably also some data you want to load, either from an API or IndexedDB. All of these things are async, they take time, but they don’t block the main thread.&lt;/p&gt;

&lt;p&gt;The real trick here is how to capture that loaded data and use it for the next step of the process. Different tools have different ways to do this, and some are ambivalent about how you accomplish it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to do it in React (with Suspense)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Since I have a lot of React experience, I’ll note here that this is what React’s &lt;em&gt;Suspense&lt;/em&gt; and &lt;em&gt;Concurrent Mode&lt;/em&gt; features are designed to tackle. Rather than being responsible for capturing and caching the newly loaded data and getting it to the next view, React lets you directly request that data within the components that power that view like you normally would, but it won’t actually &lt;em&gt;show&lt;/em&gt; the new UI until that loading is complete. With Suspense, React will either show a &lt;em&gt;fallback&lt;/em&gt; (passed as a prop to your Suspense boundary) or, if you’re using &lt;code&gt;useTransition&lt;/code&gt;, it will actually keep rendering your old UI until the Suspense loading is resolved.&lt;/p&gt;

&lt;p&gt;That means, once you understand Suspense and &lt;code&gt;useTransition&lt;/code&gt;, you can get this parallel loading basically for free—keep writing your app like normal, and sprinkle in these two features as needed to keep transitions smooth. One of the reasons I’m still rooting for React after so many years is that they’ve clearly taken these kinds of details into account as the library has evolved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to do it anywhere&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;React isn’t, and shouldn’t be, the whole web. It’s best to learn these principles from fundamentals, so stepping away from React’s particular approach—what are we trying to accomplish?&lt;/p&gt;

&lt;p&gt;It’s simple enough to fire off a &lt;code&gt;fetch&lt;/code&gt; when your user clicks a link (and prevent default navigation), wait for that fetch to resolve, and then update the URL. The part that’s a little more ambiguous is how you get your data from that event handler into your new UI state once you’ve transitioned.&lt;/p&gt;

&lt;p&gt;This is where it’s helpful to have some centralized app state. For example, if you’re using a Zustand store as your main app state, instead of simply &lt;code&gt;fetch&lt;/code&gt;ing in your &lt;code&gt;click&lt;/code&gt; handler, you can integrate that data retrieval into your central store, and cache the resulting data so that it’s ready to use in the upcoming view. This also works out-of-the-box in a higher-level API client like Apollo for GraphQL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in pseudo-code...&lt;/span&gt;

&lt;span class="c1"&gt;// a rough sketch of a very simple client&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// this handler is attached to an &amp;lt;a&amp;gt; which loads the new page&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onLinkClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// prevent immediate navigation&lt;/span&gt;
  &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// begin the initial affordance to show the user their action is registered.&lt;/span&gt;
  &lt;span class="c1"&gt;// in this case, let's assume that's done with a CSS animation on the link itself,&lt;/span&gt;
  &lt;span class="c1"&gt;// controlled by a class.&lt;/span&gt;
  &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link-loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// preload necessary data. this assumes the client will cache this data once loaded.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;... whatever query / fetch you need for the next page ...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// resume navigation&lt;/span&gt;
  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushState&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// this code renders the new page&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderOtherPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// suppose our client allows a simple get on the data from the cache. if the&lt;/span&gt;
  &lt;span class="c1"&gt;// data isn't preloaded, this returns null&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;... whatever query / fetch you need for the next page ...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;... whatever query / fetch you need for the next page ...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;renderTheUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you’re really trying to make sure happens here, is that when you move the user to the next UI state, it doesn’t trigger a re-loading of the data. To write your UI in such a way that it can effectively render whether the data is already cached, or if it still has to be fetched from storage or network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Additional affordances
&lt;/h3&gt;

&lt;p&gt;Sometimes, loading can take a while. In that case, we want to reassure the user that the app is still functioning and processing their action. One way to do this is to queue up another affordance after a set time elapses and our preload hasn’t yet finished. You'll often see this in the form of a progress bar that spans the top of the page.&lt;/p&gt;

&lt;p&gt;In React, this can be done by observing the boolean returned from &lt;code&gt;useTransition&lt;/code&gt; which indicates a transition is in progress, and a debounced effect to update additional state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLongTransition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLongTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// this is not ideal use of state but should illustrate the concept.&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setIsLongTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setIsLongTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, with any or no framework, you can likewise utilize &lt;code&gt;setTimeout&lt;/code&gt; to update state after an elapsed period. Just make sure to cancel the timeout once the loading resolves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onLinkClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link-loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// set a timer for a secondary affordance&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long-loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;... whatever query / fetch you need for the next page ...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// clear timeout. if timer already fired, also remove secondary affordance.&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long-loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushState&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Exceptions to pre-loading
&lt;/h3&gt;

&lt;p&gt;Some actions should trigger an immediate transition, even if that means showing a spinner. For example, clicking “Create Note” in a notes app should probably not sit loading on the current page until the new note is ready to edit, as this may result in user confusion depending on the order of loading. For example, in the video below, the new recipe is created and added to the list before the page transition is ready.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A purposefully broken create recipe flow demonstrated in my app, &lt;a href="https://gnocchi.club" rel="noopener noreferrer"&gt;Gnocchi&lt;/a&gt;. [&lt;a href="https://gfor.rest/images/blog/pwa-native/create-recipe.mp4" rel="noopener noreferrer"&gt;Video&lt;/a&gt;]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the loading were to take a second or two longer, the user might be confused and click the card manually. While it’s not the end of the world, this order of events drives user confusion and a sense that the app is not well-designed.&lt;/p&gt;

&lt;p&gt;Instead, we omit all of the fancy stuff above in cases like this, and go ahead and navigate to the next page immediately. Loading states on that page will take care of providing proper UX while the data needed to render the UI is being loaded.&lt;/p&gt;

&lt;p&gt;You may discover other cases where an immediate transition is needed, like when first typing into a search bar. It’ll depend on how your app functions, how you structure your views, and other factors which only you know!&lt;/p&gt;

&lt;h2&gt;
  
  
  Styling elements for interaction
&lt;/h2&gt;

&lt;p&gt;The web ships with some fairly good interactive element user-agent styles, but anyone who’s done even a little web development knows the first thing most people do is clear all that away. Each product has its own unique style. Unfortunately, that means re-inventing all those various interaction states for all your elements in your design system. I’ve observed that often, somewhere between design teams and engineers, some of these states get overlooked or misunderstood.&lt;/p&gt;

&lt;p&gt;The goal of this article isn’t really to educate on those states (the important ones are &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes#user_action_pseudo-classes" rel="noopener noreferrer"&gt;here&lt;/a&gt;, but read the whole list, you’d be surprised what you can do!). But there are some styling considerations which may not be immediately obvious which you should consider if your goal is making your web app feel native.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;:active&lt;/code&gt; or no?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;:active&lt;/code&gt; is the button’s “pressed” state, and using it is can make buttons feel responsive to user input. But it’s worth saying here: open up some native apps on your phone and tap some buttons. Very few of them seem to have a pressed state (at least on mine). That seems counter-intuitive! Native apps usually feel snappy and responsive, so what’s up with not using a state that directly responds to user input?&lt;/p&gt;

&lt;p&gt;My theory on this is: more visual transitions = more user perception of effort. Native apps feel snappy in large part because they don’t have time to show you an intermediate pressed state on a button. The very millisecond your finger leaves contact with the screen, the UI is already moving. If possible, mimic that snappiness! You may not need a pressed state at all.&lt;/p&gt;

&lt;p&gt;However, as noted in the last section, sometimes loading is inevitable. That’s where the initial affordance for loading comes in. This is often attached to the interactive element (link or button) which triggered the loading. Still, if the loading is fast enough, the user may never see that affordance, and that’s a good thing. You may even want to add a &lt;code&gt;transition-delay&lt;/code&gt; of a few milliseconds just to see if you can avoid showing it!&lt;/p&gt;

&lt;p&gt;Nevertheless, you may want to add a pressed state to your buttons, anyway. Maybe you just like the way it feels. With a warning aside that this may make your app feel &lt;em&gt;less&lt;/em&gt; native, my recommendation is to choose something subtle but recognizable—maybe some small motion, like Google’s Material “ink drop” effect, or a little scale-down as if the button is being pushed backward. Just don’t overdo it!&lt;/p&gt;

&lt;h3&gt;
  
  
  Prefer &lt;code&gt;:focus-visible&lt;/code&gt; to &lt;code&gt;:focus&lt;/code&gt;, but DO use it
&lt;/h3&gt;

&lt;p&gt;Perhaps one of the most common lines of CSS is &lt;code&gt;button:focus { outline: none; }&lt;/code&gt;. I mean, let’s face it, the built-in browser focus outlines are ugly. They don’t even follow border radius!&lt;/p&gt;

&lt;p&gt;But you can’t stop there, because accessibility matters. If you’ve ever tried applying your own focus styles, though (maybe using &lt;code&gt;box-shadow&lt;/code&gt; to get that nice border radius curve), you’ll quickly notice that buttons get “stuck” after you press them. Because they remain focused after press!&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A demonstration of sticky buttons: [&lt;a href="https://gfor.rest/images/blog/pwa-native/sticky-buttons.mp4" rel="noopener noreferrer"&gt;Video&lt;/a&gt;]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That’s why &lt;code&gt;:focus-visible&lt;/code&gt; exists. It’s only applied when focus is controlled more intentionally, like via keyboard. So put your focus styles in there, and your buttons will feel more springy.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;user-select: none&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When was the last time you accidentally highlighted an image in a native app? Never? That’s because native UIs don’t even allow that (at least, by default). In fact, more often than not, you can’t select most text in native apps… even stuff you really &lt;em&gt;should&lt;/em&gt; be able to.&lt;/p&gt;

&lt;p&gt;If you’re feeling gutsy, do a &lt;code&gt;* { user-select: none }&lt;/code&gt;, then re-enable it for blocks of text you want to let users highlight and copy. If that seems a little too extreme, you can at least safely do it on &lt;code&gt;img&lt;/code&gt; and other elements you definitely don't want selected. Avoid the nasty, fourth-wall-breaking effect of accidentally starting text selection when you were just trying to scroll!&lt;/p&gt;

&lt;h3&gt;
  
  
  Overscroll behavior
&lt;/h3&gt;

&lt;p&gt;This is a sneaky one that a lot of mobile web app makers miss. When you scroll to the end of a webpage on Android, the page stretches and bounces a bit. What this usually means, if you have stuff like an absolute-positioned navigation, is that empty white space becomes visible at the edges of your app. Really breaks the immersion!&lt;/p&gt;

&lt;p&gt;Plus, if you scroll up at the top of the page, you’ll get a pull-down-to-refresh experience. Native apps don’t have that (well, if they do, it’s one that they control—not on every single page).&lt;/p&gt;

&lt;p&gt;However, you want to be mindful of user expectations here. When viewing your app as a webpage in the browser, users should still be able to use these tools. We just want to remove them in the PWA, to get a more native feel.&lt;/p&gt;

&lt;p&gt;This is one thing I can just toss you some code for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;display-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;standalone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;overscroll-behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning off overscroll behavior removes both rubberbanding and pull-to-refresh. Now the edges of your scrolled pages will feel nice and solid.&lt;/p&gt;

&lt;p&gt;However, if you do want a pull to refresh (like on a home feed), you’re gonna have to figure that out yourself. But anyway, you probably didn’t want the blank-page full-refresh behavior, anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sheets
&lt;/h3&gt;

&lt;p&gt;If you aren’t familiar, a “sheet” is a term for a dialog that’s anchored to one edge of the screen. On mobile, you see a lot of sheets which are bottom-anchored.&lt;/p&gt;

&lt;p&gt;Sheets are simply easier to use with one hand on a phone, since they position controls near the bottom of the screen instead of the center. It’s a little trickier to pull off than just &lt;code&gt;bottom: 0px&lt;/code&gt; though.&lt;/p&gt;

&lt;p&gt;One big sticking point is the mobile keyboard. If your dialog/sheet features text input, you don’t want the keyboard to overlap the sheet when it pops open. On Android, the touch keyboard doesn’t resize the window, it overlaps the page content. That means you need to position your sheet’s bottom edge to match the height of the keyboard.&lt;/p&gt;

&lt;p&gt;The visual viewport height can also change when the user scrolls, if their mobile browser hides its URL bar and other chrome after scrolling.&lt;/p&gt;

&lt;p&gt;Not only that, but on iOS, the viewport area extends down &lt;em&gt;underneath&lt;/em&gt; the main navigation gesture bar, which can overlap your content. Why can’t anything be simple?&lt;/p&gt;

&lt;p&gt;This is a problem I’m still figuring out myself. If you’ve got simple solutions, I’d love to hear them. But here’s what I’ve come up with:&lt;/p&gt;

&lt;h4&gt;
  
  
  Establish and update CSS vars for the viewport “safe area”
&lt;/h4&gt;

&lt;p&gt;When the virtual keyboard appears or disappears, it will trigger a &lt;code&gt;resize&lt;/code&gt; event on &lt;code&gt;window.visualViewport&lt;/code&gt; (did you know that was a thing?).&lt;/p&gt;

&lt;p&gt;So what I start off with is listening to that event and the window’s &lt;code&gt;scroll&lt;/code&gt; event, and triggering a callback to apply some CSS vars to the document which I can use elsewhere in CSS calculations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--viewport-bottom-offset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--viewport-height&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// not all browsers support this.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visualViewport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, I can use the &lt;code&gt;--viewport-bottom-offset&lt;/code&gt; var to set the bottom value for a sheet. I add a fallback of &lt;code&gt;0px&lt;/code&gt; just in case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;bottom&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--viewport-bottom-offset&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Utilize &lt;code&gt;safe-area-inset&lt;/code&gt; environment vars
&lt;/h4&gt;

&lt;p&gt;On supported devices, there’s also the built-in &lt;code&gt;safe-area-inset&lt;/code&gt; collection of CSS vars which can help you avoid things like the global navigation gesture bar and camera cutouts.&lt;/p&gt;

&lt;p&gt;You don’t want to use these to offset the sheet position—otherwise the sheet would ‘hover’ above the navigation bar. Instead, I use it to pad the bottom of the sheet to ensure that content doesn’t get overlapped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;padding-bottom&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;calc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="nt"&gt;rem&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;safe-area-inset-bottom&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These techniques combined can &lt;em&gt;help&lt;/em&gt; to position sheets correctly, but I’m afraid there’s still some instability possible when doing text input with the virtual keyboard, especially if you trigger focus on the text input immediately when showing the dialog. Hopefully I can find some more stable solutions.&lt;/p&gt;

&lt;h4&gt;
  
  
  Swipe to dismiss
&lt;/h4&gt;

&lt;p&gt;Another native feature of sheets which you can copy is the swipe-to-dismiss gesture. This lets the user swipe downward on the sheet to close it (after a certain threshold).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The GitHub app has great sheet gestures. [&lt;a href="https://gfor.rest/images/blog/pwa-native/sheet-gestures.mp4" rel="noopener noreferrer"&gt;Video&lt;/a&gt;]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Some important details about this gesture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;There’s usually a visual affordance in the form of a small horizontal bar at the top of the sheet.&lt;/li&gt;
&lt;li&gt;Despite that affordance, the user can begin the gesture &lt;em&gt;anywhere&lt;/em&gt; on the sheet.&lt;/li&gt;
&lt;li&gt;If the sheet has scrolling content, the gesture only triggers when that content is scrolled to the top. Otherwise, swiping downward scrolls up, like normal.&lt;/li&gt;
&lt;li&gt;The gesture can be cancelled by either ending before the threshold, or swiping back upward.&lt;/li&gt;
&lt;li&gt;Some fancier sheets even let you drag upward to expand the sheet to be larger and show more content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It’s ambitious to copy all these behaviors, but you could start by adding swipe-to-dismiss starting just from the top edge of the sheet and work from there. Most users probably see the visual affordance and anchor their swipe there, anyways, so you can avoid worrying about scrolling content for an MVP implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bottom tab navigation
&lt;/h3&gt;

&lt;p&gt;Lots of native apps have settled on a bottom-tab navigation structure. It’s convenient and easy to reach! But just like sheets, there’s a little more to doing this right than meets the eye.&lt;/p&gt;

&lt;p&gt;Well, ok, it’s the same problem. You need to make sure the system gesture bar doesn’t overlap your nav. Time to break out &lt;code&gt;env(safe-area-inset-bottom)&lt;/code&gt; again! Just pad the bottom of your nav with that value plus a bit extra to make sure users can utilize your nav bar.&lt;/p&gt;

&lt;p&gt;That said, there’s one more important rule: don’t put more than 5 items in there (and I think 4 is a better maximum). This is an information architecture problem, and big name apps have teams of designers who have done tons of work on it for their particular use cases. You’ll have to do your best, too: figure out a maximum of 5 categories that encompass the core experiences in your app. All your pages should be structured under these categories.&lt;/p&gt;

&lt;p&gt;That’s not to say you can’t cheat a little bit. For example, Spotify doesn’t put user settings in their nav bar, they make it accessible only on the “Home” screen in the top right corner. It’s a good trick for less-frequently-used pages.&lt;/p&gt;

&lt;p&gt;But seriously, if mature and multi-featured apps like Spotify and Slack can figure out how to present 5 or fewer top-level navigation options, so can you!&lt;/p&gt;

&lt;h3&gt;
  
  
  A special note on &lt;code&gt;select&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Select is a disaster. It’s practically unstylable. But it has one key benefit: natively, it adapts well to phone input. Instead of trying to place a little pop-over somewhere on the already cramped mobile screen, HTML’s Select just drops a full modal over the whole UI for simple, finger-friendly selection.&lt;/p&gt;

&lt;p&gt;There are still problems with this, though. You can only represent a simple list of text options. Anything fancier—colors, user avatars, badges, whatever—and you’re out of luck.&lt;/p&gt;

&lt;p&gt;Take note of how native apps manage selects like this. For one, they’re not very common. Yet another reason to avoid them! But when you need one, good UX is a little more nuanced than you think.&lt;/p&gt;

&lt;h4&gt;
  
  
  For just a few options: try a pop-over
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgfor.rest%2Fimages%2Fblog%2Fpwa-native%2Fgithub-small-dropdown.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgfor.rest%2Fimages%2Fblog%2Fpwa-native%2Fgithub-small-dropdown.png" alt="A screenshot of a small popover for selecting one of three statuses in the GitHub app"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The GitHub app uses a simple dropdown for the three-option status selector&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Selects should never have fewer than 3 options (if you’ve only got 2, use a different element!). But if you’ve got 3 or 4, and they’re relatively compact labels, a pop-over is fine and gives quick, localized access.&lt;/p&gt;

&lt;h4&gt;
  
  
  For more: use a sheet
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgfor.rest%2Fimages%2Fblog%2Fpwa-native%2Fgithub-large-dropdown.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgfor.rest%2Fimages%2Fblog%2Fpwa-native%2Fgithub-large-dropdown.png" alt="A screenshot of a sheet with a filtered list of labels in the GitHub app"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The GitHub app opens a sheet for the long list of labels in the label selector&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What’s better than the browser’s native select modal dialog? A sheet positioned at the &lt;em&gt;bottom&lt;/em&gt; of the screen for easy reach. In a sheet, you can list as many options as you like, plus include fancy controls for filtering. Think of this as a UX opportunity, not extra work!&lt;/p&gt;

&lt;p&gt;Of course, how you approach this in code will be the challenge. Supporting two entirely different interactions on mobile and desktop can be a burden. I’d say it depends on your primary target platform. If you’re mobile-focused, there’s no reason you can’t get by with a sheet interface on desktop, too (maybe give it a maximum width, or even alter its positioning so it shows up as a centered, typical dialog).&lt;/p&gt;

&lt;h2&gt;
  
  
  Using platform features
&lt;/h2&gt;

&lt;p&gt;You’re a web app, not a website. Why not use some of the available features on your user’s device? While many features are need-based (like location, or the camera), there are some goodies you can sprinkle in to make your experience feel less like a page inside a browser, and more like software running right on the device.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rumble!
&lt;/h3&gt;

&lt;p&gt;Don’t overdo this! It bears repeating—don’t overdo it! But using haptic feedback can be a very situational but powerful tool for UX. I particularly like to use it when an action triggers something which may happen out of view. For example, in my groceries app, I give your phone a little shake when you add items to your list from a different page, just to confirm that those items really did get added even though you can’t see your list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Share targets
&lt;/h3&gt;

&lt;p&gt;Did you know you can get your PWA into the system share dialog? I think it’s one of the coolest things PWAs can do! When you register as a share target, users can “Share to” your app, and you get either a POST payload or a set of query parameters (your choice) that include a link or some text they shared.&lt;/p&gt;

&lt;p&gt;You don’t need a server to handle a share. Even with the POST version, you can write a handler in your service worker to respond to it on-device for static-only sites.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in your service worker...&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// detect a share event from the PWA&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/share&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// redirect so the user can refresh without resending data&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="c1"&gt;// handle the share!&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://web.dev/web-share/" rel="noopener noreferrer"&gt;Check out more on how to use share targets here.&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The extra mile(s)
&lt;/h2&gt;

&lt;p&gt;I think those things cover the table stakes of creating a good mobile app experience on the web. Now let’s get into the things that will make users forget it’s a website. We're going beyond just making it feel native, we're making it feel like a genuinely good app.&lt;/p&gt;

&lt;p&gt;Fair warning: these are even more work!&lt;/p&gt;

&lt;h3&gt;
  
  
  Follow the OS’ design principles
&lt;/h3&gt;

&lt;p&gt;iOS and Android have different design sensibilities which differ from browser user-agent styling &lt;em&gt;and&lt;/em&gt; from each other. Since native apps are often building on the same tools as first-party apps, they often have an easier time looking and feeling like their host OS. The culture around native apps is also different (and, seemingly, different between iOS and Android); there’s more expectation to fit in with the native design system.&lt;/p&gt;

&lt;p&gt;On the web, we’re a little unmoored from any native design. There’s no telling what the browser which views your page looks like, and on top of that, the host OS is also variable. Trying to conform website styles to their surroundings is a losing battle, but you &lt;em&gt;can&lt;/em&gt; make some tweaks to fit in better on iOS and Android specifically if your focus is mobile. Two customization targets are a little easier to manage than a hundred permutations (although still very… extra).&lt;/p&gt;

&lt;p&gt;And, in fact, mobile design is sort of converging a bit at a high level on flat color shapes and simplified elements, so even just adapting your own designs to those common details can help you fit in.&lt;/p&gt;

&lt;p&gt;For example, most mobile system UI buttons (right now) have large border radii, often extending across the entire horizontal edges (i.e. &lt;code&gt;border-radius: 100%&lt;/code&gt;). They’re also solid-colored. That’s a pretty easy update to make in your CSS to feel more at home.&lt;/p&gt;

&lt;p&gt;Don’t want to change your desktop styles, too? As far as I know, this is an imprecise science. You could try using a small media query size, like &lt;code&gt;(max-width: 720px)&lt;/code&gt;. But desktop windows can get that small, too. Whether you care is up to you.&lt;/p&gt;

&lt;p&gt;For a more robust option, you might try using Javascript to check the user-agent for mobile tags, then apply a class on your &lt;code&gt;body&lt;/code&gt; which other elements can use to select their own rules, like &lt;code&gt;body.ios button { ... }&lt;/code&gt;. Keep in mind that this may result in a flash of different styling, though, unless you’re doing server-side rendering and can pre-populate that class in the initial HTML payload by inspecting headers. This all sounds more trouble than it’s worth to me, though, just riffing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable context menus
&lt;/h3&gt;

&lt;p&gt;This is similar to &lt;code&gt;user-select: none&lt;/code&gt;, but a little more… contextual. Especially for links, even if &lt;code&gt;user-select: none&lt;/code&gt; is applied, the user can still long-press and get the boring old browser context menu, which offers to copy the link address, open in the browser, etc.&lt;/p&gt;

&lt;p&gt;I think &lt;em&gt;sometimes&lt;/em&gt; you want to let users do this, especially for inline text links. But for situations like app navigation, probably not! Disable the context menu on those by calling &lt;code&gt;preventDefault&lt;/code&gt; on the &lt;code&gt;contextmenu&lt;/code&gt; event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Swipe-based navigation
&lt;/h3&gt;

&lt;p&gt;This is one of those really hard things that lucky native mobile developers get more or less for free. Ever noticed how in some apps with bottom-bar tab navigation, you can often swipe to the left or right to change pages? It's not super common, but I've noticed first-party apps do it more.&lt;/p&gt;

&lt;p&gt;Yeah, stop and think about how you’d do that with your web app! Keep in mind it’s not just a binary gesture; you can move your finger around and the whole view moves with it. Move it back to cancel the gesture entirely.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;GPay is one of those rare apps that has swipe-based navigation, and it feels great. [&lt;a href="https://gfor.rest/images/blog/pwa-native/gpay-nav-gesture.mp4" rel="noopener noreferrer"&gt;Video&lt;/a&gt;]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you really want to show off, try doing this in your app. I did it with &lt;a href="https://gnocchi.club" rel="noopener noreferrer"&gt;Gnocchi&lt;/a&gt;, and it was challenging but fun to pull off. I had to create my own client-side routing library! My approach was to create a component which could render the UI for any particular URL, even if that URL doesn’t match the browser’s current location. Then, when the user’s gesture triggers a threshold of movement, I render the page to the left or right of the current one, using CSS &lt;code&gt;transform&lt;/code&gt; to control the displacement according to finger position.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;My app, &lt;a href="https://gnocchi.club" rel="noopener noreferrer"&gt;Gnocchi&lt;/a&gt;, uses swipe-based navigation. It was a pain but it's fun to play with! [&lt;a href="https://gfor.rest/images/blog/pwa-native/gnocchi-nav-gestures.mp4" rel="noopener noreferrer"&gt;Video&lt;/a&gt;]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’m sure there are other ways to approach this without writing your own router. Perhaps you can encapsulate individual pages in their own reusable components more effectively than I did, and just render those components. But using paths seemed relatively simple to me at the time. Your mileage may vary!&lt;/p&gt;

&lt;p&gt;Nonetheless, this is a small but difficult thing which adds a ton to the overall user experience feeling ‘native.’ Users take this gesture for granted in apps they use every day. If you want them to really forget it’s a web app, try replicating it!&lt;/p&gt;

&lt;p&gt;One caveat, this won't be a good gesture if your app already has horizontal gestures for actions like archiving list items.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Wow, that’s a lot of work just to feel native, right? Are web apps a bad idea?&lt;/p&gt;

&lt;p&gt;In my opinion: not at all! For one, a lot of these things are going to benefit your app when running on non-mobile platforms, too. And, at the end of the day, &lt;em&gt;your app can run on other platforms!&lt;/em&gt; It may take some effort and intentionality, even a bit of hard work, but I think it’s possible to live the dream of one cross-platform codebase without trading off a good, close-to-native experience. It’s why I love being a web developer.&lt;/p&gt;

&lt;p&gt;Here’s to better web apps!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>pwa</category>
      <category>mobile</category>
      <category>ux</category>
    </item>
    <item>
      <title>Calm tools</title>
      <dc:creator>Grant Forrest</dc:creator>
      <pubDate>Tue, 11 Jul 2023 02:43:05 +0000</pubDate>
      <link>https://dev.to/atype/calm-tools-hod</link>
      <guid>https://dev.to/atype/calm-tools-hod</guid>
      <description>&lt;p&gt;My cooking app, &lt;a href="https://gnocchi.club"&gt;Gnocchi&lt;/a&gt;, does not help you find recipes.&lt;/p&gt;

&lt;p&gt;There's no weekly newsletter, no hub for self-published recipes and comment boxes, no home feed to scroll.&lt;/p&gt;

&lt;p&gt;At first, these things were on my roadmap. After all, a lot of cooking is deciding what to cook. Doesn't it make sense that the same app that helps you plan out grocery runs and organize your favorite recipes is &lt;em&gt;also&lt;/em&gt; the place you go to discover new ideas? Wouldn't it be cool to have great chefs posting their latest inventions in a foodie social network? Think of all the shares that might generate! User growth, engagement, success!&lt;/p&gt;

&lt;p&gt;That progression may seem natural in our current tech product landscape, but it's really just one particular path. A framework, a worldview. And one which I am more and more consciously trying to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Busy Technology
&lt;/h2&gt;

&lt;p&gt;Nearly everything we interact with online is trying to acquire our attention. Cooking sites are a prime example of the phenomenon. You can't just have a grocery list, or a recipe keeper. It has to bother you to turn on notifications (why?), sign up for the daily newsletter of new recipes, scroll the infinite list of user-created content presented right on the homepage. The website is designed to keep you there, keep you scrolling, and the reason is as straightforward as it is painfully in your face: ads. Again, cooking spaces online excel (read: horrify) here. Instead of spending 10 minutes learning how to prepare the fresh green beans I got from my produce co-op, I'm stuck scrolling through paragraph after paragraph of how you met your spouse at the farmer's market while video after video obstructs half of my phone screen. Now the garlic is burning in the pan but I can't find the next step because the content layout shifted with a new batch of ads.&lt;/p&gt;

&lt;p&gt;(Seriously, turn off your adblocker/pihole, open a food blog and watch the network tab of your devtools. The amount of ad traffic is wild.)&lt;/p&gt;

&lt;p&gt;It's not at all that I blame the chefs for this, really. This is the way the technological ecosystem they operate in was designed. The incentives are encoded into the way we make websites, do business, and even think about what the internet &lt;em&gt;is.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;But as a software developer and a tool maker, I'm grateful I have some power to forge a different path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool vs Lifestyle
&lt;/h2&gt;

&lt;p&gt;The internet and smartphone era has shifted our understanding of software. Now, I'm out on a limb in saying that—I basically came of age on the internet, so I can only infer what it felt like to build and use software before, say, search engines. But my impression is that we began thinking of computers as a tool to help us implement existing life-processes (business procedures, hobbies, chores) more efficiently or effectively. Then, over the course of decades, we began thinking of computers as a medium to &lt;em&gt;re-invent&lt;/em&gt; those processes. Computing became a lifestyle.&lt;/p&gt;

&lt;p&gt;How did we once connect with friends? Far enough back, it was visiting one another. Then, sending mail. Phones closed the loop even further. Texting, further still. It might be easy to plot social media along that trendline, but I think that somewhere between SMS and message boards, the &lt;em&gt;lifestyle&lt;/em&gt; shift began. Communication is no longer an event we initiate mutually with someone we know. Our friends feel 'present' all the time, through their feeds and stories and reels in our pocket. We can scroll endless feeds of communication, often with people we don't know, and join in anytime (think how &lt;em&gt;dead&lt;/em&gt; a social network would feel if it were only friends you knew IRL. Only like, a few posts an hour, how would you scroll??). But I think many folks feel that something has been lost for all that we gained.&lt;/p&gt;

&lt;p&gt;For my part, I think about how we used to cook compared to how modern recipe websites want us to. There's not a huge difference in the general form of it. You seek out dish ideas, discover some publications you find some rapport with, and clip the recipes you want to try later. For my mom, this meant subscribing to cooking magazines which showed up in her mailbox each month. What does it look like for me? Realistically, it's googling the kind of dish I want to make and "recipe," then looking for a link to Bon Appetit or a food blog I've had good results from before, like &lt;a href="https://cafedelites.com/"&gt;Cafe Delites&lt;/a&gt; (try the &lt;a href="https://cafedelites.com/honey-garlic-butter-salmon-in-foil/"&gt;baked honey garlic salmon&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In principle these are fairly similar patterns of behavior, but things begin to diverge in terms of how our digital cooking tools have evolved. I've noticed over the years how a lot of cooking sites have created their own versions of a shopping list. Even on small food blogs, usually they're using a Wordpress plugin which offers to add the recipe ingredients to your grocery list. The problem here is obvious if you spend a lot of time browsing recipe sites: it's not the same list. Every publication or plugin has its own, separate list. The objective (cynically interpreted) is to try to lure you into a walled garden and incentivize you to visit that site more often than the others, since it's where your list is.&lt;/p&gt;

&lt;p&gt;I don't know anyone who uses these things. Why would you? Why choose to tie your shopping list to AllRecipes? So what I used to do was just copy-paste things into Google Keep. It worked fine, plus my wife also had access to the list.&lt;/p&gt;

&lt;p&gt;But it began to bug me how little software was helping me &lt;em&gt;perform my life tasks more efficiently and effectively.&lt;/em&gt; My mom would jot down ingredients she needed on a pad of paper with the Cooking Light magazine open in front of her. Now I was arduously copy-pasting text from one app to another in my phone. Honestly, the notepad is probably easier. Recipe sites don't want to help me plan groceries like I want to, but better—they want me to adopt their lifestyle for how I cook and shop.&lt;/p&gt;

&lt;p&gt;I'm not saying this is a malicious thing. Yes, there's the ad impressions incentive, but I don't think the kinds of people who painstakingly take beautiful photos of each step of a recipe are consciously doing it to lock me into their ecosystem and collect ad revenue. But that's the way the internet works these days, that's how you do business. They have to—right?&lt;/p&gt;

&lt;h2&gt;
  
  
  Reclaiming Software as Tool
&lt;/h2&gt;

&lt;p&gt;So I thought, maybe I can do this differently. I've built up a lot of skills I need to launch a web app over the years, and I love cooking and have some opinions about how I want to go about it. And since I've observed this anti-pattern at play, I also have a good shot at avoiding it if I'm careful about how I proceed.&lt;/p&gt;

&lt;p&gt;That process eventually lead to &lt;a href="https://gnocchi.club"&gt;Gnocchi&lt;/a&gt;, but first I had to create &lt;a href="https://verdant.dev"&gt;Verdant&lt;/a&gt;, my local-first web toolkit. To reinvent how web products work on a business level, I had to do a little re-invention of the technical level, too. Local-first is a burgeoning space with a lot of talented and creative builders, I'd recommend taking a look.&lt;/p&gt;

&lt;p&gt;I go into this more at length elsewhere, but one of the core motivators for me going to local-first was sustainable "free trials" — software runs on the user's device, not my servers, so I can let people use it for free without running a deficit. This aligns the incentives for paying (access to server-powered features like sync and multiplayer) with my incentives as a maker (I don't have to buy server time unless people are paying me for it). Compare that to traditional cloud models, where every free user costs you money. It's a little simplistic, but &lt;strong&gt;I think that misalignment is the source of a lot of the web's problems&lt;/strong&gt;, and drives the ad- (or data)-economy dysfunction which leads product managers to distort perfectly usable &lt;em&gt;tools&lt;/em&gt; into &lt;em&gt;lifestyle products&lt;/em&gt; and &lt;em&gt;ecosystems&lt;/em&gt; that keep your attention captive.&lt;/p&gt;

&lt;p&gt;Meanwhile, I know two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you're using the app for free, that's great, keep doing that forever. No issue for me.&lt;/li&gt;
&lt;li&gt;If you're paying for more features, that's also great. One or two subscriptions keep the server online.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It might be harder for me to "scale" with this kind of model, but it puts me in a very nice spot for focusing on solving real user problems, because I'm not under existential pressure to pay cloud bills or raise the next round of funding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It also means I am not in the attention economy.&lt;/strong&gt; I gain nothing if you have my app open for hours at a time. Which is good, because this is a grocery list; the goal is efficiency! I don't want to have an incentive to keep the user in my app when what they're trying to do is get through the grocery store or finish preparing a weeknight dinner. &lt;a href="https://en.wiktionary.org/wiki/enshittification"&gt;That would tempt me to make my product worse for my own gain.&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning Away from Engagement Addiction
&lt;/h2&gt;

&lt;p&gt;When my incentives and my user's incentives are more aligned, I am empowered to respect their goals and process more—to adapt my tool to &lt;em&gt;them&lt;/em&gt;, not try to adapt their behavior to my product.&lt;/p&gt;

&lt;p&gt;In this case, I'm my own user. We have a lot of cookbooks in our house, full of a lot of recipes I've never tried. I want to cook some of them! So every once in a while I'll open one up and find something that sounds delicious. My problem really wasn't not being able to discover new recipes; they're everywhere! It was just getting them all in one place and pulling a grocery list together.&lt;/p&gt;

&lt;p&gt;So, while it might increase 'engagement' to build that recipe social hub into my app, I eventually realized this was my "web as lifestyle" training speaking. Did I, the guy cooking dinner, really want a recipe social hub? Or was that just me, the guy who thinks "user engagement" is important for his app? I had to remind myself: I can keep this app going indefinitely. Nobody really &lt;em&gt;needs&lt;/em&gt; to spend their attention on it. It can just be a good tool, used when needed, invisible when not.&lt;/p&gt;

&lt;p&gt;Will people find that worth paying for? I'm not really sure yet, but I suppose I'll find out. Either way, I'm happy with what I've created, and I use it every week. That feels great.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools are Calm
&lt;/h2&gt;

&lt;p&gt;Think about a hammer. Nothing about its appearance really grabs your attention. It doesn't have a notification chime to remind you to pick it up. There's no LCD screen to keep track of your total number of swings and award badges. The tool is there when you need it, crafted solely for its task. When we need a tool, we reach for it. And, as it happens, &lt;strong&gt;the more we value a process, the more we invest in our tools.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tools like that are an example of a Calm Technology. Fit to their purpose, respectful of your objective, and demanding nothing else of you but to use them well. I hope as we continue to grapple with the busy, disruptive, demanding experiences we have created in this era of software, we can begin to reclaim a bit of that calm.&lt;/p&gt;

</description>
      <category>calmtech</category>
      <category>webdev</category>
      <category>localfirst</category>
    </item>
  </channel>
</rss>
