テクニック

モーダル dialog 要素の実装|背景固定もフォーカス管理も標準機能で

この記事の対象: HTML/CSS/JavaScript を自分で書ける制作者。既存サイトにモーダルを追加する作業を今日終わらせたい人
読了時間: 約12分

モーダルウィンドウを自前で組むと、開閉のフラグ管理、背景スクロールの固定、Esc キーの監視、フォーカスをモーダル内に閉じ込める処理、閉じたあとに元のボタンへフォーカスを戻す処理——最低5つの面倒を抱えることになります。<dialog> 要素と showModal() を使うと、このうち4つがブラウザ側の仕事になります。残る1つが背景スクロールの固定で、これも CSS 数行で済みます。この記事は既存の HTML にコピーして動くコードを載せ、つまずく箇所を先に潰します。


前提を先に確認する

前提を先に確認する

作業に入る前に、次の3点を確認してください。ここが違うとコードの置き場所が変わります。

確認すること この記事の前提
環境 素の HTML / CSS / JavaScript。フレームワーク不問
ブラウザ <dialog> 自体は Chrome 37以降・Safari 15.4以降・Firefox 98以降。この記事のコードの中心部分は全ブラウザで動く
外部ライブラリ 不要。jQuery も Bootstrap も Micromodal も読み込まない

<dialog> は 2022年3月に Firefox が対応して全モダンブラウザで揃いました。Baseline の Widely available(主要ブラウザに載ってから十分な期間が経ち、安定して使える状態)に入っているので、2026年時点で「新しすぎて使えない」段階ではありません。ただし後述する ::backdrop の一部プロパティ、closedby 属性、@starting-style を使ったフェードインは対応状況が別なので、それぞれの節で条件を書きます。

自分の環境で確認するには、DevTools のコンソールに次を打ちます。

console.log('dialog:', typeof HTMLDialogElement !== 'undefined');
console.log('closedby:', 'closedBy' in document.createElement('dialog'));
console.log('starting-style:', CSS.supports('@starting-style', ''));

1行目が false になる環境(IE や相当古い WebView)を相手にする案件なら、この記事の方法は採れません。その場合は従来の div + aria-modal 方式に戻ってください。

CMS のテーマに入れる場合の置き場所

WordPress や Shopify のテーマに組み込むなら、置き場所はそれぞれ次のようになります。この記事では素の HTML ファイルを基準に書きますが、パスだけ読み替えれば同じです。

CMS HTML CSS JS
WordPress(ブロックテーマ) parts/ 配下または single.php などのテンプレート 子テーマの style.css 子テーマの assets/js/modal.js を functions.php から wp_enqueue_script()
WordPress(クラシックテーマ) footer.php の </body> 直前 子テーマの style.css 同上
Shopify(Online Store 2.0) snippets/ts-modal.liquid を作り theme.liquid で {% render %} assets/ts-modal.css assets/ts-modal.js

Shopify で assets/ に置いたファイルは {{ 'ts-modal.css' | asset_url | stylesheet_tag }} で読み込みます。テーマ側の命名規約に合わせて ts- などのプレフィックスを付けておくと、テーマ更新時に自分の追加分が判別できます。

Shopify のセクションを素の JavaScript で組む考え方は、スライダーの記事でも同じ方針を採っています。


dialog要素のモーダル実装|最小構成のコード

dialog要素のモーダル実装|最小構成のコード

まず動く最小構成を置きます。これをコピーすれば、開く・閉じる・Escで閉じる・フォーカスが戻る、までが揃います。

HTML

</body> の直前に置きます。<dialog> はページのどこに書いても最前面に出るため、レイアウト構造の中に埋め込む必要はありません。むしろ埋め込むと、親要素の overflow: hidden や transform に巻き込まれる事故が起きます。

<!-- 開くボタン。ページ内のどこでも良い -->
<button type="button" class="js-modal-open" data-modal-target="contact-modal">
  お問い合わせ
</button>

<!-- ここから下は </body> の直前に置く -->
<dialog id="contact-modal" class="ts-modal" aria-labelledby="contact-modal-title">
  <div class="ts-modal__inner">
    <button type="button" class="ts-modal__close js-modal-close" aria-label="閉じる">
      <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
        <path d="M4 4 L16 16 M16 4 L4 16" stroke="currentColor" stroke-width="2" fill="none" />
      </svg>
    </button>

    <h2 id="contact-modal-title" class="ts-modal__title">お問い合わせ</h2>

    <div class="ts-modal__body">
      <p>ご相談内容をお知らせください。2営業日以内にご返信します。</p>
      <form method="dialog">
        <label for="modal-name">お名前</label>
        <input type="text" id="modal-name" name="name" required>

        <label for="modal-message">ご相談内容</label>
        <textarea id="modal-message" name="message" rows="4" required></textarea>

        <button type="submit" value="send">送信する</button>
      </form>
    </div>
  </div>
