<div id="js-contents-wrapper" class="wrapper">
  <header class="global-header" role="banner">
    <div class="global-header__container">
      <div class="global-header__title" data-inert-target="menu">
        <p class="logo">Drawer Menu Two</p>
      </div>
      <nav class="global-header__nav" aria-label="サイト内メニュー">
        <button class="menu-button js-menu-open-trigger" type="button" aria-label="メニューを開く">
          <span class="menu-button__icon" data-type="open"></span>
        </button>
      </nav>
    </div>
  </header>
  <main class="main-content" data-inert-target="menu">
    <article class="article">
      <h2 class="article__title">このドロワーメニューについて</h2>
      <div class="article__sentence">
        <p>ユーザビリティおよびにアクセシビリティを意識した自作ドロワーメニューです。</p>
      </div>
      <section class="article__section">
        <h3 class="article__subtitle">自己チェック</h3>
        <ul class="article__list">
          <li class="article__list-item -checked">メニューが開いている時に背景のスクロールを無効にする</li>
          <li class="article__list-item -checked">メニュー内のリンクをクリック時にメニューを閉じることができる(非同期遷移のサイトやページ内リンクをメニューに含む場合は必須)</li>
          <li class="article__list-item -checked">支援技術向けのラベルをボタンとメニュー本体に付与する</li>
          <li class="article__list-item -checked">裏側のコンテンツの読み上げを制御する</li>
          <li class="article__list-item -checked">裏側のコンテンツを操作対象外にする(inert属性を利用)</li>
          <li class="article__list-item -checked">メニュー本体をEsc操作で閉じられる</li>
          <li class="article__list-item -checked">メニューを閉じた時にフォーカスをボタンに当てる</li>
          <li class="article__list-item -checked">背景固定時のスクロールバー消失によるガタツキを防止する</li>
          <li class="article__list-item -checked">durationの設定を反映したCSSカスタムプロパティを生成することで、JSのオプションで一括でアニメーションのdurationを変えられるようにする</li>
        </ul>
      </section>
      <section class="article__section">
        <h2 class="article__subtitle">課題点</h2>
        <ul class="article__list">
          <li class="article__list-item -not-checked">裏側のコンテンツの読み上げ制御はDOM依存のため要検討</li>
          <li class="article__list-item -not-checked">自作するよりMicromodal.js使ったほうが良さそう</li>
        </ul>
      </section>
      <section class="article__section">
        <h3 class="article__subtitle">参考サイト</h3>
        <ul class="article__list">
          <li class="article__list-item -link"><a class="article__link" href="https://magazine.techcareer.jp/instacart-blog/technology-instacart-blog/6418/" target="_blank" rel="noopener noreferrer">アクセシブルなWebモーダルを作る | techcareer magazine</a></li>
          <li class="article__list-item -link"><a class="article__link" href="https://www.w3.org/TR/wai-aria-practices-1.1/examples/dialog-modal/dialog.html" target="_blank" rel="noopener noreferrer">Modal Dialog Example | WAI-ARIA Authoring Practices 1.1</a></li>
          <li class="article__list-item -link"><a class="article__link" href="https://bootstrap-guide.com/components/modal" target="_blank" rel="noopener noreferrer">モーダル~Bootstrap5設置ガイド</a></li>
          <li class="article__list-item -link"><a class="article__link" href="https://standard.shiftbrain.com/blog/unavailable-inert-regions-and-inert-attribute" target="_blank" rel="noopener noreferrer">UIにおける見えるけど利用できない非活性な領域の実装とinert属性について - シフトブレイン/スタンダードデザインユニット</a></li>
        </ul>
      </section>
    </article>
  </main>
</div>

<div id="js-menu-content" class="drawer-menu" aria-label="メニュー" style="display:none">
  <div class="drawer-menu__container">
    <ul class="drawer-menu__list">
      <li class="drawer-menu__item">
        <a class="drawer-menu__link" href="#">
          <span class="drawer-menu__en-label">Drawer Menu</span>
          <span class="drawer-menu__jp-label">ドロワーメニュー</span>
        </a>
      </li>
      <li class="drawer-menu__item">
        <a class="drawer-menu__link" href="https://codepen.io/tak-dcxi/pen/VwZbwyg" target="_blank" rel="noreferrer noopener">
          <span class="drawer-menu__en-label">Hero Header</span>
          <span class="drawer-menu__jp-label">ヒーローヘッダー</span>
        </a>
      </li>
      <li class="drawer-menu__item">
        <a class="drawer-menu__link" href="https://codepen.io/tak-dcxi/pen/jOEVKEX" target="_blank" rel="noreferrer noopener">
          <span class="drawer-menu__en-label">Observe Animation</span>
          <span class="drawer-menu__jp-label">スクロール連動アニメーション</span>
        </a>
      </li>
      <li class="drawer-menu__item">
        <a class="drawer-menu__link" href="https://codepen.io/tak-dcxi/pen/yLaaJYj" target="_blank" rel="noreferrer noopener">
          <span class="drawer-menu__en-label">Accordion</span>
          <span class="drawer-menu__jp-label">アコーディオン</span>
        </a>
      </li>
      <li class="drawer-menu__item">
        <a class="drawer-menu__link" href="https://codepen.io/tak-dcxi/pen/vYEyrGJ" target="_blank" rel="noreferrer noopener">
          <span class="drawer-menu__en-label">Tab</span>
          <span class="drawer-menu__jp-label">タブコンポーネント</span>
        </a>
      </li>
      <li class="drawer-menu__item">
        <a class="drawer-menu__link" href="https://codepen.io/tak-dcxi/pen/vYXWPvZ" target="_blank" rel="noreferrer noopener">
          <span class="drawer-menu__en-label">Form Template</span>
          <span class="drawer-menu__jp-label">フォームテンプレート</span>
        </a>
      </li>
    </ul>
    <div class="drawer-menu__close-button">
      <button class="menu-button js-menu-close-trigger" type="button" aria-label="メニューを閉じる">
        <span class="menu-button__icon" data-type="close"></span>
      </button>
    </div>
  </div>
  <div class="drawer-menu__overlay js-menu-close-trigger"></div>
</div>
// z-indexをmap化して管理する
$z-index: (global-header, drawer-menu);

@function z-index($module) {
  @return index($z-index, $module);
}

html {
  box-sizing: border-box;
  font-size: 75%;
  -webkit-font-smoothing: subpixel-antialiased;
  -moz-osx-font-smoothing: auto;

  @media screen and (min-width: 768px) {
    font-size: 87.5%;
  }

  @media screen and (min-width: 1200px) {
    font-size: 100%;
  }
}

:root {
  --base-color: #434a56;
  --white-color-primary: #f7f8f8;
  --gray-color-primary: #676f79;
  --gray-color-secondary: #e2e2e2;
  --gray-color-tertiary: #aaa;
  --active-color: #006e9b;
  --header-height: 52px;

  @media screen and (min-width: 768px) {
    --header-height: 56px;
  }

  @media screen and (min-width: 1200px) {
    --header-height: 60px;
  }
}

*,
*::before,
*::after {
  box-sizing: inherit;
  margin: 0;
}

body {
  background-color: var(--white-color-primary);
  color: var(--base-color);
  font-family: "Helvetica Neue", "Segoe UI", "Hiragino Sans",
    "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
  font-size: 1em;
  line-height: 1.5;
}

.menu-button {
  appearance: none;
  background-color: var(--base-color);
  border: none;
  box-shadow: 0 0 12px rgba(0, 0, 0, 0.15);
  cursor: pointer;
  height: var(--header-height);
  padding: 0;
  position: relative;
  transition: background-color 0.3s;
  width: var(--header-height);

  &.focus-visible {
    background-color: var(--gray-color-primary);
  }

  @media (hover) {
    &:hover {
      background-color: var(--gray-color-primary);
    }
  }
}

.menu-button__icon {
  bottom: 0;
  height: 2px;
  left: 0;
  margin: auto;
  position: absolute;
  right: 0;
  top: 0;
  width: 18px;

  &::before,
  &::after {
    background-color: var(--white-color-primary);
    content: "";
    height: 100%;
    left: 0;
    position: absolute;
    top: 0;
    width: 100%;
  }

  &[data-type="open"] {
    background-color: var(--white-color-primary);

    &::before {
      transform: translateY(-6px);
    }

    &::after {
      transform: translateY(6px);
    }
  }

  &[data-type="close"] {
    &::before {
      transform: rotate(45deg);
    }

    &::after {
      transform: rotate(-45deg);
    }
  }
}

.drawer-menu {
  height: 100dvh;
  left: 0;
  overflow: hidden;
  position: fixed;
  top: 0;
  width: 100%;
  z-index: z-index(drawer-menu);
}

.drawer-menu__overlay {
  animation-duration: var(--menu-toggle-duration);
  animation-fill-mode: forwards;
  background-color: rgba(0, 0, 0, 0.1);
  height: 100%;
  left: 0;
  position: absolute;
  top: 0;
  width: 100%;
  z-index: -1;

  .drawer-menu:not([inert]) & {
    animation-name: menu-overlay-appeared;
  }

  .drawer-menu[inert] & {
    animation-name: menu-overlay-leaved;
  }
}

@keyframes menu-overlay-appeared {
  0% {
    opacity: 0;
  }

  100% {
    opacity: 1;
  }
}

@keyframes menu-overlay-leaved {
  0% {
    opacity: 1;
  }

  100% {
    opacity: 0;
  }
}

.drawer-menu__container {
  animation-duration: var(--menu-toggle-duration);
  animation-fill-mode: forwards;
  background-color: var(--white-color-primary);
  border-left: var(--gray-color-secondary) 1px solid;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
  height: 100%;
  max-width: 440px;
  min-width: 280px;
  overflow: hidden;
  position: absolute;
  right: 0;
  top: 0;
  width: 80%;

  .drawer-menu[inert] & {
    animation-name: menu-container-leaved;
  }

  .drawer-menu:not([inert]) & {
    animation-name: menu-container-appeared;
  }
}

@keyframes menu-container-appeared {
  0% {
    transform: translateX(100%);
  }

  100% {
    transform: translateX(0);
  }
}

@keyframes menu-container-leaved {
  0% {
    transform: translateX(0);
  }

  100% {
    transform: translateX(100%);
  }
}
.drawer-menu__list {
  flex: 1;
  list-style: none;
  margin: 0;
  overflow-y: auto;
  padding: 0;
}

.drawer-menu__item {
  border-bottom: var(--gray-color-secondary) 1px dashed;
}

.drawer-menu__link {
  align-items: center;
  color: inherit;
  display: block;
  justify-content: space-between;
  letter-spacing: 0.01em;
  padding: 1em 2.5em 1em 2em;
  position: relative;
  text-decoration: none;
  transition: background-color 0.3s;

  &::after {
    border-right: 2px solid var(--gray-color-tertiary);
    border-top: 2px solid var(--gray-color-tertiary);
    bottom: 0;
    content: "";
    display: inline-block;
    height: max(8px, 0.5em);
    margin: auto 0;
    position: absolute;
    right: 24px;
    top: 0;
    transform: rotate(45deg);
    width: max(8px, 0.5em);
  }

  &:focus {
    background-color: rgba(0, 0, 0, 0.05);
  }

  &[aria-current] {
    background-color: rgba(0, 0, 0, 0.1);

    &::after {
      content: none;
    }
  }

  @media (hover) {
    &:hover {
      background-color: rgba(0, 0, 0, 0.05);
    }
  }
}

.drawer-menu__en-label {
  display: block;
  font-family: "Montserrat", sans-serif;
  font-weight: 500;
  text-transform: uppercase;
}

.drawer-menu__jp-label {
  color: var(--gray-color-tertiary);
  display: block;
  font-size: max(10px, 0.75em);
}

