DEV Community

vibhanshu pandey
vibhanshu pandey

Posted on • Updated on

How to create a Full-Featured Modal Component in Svelte, and trap focus-within

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Ok, so what do we have until now:

  1. 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.
  2. 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.
  3. The Modal contains a content-wrapper element for applying common styles e.g background-color, font-size, and other responsive styles.
  4. 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>
Enter fullscreen mode Exit fullscreen mode
As you can see, we have 4 slots:
  1. trigger, for opening the Modal.
  2. header, for containing the title of the Modal
  3. content, for containing the body of the Modal i.e the main content.
  4. 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}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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),
  }
}
Enter fullscreen mode Exit fullscreen mode

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}

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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. :)

Oldest comments (6)

Collapse
 
yawnxyz profile image
Jan Z

Wow, this is so great! Thanks for this writeup and demo, it really saved me a ton of time. Also, there's a small typo/bug — in your complete implementation:
<slot name="footer" {store}>

should say:
<slot name="footer" {close}>

in order to properly expose the close method.

Cheers and thanks!

Collapse
 
vibhanshu909 profile image
vibhanshu pandey

Thank You for noticing,
Fixed It, by updating its usage:-

<div slot="footer" let:store={{close}}>
    <button on:click={close}>Close First Modal</button>
</div>
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dubfonik profile image
dubfonik

I'm trying to build my own reusable modal component and having trouble. It'd be good to see this in the Svelte REPL to play with it and understand it better.

Collapse
 
vibhanshu909 profile image
vibhanshu pandey
Collapse
 
dubfonik profile image
dubfonik

awesome, thanks! that helps visualise it better :)

Collapse
 
hnakamur profile image
Hiroaki Nakamura

Thanks a million for this nice article and implementation!
I've learned a lot about implementing a Modal in Svelte.
I ended up porting a JavaScript based solution from Modal Dialog Example | WAI-ARIA Authoring Practices 1.1.
The result is at github.com/hnakamur/svelte-modal-e...