</dialog>

読者が変更する箇所

  • id="contact-modal" と data-modal-target="contact-modal":この2つは同じ値にします。1ページに複数のモーダルを置くなら id を変えて、対応するボタンの data-modal-target も揃えます
  • aria-labelledby と id="contact-modal-title":この2つも対応させます。モーダルの見出しを読み上げさせるための紐付けです
  • ts- プレフィックス:既存サイトの命名規約に合わせて置き換えます。ただし後述の JavaScript が js-modal-open / js-modal-close を参照しているので、変えるなら JS も同時に変えます

aria-modal="true" や role="dialog" は書いていません。showModal() で開いた <dialog> にはブラウザが同等の扱いを与えるためです。手で付けると、実装によっては二重指定になります。

CSS

/* ---- ダイアログ本体 ---- */
.ts-modal {
  /* dialog のデフォルトを打ち消す */
  margin: auto;                 /* 画面中央に置く */
  padding: 0;
  border: none;
  border-radius: 12px;
  background: #fff;
  color: #1a1a1a;

  /* 幅:小さい画面では余白を残して縮む */
  width: min(560px, calc(100% - 2rem));
  max-height: min(80vh, calc(100% - 2rem));

  /* 中身が長いときはダイアログ内でスクロールさせる */
  overflow: auto;
  overscroll-behavior: contain;

  box-shadow: 0 20px 50px -12px rgb(0 0 0 / 0.35);
}

/* ---- 背景の暗幕 ---- */
.ts-modal::backdrop {
  background: rgb(0 0 0 / 0.55);
}

/* ---- 中身 ---- */
.ts-modal__inner {
  padding: 2rem;
  position: relative;
}

.ts-modal__title {
  margin: 0 0 1rem;
  font-size: 1.25rem;
  line-height: 1.5;
}

.ts-modal__body {
  line-height: 1.9;
}

/* ---- 閉じるボタン ---- */
.ts-modal__close {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
  display: grid;
  place-items: center;
  width: 40px;               /* タップ領域を確保する。40px 未満にしない */
  height: 40px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: #555;
  cursor: pointer;
}

.ts-modal__close:hover {
  background: rgb(0 0 0 / 0.06);
}

.ts-modal__close:focus-visible {
  outline: 2px solid #1a73e8;
  outline-offset: 2px;
}

/* ---- 背景スクロールの固定 ---- */
/* JavaScript 側で <html> に .is-modal-open を付ける */
html.is-modal-open {
  overflow: hidden;
}

読者が変更する箇所

  • width: min(560px, calc(100% - 2rem)) の 560px:モーダルの最大幅。フォームなら 480〜600px、画像を見せるなら 800px 程度
  • border-radius・background・box-shadow:サイトのトンマナに合わせます
  • ::backdrop の不透明度 0.55:暗さの調整。0.4 で軽め、0.7 でしっかり遮断
  • #1a73e8(フォーカスリングの色):サイトのアクセントカラーに置き換えます

margin: auto を書いている理由が重要です。<dialog> のブラウザ既定スタイルは margin: auto で、inset: 0 と組み合わさって中央に来ています。ここを padding: 0; border: none だけ上書きして margin を書き忘れると、リセット CSS が margin: 0 を当てている環境で左上に張り付きます。

JavaScript

// assets/js/modal.js
(() => {
  'use strict';

  const OPEN_CLASS = 'is-modal-open';

  /** 開いているモーダルの数。入れ子や連続表示に耐えるため数で持つ */
  let openCount = 0;

  /** 背景スクロールを止める */
  function lockScroll() {
    if (openCount === 0) {
      // スクロールバーが消える分の横ズレを打ち消す
      const gap = window.innerWidth - document.documentElement.clientWidth;
      document.documentElement.style.setProperty('--scrollbar-gap', `${gap}px`);
      document.documentElement.style.paddingRight = `${gap}px`;
      document.documentElement.classList.add(OPEN_CLASS);
    }
    openCount += 1;
  }

  /** 背景スクロールを戻す */
  function unlockScroll() {
    openCount = Math.max(0, openCount - 1);
    if (openCount === 0) {
      document.documentElement.classList.remove(OPEN_CLASS);
      document.documentElement.style.paddingRight = '';
      document.documentElement.style.removeProperty('--scrollbar-gap');
    }
  }

  /** 指定 id のモーダルを開く */
  function openModal(id) {
    const dialog = document.getElementById(id);
    if (!dialog || typeof dialog.showModal !== 'function') return;
    if (dialog.open) return;
    dialog.showModal();
  }

  // --- 開くボタン ---
  document.addEventListener('click', (event) => {
    const opener = event.target.closest('.js-modal-open');
    if (opener) {
      event.preventDefault();
      openModal(opener.dataset.modalTarget);
      return;
    }

    // --- 閉じるボタン ---
    const closer = event.target.closest('.js-modal-close');
    if (closer) {
      event.preventDefault();
      closer.closest('dialog')?.close();
    }
  });

  // --- 背景クリックで閉じる ---
  // dialog 要素自体がクリックされたときだけ閉じる。
  // .ts-modal__inner の中をクリックしたときは event.target が inner 側になる
  document.addEventListener('click', (event) => {
    const dialog = event.target;
    if (dialog instanceof HTMLDialogElement && dialog.open) {
      dialog.close();
    }
  });

  /** 1つの dialog にスクロールロックを結線する */
  function bindScrollLock(dialog) {
    if (dialog.dataset.tsModalBound === 'true') return;
    dialog.dataset.tsModalBound = 'true';

    // showModal / close は「開いた」という独自イベントを出さないので、
    // showModal をラップして開いた瞬間を捕まえる
    const nativeShowModal = dialog.showModal.bind(dialog);
    dialog.showModal = () => {
      nativeShowModal();
      lockScroll();
    };

    dialog.addEventListener('close', () => {
      unlockScroll();
    });

    // cancel(Esc)は close を必ず伴うので、ここでは何もしない
  }

  document.querySelectorAll('dialog').forEach(bindScrollLock);

  // 動的に追加したモーダルは、追加直後にこれを呼ぶ
  window.tsBindModal = bindScrollLock;
})();

読者が変更する箇所

  • .js-modal-open / .js-modal-close:既存サイトのクラス命名に合わせるなら、HTML 側と同時に変えます
  • 背景クリックで閉じたくない場合(入力途中の離脱を防ぎたいフォームなど):「背景クリックで閉じる」ブロックをまるごと削除します
  • window.tsBindModal:グローバルを増やしたくないなら、モジュールとして export する形に変えます

読み込みは </body> 直前、または <script defer src="..."> で <head> に置きます。document.addEventListener でイベント委譲しているので、モーダルを Ajax で後から追加しても開閉は動きます。ただし querySelectorAll('dialog') のループは初期化時点の DOM しか見ないため、後から追加したモーダルには tsBindModal(dialog) を呼んでスクロールロックを結線してください。二重に呼んでも data-ts-modal-bound で弾かれます。


標準機能で済むもの、自分で書くもの

モーダルの要件をブラウザと自分で分担する

ブラウザがやってくれる

  • 最前面への配置(top layer)
  • Escキーで閉じる
  • 背景要素の不活性化
  • フォーカスをモーダル内に閉じ込める
  • 閉じたあと元の要素にフォーカスを戻す
  • 暗幕の描画(::backdrop)

自分で書く必要があるもの

  • 背景スクロールの固定
  • 背景クリックで閉じる挙動
  • 開閉アニメーション
  • 見た目のリセットと装飾

自前の div 実装だと左側の6つも全部書くことになります。showModal() に寄せると、書くコードは右側の4つに絞られます。

top layer に載る、という一点が大きい

showModal() で開いた <dialog> は top layer という特殊な描画層に移されます。ここに載ると、祖先要素の z-index・overflow: hidden・transform・filter の影響を一切受けません。

自前実装で一番よく踏むのがここです。position: fixed のモーダルを、transform が当たっている親の中に書いてしまい、fixed が親を基準に固定されてスクロールに追従しなくなる。あるいは overflow: hidden の親に切られて一部が見えなくなる。z-index の値を 9999 → 99999 と上げていく作業に見覚えがあるなら、その作業ごと消せます。

背景要素が「不活性」になる

showModal() の間、<dialog> 外の要素はブラウザによって inert(不活性)として扱われます。クリックできず、Tab でも到達せず、支援技術からも読み上げ対象外になります。

自前実装だと、これを再現するために「モーダル内のフォーカス可能な要素を全部集めて、最初と最後で Tab を折り返す」というフォーカストラップを書くことになります。tabindex="-1" の扱い、disabled の除外、display: none の要素の除外、<details> の中身——考慮すべき条件が多く、自作のフォーカストラップは静かに壊れていることが珍しくありません。ここが不要になるのが実務では一番ありがたい部分です。

閉じたあとフォーカスが戻る

close() を呼ぶと、showModal() を呼んだ時点でフォーカスがあった要素にフォーカスが返ります。「モーダルを閉じたらページ先頭に飛ばされる」「Tab を押したらページの最初からやり直しになる」という不具合が、そもそも発生しません。

ただし条件があります。モーダルを開いたボタンが、閉じたあとも DOM に存在していること。 ボタンを消す設計(「もっと見る」を押すとボタン自体が消える等)では戻り先が失われます。その場合は自分で戻り先を指定します。

// 開いたボタンを閉じたあとに消す場合の、戻り先の指定
const dialog = document.getElementById('contact-modal');
const fallback = document.getElementById('section-heading'); // 戻したい見出しなど

dialog.addEventListener('close', () => {
  if (!document.activeElement || document.activeElement === document.body) {
    fallback.setAttribute('tabindex', '-1'); // 見出しなど、本来フォーカスできない要素に必要
    fallback.focus();
  }
});

tabindex="-1" は「JS からのフォーカスは受けるが Tab の巡回には入らない」という指定です。見出しやコンテナに一時的にフォーカスを当てるときに使います。


背景スクロールの固定をどう書くか

背景スクロールの固定をどう書くか

<dialog> が面倒を見てくれない唯一の要件です。showModal() で開いてもページ背後はスクロールします。マウスホイールを回すと後ろのコンテンツが動き、閉じたら別の位置にいる——という状態になります。

基本形は overflow: hidden

上のコードでやっているのがこれです。<html> に overflow: hidden を当てます。

html.is-modal-open {
  overflow: hidden;
}

<body> ではなく <html>(document.documentElement)に当てる理由は、<body> 側だと環境によってスクロールが止まらないことがあるためです。<html> に当てておけば挙動が安定します。

スクロールバー幅の横ズレを打ち消す

overflow: hidden にするとスクロールバーが消え、その幅だけコンテンツが右に広がります。モーダルを開いた瞬間に背景が「ガタッ」と動くのはこれです。

JavaScript のコードで paddingRight を足しているのが対策です。

const gap = window.innerWidth - document.documentElement.clientWidth;
document.documentElement.style.paddingRight = `${gap}px`;

window.innerWidth はスクロールバーを含む幅、clientWidth は含まない幅なので、差分がスクロールバーの実測幅になります。決め打ちの数値を書かないのは、OS とブラウザ設定で幅が変わるためです。macOS のオーバーレイスクロールバーでは差分が 0 になり、余分な padding も付きません。

ヘッダーを position: fixed で固定している場合、そのヘッダーにも同じ幅の padding が必要です。CSS 変数を出しておいたので、こう書けます。

html.is-modal-open .site-header {
  padding-right: calc(var(--scrollbar-gap, 0px) + 1rem);
}

.site-header は自分のヘッダーのクラス名に、1rem は元々のヘッダー右パディングに置き換えます。

iOS Safari で背景が動いてしまうとき

overflow: hidden だけでは iOS Safari の慣性スクロールを止めきれないことがあります。確実に止めたいなら位置を固定します。

/** iOS も含めて確実に止める版。lockScroll / unlockScroll を差し替える */
let savedScrollY = 0;

function lockScroll() {
  if (openCount === 0) {
    savedScrollY = window.scrollY;
    const gap = window.innerWidth - document.documentElement.clientWidth;
    document.documentElement.style.setProperty('--scrollbar-gap', `${gap}px`);
    document.body.style.position = 'fixed';
    document.body.style.top = `-${savedScrollY}px`;
    document.body.style.left = '0';
    document.body.style.right = '0';
    document.body.style.width = '100%';
  }
  openCount += 1;
}

function unlockScroll() {
  openCount = Math.max(0, openCount - 1);
  if (openCount === 0) {
    document.body.style.position = '';
    document.body.style.top = '';
    document.body.style.left = '';
    document.body.style.right = '';
    document.body.style.width = '';
    document.documentElement.style.removeProperty('--scrollbar-gap');
    window.scrollTo(0, savedScrollY);   // 元の位置に戻す。これを忘れると先頭に飛ぶ
  }
}

こちらは確実に止まりますが、副作用があります。<body> を position: fixed にする瞬間にスクロール位置が失われるため、閉じたときの window.scrollTo() が必須です。また position: sticky のヘッダーは <body> が固定された時点で機能しなくなります。

どちらを採るかの判断は次のとおりです。

状況 採る方法
PC メインのサイト、モーダルは短時間 overflow: hidden の基本形
スマホ流入が多い、モーダル内でフォームを長く操作する position: fixed 版
モーダル内が長くスクロールする どちらでも良いが overscroll-behavior: contain を必ず併記

CSS 側に書いた overscroll-behavior: contain は、モーダル内のスクロールが端まで行ったときに背景へスクロールが伝播するのを止めます。これは Chrome 63以降・Safari 16以降・Firefox 59以降で効きます。


Escキーと閉じ方の制御

Escは何もしなくても効く

showModal() で開いた <dialog> は Esc キーで閉じます。keydown の監視は不要です。このとき発火するイベントの順序は次のとおりです。

Escキーを押したときのイベントの流れ
cancelEscによる閉じ要求。ここで止められる
close実際に閉じた。後片付けはここ

cancel は Esc のときだけ発火し、close() を直接呼んだときは発火しません。後片付けは close に書くのが正解です。

入力途中のEscを確認してから閉じる

フォームを入れたモーダルで、Esc の誤爆を防ぎたいときは cancel を止めます。

const dialog = document.getElementById('contact-modal');
const form = dialog.querySelector('form');

dialog.addEventListener('cancel', (event) => {
  // 入力が1つでもあれば確認する
  const hasInput = [...new FormData(form).values()].some((v) => String(v).trim() !== '');
  if (hasInput && !window.confirm('入力内容が失われます。閉じてもよろしいですか?')) {
    event.preventDefault();   // 閉じるのをキャンセルする
  }
});

event.preventDefault() で閉じるのを止められるのは cancel だけです。close は「もう閉じた」通知なので止められません。

window.confirm は見た目がブラウザ依存なので、実案件では自作の確認ダイアログに差し替えることになります。その場合は cancel で常に preventDefault() してから、確認ダイアログの結果を見て dialog.close() を呼びます。

Escで閉じさせない

決済処理中など、絶対に閉じさせたくない場面。

dialog.addEventListener('cancel', (event) => {
  event.preventDefault();
});

これで Esc も、後述の closedby による軽い閉じ方も止まります。ただし閉じる手段を完全に奪うのは避けてください。 ユーザーが行き詰まります。「処理中は閉じられない、完了したら自動で閉じる」のように、必ず出口を用意します。

closedby属性(新しい書き方)

closedby 属性を使うと、背景クリックで閉じる処理を JavaScript なしで書けます。

<!-- 背景クリックとEscで閉じる -->
<dialog id="contact-modal" closedby="any">...</dialog>

<!-- Escだけで閉じる(背景クリックでは閉じない) -->
<dialog id="contact-modal" closedby="closerequest">...</dialog>

<!-- 明示的な close() だけで閉じる -->
<dialog id="contact-modal" closedby="none">...</dialog>

closedby は Chrome 134以降・Safari 18.4以降で対応しています。未対応ブラウザでは属性ごと無視されるため、closedby="any" を書いても背景クリックで閉じません。この記事の JavaScript に背景クリックの処理を入れているのは、それも動かすためです。両方書いても二重に閉じることはありません(すでに閉じた dialog の close() は無視される)。

対応状況を自分で確かめるには、先に載せた 'closedBy' in document.createElement('dialog') を使います。

closedby="closerequest" を使うと「Esc で閉じるが背景クリックでは閉じない」が属性1つで書けます。フォーム入りのモーダルでは、この設定が扱いやすいはずです。


開閉アニメーションを付ける

開閉アニメーションを付ける

showModal() は display: none から表示に切り替わるため、素直に transition を書いても効きません。display プロパティが遷移対象になったこと、overlay プロパティ、@starting-style の3つを組み合わせて解決します。

/* ---- フェード+わずかに上がる動き ---- */
.ts-modal {
  opacity: 0;
  translate: 0 8px;

  transition:
    opacity 0.24s ease,
    translate 0.24s ease,
    display 0.24s allow-discrete,
    overlay 0.24s allow-discrete;
}

/* 開いている状態 */
.ts-modal[open] {
  opacity: 1;
  translate: 0 0;
}

/* 開き始めの状態(@starting-style がないと初回のトランジションが起きない) */
@starting-style {
  .ts-modal[open] {
    opacity: 0;
    translate: 0 8px;
  }
}

/* ---- 暗幕も一緒にフェードさせる ---- */
.ts-modal::backdrop {
  background: rgb(0 0 0 / 0);
  transition:
    background-color 0.24s ease,
    display 0.24s allow-discrete,
    overlay 0.24s allow-discrete;
}

.ts-modal[open]::backdrop {
  background: rgb(0 0 0 / 0.55);
}

@starting-style {
  .ts-modal[open]::backdrop {
    background: rgb(0 0 0 / 0);
  }
}

/* ---- 動きを減らす設定を尊重する ---- */
@media (prefers-reduced-motion: reduce) {
  .ts-modal,
  .ts-modal::backdrop {
    transition-duration: 0.01ms;
  }
}

それぞれの役割

書いたもの なぜ必要か
display に付けた allow-discrete display のような離散値のプロパティを、遷移の終わりまで切り替えを待たせる
overlay top layer からの離脱を遷移の終わりまで待たせる。これがないと閉じる瞬間に消える
@starting-style 「表示された直後の初期値」を定義する。ないと開くアニメーションが飛ぶ
prefers-reduced-motion OS で動きを減らす設定をしている人への配慮。0 ではなく 0.01ms にするのは、transitionend に依存する処理を壊さないため

@starting-style と transition-behavior: allow-discrete は Chrome 117以降・Safari 17.5以降で対応しています。Firefox は 129以降で @starting-style に対応しました。未対応ブラウザではアニメーションが効かず、パッと開いて閉じるだけになります。 機能自体は壊れないので、この書き方は進行的強化として安全に入れられます。

注意が必要なのは .ts-modal の初期値に opacity: 0 を書いている点です。@starting-style 非対応で、かつ [open] のスタイルが当たらない状況があると、モーダルが透明のまま出る可能性があります。心配なら @supports で囲みます。

@supports (transition-behavior: allow-discrete) {
  .ts-modal { opacity: 0; translate: 0 8px; }
  .ts-modal[open] { opacity: 1; translate: 0 0; }
}

事例:Soreiine!!掲載サイトのモーダルの使い方

事例:Soreiine!!掲載サイトのモーダルの使い方

ギャラリーに掲載されているサイトを見ていると、モーダルの使い方が上手いサイトにはいくつか共通点があります。ここでは掲載サイトから3つの型を取り出し、<dialog> で組むならどう書くかまで落とします。

商品詳細を出すECサイトの例

食品系の EC サイトで、商品一覧から「原材料を見る」でモーダルを開く実装がありました。モーダルの中に一覧へのナビゲーションを持ち込んでいないのが効いています。開いたら原材料表示だけがあり、閉じると元の一覧のスクロール位置に戻る。モーダル内にグローバルナビを入れてしまうと、開いたのに別ページへ飛べる状態になり、ユーザーは「今どこにいるか」を失います。

具体的に真似できるのは、閉じるボタンが右上と本文末尾の2か所にあることです。スマホで長い原材料表示を読み切ったあと、指を右上まで戻さずに閉じられます。<dialog> で組むなら、本文末尾にもう1つボタンを置いて同じクラスを付けるだけです。

<div class="ts-modal__body">
  <!-- 原材料表 -->
  <button type="button" class="ts-modal__close-text js-modal-close">閉じる</button>
</div>

一覧のスクロール位置が戻るのも、<dialog> なら追加の実装が要りません。ページ遷移をしていないので位置はそのままで、showModal() を呼んだボタンにフォーカスも返ります。同じ体験を別ページ遷移で作ると、戻り時のスクロール復元を自前で書くことになります。

予約フォームをモーダルで出すサービスサイトの例

サロン系のサイトで、日時選択をモーダルに切り出している例。モーダル内で完結する範囲を「1つの意思決定」に絞っているのが効いています。日時だけを選ばせ、氏名や連絡先はモーダルを閉じたあとの本体フォームで入力させる。モーダルの中にフォーム全部を詰めると縦に伸び、スマホではモーダル内スクロールと背景スクロールが混ざって操作しづらくなります。

選んだ値を本体フォームへ渡す部分は、returnValue を使うと素直に書けます。

<form method="dialog">
  <button type="submit" value="2026-09-20T14:00">9/20 14:00</button>
  <button type="submit" value="2026-09-20T16:00">9/20 16:00</button>
</form>
dialog.addEventListener('close', () => {
  if (!dialog.returnValue) return;           // Escや閉じるボタンなら空
  document.getElementById('reserve-datetime').value = dialog.returnValue;
});

こちらは背景クリックで閉じない設計になっていました。日時を選んでいる途中に誤って暗幕を触っても消えない。closedby="closerequest" に相当する挙動です。入力を含むモーダルでは、この判断を採るサイトのほうが多い印象です。

画像を拡大するポートフォリオサイトの例

制作物を並べたポートフォリオで、サムネイルをクリックすると拡大表示されるタイプ。ここでは逆に背景クリックで閉じるのが正解で、実際そうなっていました。閲覧だけのモーダルは、失うものがないので閉じやすさを優先します。

参考になったのは、暗幕を rgb(0 0 0 / 0.9) 近くまで濃くしていた点です。画像を見せるモーダルでは背景を強く落としたほうが作品が立ちます。逆にフォームのモーダルで暗幕を濃くしすぎると、閉じたあとの行き先が見えず不安になります。用途で暗幕の濃さを変える、という判断がされていました。

この型を写すなら、画像用のモーダルだけ別クラスにして上書きします。

.ts-modal--media { width: min(1100px, calc(100% - 2rem)); background: transparent; }
.ts-modal--media::backdrop { background: rgb(0 0 0 / 0.9); }
.ts-modal--media .ts-modal__inner { padding: 0; }

3つに共通しているのは、モーダルの中身の量ではなく「閉じたときに何を失うか」で閉じ方を決めていることです。失うものがなければ閉じやすく、入力を抱えているなら閉じにくく。この基準は closedby の値を選ぶときにそのまま使えます。

事例の探し方や見方は、ツール選定の考え方と地続きの部分があります。


モーダルの開閉を計測する

モーダルの開閉を計測する

モーダルを設置したら、実際に開かれているか、開いたあと何が起きたかを見ます。<dialog> のイベントに載せるだけで GA4 に送れます。

// GA4(gtag.js)に開閉を送る。gtag が読み込まれている前提
document.querySelectorAll('dialog').forEach((dialog) => {
  const label = dialog.id || 'unnamed-modal';

  dialog.addEventListener('close', () => {
    if (typeof gtag !== 'function') return;
    gtag('event', 'modal_close', {
      modal_id: label,
      return_value: dialog.returnValue || '(none)',  // 送信ボタンの value が入る
    });
  });
});