.drawer-menu__close-button {
  border-bottom: var(--gray-color-secondary) 1px solid;
  order: -1;
  text-align: right;
}

.global-header {
  background-color: var(--white-color-primary);
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  height: var(--header-height);
  position: sticky;
  top: 0;
  z-index: z-index(global-header);
}

.global-header__container {
  align-items: center;
  display: flex;
  justify-content: space-between;

  &::before {
    content: "";
    display: inline-block;
    width: var(--header-height);
  }
}

.logo {
  font-family: "Lobster", cursive;
  font-size: 20px; // 拡大しないようにあえてのpx指定
  margin: 0;
  white-space: nowrap;
}

.main-content {
  overflow-x: hidden;
  padding: 4.5em 5%;
}

.article {
  font-size: 1rem;
  margin: auto;
  max-width: 1024px;
}

.article__title {
  font-size: 1.778em;
  font-weight: normal;
  margin: 0;
}

.article__sentence {
  line-height: 1.75;
  margin-top: 1.5em;

  & > p + p {
    margin-top: 1em;
  }
}

.article__section {
  margin-top: 2em;
}

.article__subtitle {
  border-bottom: 2px solid var(--gray-color-secondary);
  font-size: 1.333em;
  font-weight: normal;
  padding: 0.2em 0;
}

.article__list {
  display: table;
  margin-top: 1.5em;
  padding: 0;
}

.article__list-item {
  display: flex;

  & + & {
    margin-top: 0.5em;
  }

  &::before {
    background-position: center center;
    background-repeat: no-repeat;
    background-size: contain;
    content: "";
    display: inline-block;
    flex-shrink: 0;
    height: 1.5em;
    margin-right: 0.5em;
    vertical-align: middle;
    width: 1.5em;
  }

  &.-checked {
    &::before {
      background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%20%3Cpath%20d%3D%22M9.86%2018a1%201%200%200%201-.73-.32l-4.86-5.17a1%201%200%201%201%201.46-1.37l4.12%204.39%208.41-9.2a1%201%200%201%201%201.48%201.34l-9.14%2010a1%201%200%200%201-.73.33z%22%20fill%3D%22%2344c08a%22%2F%3E%3C%2Fsvg%3E");
    }
  }

  &.-not-checked {
    &::before {
      background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%20%3Cpath%20d%3D%22M13.41%2012l4.3-4.29a1%201%200%201%200-1.42-1.42L12%2010.59l-4.29-4.3a1%201%200%200%200-1.42%201.42l4.3%204.29-4.3%204.29a1%201%200%200%200%200%201.42%201%201%200%200%200%201.42%200l4.29-4.3%204.29%204.3a1%201%200%200%200%201.42%200%201%201%200%200%200%200-1.42z%22%20fill%3D%22%23f72f47%22%20%2F%3E%3C%2Fsvg%3E");
    }
  }

  &.-link {
    &::before {
      background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224px%22%20height%3D%2224px%22%20viewBox%3D%220%200%2024%2024%22%20stroke%3D%22%23676f79%22%20stroke-width%3D%221%22%20stroke-linecap%3D%22square%22%20stroke-linejoin%3D%22miter%22%20fill%3D%22none%22%3E%20%3Crect%20width%3D%2213%22%20height%3D%2213%22%20x%3D%223%22%20y%3D%223%22%20%2F%3E%20%3Cpolyline%20points%3D%2216%208%2021%208%2021%2021%208%2021%208%2016%22%20%2F%3E%3C%2Fsvg%3E");
    }
  }
}

.article__link {
  color: var(--active-color);

  @media (hover) {
    &:hover {
      opacity: 0.8;
      text-decoration: none;
    }
  }
}

.js-focus-visible :focus:not(.focus-visible) {
  outline: 0;
}

[inert] {
  cursor: default;
  pointer-events: none;
}

[inert],
[inert] * {
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}

.u-visually-hidden {
  border: 0 !important;
  clip: rect(0 0 0 0) !important;
  clip-path: inset(50%) !important;
  height: 1px !important;
  margin: -1px !important;
  overflow: hidden !important;
  padding: 0 !important;
  position: absolute !important;
  white-space: nowrap !important;
  width: 1px !important;
}
View Compiled
const attachEvent = (element, event, handler, options) => {
  element.addEventListener(event, handler, options);
  return {
    unsubscribe() {
      element.removeEventListener(event, handler);
    }
  };
};

// export { attachEvent }

const backfaceFixed = (fixed) => {
  /**
   * 表示されているスクロールバーとの差分を計測し、背面固定時はその差分body要素に余白を生成する
   */
  const scrollbarWidth = window.innerWidth - document.body.clientWidth;
  document.body.style.paddingRight = fixed ? `${scrollbarWidth}px` : "";

  /**
   * 背面固定する対象を決定する
   */
  const scrollingElement =
    "scrollingElement" in document
      ? document.scrollingElement
      : document.documentElement;

  /**
   * 背面固定時に変数にスクロール量を格納
   */
  const scrollY = fixed
    ? scrollingElement.scrollTop
    : parseInt(scrollingElement.style.top || "0");

  /**
   * CSSで背面を固定
   */
  const styles = {
    height: "100vh",
    left: "0",
    overflow: "hidden",
    position: "fixed",
    top: `${scrollY * -1}px`,
    width: "100vw"
  };

  Object.keys(styles).forEach((key) => {
    scrollingElement.style[key] = fixed ? styles[key] : "";
  });

  /**
   * 背面固定解除時に元の位置にスクロールする
   */
  if (!fixed) window.scrollTo(0, scrollY * -1);
};

// export { backfaceFixed }

// import { attachEvent } from '../utils/attachEvent'
// import { backfaceFixed } from '../utils/backfaceFixed'

class DrawerMenu {
  constructor(root, options) {
    this.root = root;
    if (!this.root) return;

    const defaultOptions = {
      openTriggerSelector: ".js-menu-open-trigger", // メニューを開く際のターゲットとなるセレクタ
      closeTriggerSelector: ".js-menu-close-trigger", // メニューを閉じる際のターゲットとなるセレクタ
      inertTargetSelector: "#js-contents-wrapper", // 開いている時はこのセレクタを読み上げ対象外にする。
      clickLinkToClose: true, // メニュー内のリンクをクリック時にメニューを閉じるか
      toggleDuration: 500 // メニューの遷移時間
    };

    const mergedOptions = Object.assign(defaultOptions, options);
    this.options = mergedOptions;

    /**
     * メニューを開くトリガーとなるセレクタの設定
     */
    this.openTrigger = document.querySelector(this.options.openTriggerSelector);
    if (!this.openTrigger)
      throw TypeError("メニューを開く要素が見つかりません");

    /**
     * ボタンとは別にメニューを閉じるトリガーとなるセレクタの設定
     * メニュー内のリンクをクリック時にメニューを閉じる設定をしている場合はメニュー内のa要素をクリック時にもメニューを閉じる
     */
    this.closeTrigger = document.querySelectorAll(
      this.options.closeTriggerSelector
    );

    /**
     * メニュー内のリンクをクリック時にメニューを閉じる設定をしている場合はメニュー内のa要素をクリック時にもメニューを閉じる
     */
    this.innerLink = this.root.querySelectorAll("a:not(.js-ignore-target)");

    /**
     * メニューが開かれている際に読み上げ対象外とするセレクタの指定
     */
    this.inertTarget = document.querySelector(this.options.inertTargetSelector);
    if (!this.inertTarget)
      throw TypeError("inert対象のセレクタの指定は必須です");

    /**
     * イベントハンドラの設定
     */
    this.openTriggerHandler = this.handleOpenTriggerClick.bind(this);
    this.closeTriggerHandler = this.handleCloseTriggerClick.bind(this);
    this.innerLinkHandler = this.handleInnerLinkClick.bind(this);
    this.keyupHandler = this.handleKeyup.bind(this);
  }

  init() {
    /**
     * 状態を格納する変数
     */
    this.isExpanded = false; // メニューの開閉状態を格納する

    /**
     * 初期化時に属性を付与
     */
    this.prepareAttributes();

    attachEvent(this.openTrigger, "click", this.openTriggerHandler);
  }

  prepareAttributes() {
    this.root.setAttribute("role", "dialog");
    this.root.setAttribute("aria-modal", "true");
    this.root.setAttribute("tabindex", "-1");
    this.root.setAttribute("inert", "");
    this.root.style.display = "none";

    this.openTrigger.setAttribute("aria-haspopup", "true");

    document.documentElement.style.setProperty(
      "--menu-toggle-duration",
      `${this.options.toggleDuration}ms`
    );
  }

  handleOpenTriggerClick(event) {
    event.preventDefault();
    this.open();
  }

  handleCloseTriggerClick(event) {
    event.preventDefault();
    this.close();
  }

  handleInnerLinkClick(event) {
    this.close();
  }

  handleKeyup(event) {
    if (event.key === "Escape" || event.key === "Esc") this.close();
  }

  open() {
    // メニューの`display:none`を削除
    this.root.style.display = "";
    // Esc押下時イベントを追加
    attachEvent(document, "keyup", this.keyupHandler);
    // 開くボタンクリック時のイベントを削除
    attachEvent(
      this.openTrigger,
      "click",
      this.openTriggerHandler
    ).unsubscribe();
    // 閉じるボタンクリック時のイベントを追加
    this.closeTrigger.forEach((trigger) => {
      attachEvent(trigger, "click", this.closeTriggerHandler);
    });
    // メニュー内リンククリック時のイベントを削除
    if (this.options.clickLinkToClose) {
      this.innerLink.forEach((link) => {
        attachEvent(link, "click", this.innerLinkHandler);
      });
    }

    backfaceFixed(true);
    this.changeAttribute(true);

    setTimeout(() => {
      this.root.focus();
      this.isExpanded = true;
    }, 100);
  }

  close() {
    // Esc押下時のイベントを削除
    attachEvent(document, "keyup", this.keyupHandler).unsubscribe();
    // 閉じるボタンクリック時のイベントを削除
    this.closeTrigger.forEach((trigger) => {
      attachEvent(trigger, "click", this.closeTriggerHandler).unsubscribe();
    });
    // メニュー内リンククリック時のイベントを削除
    if (this.options.clickLinkToClose) {
      this.innerLink.forEach((link) => {
        attachEvent(link, "click", this.innerLinkHandler).unsubscribe();
      });
    }

    backfaceFixed(false);
    this.changeAttribute(false);

    setTimeout(() => {
      // 開くボタンクリック時のイベントを再登録
      attachEvent(this.openTrigger, "click", this.openTriggerHandler);
      // メニューに`hidden`を付与
      this.root.style.display = "none";
      // 開くボタンにフォーカスを移動
      this.openTrigger.focus();

      this.isExpanded = false;
    }, this.options.toggleDuration);
  }

  changeAttribute(expanded) {
    if (expanded) {
      this.root.removeAttribute("inert");
      this.openTrigger.setAttribute("inert", "");
      this.inertTarget.setAttribute("inert", "");
    } else {
      this.root.setAttribute("inert", "");
      this.openTrigger.removeAttribute("inert");
      this.inertTarget.removeAttribute("inert");
    }
  }
}

document.addEventListener("DOMContentLoaded", () => {
  const drawerElement = document.getElementById("js-menu-content");
  const drawer = new DrawerMenu(drawerElement);
  drawer.init();
});
View Compiled

External CSS

  1. https://fonts.googleapis.com/css2?family=Lobster&amp;family=Montserrat:wght@500&amp;display=swap

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/stickyfill/2.1.0/stickyfill.min.js
  2. https://cdn.jsdelivr.net/npm/focus-visible@5.2.0/dist/focus-visible.min.js
  3. https://cdn.jsdelivr.net/npm/wicg-inert@3.1.0/dist/inert.min.js