Modal

@use 'sprucecss/scss/spruce' as *;

// The dialog covers the viewport and scrolls; the panel inside is the visible box.
.modal {
  --inline-size: 34rem;
  background-color: transparent;
  block-size: 100%;
  border: 0;
  color: color('text');
  inline-size: 100%;
  max-block-size: none;
  max-inline-size: none;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: spacer('s');

  &[open] {
    display: grid;
    justify-items: center;
  }

  &::backdrop {
    backdrop-filter: blur(4px);
    background-color: hsl(220deg 40% 8% / 50%);
  }

  // Fade and scale in (and out) where the browser supports it.
  @include motion-safe {
    opacity: 0;
    transition: opacity 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;

    .modal__panel {
      scale: 0.96;
      transition: scale 0.2s;
    }

    &[open] {
      opacity: 1;

      @starting-style {
        opacity: 0;
      }

      .modal__panel {
        scale: 1;

        @starting-style {
          scale: 0.96;
        }
      }
    }

    &::backdrop {
      opacity: 0;
      transition: opacity 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
    }

    &[open]::backdrop {
      opacity: 1;

      @starting-style {
        opacity: 0;
      }
    }
  }

  &--lg {
    --inline-size: 48rem;
  }

  // The auto margins center the panel vertically; a taller panel starts at the top and the dialog scrolls.
  &__panel {
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('lg');
    box-shadow: shadow('xl');
    inline-size: min(var(--inline-size), 100%);
    margin-block: auto;
  }

  &__header {
    align-items: start;
    display: flex;
    gap: spacer('s');
    justify-content: space-between;
    padding: spacer-clamp('s', 'm') spacer-clamp('s', 'm') 0;

    &-caption {
      @include layout-stack('xxs');
    }
  }

  &__title {
    font-size: font-size('h3');
    margin-block: 0;
  }

  &__subtitle {
    color: color('muted');
  }

  &__close {
    flex-shrink: 0;
  }

  &__body {
    @include layout-stack('s');
    padding: spacer-clamp('s', 'm');
  }

  &__footer {
    align-items: center;
    background-color: color-alpha('primary', 0.03);
    border-block-start: config('border-width-base', $display) solid color('border');
    display: flex;
    flex-wrap: wrap;
    gap: spacer('s');
    justify-content: end;
    padding: spacer('s') spacer-clamp('s', 'm');

    &--space-between {
      justify-content: space-between;
    }
  }
}

// Keep the page from scrolling behind an open modal.
html:has(.modal[open]) {
  overflow: hidden;
}
<button class="btn btn--primary" type="button" data-modal-open="invite-modal">Invite teammates</button>
<dialog class="modal" id="invite-modal" aria-labelledby="invite-modal-title" aria-describedby="invite-modal-description">
    <form class="modal__panel" method="dialog" data-modal-form>
        <header class="modal__header">
            <div class="modal__header-caption">
                <h2 class="modal__title" id="invite-modal-title">Invite teammates</h2>
                <p class="modal__subtitle" id="invite-modal-description">They’ll get an email with a link to join the Acme workspace.</p>
            </div>
            <button class="btn btn--icon btn--sm btn--ghost-primary modal__close" type="button" data-modal-close>
                <span class="sr-only">Close</span>
                <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 6 6 18M6 6l12 12"/></svg>
            </button>
        </header>
        <div class="modal__body">
            <div class="form-group">
                <label class="form-label" for="invite-emails">Email addresses</label>
                <textarea class="form-control" id="invite-emails" name="emails" rows="3" required aria-describedby="invite-emails-help" autofocus></textarea>
                <p class="form-description" id="invite-emails-help">Separate the addresses with commas.</p>
            </div>
            <div class="form-group">
                <label class="form-label" for="invite-role">Role</label>
                <select class="form-control" id="invite-role" name="role">
                    <option>Member</option>
                    <option>Admin</option>
                    <option>Viewer</option>
                </select>
            </div>
        </div>
        <footer class="modal__footer">
            <button class="btn btn--outline-primary" type="button" data-modal-close>Cancel</button>
            <button class="btn btn--primary" type="submit" value="send">Send invites</button>
        </footer>
    </form>
</dialog>
(() => {
  document.querySelectorAll('[data-modal-open]').forEach((opener) => {
    const modal = document.getElementById(opener.dataset.modalOpen);

    if (!modal) return;

    // showModal() traps the focus, makes the page inert and closes on Escape.
    opener.addEventListener('click', () => modal.showModal());

    modal.addEventListener('click', (event) => {
      // A click on the backdrop targets the dialog itself.
      if (event.target === modal || event.target.closest('[data-modal-close]')) {
        modal.close('cancel');
      }
    });

    // The native dialog returns the focus to the opener on close; returnValue tells how it was closed.
    modal.addEventListener('close', () => {
      if (modal.returnValue === 'send') {
        modal.querySelector('[data-modal-form]').reset();
        modal.dispatchEvent(new CustomEvent('modal:submit', { bubbles: true }));
      }
    });
  });
})();

Modal

A modal dialog built on the native dialog element: it traps the focus, makes the rest of the page inert, closes with Esc and returns the focus to its opener, with a few lines of JavaScript.

Anatomy

  • A button with data-modal-open="{id}" opens the modal.
  • .modal is the dialog element, named by its title (aria-labelledby) and described by its subtitle (aria-describedby). .modal--lg makes it wider; any width works with --inline-size.
  • .modal__panel is the visible box inside the dialog. Here it's a form method="dialog": submitting it closes the dialog and sets its returnValue to the submit button's value.
  • .modal__header, .modal__body and .modal__footer (with --space-between) structure the content. Every [data-modal-close] element closes it.

Accessibility

  • showModal() gives the hard parts for free: the focus moves into the dialog (to the autofocus field, or the first focusable element), the page behind becomes inert, and Esc closes it.
  • When it closes, the browser returns the focus to the element that opened it.
  • The close button has a visually hidden "Close" label; a click on the backdrop closes it too.
  • Use a modal for focused tasks that block the page (confirmations, short forms). For long content or navigation, use the Drawer or a page.

Technical Details

  • No dependencies. The script only wires the openers, the backdrop click and the close buttons; the close event and returnValue tell you how it was closed (here, the form is reset and a modal:submit event fires).
  • The open and close animations use @starting-style and transition-behavior: allow-discrete; browsers without them show the modal without the animation, and nothing animates with reduced motion.
  • The dialog covers the viewport and is the scroll container, so a panel taller than the screen scrolls with the whole viewport instead of inside the box. A click on the area around the panel targets the dialog itself, so it closes the modal like a backdrop click.
  • The page behind doesn't scroll while a modal is open (html:has(.modal[open])).
  • The colors, the radius and the shadow come from the tokens; the old modal color group isn't needed any more.

Usage

const modal = document.getElementById('invite-modal');

modal.addEventListener('modal:submit', () => {
  // Send the form data to your back end.
});

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.