dialog.returnValue には、method="dialog" のフォームで送信されたボタンの value が入ります。冒頭の HTML では送信ボタンに value="send" を付けているので、送信して閉じたときは "send"、Esc や閉じるボタンなら空文字になります。この1つの値で「開いたが離脱した」と「送信まで進んだ」を分けられます。

「開いた」側も取りたいなら、スクロールロックと同じく showModal() をラップした位置に1行足します。開いた数と閉じ方の内訳が揃うと、モーダル単位の完了率が出せます。

const nativeShowModal = dialog.showModal.bind(dialog);
dialog.showModal = () => {
  nativeShowModal();
  lockScroll();
  if (typeof gtag === 'function') gtag('event', 'modal_open', { modal_id: label });
};

modal_id と return_value をカスタムディメンションとして登録すれば、GA4 の探索レポートでモーダルごとの完了率が出せます。

計測の全体設計は別記事にまとめています。


うまくいかないとき

作業中に詰まりやすい箇所を、症状から引ける形で並べます。

モーダルが画面の左上に張り付く

margin が打ち消されています。リセット CSS の * { margin: 0 } や dialog { margin: 0 } が効いている状態です。.ts-modal { margin: auto; } を明示してください。<dialog> は inset: 0 が既定なので、margin: auto だけで上下左右中央になります。

暗幕が出ない、クリックが背景に抜ける

showModal() ではなく show() を呼んでいます。show() は非モーダル表示で、暗幕もフォーカストラップも付きません。open 属性を HTML に直書きしている場合も同じで、暗幕は出ません。必ず JavaScript から showModal() を呼びます。

開いた瞬間にフォームの1つ目の入力欄がフォーカスされて、画面が少し動く

showModal() は、モーダル内の最初のフォーカス可能な要素に自動でフォーカスを当てます。スマホでこれが起きると、入力欄がフォーカスされてキーボードが立ち上がることがあります。意図した要素にフォーカスさせるには autofocus を明示します。

<dialog id="contact-modal" class="ts-modal">
  <div class="ts-modal__inner" tabindex="-1" autofocus>
    <!-- ここにフォーカスが当たる。入力欄は触られない -->
  </div>
</dialog>

tabindex="-1" と autofocus を両方付けるのがポイントです。tabindex="-1" がないと div はフォーカスを受け取れません。

背景がガタッと横に動く

スクロールバー幅の補正が入っていません。この記事の lockScroll() の paddingRight の部分をコピーしてください。position: fixed のヘッダーがあるなら、そのヘッダーにも --scrollbar-gap 分の padding が必要です。

iOSで背景がスクロールしてしまう

overflow: hidden では止まりきらないケースです。「背景スクロールの固定をどう書くか」の position: fixed 版に差し替えてください。差し替えたら、window.scrollTo(0, savedScrollY) を必ず入れます。これを忘れると、閉じた瞬間にページ先頭へ飛びます。

閉じたあとページ先頭にスクロールする

上と同じ原因か、閉じるボタンが <a href="#"> になっているかのどちらかです。<a href="#"> はハッシュのないアンカーへの移動としてページ先頭へ飛びます。閉じるボタンは <button type="button"> にしてください。

モーダル内のフォームを送信するとページが遷移する

<form method="dialog"> になっていない、あるいは通常のフォーム送信をしています。method="dialog" はサーバーへ送信せずモーダルを閉じるだけの指定です。実際に送信したいなら、通常の method="post" にして event.preventDefault() + fetch() で送り、成功したら dialog.close() を呼びます。

<form id="modal-form" method="post" action="/contact">
  <!-- 入力欄 -->
  <button type="submit">送信する</button>
</form>
document.getElementById('modal-form').addEventListener('submit', async (event) => {
  event.preventDefault();
  const form = event.currentTarget;
  const res = await fetch(form.action, { method: 'POST', body: new FormData(form) });
  if (res.ok) {
    form.closest('dialog').close('send');   // close() の引数が returnValue になる
  } else {
    // エラー表示。モーダルは閉じない
  }
});

/contact は自分のフォーム送信先に置き換えます。

背景クリックで閉じるはずが、モーダル内のクリックでも閉じる

<dialog> の直下に .ts-modal__inner のようなラッパーを置いていない可能性があります。<dialog> はブラウザ既定の padding を持つため、直接テキストを入れるとその padding 部分が <dialog> 自身になり、クリック判定で「背景」と見なされます。必ず内側にラッパーを1枚入れ、<dialog> 側の padding: 0 を書きます。 この記事の CSS はそうなっています。

モーダル内でドラッグして指を離すと閉じてしまう

テキストを選択しようとして、指やマウスをモーダル内から暗幕の上まで動かした場合です。click は押した要素と離した要素の共通祖先で発火するため、event.target が <dialog> になり、背景クリックと誤判定されます。押した位置も見て判定します。

let pressedOnBackdrop = false;

document.addEventListener('pointerdown', (event) => {
  pressedOnBackdrop = event.target instanceof HTMLDialogElement;
});

document.addEventListener('click', (event) => {
  if (!pressedOnBackdrop) return;
  const dialog = event.target;
  if (dialog instanceof HTMLDialogElement && dialog.open) dialog.close();
});

closedby="any" を使う場合、この誤爆はブラウザ側で処理されるので自前の判定は不要です。

入れ子のモーダルを開いたら背景ロックが解けない

openCount の増減が合っていません。この記事の lockScroll / unlockScroll はカウント方式なので、showModal() を経由せず dialog.open = true のように直接いじると数が合わなくなります。開くのは必ず showModal() から行ってください。

Escを押しても閉じない

cancel で preventDefault() している箇所がないか探してください。または、フォーカスがモーダル外に外れている可能性があります(showModal() を使わず open 属性で表示している場合など)。

position: sticky のヘッダーが効かなくなった

position: fixed 版のスクロールロックを使っています。<body> を固定する副作用です。sticky を維持したいなら overflow: hidden の基本形に戻すか、ヘッダーを position: fixed に変えてください。


よくある質問

dialog要素はどのブラウザから使えますか

<dialog> と showModal() は Chrome 37以降、Safari 15.4以降、Firefox 98以降で使えます。2022年3月に主要ブラウザで揃い、Baseline の Widely available に入っています。ただし closedby 属性(Chrome 134以降・Safari 18.4以降)や @starting-style(Chrome 117以降・Safari 17.5以降)は対応時期が別なので、これらは未対応でも機能が壊れない書き方で入れてください。

show() と showModal() はどう違いますか

show() は非モーダル表示で、暗幕が出ず背景も操作できます。showModal() はモーダル表示で、top layer に載り、暗幕が描画され、背景要素が不活性になり、フォーカスがモーダル内に閉じ込められます。この記事で扱っている機能はすべて showModal() のもので、show() では得られません。

Bootstrap や Micromodal から乗り換える価値はありますか

新規に組むなら <dialog> を選ぶ理由が明確にあります。フォーカストラップと背景の不活性化を自分で書かずに済み、外部 JS の読み込みが消えて表示速度に効きます。既存サイトの改修では、モーダルが数個で他の機能をライブラリに依存していないなら乗り換える価値があります。ライブラリのタブやドロップダウンも使っている場合は、モーダルだけ剥がしても読み込みは減らないので優先度は下がります。

モーダルの中でさらにモーダルを開けますか

開けます。showModal() を重ねると top layer に積まれ、上のモーダルが手前に来ます。Esc は手前の1枚だけを閉じます。ただし背景スクロールのロックはカウント方式で管理しないと、内側を閉じた時点で解除されてしまいます。この記事の openCount がその対策です。UI としては、入れ子のモーダルは階層が分かりにくいので避けたほうが無難です。

背景クリックで閉じるべきか、閉じないべきか

閲覧だけのモーダル(画像拡大、詳細表示)は閉じる、入力を伴うモーダル(フォーム、設定変更)は閉じない、で分けるのが実務的です。入力途中に暗幕を誤タップして内容が消えるのは、ユーザーが最も嫌う挙動のひとつです。closedby="closerequest" を使えば「Esc では閉じるが背景クリックでは閉じない」を属性1つで書けます。

モーダルの中身を後から fetch で差し込めますか

できます。showModal() を先に呼んでローディング表示を出し、fetch() の結果が届いたら .ts-modal__body を差し替える形が扱いやすいです。注意点は2つあります。1つは、差し込みでフォーカスされていた要素が消えると、フォーカスが <body> に落ちて Tab の巡回が崩れること。差し替え後に .ts-modal__inner(tabindex="-1")へフォーカスを当て直します。もう1つは、中身が入れ替わって高さが変わると位置が飛ぶので、min-height を先に確保しておくことです。


まとめ

<dialog> + showModal() に寄せると、モーダル実装で書くコードは大きく減ります。この記事の内容を作業順に並べると次のとおりです。

dialog要素でモーダルを組む手順
HTMLを置くbody直前に dialog、中にラッパー1枚
CSSを当てるmargin:auto と padding:0 を忘れない
JSを読む開閉とスクロールロックの4関数
閉じ方を決める背景クリックを許すか、Escを止めるか

4つ目の「閉じ方を決める」だけは、コピーで済まない判断です。判断の軸は事例の節で書いたとおりで、閉じたときにユーザーが何を失うかの一点で決まります。何も失わない閲覧用は closedby="any" 相当、入力を抱えるものは closedby="closerequest" 相当に寄せます。

やらないことのほうが多いのがこの実装の要点です。フォーカストラップを書かない、z-index を積み上げない、aria-modal を手で付けない、外部ライブラリを読み込まない。ブラウザがすでに持っている機能を使い、残った背景スクロールの固定だけ自分で書きます。

同じ発想で、プラグインやライブラリを外して素で組む手順は他の記事でも扱っています。