< back

bird wrongs

resources: diy modal.

You might also want to see the photo gallery tutorial that pairs well with this—I figured it would be useful to separate it out, though, since it can be used in a lot more situations. In this example, it's for a lightbox, but you can probably see how you could just use other elements, or insert text instead of a photo, or so on.

So let's get some markup on the page:

<div id="overlay">
    <div id="lightbox" aria-live="assertive">
      <a href="#" class="close">close</a>
      <div id="lightbox-image"></div>
      <div id="lightbox-caption"></div>
    </div>
  </div>

And also some basic CSS:

#overlay {
  pointer-events: none; // this means that even though it's always layered over everything, we can click through it!
  background-color: rgba(0,0,0,.5); // this can be whatever color you want
  opacity: 0;
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  
  &.show {
    opacity: 1;
    pointer-events: all; // this adds back the pointer events, so we can click on things.
  }
  
  #lightbox {
    position: relative;
    
    .close {
      position: absolute;
      bottom: 100%;
      right: 0;
    }
    
    #lightbox-image {
      max-width: 600px;
      max-height: 600px;
    }
    
    img {
      max-width: 100%;
      max-height: 600px;
    }
    
    #lightbox-caption {
      margin-top: 5px;
      text-align: left;
    }
  }
}

Of course, you can name the inner elements anything you like. Note the aria-live attribute, which makes sure to update screenreaders when the content in the modal changes.

I start by making an event listern for click events, so that I can identify when a modal link has been clicked. Then, what I do is create a MouseEvent object for the focus event, because I'm going to need to control the element focus a bunch during this for some accessibility reasons. We don't want the user to be able to tab around outside the modal while the modal is up, for example.

Next, I check if it's a thing I should be applying a modal to, which in this case is the main > article > a selector. If so, we prevent the default behavior, and add a class to the link to mark it as open so that we can go back to it later.

Then, I'm grabbing some data from the link—you can use either data-attributes or other various appropriate attributes like title and href to pass information—and pulling together the elements I want to manipulate so I can reference them easily. I replace the contents that I need to replace (the lightbox image and caption), and then add the class show to the overlay so it's visible.

After that, I force the focus inside the modal to the first focusable element (the close button). As for making sure focus stays inside the modal while it's open, I'll handle that in a sec.

I also check for whether the user's clicked the "close" button, or the overlay, and if so, we remove the show class from the overlay and re-focus the link with the class open before removing that class as well.

document.addEventListener('click', (e) => {
    const fevent = new MouseEvent("focus", {
      view: window,
      bubbles: true,
      cancelable: true,
    });

    if (e.target.closest('main > article > a')) {
      e.preventDefault();
      e.target.closest('a').classList.add('open');
      const img = e.target.querySelector('img');
      const photoSpace = document.getElementById('lightbox-image');
      const captionSpace = document.getElementById('lightbox-caption');
      const overlay = document.getElementById('overlay');
      const closeButton = document.querySelector('.close');
      photoSpace.innerHTML = `<img src="${e.target.src}" />`;
      captionSpace.innerHTML = `${e.target.title}`;
      overlay.classList.add('show');
      document.querySelector('#lightbox-caption').style.width = document.querySelector('#lightbox-image img').width+"px"; // this sets the caption to the width of the photo here so that we don't have the caption stretching the modal out
      closeButton.dispatchEvent(fevent);
    } else if (e.target.classList.contains('close') || e.target.id == 'overlay') {
      e.preventDefault();
      document.querySelector('.open').dispatchEvent(fevent);
      document.querySelector('.open').classList.remove('open');
      overlay.classList.remove('show');
    }
  });

Neat, right? Now, as promised, I need to get a list of focusable elements inside the #overlay element (buttons, links, form inputs, things with the [tabindex] property), and if we've reached the last one, cycle to the first.

const focusable = document.querySelector('#overlay').querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');

document.addEventListener('focus', (e) => {
  const overlay = document.querySelector('#overlay');
  const fevent = new MouseEvent("click", {
    view: window,
    bubbles: true,
    cancelable: true,
  });

  if (overlay.classList.includes('show')) {
    if (focusable.length > 0) {
      focusable[0].dispatchEvent(fevent);
    }
  }
});

And there you have it! No JQuery, no React, just plain javascript in not too many lines. There's plenty of ways to do modals, of course, and this is only one—there's even ways to do it without javascript! But this one's pretty easy, and easy to work with.