My journey into the lands of Svelte & Sapper
To view the original article please go here. But if you can do without colored code samples you can probably enjoy as-is it here ;-)
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:
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> 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:
- Any document.body and document.querySelector types of code need to happen in the special onMount hook.
- 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:
- The onMount lifecycle hook needs to be used before we try to do our document.querySelector as it ensures SSR compatibility since:
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="https://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="https://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.
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, here are some nice resources: