How to create a Full-Featured Modal Component in Svelte, and trap focus-within
vibhanshu pandey
Posted on July 27, 2020
Note: Although the javascript used in this tutorial is svelte specific, the idea remains the same, and can be easily applied to other frameworks and libraries, such as ReactJS. You can reuse the HTML and CSS just by copy-pasting.
pre-requisite: Before we begin, make sure you have a good-enough understanding of svelte's syntax and concepts of stores, actions, slots, and slot-props.
TL;DR
Check out the REPL here
Let's start by creating a Modal.svelte
file.
<!-- if you're not using typescript, remove lang="ts" attribute from the script tag -->
<script lang="ts"></script>
<style></style>
<div></div>
Now let's add a minimal HTML and CSS required for a Modal.
<!-- if you're not using typescript, remove lang="ts" attribute from the script tag -->
<script lang="ts">
</script>
<style>
div.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
div.backdrop {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
}
div.content-wrapper {
z-index: 10;
max-width: 70vw;
border-radius: 0.3rem;
background-color: white;
overflow: hidden;
}
div.content {
max-height: 50vh;
overflow: auto;
}
</style>
<div class="modal">
<div class="backdrop" />
<div class="content-wrapper">
<div>
<!-- Modal header content -->
</div>
<div class="content">
<!-- content goes here -->
</div>
<div>
<!-- Modal footer content -->
</div>
</div>
</div>
Ok, so what do we have until now:
- We have a Modal container, which is styled to be fixed and takes the full width and the full height of its document's viewport.
- The Modal contains a backdrop container, which is absolutely positioned and has a background-color with opacity/alpha of 0.4 making the content behind visible.
- The Modal contains a content-wrapper element for applying common styles e.g background-color, font-size, and other responsive styles.
- The content-wrapper element contains 3 children for three different sections of a Modal i.e header, content, and footer(also called actions area).
SideNote: Using a separate backdrop element instead of applying the backdrop styling to the Modal element itself, allows you to have the flexibility of changing the backdrop dynamically, for example, you can pass custom styles to your backdrop without interfering with the Modal, you may style it according to different themes your website implements i.e light, dark, etc.
Now let's modify our Modal to have slots.
...
<slot name="trigger">
<!-- fallback trigger -->
<button>Open Modal</button>
</slot>
<div class="modal">
<div class="backdrop" />
<div class="content-wrapper">
<slot name="header">
<!-- fallback -->
<div>
<h1>Your Modal Heading Goes Here...</h1>
</div>
</slot>
<div class="content">
<slot name="content" />
</div>
<slot name="footer">
<!-- fallback -->
<div>
<h1>Your Modal Footer Goes Here...</h1>
</div>
</slot>
</div>
</div>
As you can see, we have 4 slots:
- trigger, for opening the Modal.
- header, for containing the title of the Modal
- content, for containing the body of the Modal i.e the main content.
- footer, for containing action buttons like- Ok, Close, Cancel, etc.
Now let's add some state and events to our Modal to control opening/closing.
<!-- if you're not using typescript, remove lang="ts" attribute from the script tag -->
<script lang="ts">
let isOpen = false
function open() {
isOpen = true
}
function close() {
isOpen = false
}
</script>
...
<slot name="trigger" {open}>
<!-- fallback trigger to open the modal -->
<button on:click={open}>Open</button>
</slot>
{#if isOpen}
<div class="modal">
<div class="backdrop" on:click={close} />
<div class="content-wrapper">
<slot name="header">
<!-- fallback -->
<div>
<h1>Your Modal Heading Goes Here...</h1>
</div>
</slot>
<div class="content">
<slot name="content" />
</div>
<slot name="footer" {close}>
<!-- fallback -->
<div>
<h1>Your Modal Footer Goes Here...</h1>
<button on:click={close}>close</button>
</div>
</slot>
</div>
</div>
{/if}
Usage
Now, this is a working Modal, all you need to do is render it with some content e.g:
<script lang="ts">
import Modal from './components/Modal.svelte'
</script>
<Modal>
<div slot="content">
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Similique, magni earum ut ex
totam corporis unde incidunt deserunt, dolorem voluptatum libero quia. Maiores,
provident error vel veritatis itaque nemo commodi.
</p>
</div>
</Modal>
Now let's add the keydown
listener to close Modal when the user press' es the Escape
key, let's try doing it the obvious way which is less friendly and understand it's caveats, then we'll implement it in a more robust way.
<script lang="ts">
...
function keydown(e: KeyboardEvent) {
e.stopPropagation()
if (e.key === 'Escape') {
close()
}
}
</script>
...
{#if isOpen}
<!-- tabindex is required, because it tells the browser that this div element is focusable and hence triggers the keydown event -->
<div class="modal" on:keydown={keydown} tabindex={0} autofocus>
...
</div>
{/if}
You'll notice that when you open the Modal, and tab around, and you happen to move your focus outside the Modal, pressing Escape
key is not closing the Modal. Here's the fix.
Suggested Read: how to trap focus.
Using the same approach illustrated in the above article, let's implement the same in our Modal. But first, let's move our local state and functions to a svelte store.
// store/booleanStore.ts
import { writable } from 'svelte/store'
export function booleanStore(initial: boolean) {
const isOpen = writable<boolean>(initial)
const { set, update } = isOpen
return {
isOpen,
open: () => set(true),
close: () => set(false),
toggle: () => update((n) => !n),
}
}
Trapping focus within our Modal
Here is the complete implementation of our full-featured Modal, which is responsive((ish), there's room for further improvement), properly handles the opening and closing of multiple Modals, handles keydown listeners, accessible(follows accessibility guidelines(could be further improved)) and traps focus within the top-most opened Modal.
<!-- if you're not using typescript, remove lang="ts" attribute from the script tag -->
<script context="module" lang="ts">
// for passing focus on to the next Modal in the queue.
// A module context level object is shared among all its component instances. [Read More Here](https://svelte.dev/tutorial/sharing-code)
const modalList: HTMLElement[] = []
</script>
<script lang="ts">
import { booleanStore } from '../stores/booleanStore'
const store = booleanStore(false)
const { isOpen, open, close } = store
function keydown(e: KeyboardEvent) {
e.stopPropagation()
if (e.key === 'Escape') {
close()
}
}
function transitionend(e: TransitionEvent) {
const node = e.target as HTMLElement
node.focus()
}
function modalAction(node: HTMLElement) {
const returnFn = []
// for accessibility
if (document.body.style.overflow !== 'hidden') {
const original = document.body.style.overflow
document.body.style.overflow = 'hidden'
returnFn.push(() => {
document.body.style.overflow = original
})
}
node.addEventListener('keydown', keydown)
node.addEventListener('transitionend', transitionend)
node.focus()
modalList.push(node)
returnFn.push(() => {
node.removeEventListener('keydown', keydown)
node.removeEventListener('transitionend', transitionend)
modalList.pop()
// Optional chaining to guard against empty array.
modalList[modalList.length - 1]?.focus()
})
return {
destroy: () => returnFn.forEach((fn) => fn()),
}
}
</script>
<style>
div.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
opacity: 1;
}
div.modal:not(:focus-within) {
transition: opacity 0.1ms;
opacity: 0.99;
}
div.backdrop {
background-color: rgba(0, 0, 0, 0.4);
position: absolute;
width: 100%;
height: 100%;
}
div.content-wrapper {
z-index: 10;
max-width: 70vw;
border-radius: 0.3rem;
background-color: white;
overflow: hidden;
}
@media (max-width: 767px) {
div.content-wrapper {
max-width: 100vw;
}
}
div.content {
max-height: 50vh;
overflow: auto;
}
h1 {
opacity: 0.5;
}
</style>
<slot name="trigger" {open}>
<!-- fallback trigger to open the modal -->
<button on:click={open}>Open</button>
</slot>
{#if $isOpen}
<div class="modal" use:modalAction tabindex="0">
<div class="backdrop" on:click={close} />
<div class="content-wrapper">
<slot name="header" {store}>
<!-- fallback -->
<div>
<h1>Your Modal Heading Goes Here...</h1>
</div>
</slot>
<div class="content">
<slot name="content" {store} />
</div>
<slot name="footer" {store}>
<!-- fallback -->
<div>
<h1>Your Modal Footer Goes Here...</h1>
<button on:click={close}>Close</button>
</div>
</slot>
</div>
</div>
{/if}
Usage
<script lang="ts">
import Modal from './components/Modal.svelte'
</script>
<Modal>
<div slot="trigger" let:open>
<Button on:click={open}>Open Modal</Button>
</div>
<div slot="header">
<h1>First Modal</h1>
</div>
<div slot="content">
<!-- Modal within a Modal -->
<Modal>
<div slot="trigger" let:open>
<Button on:click={open}>Open Second Modal</Button>
</div>
<div slot="header">
<h1>Second Modal</h1>
</div>
<div slot="content">
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Similique, magni earum ut ex
totam corporis unde incidunt deserunt, dolorem voluptatum libero quia. Maiores,
provident error vel veritatis itaque nemo commodi.
</p>
</div>
</Modal>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Similique, magni earum ut ex
totam corporis unde incidunt deserunt, dolorem voluptatum libero quia. Maiores, provident
error vel veritatis itaque nemo commodi.
</p>
</div>
<div slot="footer" let:store={{close}}>
<button on:click={close}>Close First Modal</button>
</div>
</Modal>
You can see the beauty of slot and slot-props and how it takes component composition to the next level.
Note: For my fellow developers who are more comfortable with javascript, can simply strip out all the typescript's type annotations in front for variables, and it should be good to go.
Hope you enjoyed it, feel free to comment down below if you have any questions or suggestions. :)
Posted on July 27, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.