Logo and link back to home page

My Svelte & Sapper Power Notes

Notes on my journey into the lands of Svelte and Sapper…

d2 yellow hero illo

Svelte

This post is a sort of “mega pile of notes” that speaks to specific lessons I learn as I play with Svelte and Sapper and create the blog you're reading right now. If you'd like an overview on these technologies, jump to the bottom where I've listed resources that explain why you should consider them better then I can.

Although this post is mainly so that I can refer back these notes myself, maybe there will be something here that will help you in your own adventures ramping up on Svelte and Sapper.

Disclaimer: Currently this is a sort of brain dump, so expect frequent changes unordered lessons for the time being ;-)

Svelte's key value props

  • Svelte is reactive meaning it reacts and updates the UI when your state changes
  • Svelte precompiles your code and, for most part does not “ship itself” with your deployed app
  • It's lightweight and blazingly fast
  • Animation feels easier then other frameworks
  • Excels for rapid prototyping, speed of development, and overall improved DevUX

Components

  • Core to components, Svelte uses the convention of having your <script>, <style>, and markup all within the a single component. You don't need to use all of these but the convention is to do.

  • Props can be accessed via curly braces in the markup

<script>
  let name = 'Rob Levin';
</script>

and then in my markup it'd be used with:

<p>My name is {name}</p>

We can create truly reactive state by adding a button for this variable:

<script>
  let name = 'Rob Levin';

  const clickHandler = () => {
    name = 'Bjorn Borg';
  }
...
<button on:click={clickHandler}>update name</button>

Now, it will start as Rob Levin, but when you click the button, it will change to Bjorn Borg.

For inputs you could grab the current value with something like:

<script>
  let name = 'Rob Levin';

  const inputHandler = (e) => {
    name = e.target.value
  }
...
<input type="text" on:input={inputHandler}>

And we'd see the name updated with the “live input” we type since we've wired up Svelte's reactivity between the state of name and our user input.

Two-way binding

In the above input example, we have a one-way binding between the input --> to the name. However, the input itself can also be bound back to the name simply by adding the value attribute like so:

<input type="text" on:input={inputHandler} value={name}>

This is of course useful if you want to preinitialize the input.

Two-way binding shorthand

To combine the two steps above we can just do:

bind:value={name}

Curly brackets ftw!

Curly bracket values can be used for most everything—for example, to determine CSS classes with a ternary like React: class="{someCondition ? 'the-klass' : ''}. This is really intuitive for any experienced frontend developer as we've been doing this since back in the day with mustache and handlebars, do it now with ES6 string templates, and probably in the last framework we just used too.

Reactive values

Similar to Vue's computed values you can do something like:

$: politeName = `${honorific}. ${firstName} ${lastName}`

The nice thing about reactive values is that if any of the interpolated variables change, the reactive value will reflect the change. Similar to basic reactivity, but perhaps more convenient.

Reactive statements

Correspondingly, statements can be reactive as well:

$: {
  console.log('firstName: ', firstName, 'can be more politely referred to as:')
  console.log(politeName)
}

And it will update in real-time if state changes amongst any of the utilized variables.

Components can leverage and compose other components:

<script>
  import Card from './Card.svelte'
  ...
</script>

and then render it in the markup very much like we do in React with JSX:

<Card />

Slots

If you're used to children in React, or outlets in Ember, you can pretty much mentally map Slots in Svelte to those. For example, to have arbitrary children like in React, we use a slot like:

<div class="card">
  <slot />
</div>

And can now do:

<Card><p>My children</p></Card>

Template looping & inline binding

Taking the examples above a bit further, let's say we have a list of posts that we want to show in as card views (like we do on this blog!).

We can loop through lists in familiar templating fashion and also bind to each item as in the following somewhat contrived example:

<script>
  const deletePost = (slug) => {
    posts = posts.filter((post) => post.slug != slug)
    updatePostsState(posts)
  }
</script>
<main>
  <ul>
    {#each posts as post}
      <Card>
        <li>
          <a rel="prefetch" href={post.slug}>
            <h1>{post.title}</h1>
          </a>
          <span class="category">{post.category}</span>
          <button on:click={() => deletePost(post.slug)}>Update Post</button>
        </li>
      </Card>
    {:else}
      <p>No posts to show…</p>
    {/each}
  </ul>
</main>

Props

In order to pass in props in Svelte, you have to export the prop from the receiving component:

/* In Toast.svelte */
<script>
  export let message //gets displayed in the toast notification
</script>
/* In View.svelte (or whatever's using the Toast) */
<script>
import Toast from './components/Toast.svelte'
</script>

{#if someErrorHappened}
  <div class="toast-wrap">
    <Toast message="You just got notified." />
  </div>
{/if}

Stores

Svelte offers a few variations on stores, with readable, writable, and derived stores. You can read about them but I'll show how I used the writable store on this blog to implement “dark-mode” which you hopefully already see (it's the toggle button on the header on this very page!)

Why Stores?

From a very high-level and hand-wavy place, I will declare that Svelte state is actually quite similar to React in that you'd first prefer top down props passing while it's practical, and until it starts to get convoluted because the component tree is too complex to keep track of. That's pretty much when I start moving away from prop drilling.

As such, the use of Svelte's state, is probably best reserved for the same use cases that would best be solved with React's Context or other state alternatives like Redux, if you were coding things up in that eco-system.

Essentially, I created a custom store and kept it in isDarkModeEnabled:

// src/common/store.js
import { writable } from 'svelte/store';
export const isDarkModeEnabled = writable(false);

Then toggled this state from my Toggle.svelte component:

<script>
  import { isDarkModeEnabled } from '../common/store.js'
  import { get } from 'svelte/store'
  let localDarkEnabled

  function toggleViewMode() {
    isDarkModeEnabled.update(mode => {
      localDarkEnabled = !mode
      return localDarkEnabled
    })
  }
</script>

Obviously, this code simply toggles a the global boolean which represents if we should kick in dark mode or not.

State subscribers

In this case, the state subscribers were my two main views which are a list view for the home page and a post details view for posts, and the view you're on now as you read this post. Here's how those look:

<script>
  import { onMount } from 'svelte'
  import { isDarkModeEnabled } from '../common/store.js'
  let isDarkMode
  onMount(() => {
    const unsubscribe = isDarkModeEnabled.subscribe(value => {
      isDarkMode = value
    })
  })
</script>

With that setup, we now have the answer to our main question "Should I use light mode or dark mode?". I elected to style the light mode as a default, and the dark mode as an override:

<svelte:head>
  <title>Develop to Design</title>
  <meta name="description" content="Develop to Design posts" />
  {#if $isDarkModeEnabled}
    <style>
      /* dark mode styles here */
    </style>
  {/if}
</svelte:head>

If you're a savvy frontend dev, I'm pretty sure you can take my above explanation and reverse engineer what you see in dev tools.

Disclaimer: I haven't tapped into native dark mode detection at the OS or browser level, but am keen to once those get more support. It'll likely mean I have to completely overhaul things, but that's fine.

Animations

Svelte is declared to lend itself to animation and Rich Harris's New York times interactive pages which use Svelte make that self evident.

An example of a fairly simple animation on this blog is the back to top button that appears once you've scrolled down a ways. You should see it on this very page in fact, once you've scrolled to about half way down the main hero image on the article. Let's see how that was done and break it down step-by-step in the order I actually coded it up…

Detect scroll position

The first order of business is to be able to determine when the user's scrolled down to a certain point and we want to unhide the back to top button.

After a bit of tinkering, I went with halfway down the hero image.

To pull this off involves attaching a window scroll listener, making some positional scroll calculations, and attaching a CSS class to the document.body (many other ways to accomplish this, but we'll just go with what I opted with here).

However, since this blog is using Sapper with SSR so we have to use some specific techniques to have access to things like window and document. In Svelte / Sapper land this means we need:

  1. Any document.body and document.querySelector types of code need to happen in the special onMount hook.
  2. For our window scroll listener, we need to use: <svelte:window bind:scrollY={y} /> which will now make y hold the current scroll position. Magic.

I'll show the BackToTop component in a minute, but the main post details page puts the following in a Svelte <script> tag:

<script>
  import BackToTop from '../components/BackToTop.svelte'
  import { onMount } from 'svelte'
  let navToMiddleOfCardDistance
  let y
  let firstHero

  const determineIfShouldShowBackToTop = y => {
    // If we mounted and were able to set these up
    if (firstHero && navToMiddleOfCardDistance) {
      if (y >= navToMiddleOfCardDistance) {
        document.body.classList.add('show-back-to-top')
      } else {
        document.body.classList.remove('show-back-to-top')
      }
    }
  }
  $: backToTopClass = determineIfShouldShowBackToTop(y)

  onMount(() => {
    firstHero = document.querySelector('figure')
    navToMiddleOfCardDistance =
      firstHero.offsetTop + Math.floor(firstHero.offsetHeight / 2)
  })
</script>

<svelte:window bind:scrollY={y} />

Let's break that down a bit:

there is no window in a server-side environment like Sapper's,

  • We calculate the distance to the middle of the hero figure element. In english we're simply “adding the distance from the top of the figure to the top of viewport, plus, half the height of the figure element itself". This will be used to trigger a class getting added to the body element when we've scrolled just below this point:
if (y >= navToMiddleOfCardDistance) {
  document.body.classList.add('show-back-to-top')
} else {
  // CODE HERE REMOVES the class
}

Ultimately, all of this results in the show-back-to-top getting added/removed on the <body> element. I'd suggest opening dev tools and having a look at the Elements panel and scrolling. You'll see the class added/removed as I've described.

BackToTop component

With the earlier setup which means we have a global CSS class on the <body> when appropriate, we can show the actual component which in my setup resided in: src/components/BackToTop.svelte:

<script>
  function scrollToTheTop() {
    const current =
      document.documentElement.scrollTop || document.body.scrollTop
    if (current > 0) {
      document.documentElement.scrollTop = 0
      document.body.scrollTop = 0
    }
  }
</script>

<style>
  .back-to-top-button {
    position: fixed;
    right: 16px;
    bottom: 115px;
    border: none;
    background: transparent;
    padding: 0;
    cursor: pointer;
    opacity: 0;
    transform: translateY(-100%);
    transition: opacity 0.3s linear, transform 0.5s linear;
  }
  :global(.show-back-to-top) .back-to-top-button {
    opacity: 1;
    transform: translateY(0%);
  }

  .back-to-top {
    width: 40px;
  }
</style>

<button class="back-to-top-button" on:click|preventDefault={scrollToTheTop}>
  <svg
    class="back-to-top"
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 58 90"
    aria-labelledby="back-to-top"
    role="presentation"
    fill="none">
    <text x="0" y="15" fill="red">You're for realz SVG stuff here :)</text>
  </svg>
</button>
  • The scrollToTheTop function could be implemented many ways including the use of requestAnimationFrame but I didn't bother—essentially, if we're not already at the top it makes it so!

  • The button's on:click|preventDefault={scrollToTheTop} ensures the scrollToTheTop actually fires when the user clicks. The pipe preventDefault syntax is Svelte's nifty event modifier shorthand. Another practical use of an event modifier might be to do utilize the |self modifier which only triggers the handler if event.target is the element itself. This means no need to check for e.currentTarget for those of you that understand event delegation techniques. Super convenient!

  • We initially set the opacity to 0 and setup the transition for opacity and transform properties. The duration differences are just the result of me tinkering with what seemed to look best. It seemed that the “fade-in” needed to happen in 300 milliseconds, leaving about 200 milliseconds left with the button fully visible for the linear transform (the button falling or rising via translateY animation).

  • Since we're attaching the .show-back-to-top class to the body, we need to use the :global(.YOURCLASS) Svelte idiom to access that from within the component.

Parting quandaries

As requestAnimationFrame is a property of the window, I didn't see a Svelte <svelte:window bind:raf> or whatever. This, however, seemed to work just fine and without jitter, but I felt my frontend developer code smells apprehension kick in ¯\_(ツ)_/¯

With that said, I believe this little CSS scroll-behavior setting really helped keep the scrolling UX feel nice and smooth:

/* I put this in global.css */
html { scroll-behavior: smooth;}

Refactoring BackToTop

But wait! We can improve this and get rid of the class on <body> ugliness better encapsulating our back to top functionality.

The idea is to replace the body class strategy with a simple boolean prop leveraging Svelte's binding mechanism. The easiest explanation for the refactor is the diff:

diff --git a/src/routes/index.svelte b/src/routes/index.svelte
index b4ca1e3..5ee18a8 100644
--- a/src/routes/index.svelte
+++ b/src/routes/index.svelte
@@ -15,6 +15,7 @@
   let y
   let firstHero
   let navToMiddleOfCardDistance
+  let showBackToTop = false

   import { onMount } from 'svelte'

@@ -22,9 +23,9 @@
     // If we mounted and were able to set these up
     if (firstHero && navToMiddleOfCardDistance) {
       if (y >= navToMiddleOfCardDistance) {
-        document.body.classList.add('show-back-to-top')
+        showBackToTop = true
       } else {
-        document.body.classList.remove('show-back-to-top')
+        showBackToTop = false
       }
     }
   }
@@ -41,4 +42,4 @@

 <Listings {posts} />

-<BackToTop />
+<BackToTop {showBackToTop} />

And in the BackToTop.svelte component itself:

diff --git a/src/components/BackToTop.svelte b/src/components/BackToTop.svelte
index 3af812f..136c75e 100644
--- a/src/components/BackToTop.svelte
+++ b/src/components/BackToTop.svelte
@@ -1,4 +1,5 @@
 <script>
+  export let showBackToTop = false
   function scrollToTheTop() {
     const current =
       document.documentElement.scrollTop || document.body.scrollTop
@@ -22,17 +23,23 @@
     transform: translateY(-100%);
     transition: opacity 0.3s linear, transform 0.5s linear;
   }
-  :global(.show-back-to-top) .back-to-top-button {
+  .show {
     opacity: 1;
     transform: translateY(0%);
   }
+  .hide {
+    opacity: 0;
+    transform: translateY(-100%);
+  }

   .back-to-top {
     width: 40px;
   }
 </style>

-<button class="back-to-top-button" on:click={scrollToTheTop}>
+<button
+  class="back-to-top-button {showBackToTop ? 'show' : 'hide'}"
+  on:click|preventDefault={scrollToTheTop}>
   <svg
     class="back-to-top"
     xmlns="http://www.w3.org/2000/svg"

It's a pretty straight forward change but much cleaner.

Sapper notes

I used Sapper for this blog, and ran into a couple of interesting things…

Markdown authoring

By default, the Sapper template comes with a sort of yml based approach for the posts. However, I wanted to use markdown for mine, and found it quite convenient to just look and see how Scott Tolinski had implemented it having heard about his explorations on his podcast. At time of writing, he'd just started on this a few weeks back, so I knew the libs would be current ☉ ‿ ⚆

I put my markdown utilities in src/common/utils.js, but you can also have a look at Scott's—big shout out to Scott for being so kind as to share his setup on GitHub so we could get setup all the faster on our own blog 🙏🙌

Prerequisites

I have following package.json package dependencies required for this sort of setup:

  "dependencies": {
    "gray-matter": "^4.0.2",
    "highlight.js": "^10.0.3",
    "marked": "^1.1.0",
    ...more stuffs omitted
  },

Here's my getMarkdownContent:

import fs from 'fs'
import path from 'path'
import grayMatter from 'gray-matter'

export const getMarkdownContent = () =>
  fs.readdirSync('src/content').map((fileName) => {
    const post = fs.readFileSync(path.resolve('src/content', fileName), 'utf-8')
    const { data } = grayMatter(post)
    return data
  })

As an example, on the home page where I have the card views, I call getMarkdownContent from src/routes/index.json.js, and use it to suck in my posts:

import { getMarkdownContent } from '../common/utils'

export function get(req, res) {
  res.writeHead(200, {
    'Content-Type': 'application/json',
  })
  const posts = getMarkdownContent()

  /**
   * Might be a better way to set this up statically with dynamic routing or something. Some discussion on discord:
   * https://discord.com/channels/457912077277855764/473466028106579978/721410004023902278
   * 
   * Seems like I could use dynamic parameters:
   * https://sapper.svelte.dev/docs#Pages
   * 
   * For now rolling with linear scan  ¯\_(ツ)_/¯
   */
  const reverseChronoPosts = posts.sort((a, b) => {
    if (a['machine-date'] > b['machine-date']) {
      return 1;
    } else if (a['machine-date'] < b['machine-date']) {
      return -1
    } else {
      return 0
    }
  })
  res.end(JSON.stringify(reverseChronoPosts))
}

For what it's worth, I'm electing to be a bit of an open book, leaving some slightly embarrassing sorting code there. I really ought to be setting it up to pull it that already sorted without the additional linear scan. But, everything is a compromise against the amount of time I'm willing to put into a personal blog ;-)

Reverse chronological ordering of posts

In my setup, it seems that node.js was reading in the static blog posts in alphabetical order, but I wanted it in the typical reverse chronological order. I also did not want to resort to some sort of numeric prefix to force ordering. So I sorted after file system read like:

  const posts = getMarkdownContent()
  const reversedPosts = posts.sort((a, b) => {
    if (a['machine-date'] > b['machine-date']) {
      return 1;
    } else if (a['machine-date'] < b['machine-date']) {
      return -1
    } else {
      return 0
    }
  }

I went with this linear scan approach out of laziness, but I think the better way would be to use dynamic parameters as described in the page docs and some nice hints from pngwn on the discord if the linear scan bothers you.

Lighthouse

In devtools there's a Lighthouse tab which you can use to see how Google thinks you've done in terms of using best practices and making your site efficient to download. Don't attempt this against your yarn dev build, do yarn export && yarn verify. You'll need to add the verify in the package.json scripts:

    "verify": "npx serve __sapper__/export",

Now go to localhost:5000 and open dev tools and go to the lighthouse tab and generate a report. With that report created, you can fix each problem area as listed from top to bottom.

I'll go over the the things I was told to fix and how I did so next.

WebP Images

In my report this came under Serve images in next-gen formats and the fix involved adding imagemin and imagemin-webp npm packages, adding an image optimization script in my build process, and finally changing the image markup across the site…

  • I added a script webp.js (I used lossless but you'll have to figure out what quality settings best suits your needs):
const imagemin = require('imagemin');
const imageminWebp = require('imagemin-webp');

(async () => {
  await imagemin(['static/*.{jpg,png}'], {
    destination: 'static',
    plugins: [
      imageminWebp({ lossless: true })
    ]
  });

  console.log('Images optimized');
})();
  • Added that as a package.json script that gets loaded before anything else:
  "scripts": {
    "images": "node webp.js",
    "dev": "yarn images && sapper dev -p 4000",
    "build": "yarn images && sapper build --legacy",
    "export": "yarn images && sapper export --legacy",
    ...
  }
  <picture>
    <source type="image/webp" srcset="./foo.webp">
    <source type="image/jpeg" srcset="./foo.jpg" >
    <img src="foo.jpg" alt="Foo alt text" />
  </picture>

Edge Case

I use gray-matter (yml) from the top of each post to specify which hero image to use in the post, and, which same image to use in the card list view. As I'm currently combining the use of raster and SVG images, I wanted to only use webp for the raster (png or jpg) images, not the SVGs.

This was easy to account for with Svelte's template conditionals:

  <div class="img-card-wrapper">
    {#if post.imageSource.endsWith('.svg')}
      <img
        class="img-card blob"
        src={post.imageSource}
        alt="Hero Illustration" />
    {:else}
      <picture>
        <source
          type="image/webp"
          srcset={`${post.imageSource}.webp`} />
        <source
          type="image/jpeg"
          srcset={`${post.imageSource}.jpg`} />
        <img
          class="img-card {post.imageSource.includes('blob') ? 'blob' : ''}"
          src={`${post.imageSource}.jpg`}
          alt="Hero Illustration" />
      </picture
    {/if}
  </div>

Big Ole' Gotcha!

Be careful when testing out the webp locally. What happened for me, was that I did the usual yarn export && yarn verify, things looked good, but then after I deployed, the card images in list view failed on my iPhone because I had inadvertently forced webp to kick in, but it worked just fine in Chrome (which of course supports webp); and so I'd never tested the fallback code. I never bothered to check root-cause and capture, but I believe I also might have used the Responsive option (not iPhone 5/SE or similar) from the devices dropdown list in devtools and that's why webp was being applied preventing the fallback test.

Properly Sized Images

So at this point I'd implemented webp, but was still serving large images and just sizing them down in the CSS. Responsive images actually swap a smaller image—the idea is a mobile user shouldn't be penalized as your CSS makes it look ok, but there's no reason for having say a 1000x2000 image squashed down to a 300x600 visually only.

Lighthouse at time of writing has this listed as:

Serve images that are appropriately-sized to save cellular data and improve load time.

One way to fix this is to either output various sizes straight from whatever tool you're using to create your assets, or, after the fact with a tool like ImasdfaMagik which gives you a convert command which can be used like:

convert -resize 33% mypic.jpg mypic-small.jpg

And then in your tags, you utilize media query syntax to specify which image to use:

<picture>
  <source
    type="image/webp"
    srcset={`${post.imageSource}.webp`}
    media="(min-width: 600px) and (max-width: 828px)" />
  <source
    type="image/webp"
    srcset={`${post.imageSource}-small.webp`} />
  <source
    type="image/jpeg"
    srcset={`${post.imageSource}.jpg`}
    media="(min-width: 600px) and (max-width: 828px)" />
  <source
    type="image/jpeg"
    srcset={`${post.imageSource}-small.jpg`} />
  <img
    class="img-card"
    src={`${post.imageSource}.jpg`}
    alt="Hero Illustration" />
</picture>

Yes, it gets a bit unruly, but that's the code to deliver my listing page's cards responsively as I stack the cards up until 828px and then turn the listings into a 2x2 grid of cards (so they get small again when we exceed that breakpoint)

a11y

My accessibility score started pretty low which is probably pretty typical as the issues flagged turned out to be stupid human prone errors or misunderstandings for the most part. Easily fixed!

  • I had placed an aria-labelledby on the back to top SVG that's wrapped in a button and a role="presentation" as well. The fix was to remove those, and then add aria-label="Back to top trigger button" on the wrapping <button> element itself.
  • I'm not sure what I was thinking, but I'd inadvertantly made the card components divs when they were descendants of the <ul> and so, of course, should have been <li> elements:
-<div class="card">
+<li class="card">

Of course this required some CSS fiddling but ultimately was also an easy fix.

  • The go dark or go light label by the toggle turned out to break contrast requirements. This is common for me as I seem to lean towards nuanced ghosted text aesthetically, but if I have to choose between some subtle visual preference and a11y, a11y is going to win. Besides, I already use a light weight font there. Easy fix:
-    color: var(--neutral-mid-light);
+    color: var(--neutral-mid-dark);

Where those have values:

  --neutral-mid-dark: #666666;
  --neutral-mid-light: #999999;
  • I had a couple places where I hadn't supplied text within a button or label and that was also easily fixed with my .screenreader-only utility class:
   <label class="switch">
+    <span class="screenreader-only">Dark and light mode toggle switch</span>

And with those simple fixes Lighthouse gave me a 100 Accessibility score. Tooling ftw!

Cache Resources

Lighthouse called this Serve static assets with an efficient cache policy and on my cheapo shared hosting plan, it meant just adding an .htaccess file to my web root. What I chose to do, was for now, not worry about coming up with a cache busting strategy and roll with a slightly short cache life of 1 week.

I found the html5 boilerplate's .htaccess a nice starter place. I didn't want all of it, just the caching and expiry stuff. I searched for IfModule mod_expires.c and then adjusted the values for 1 week. Then I added this to the bottom of the .htaccess:

# 1 Week for most static assets
<filesMatch ".(css|jpg|jpeg|png|gif|js|ico)$">
  Header set Cache-Control "max-age=604800, public"
</filesMatch>

I should mention this is all because my shared hosting plan is on Apache, for Nginx or other servers you'll have to figure out how they're configured.

Lighthouse wasn't exactly thrilled about a 1 week caching policy for obvious reasons, but it turned into a minor warning not red error :-)

Note that since everything in /static gets built out to top-level directory, I stuck this in /static/.htaccess in my project.

Conclusion

So far I'm enjoying working with Svelte and Sapper. I'll be sure to keep brain dumping to this post as I learn new things, find workarounds, etc. In the meantime, DM me if you've found an egregious error or have something interesting to add or discuss about Sapper and Svelte (my first then last name then my favorite sport tennis [all as one word] at gmail dot com). Oh, and here are some nice resources that I certainly found helpful: