Drawer

Filters

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

.drawer {
  --drawer-inline-size: 24rem;
  background: transparent;
  block-size: 100dvb;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  inset-inline: auto 0;
  margin: 0;
  max-block-size: none;
  max-inline-size: 100vi;
  // The body scrolls, not the dialog (`dialog:modal` defaults to `overflow: auto`); visible keeps the panel shadow.
  overflow: visible;
  padding: 0;

  &::backdrop {
    background-color: color('overlay');
  }

  // Slide in and out (and fade the backdrop) where the browser supports it.
  @include motion-safe {
    --drawer-offset: 100%;
    transition: translate 0.3s cubic-bezier(0.32, 0.72, 0, 1), overlay 0.3s allow-discrete, display 0.3s allow-discrete;
    translate: var(--drawer-offset) 0;

    &[open] {
      translate: 0 0;

      @starting-style {
        translate: var(--drawer-offset) 0;
      }
    }

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

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

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

  &__panel {
    background-color: color('background');
    block-size: 100%;
    border-inline-start: config('border-width-base', $display) solid color('border');
    box-shadow: shadow('xl');
    display: flex;
    flex-direction: column;
    inline-size: min(var(--drawer-inline-size), 100vi);
  }

  &__header,
  &__footer {
    align-items: center;
    display: flex;
    gap: spacer('s');
    padding: spacer('s') spacer('m');
  }

  &__header {
    border-block-end: config('border-width-base', $display) solid color('border');
    justify-content: space-between;
  }

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

  &__body {
    flex-grow: 1;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: spacer('m');
    @include layout-stack('m');
  }

  &__footer {
    border-block-start: config('border-width-base', $display) solid color('border');
    justify-content: flex-end;
  }
}

// The drawer comes from the inline end: from the left in RTL.
[dir='rtl'] .drawer {
  --drawer-offset: -100%;
}

// No page scroll behind the open drawer.
html:has(.drawer[open]) {
  overflow: hidden;
  scrollbar-gutter: stable;
}
<button class="btn btn--primary" type="button" data-drawer-open="drawer-cart" aria-haspopup="dialog">Open drawer</button>
<dialog class="drawer" id="drawer-cart" aria-labelledby="drawer-cart-title">
    <div class="drawer__panel">
        <header class="drawer__header">
            <h2 class="drawer__title" id="drawer-cart-title">Filters</h2>
            <button class="btn btn--ghost-secondary btn--icon btn--sm" type="button" data-drawer-close aria-label="Close">
                <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg>
            </button>
        </header>
        <div class="drawer__body">
            <fieldset>
                <legend>Category</legend>
                <div class="form-group form-group--vertical-check">
                    <label class="form-check"><input class="form-check__control" type="checkbox" checked> <span class="form-label form-check__label">Backpacks</span></label>
                    <label class="form-check"><input class="form-check__control" type="checkbox"> <span class="form-label form-check__label">Accessories</span></label>
                    <label class="form-check"><input class="form-check__control" type="checkbox"> <span class="form-label form-check__label">Outdoor</span></label>
                </div>
            </fieldset>
            <div class="form-group">
                <label class="form-label" for="drawer-price">Maximum price</label>
                <input class="form-range" id="drawer-price" type="range" min="0" max="500" value="250">
            </div>
        </div>
        <footer class="drawer__footer">
            <button class="btn btn--outline-primary" type="button" data-drawer-close>Reset</button>
            <button class="btn btn--primary" type="button" data-drawer-close>Show 24 results</button>
        </footer>
    </div>
</dialog>
(() => {
  document.querySelectorAll('[data-drawer-open]').forEach((opener) => {
    const drawer = document.getElementById(opener.dataset.drawerOpen);

    if (!drawer) {
      return;
    }

    // Where the pointer went down: a drag that starts in the panel (a range input, a text selection)
    // and ends on the backdrop is not a backdrop click.
    let pressedOnBackdrop = false;

    opener.addEventListener('click', (event) => {
      // A link opener (like a cart link) goes to its page without JavaScript; here it opens the drawer.
      event.preventDefault();

      if (!drawer.open) {
        drawer.showModal();
      }
    });

    drawer.addEventListener('pointerdown', (event) => {
      pressedOnBackdrop = event.target === drawer;
    });

    drawer.addEventListener('click', (event) => {
      // A click on the backdrop targets the dialog itself.
      const backdropClick = event.target === drawer && pressedOnBackdrop;

      if (backdropClick || event.target.closest('[data-drawer-close]')) {
        drawer.close();
      }

      pressedOnBackdrop = false;
    });
  });
})();

Drawer

On this page

An off-canvas drawer (side sheet) for filters, a cart or a mobile menu, built on the native <dialog> element.

Technical Details

  • showModal() gives you focus trapping, the Esc key, the inert background and the ::backdrop for free.
  • Any element with data-drawer-open="id" opens the drawer; any element with data-drawer-close closes it. A link opener (<a href="/cart" data-drawer-open="cart-drawer">) still goes to its page without JavaScript. A click on the backdrop closes it too, but a drag that starts in the panel (a range input, a text selection) and ends on the backdrop doesn't.
  • Focus returns to the opener when the drawer closes (the browser does it for dialog).
  • The drawer slides in and out with a CSS transition (@starting-style, overlay and display with allow-discrete), so the Esc key animates too. It runs only when the user doesn't prefer reduced motion, and it mirrors in RTL (--drawer-offset).
  • The page behind the open drawer doesn't scroll (html:has(.drawer[open])), and the body of the drawer scrolls on its own (overscroll-behavior: contain).
  • Change the width with the --drawer-inline-size custom property.

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.