pixel art of Toronto

Creating Responsive views in Svelte.js and SvelteKit

Svelte builds in flow control into its HTML Markup, here's a basic example.

{#if mobile}
  <SomeMobileComponent />
{:else}
  <SomeDesktopComponent />
{/if}

We could leave this post here and that would be the end of it. You can work out how to determine if a device is mobile, using whatever set of criteria you like. You build two components, one mobile and one desktop and your done, responsive right down to the HTML with little to no effort...

...but, this isn't particularly efficient for Svelte.js and especially not for SvelteKit. Why? Well there's (at least) three reasons:

  1. You essentially have two import hierarchy trees per javascript file. Let's say that you implement this logic in your Root component (that's the component that renders your entire page), you essentially create two separate rendering processes for a single page. Assuming that the components within SomeMobileComponent and SomeDesktopComponent share no components below this becomes incredibly bloated
  2. You're increasing the overhead of maintaining your codebase. Writing essentially two root components and switching between them depending on whether the page is rendered on desktop or mobile creates a lot of code bloat.
  3. If you use SvelteKit, you may end up rendering the wrong view on the server, sending it to the client which the client then has to correct when it receives the page, making the entire overhead of computing the page on the server redundant.

This approach though, is still largely the right way to do it, just with a few tweaks. Let's see what those tweaks are.

Individual Component Responsiveness

Firstly, unless your entire page changes depending on whether you're on mobile or desktop (and I promise in almost every case it doesn't) you don't need to do this switch at the root. Instead, determine what components are actually going to change between mobile and desktop, then do it at their root. For example, the menus on this site - AdamGreen.Tech - change depending on if we're on Mobile (default), Tablet (>600px) or Desktop (>1000px). Let's look at our components/Menu/index.svelte.

<script>
  import Desktop from './Desktop.svelte';
  import Mobile from './Mobile.svelte';
  import {device} from '$lib/utils/responsive.js';
</script>

