Accessible Routing for Sapper

On route-change, manage focus and update an ARIA Live Region with an indication of the current page.

Post meta:

Alt. 1: Resembling the Traditional Website

Since implementing the Gatsby team’s recommendation, I now prefer an accessible SPA method that resembles a traditional website. Note that I will probably never test this to the extent the Gatsby team has done.

  1. On route change an app/site wrapper gets focus. The next focusable element is a skip link to main-element.
  2. The page’s h1 element is always present in the root layout with the attributes role="region" and aria-live="polite". The text is updated by using a store.

I made this to a component that must wrap the app. See implementation in my Sapper template.

Alt. 2: The Gatsby Teams’s Recommendations

Here is how to implement the Gatsby team’s recommendation for accessible SPA routing plus a “Skip to main content” link in a Sapper app. This works for both the exported static version without JS activated and the SPA version.

The Gatsby team’s main recommendations:

  1. Provide a skip link that takes focus on a route change within the site, with a label that indicates what the link will do when activated: e.g. “skip to main navigation”.
    - My implementation links to a skip link that skips to main content. - I also implemented said “skip to main content” link.
  2. Include an ARIA Live Region on page load. On a route change, append text to it indicating the current page, e.g. “Portfolio page”.

After testing with keyboard-navigation and Macos Voice Over, I arrived at the following code for skip links. The HTML and JS code is from the Sapper specific Svelte component _layout.svelte.

HTML

First off, the top HTML layout looked like this:

<a
    href="{fullPath}#main-content"
    class="sr-only-focusable"
    bind:this={skipToContent}
    on:click|preventDefault={focusMain}
    >
    Skip to content
</a>
  
<header><!-- ... --></header>

<main id="main-content">
     
    <!-- `tabindex="-1"` to not give focus when navigating by keyboard. -->
    <a
        href="{fullPath}#"
        class="sr-only-focusable"
        tabindex="-1"
        bind:this={skipToTop}
        on:click|preventDefault={focusTop}
        >
        Go to top of page
    </a>

    <slot></slot>
</main>

Note, the skip link for going back to top shows on focus. If you want to avoid that, another solution would be to still keep the link but give focus to the main-element (this requires that it has tabindex).

Javascript

I used Svelte’s tick function to not interfere with how Sapper handles new routes in the first place (like scrolling to the top automatically).

preventScroll was added to focus so Sapper and the browser can be smart about who handles scrolling. The browsers that supports preventScroll will handle back/forward navigation from the browser like a normal non SPA-site (retain scroll position, etc.).

For the skip links to work without JS in the default Vercel config for Sapper, I added full paths to the skip links.

The script looked like this:

import { stores } from '@sapper/app'
import { tick } from 'svelte'

const { page } = stores()

let fullPath = ''
let skipToContent
let skipToTop

page.subscribe(value => {
    fullPath = value.path
    accessibleRouteChange()
})

async function accessibleRouteChange () {
    if (!skipToTop) return
    await tick()
    skipToTop.focus({
        preventScroll: true
    })
}

function focusMain () {
    if (!skipToTop) return
    skipToTop.focus()
}

function focusTop () {
    if (!skipToContent) return
    skipToContent.focus({
        preventScroll: true
    })
}

CSS

The ‘classic’ screen reader only CSS classes (popularized by Bootstrap):

.sr-only,
.sr-only-focusable {
    position: absolute !important;
    width: 1px !important;
    height: 1px !important;
    padding: 0 !important;
    margin: -1px !important;
    overflow: hidden !important;
    clip: rect(0, 0, 0, 0) !important;
    border: 0 !important;
}

.sr-only-focusable:active,
.sr-only-focusable:focus {
    position: static !important;
    width: auto !important;
    height: auto !important;
    margin: 0 !important;
    overflow: visible !important;
    clip: auto !important;
}

2. ARIA Live With Page Name

This can be implemented anywhere on an element that is always/already present in the layout—“add the attribute [aria-live] before the changes occur—either in the original markup, or dynamically”. The following attributes must at least be used on the element you choose: role="region" and aria-live="polite".