21
Building an Alert Component in Svelte
If you were on the internet in the 90s then you probably remember what life was like before pop-up blockers. Even the person who invented pop-up ads regrets doing it. Using the internet today can feel the same as 30 years ago, with alerts and banners everywhere. With things like GDPR cookie warnings starting in 2018 and COVID alerts in 2020 the web looks more and more like this parody site with every passing year. What's worse is that according to this accessibe.com article the large majority of these pop-ups don't pass basic WCAG accessibility guidelines. These modals and alerts often contain agreements that need acceptance before proceeding. This means that people with different browsing needs can often be blocked from the entire site.
All that said, sometimes, there are things you still want to show a user before they proceed. I needed such an alert to warn users that there's most likely unexpectedly broken code in the "lab" section of my site.
This post will walk through how I used Svelte to build an accessible alert that also won't annoy users too badly. It also incorporates a cache-busting mechanism in case the content ever changes and users need to see it again.
The <Alert />
component markup is pretty straightforward. It accepts a few props
— show
for defining when the alert is visible, a title
to display as an internal header, and a close
function.
Since this is going to be a reusable component, I'm using the Svelte <slot />
element to pass content into the main body of the component.
<!-- alert.svelte -->
<script>
export let show
export let close
export let title
</script>
{#if show}
<div>
<section>
<header>
<strong>
{title}
</strong>
<button on:click={close}>
Close
</button>
</header>
<div>
<slot />
</div>
</section>
</div>
{/if}
When trying to make accessible content, people tend to reach for adding ARIA attributes to their HTML elements. But, the first rule of thumb for ARIA is actually to try not to use ARIA.
Many of these widgets were later incorporated into HTML5, and developers should prefer using the correct semantic HTML element over using ARIA, if such an element exists.
HTML5 introduced the <dialog>
element for content like this, so it will be the foundattion for my component. The <dialog>
element will be understood by modern screen readers and also comes with built-in conveniences like an open
attribute to toggle visibility and a ::backdrop
pseudo element to handle the overlay on the content behind it.
<!-- alert.svelte -->
<script>
export let show
export let close
export let title
</script>
{#if show}
<dialog open on:click={close}>
<section>
<header>
<strong>
{title}
</strong>
<button on:click={close}>
Close
</button>
</header>
<div>
<slot />
</div>
</section>
</dialog>
{/if}
It might seem weird to be attaching a click event to fire the close
function to the actual <dialog>
element, but there's a reason for that I'll cover in a bit.
Unfortunately, Safari doesn't fully support the <dialog />
tag. For now, that means ARIA is required, although maybe as browser support improves this could one day be removed.
The good news is that rather than render nothing, Safari will still render the element but instead treat it like a normal <div>
. Because it will be rendered as a non-semantic element, I can mark it with the ARIA role='dialog'
so that any Safari screen reader will still treat it correctly.
<!-- alert.svelte -->
<script>
export let show
export let close
export let title
</script>
{#if show}
<dialog
open
on:click={close}
role='dialog'
>
<section>
<header>
<strong>
{title}
</strong>
<button on:click={close}>
Close
</button>
</header>
<div>
<slot />
</div>
</section>
</dialog>
{/if}
In order to be fully accessible, any role
element needs to meet two other criteria:
- The dialog must be properly labeled
- Keyboard focus must be managed correctly
To address the first point, two more attributes can be added to the <dialog />
tag — aria-labelledby
and aria-describedby
— and pointed to their corresponding elements using a generated id
. aria-labelledby
should point to the primary identifier of the element, the <header />
. aria-describedby
needs to identify additional context, which in this case is the main <div />
.
I'll get back to the second criteria later in the post.
<!-- alert.svelte -->
<script>
export let show
export let close
export let title
const id = slugify(title)
</script>
{#if show}
<dialog
open
on:click={close}
role='dialog'
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-content`}
>
<section>
<header id={`${id}-title`}>
<strong>
{title}
</strong>
<button on:click={close}>
Close
</button>
</header>
<div id={`${id}-content`}>
<slot />
</div>
</section>
</dialog>
{/if}
Visually, one of the most important features of a modal or alert is that it appears on top of the rest of the site content. It might seem sufficient to just add a very high z-index
, like 9999
, but because of how stacking context
works this sometimes still isn't enough to get an element to the forefront of the page. Josh W Comeau has a good article that explains why z-index
is more complicated than just assigning the highest number.
Instead of relying on only CSS, I wanted my modal to take advantage of the fact that the HTML document will stack elements according to DOM source order. This means that the closer to the end of a document an element is, the higher it will appear by default on the z-axis.
My site is already set up in such a way that each page is created with a <Page />
Svelte component that I use to include shared page elements like the header and the footer. Adding a place for the alert to render at the end of the pages is as easy as adding a named <slot />
. Because I don't want errant HTML to render on pages that don't need it, I'm also checking to see if an alert is present using the $$slots
object that Svelte provides.
<!-- page.svelte -->
<SEO {...$$props} />
<div id='site'>
<Header />
<main>
<slot />
</main>
<Footer />
</div>
{#if $$slots.alert}
<slot name='alert' />
{/if}
The particular alert I'm building needs to go on any pages in the /lab
section of my site, so I need to add the code for the actual alert to the <Lab />
component that serves as the template for those subpages. To learn more about how exactly templates on my blog work, I have another post that goes into depth, or there are the official mdsvex
docs. Either way, just know that anything added to the <Lab />
component outside of the default <slot />
will appear on every page that matches the url /lab/[slug]
. The <Lab />
template is also responsible for managing the showAlert
and closeAlert
props.
<!-- lab.svelte -->
<script>
import Page from './page.svelte'
import Alert from '../components/misc/alert.svelte'
let showAlert = true
const closeAlert = () => {
showAlert = false
}
</script>
<Page {...$$props} >
<slot />
<Alert
title='Caution!'
show={showAlert}
slot='alert'
>
<p>
Some features in the section require JavaScript,
might not work great on mobile,
and may not have been thoroughly tested.
</p>
<button on:click={closeAlert}>
That's okay.
</button>
</Alert>
</Page>
Using the slot='alert'
prop on an element inside of the <Page />
component will render it inside of the <slot name='alert' />
in the parent component and add it to the bottom of the page.
Many of the styles applied to the alert will vary depending on the site, but there are a few positioning styles that I wanted to cover.
The first thing to do is to make the <dialog />
element cover the entire screen using position: fixed
. position: fixed
works similarly to position: absolute
, but instead of using the closest position parent like absolute
does, fixed
will position the element relative to the browser window. By combining this position with height
and width
of 100%
and a top
, right
, bottom
, and left
of 0
, the <dialog />
box will be stretched to cover the entire viewport. Just to be safe, I gave it an incredibly high z-index
as well, even though we shouldn't need it because of the document ordering from earlier.
dialog {
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
z-index: 9999;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background: transparent;
}
The page-height element is also set to display: flex
with both align-items
and justify-content
set to center
to keep the content in the center of the screen.
At first it might seem odd to set background: transparent;
on the element, since usually these types of alerts show a dark overlay while covering the site. This, again, has to do with the <dialog />
element not being supported in Safari. Instead of using its native ::backdrop
I have to fake this functionality with a normal ::after
pseudo element, stretched to cover its entire parent with position: absolute
. It also has a z-index: -1
to make sure that it stays behind the <section />
element that will contain the main content of the alert.
dialog::after {
content: '';
display: block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: black;
opacity: .5;
z-index: -1;
pointer-events: none;
}
section {
width: 100%;
max-width: 50rem;
background: white;
}
It is entirely possible to set a background color directly on the <dialog />
itself, but I wanted to use the opacity
property to adjust the alpha value to make a transparent version of one of my site's existing colors.
One other important note here is to make sure and set pointer-events: none;
on the ::after
element that is covering the <dialog />
. Setting the pointer-events
property to none
will let any clicks pass through the ::after
element and activate the on:click
event attached to the background <dialog />
.
Stacking elements like this can quickly get confusing, so I highly recommend using the new 3D View DevTools in Microsoft Edge. This screenshot shows the <dialog />
element covering the viewport, the <section />
at the forefront of the site, and the rest of the site content being covered by the alert and it's overlay.
[[clearfix]]
I have to manually swap a few tabindex
attributes to make everything else on the page inert to someone tabbing through the site while the alert is open.
<!-- page.svelte -->
<SEO {...$$props} />
<div
id='site'
tabindex={$$slots.alert ? -1 : 0}
>
<Header />
<main>
<slot />
</main>
<Footer />
</div>
{#if $$slots.alert}
<slot name='alert' />
{/if}
To make everything within the main body of the site in the <Page />
component non-focusable when the alert is active, I set a tabindex
of -1
to remove it and its children from the native document tab flow. Because the only two things on the page are the #site
and the <Alert />
, a user shouldn't be able to focus on any element in the background. When $$slots.alert
is empty, the ternary will return falsy and set tabindex='0'
, which will return the element to its original, unset tab order.
tabindex='-1'
means that a user can't focus on the element using their keyboard, but it also makes any element, even things like a regular <div>
, focusable with JavaScript.
A negative value (usually tabindex="-1") means that the element is not reachable via sequential keyboard navigation, but could be focused with JavaScript or visually by clicking with the mouse. It's mostly useful to create accessible widgets with JavaScript.
<!-- alert.svelte -->
<script>
export let show
export let close
export let title
const id = slugify(title)
</script>
{#if show}
<dialog
open
on:click={close}
role='dialog'
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-content`}
tabindex='-1'
>
<section>
<header id={`${id}-title`}>
<strong>
{title}
</strong>
<button on:click={close}>
Close
</button>
</header>
<div id={`${id}-content`}>
<slot />
</div>
</section>
</dialog>
{/if}
By also adding tabindex='-1'
to the <dialog />
element inside the alert, JavaScript can make sure a user's focus is set when it is opened.
Another accessibility requirement for modal content is that it "traps focus," meaning that a keyboard user cannot navigate outside of the area, analogous to how an overlay on the rest of the site prevents a mouse user from clicking on background elements. This is the second rule from the role
element guidlines. The ARIA dialog best practices guide explain what this means pretty explicitly:
Key | Function |
---|---|
Tab |
Moves focus to next focusable element inside the dialog. When focus is on the last focusable element in the dialog, moves focus to the first focusable element in the dialog. |
Shift + Tab |
Moves focus to previous focusable element inside the dialog. When focus is on the first focusable element in the dialog, moves focus to the last focusable element in the dialog. |
Escape |
Closes the dialog. |
To handle the escape
key the special <svelte:window />
component can accept an event listener to create a global way to exit the modal.
The main component can use a Svelte Action to attach and remove the events listeners for trapping focus.
<!-- alert.svelte -->
<script>
export let show
export let close
export let title
const id = slugify(title)
import focusTrap from 'actions/focus-trap.js'
</script>
<svelte:window on:keydown={event => {event.key === 'Escape' ? close() : null}}/>
{#if show}
<dialog
open
on:click={close}
role='dialog'
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-content`}
tabindex='-1'
use:focusTrap
>
<section>
<header id={`${id}-title`}>
<strong>
{title}
</strong>
<button on:click={close}>
Close
</button>
</header>
<div id={`${id}-content`}>
<slot />
</div>
</section>
</dialog>
{/if}
An action
is just a function that will be passed the node
on which the use:
directive was called. The function is called as soon as the element is created, which in the case of my alert means whenever the show
prop becom true
.
The first thing I want to do when an alert is created is focus on it, which I can do with the .focus()
method thanks to the tabindex='-1
from earlier.
// focus-trap.js
function focusTrap(element) {
element.focus()
}
The next step is to get a list of every element that should be focusable within the <dialog />
box. Taking inspiration from this article by Zell Liew, I used a list of natively focusable elements and created a focusableElements
array, filtering out any that should be skipped.
// focus-trap.js
const elements = [
'a',
'button',
'input',
'textarea',
'select',
'details',
'[tabindex]'
]
function focusTrap(element) {
element.focus()
const focusableElements = [...element.querySelectorAll(elements.join(', '))]
.filter(element => !(element.hasAttribute('disabled') || element.tabIndex === -1))
}
Next, borrowing more logic from this article by Hidde de Vries, the <dialog />
can listen for all keydown
events and check if they were the Tab
key.
If a user has pressed the Tab
key, I want to prevent the default response and implement my own. The two things I need to know for this custom behavior are which element is currently in focus, which I can do by checking the document.activeElement
against my focusableElements
array, and whether the user intends to move forwards or backwards through the DOM, which I can do by checking whether or not they have included the shiftKey
. The idea is to call .focus()
on the next focusableElements
item when the user presses just Tab
, and on the previous element if they have pressed Shift + Tab
.
// focus-trap.js
const elements = [
'a',
'button',
'input',
'textarea',
'select',
'details',
'[tabindex]'
]
function focusTrap(element) {
element.focus()
const focusableElements = [...element.querySelectorAll(elements.join(', '))]
.filter(element => !(element.hasAttribute('disabled') || element.tabIndex === -1))
element.addEventListener('keydown', event => {
if (event.key === 'Tab' || event.keyCode === 9) {
const currentElementIndex = focusableElements.indexOf(document.activeElement)
event.preventDefault()
if (event.shiftKey) {
currentElementIndex === 0
? focusableElements[focusableElements.length - 1].focus()
: focusableElements[currentElementIndex - 1].focus()
} else {
currentElementIndex === focusableElements.length - 1
? focusableElements[0].focus()
: focusableElements[currentElementIndex + 1].focus()
}
}
})
}
Navigating the focusableElements
array is slightly more complicated because the user needs to loop through the elements if they move forwards from the last element or backwards from the first.
Dealing with focus trapping can get much more complicated than what I need to account for in my fairly simple alert. Luckily, there are existing libraries out there to help deal with these complicated situations. If I were dealing with this again, I might reach for something like focus-trap
, or maybe even svelte-focus-trap
which takes this same logic and pre-packages it into an action
.
The very last thing to do is make sure that users don't see the same alert over and over, every time they re-visit the site. Since this data is going to be specific to a user and I want it to persist between visits, localStorage
is a good candidate.
When a user closes the alert, instead of just toggling the showAlert
variable, the function also needs to write to the browser's storage. localStorage
will store key-value pairs, so the function can set false
for 'labAlert'
and this will exist until a user manually clears their browser data.
When the page loads, the component also needs to look into localStorage
and see if the user has closed the modal in the past. Svelte component code runs in both the client, where the window
object exists, and on the server, where window
will be undefined. Before trying to access window.localStorage
the component needs to check if typeof window !== 'undefined'
to avoid erroring on the server. This check isn't necessary inside of closeAlert
since running that function can only be triggered by a user in their browser and not ever on the server. It's also important to check if the returned value is equal to a string value of 'false'
rather than the boolean false
because localStorage
will coerce any set value to a string.
<!-- lab.svelte -->
<script>
import Page from './page.svelte'
import Alert from '../components/misc/alert.svelte'
let showAlert = true
if (typeof window !== 'undefined' && window.localStorage.getItem('labAlert') === 'false') {
showAlert = false
}
const closeAlert = () => {
showAlert = false
window.localStorage.setItem('labAlert', false)
}
</script>
<Page {...$$props} >
<slot />
<Alert
title='Caution!'
show={showAlert}
slot='alert'
>
<p>
Some features in the section require JavaScript,
might not work great on mobile,
and may not have been thoroughly tested.
</p>
<button on:click={closeAlert}>
That's okay.
</button>
</Alert>
</Page>
But wait... what if the content in the modal changes and a user needs to see new, important data before they proceed? This can be accomplished by storing the content of the alert in localStorage
instead of only a one time boolean value.
The text content of the alert can be abstracted to a variable that will hold the HTML as a string. It can then be used in the body of the component using Svelte's @html
template syntax. When a user calls the closeAlert
function, instead of storing 'false'
, the function can use the browser's btoa
method to convert the alertContent
to a base64 string and store it. Since btoa(alertContent)
will return a different string given new content, when the component checks localStorage.getItem('labAlert')
, showAlert
will be set only if a user has seen this exact content before.
<!-- lab.svelte -->
<script>
import Page from './page.svelte'
import Alert from '../components/misc/alert.svelte'
const alertContent = `
<p>
Some features in the section require JavaScript,
might not work great on mobile,
and may not have been thoroughly tested.
</p>
`
let showAlert = true
if (typeof window !== 'undefined' && window.localStorage.getItem('labAlert') === btoa(alertContent)) {
showAlert = false
}
const closeAlert = () => {
showAlert = false
window.localStorage.setItem('labAlert', btoa(alertContent))
}
</script>
<Page {...$$props} >
<slot />
<Alert
title='Caution!'
show={showAlert}
slot='alert'
>
{@html alertContent}
<button on:click={closeAlert}>
That's okay.
</button>
</Alert>
</Page>
btoa
, just like localStorage
only exists in the browser so it will need the same window
check to not error on the server. Since btoa
(which stands for "binary to ASCII") returns a string, it can be used directly in the ===
check, unlike a boolean that needs to be coerced.
The final component should be something that has both an ergonomic experience for the developer, and a non-intrusive experience for the site user. Hopefully I managed to accomplish both.
As usual, thanks to the Svelte discord for helping me figure out a few aspects of the code, particularly kursus75
and c-bandy
with help around localStorage
and Svelte stores
and GrygrFlzr
for help with my deploy.
I hope this walkthrough helps people build their own inclusive version of this component. If this does help you build something cool, shoot me a link on Twitter because I'd love to see it! Reach out with any questions or concerns too, especially if there are any errors I need to fix with this component's accessibility.
Also head over to the /lab
section of my site to see this component in action, as well as all the weird experiments I've got there.
21