{#if $device === 'desktop'}
  <Desktop />
{:else}
  <Mobile />
{/if}

Placing the switch at the root of the component you're actually changing removes the need to maintain two separate root components. This helps to fix the first problem, codebase bloat, by only maintaining separate Mobile, Desktop and Tablet components for the components that need to change between devices.

Grouping Shared Behaviour

In most responsive situations, a lot of the layout isn't actually changing, but a few components are potentially being reordered. Take this blog post as an example. If you're viewing this post on desktop you'll notice that the metadata (author, publish date etc.) is shown on the right side of the display in a green box. On mobile this same box is moved inside the post and placed at the bottom. The responsive part isn't the box itself, just the position it's placed in.

So, in practice we have four files index.svelte the root file that switches between desktop and mobile components Mobile.svelte which is the mobile view Desktop.svelte which is the desktop view, and Meta.svelte which is the component that actually shows the meta data. Here's what they look like

<!-- index.svelte -->
<script>
  import Desktop from './Desktop.svelte';
  import Mobile from './Mobile.svelte';
  import {device} from '$lib/utils/responsive.js';
</script>

{#if $device === 'desktop'}
  <Desktop />
{:else}
  <Mobile />
{/if}
<!-- Mobile.svelte -->
<script>
  import Meta from './Meta.svelte';
</script>
<div class="border-pink">
  <!-- Some blog stuff -->
  <Meta />
</div>
<!-- Desktop.svelte -->
<script>
  import Meta from './Meta.svelte';
</script>
<div class="border-pink left">
  <!-- Some blog stuff -->
</div>
<div class="right">
  <Meta />
</div>

Notice that Desktop.svelte and Mobile.svelte are nearly identical, except for the fact that the desktop version places the meta box in a separate <div> which then is placed on the right hand side of the page.

By extracting out the Meta.svelte component, we're essentially creating a shared component between the two responsive components. When Webpack, Rollup, Vite etc. comes to build this project, it'll create one function to represent the Meta.svelte component which the functions representing both Desktop.svelte and Mobile.svelte will call in order to render the Meta component. If a particularly large component needs to be made responsive at a high level, regrouping shared components can ultimately help to reduce the size of the output.

Informing SvelteKit how to render

One of the biggest issues this approach faces is utilising server-side rendering from SvelteKit effectively. Good web design says that a mobile view should scale well onto a desktop device without much change. However, depending on how the logic for deciding between mobile and desktop components is written, what tends to happen with this approach is the server builds a mobile view that hits a desktop, the logic takes over client-side and rerenders the view for desktop creating a dramatic cumulative layout shift.

At the end of the day, all these components are set up to default to their mobile component. What we should really be doing is determining server-side whether to return a mobile or desktop rendered page. If we figure this out, the cumulative layout shift will be negligible (and mostly a consequence of loading fonts, images etc.).

Here's what we're going to do:

  1. We're going to create a hooks.js file to pull a special header called Sec-CH-UA-Mobile. This header will be set to "?1" if a mobile view should be returned and "?0" if a desktop view should be returned. We're going to stuff this information into the session object which all load functions have access to.
  2. In the routes/__layout.svelte load function (which runs no matter what page is loaded) we'll add the boolean in the session information to the props of the layout component.
  3. Using the information gleaned from the header, we're going to update a store that all responsive components can access to decide which of their Mobile or Desktop components to show.
  4. We'll create a derived store that returns a string saying "desktop", "tablet" or "mobile" depending screen width and the server override.

First let's look at the hooks.js file located in src.

// src/hooks.js
export function getSession(request) {
    return {
        mobile: request.headers['sec-ch-ua-mobile'] === '?1'
    }
}

Essentially, every load function for every svelte component will receive the returned object as session. We can see this in the __layout.svelte component below. The __layout.svelte component is used as a wrapped for every page (assuming it's placed in src/routes.

<!-- src/routes/__layout.svelte -->
<script context="module">
    //the session object destructured below comes from the hooks.js file above
    export async function load({ session }) {
        return {
            props: {
                //Add the boolean as a property of the __layout.svelte component
                overrideMobile: session.mobile,
            }
        }
    }
</script>
<script>
    import {width, mobile} from '$lib/utils/device';
    //receive whether to override and render as mobile
    export let overrideMobile = true;
    //store this in the shared stores located in '$lib/utils/device'
    $mobile = overrideMobile;
</script>
<!-- notice we also bind the inner width of the HTML document to a store -->
<svelte:window bind:innerWidth={$width} />
<slot></slot>

Notice that we also bind the innerWidth of the HTML document (using svelte:window) to another store in our $lib/utils/device.js file.

// src/lib/utils/device.js
import {writable, derived} from 'svelte/store';
// a SvelteKit provided variable that indicates if we're on browser
import {browser} from '$app/env';

//default to 0 width - this won't be updated server side because svelte:window won't work server side
export const width = writable(0);
//this will be set server side, from the __layout.svelte component
export const mobile = writable(true);

//This store computes what device to render for
export const device = derived([width, mobile], ([width, mobile]) => {
    // if width is >1000 (px) or we're not on browser and a mobile view hasn't been requested
    if (width > 1000 || (!browser && !mobile)) {
        //then we're on desktop
        return 'desktop';
    // if width is >420 (px) then we're on the browser and we're on a tablet
    } else if (width > 420) {
        // we're on a tablet
        return 'tablet';
    } else {
        // if none of the previous conditions were satisfied, we're either not on a device >420px, 
        // or we're on the server, and a mobile view has been requested
        return 'mobile';
    }
});

Server-side, this logic essentially determines if we're on mobile, or desktop. Note that it can't determine server-side if the device is large enough to be considered a tablet, however, generally the approach we take is that a mobile view sufficiently scales to suit a tablet, and that the changes between mobile and tablet are not as stark as the changes between mobile and desktop (and therefore don't result in a large cumulative layout shift).

This approach allows you to do a rough and ready responsive view server-side. This is ideal particularly for SEO and bots that'll visit your site in both a tablet and desktop mode.

Summary

Svelte.js and SvelteKit let you do some pretty cool responsive work without bloating your code base, or the end output. More than that, you can deliver an SPA to client that is already SSR'd to their particular device (within reason) reducing the amount of time it takes to load and the layout shift they experience.

Author:

Adam Green

Published:

Published 1/24/2022

Tags: