Modal 1 — 商品詳細ダイアログ

通知・オーバーレイ 初級

このコンポーネントについて

モーダルダイアログ(modal dialog)は、現在の画面に重なる形で表示されるポップアップです。ユーザーに追加情報を確認させたり、重要な操作を促したりする場面で広く使われます。背景のページを操作できない状態にすることで、ユーザーの注意をモーダルの内容に集中させる効果があります。

このページでは「商品一覧カードから詳細を確認する」というよくある実装パターンを例に、モーダルの基本的な作り方を紹介します。商品データはJSONで定義し、カード一覧とモーダル詳細の両方を動的に生成する構成です。

  • 商品カードグリッド — JSONデータから5件の商品カードを動的生成。絵文字と背景色で各商品を視覚的に識別できる
  • 詳細モーダル — 「詳細を見る」ボタンで開く。商品名・カテゴリ・価格(税込)・在庫・説明・スペックテーブルを表示
  • × ボタンのみで閉じる — 枠外クリックでは閉じない設計。詳細確認中の誤操作を防ぐ
  • ESCキー対応 — キーボードでも閉じられる
  • 在庫バッジ — 在庫あり(緑)・残りわずか(橙)・在庫切れ(赤)の3種類で状態を色分け

実装のポイント・注意点

モーダルの表示・非表示は hidden 属性のON/OFFで制御しています。style.display をJSで操作するより element.hidden = true / false の方が意図が明確で、CSSとの干渉も起きにくいです。ただし、CSSで .mdl-overlay[hidden] { display: none; } を明示することで、他のスタイルに上書きされても正しく非表示になります。

モーダルを開いたとき document.body.classList.add('mdl-scroll-lock') を呼び、bodyoverflow: hidden を適用してページのスクロールを止めています。これを忘れると、モーダルの下でページが一緒にスクロールしてしまい見た目が崩れます。閉じるときは必ずクラスを外すことも忘れずに。

モーダルの位置決めは position: fixed; inset: 0 で全画面を覆うオーバーレイを作り、display: flex; align-items: center; justify-content: center でダイアログを中央に配置しています。inset: 0top: 0; right: 0; bottom: 0; left: 0 の省略形です(モダンブラウザ対応)。

テキストの挿入はすべて textContent を使っています。innerHTML に変数を直接渡すとXSS(クロスサイトスクリプティング)の脆弱性になるため、ダイナミックなテキスト表示には textContentcreateElement + appendChild を使うのが安全です。

モーダルが開いたとき document.getElementById('mdl-close-btn').focus() で × ボタンにフォーカスを移しています。これにより、キーボードユーザーがすぐにESCキーやTabキーでモーダルを操作できます(アクセシビリティ対応)。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>商品一覧モーダル サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<!-- 商品カードグリッド(JSで自動生成) -->
<div class="mdl-grid" id="mdl-grid"></div>

<!-- モーダルオーバーレイ(hidden で非表示) -->
<div class="mdl-overlay" id="mdl-overlay" hidden>
  <div class="mdl-dialog" role="dialog" aria-modal="true" aria-labelledby="mdl-title">

    <!-- × 閉じるボタン -->
    <button class="mdl-close-btn" id="mdl-close-btn" type="button" aria-label="閉じる">✕</button>

    <!-- 絵文字エリア(背景色はJSで動的に設定) -->
    <div class="mdl-img-area" id="mdl-img-area">
      <span id="mdl-emoji" aria-hidden="true"></span>
    </div>

    <!-- モーダル本文 -->
    <div class="mdl-dialog-body">
      <div class="mdl-name-row">
        <h2 class="mdl-dialog-name" id="mdl-title"></h2>
        <span class="mdl-cat-badge" id="mdl-cat-badge"></span>
      </div>
      <div class="mdl-price-row">
        <span class="mdl-price" id="mdl-price"></span>
        <span class="mdl-stock-badge" id="mdl-stock-badge"></span>
      </div>
      <p class="mdl-desc" id="mdl-desc"></p>
      <p class="mdl-specs-title">スペック</p>
      <table class="mdl-specs-table">
        <tbody id="mdl-specs-body"></tbody>
      </table>
    </div>

  </div>
</div>

<script src="./script.js"></script>
</body>
</html>
/* ===== ベース ===== */
:root {
  --mdl-primary: #2563EB;
  --mdl-radius:  12px;
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  padding: 24px;
  max-width: 800px;
  margin: 0 auto;
  color: #1A2332;
}

/* モーダル表示中はスクロールをロック */
body.mdl-scroll-lock { overflow: hidden; }

/* ===== 商品カードグリッド ===== */
.mdl-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 16px;
}

/* ===== 商品カード ===== */
.mdl-card {
  background: #fff;
  border: 1.5px solid #E5E7EB;
  border-radius: var(--mdl-radius);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  transition: box-shadow 0.2s;
}

.mdl-card:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); }

/* 絵文字エリア(背景色はJSで設定) */
.mdl-card-emoji-area {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100px;
  font-size: 48px;
}

.mdl-card-body {
  padding: 12px 14px 14px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  flex: 1;
}

.mdl-card-name {
  font-size: 13px;
  font-weight: 700;
  margin: 0;
  line-height: 1.4;
}

.mdl-card-price {
  font-size: 14px;
  font-weight: 700;
  margin: 0;
}

/* ===== バッジ共通 ===== */
.mdl-cat-badge {
  display: inline-block;
  padding: 2px 8px;
  background: #EFF6FF;
  color: #2563EB;
  border-radius: 100px;
  font-size: 11px;
  font-weight: 600;
  align-self: flex-start;
}

.mdl-stock-badge {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 100px;
  font-size: 11px;
  font-weight: 600;
}

.mdl-stock-badge--in-stock     { background: #DCFCE7; color: #166534; }
.mdl-stock-badge--low-stock    { background: #FEF3C7; color: #92400E; }
.mdl-stock-badge--out-of-stock { background: #FEE2E2; color: #991B1B; }

/* ===== 詳細ボタン ===== */
.mdl-detail-btn {
  margin-top: auto;
  padding: 8px 0;
  background: var(--mdl-primary);
  color: #fff;
  border: none;
  border-radius: 6px;
  font-size: 13px;
  font-family: sans-serif;
  cursor: pointer;
  transition: background 0.15s;
  width: 100%;
}

.mdl-detail-btn:hover { background: #1D4ED8; }

/* ===== モーダルオーバーレイ ===== */
.mdl-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  padding: 16px;
}

.mdl-overlay[hidden] { display: none; }

/* ===== モーダルダイアログ ===== */
.mdl-dialog {
  position: relative;
  background: #fff;
  border-radius: var(--mdl-radius);
  width: 100%;
  max-width: 460px;
  max-height: 90vh;
  overflow-y: auto;
}

/* × 閉じるボタン */
.mdl-close-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 32px;
  height: 32px;
  background: rgba(0, 0, 0, 0.08);
  border: none;
  border-radius: 50%;
  font-size: 16px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1;
  transition: background 0.15s;
  color: #1A2332;
  line-height: 1;
}

.mdl-close-btn:hover { background: rgba(0, 0, 0, 0.16); }

/* 絵文字エリア(モーダル上部) */
.mdl-img-area {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 140px;
  border-radius: var(--mdl-radius) var(--mdl-radius) 0 0;
  font-size: 72px;
}

/* モーダル本文 */
.mdl-dialog-body { padding: 20px 24px 24px; }

.mdl-name-row {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin-bottom: 10px;
  flex-wrap: wrap;
}

.mdl-dialog-name {
  font-size: 18px;
  font-weight: 700;
  margin: 0;
  line-height: 1.3;
}

.mdl-price-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 14px;
  flex-wrap: wrap;
}

.mdl-price { font-size: 15px; font-weight: 700; }

.mdl-desc {
  font-size: 14px;
  color: #5A6A7A;
  line-height: 1.7;
  margin: 0 0 18px;
}

.mdl-specs-title {
  font-size: 12px;
  font-weight: 700;
  color: #5A6A7A;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin: 0 0 8px;
}

/* ===== スペックテーブル ===== */
.mdl-specs-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}

.mdl-specs-table th,
.mdl-specs-table td {
  padding: 8px 10px;
  text-align: left;
  border-bottom: 1px solid #F1F5F9;
}

.mdl-specs-table th {
  width: 40%;
  color: #5A6A7A;
  font-weight: 600;
  background: #F8FAFC;
}

.mdl-specs-table td { color: #1A2332; }

.mdl-specs-table tr:last-child th,
.mdl-specs-table tr:last-child td { border-bottom: none; }

/* ===== スマホ対応 ===== */
@media (max-width: 480px) {
  .mdl-grid { grid-template-columns: repeat(2, 1fr); }
  .mdl-dialog-name { font-size: 16px; }
  .mdl-img-area { height: 110px; font-size: 56px; }
}
// ========================================
// 商品データ
// このオブジェクト配列を products.json として
// 切り出して fetch('./products.json') で読み込む方法もあります
// ========================================
var PRODUCTS = [
  {
    id: 1, name: 'ワイヤレスイヤホン Pro', category: 'オーディオ',
    price: 12800, stock: '在庫あり', stockLevel: 'in-stock',
    emoji: '🎧', color: '#DBEAFE',
    description: 'アクティブノイズキャンセリング搭載の高音質ワイヤレスイヤホン。最大30時間の連続再生が可能で、急速充電にも対応しています。',
    specs: [
      { key: '接続方式', value: 'Bluetooth 5.3' },
      { key: '連続再生', value: '最大30時間' },
      { key: 'ノイズキャンセリング', value: 'あり(ANC)' },
      { key: '重量', value: '約5g(片耳)' },
      { key: '防水', value: 'IPX4' }
    ]
  },
  {
    id: 2, name: 'スマートウォッチ S7', category: 'ウェアラブル',
    price: 24800, stock: '残りわずか', stockLevel: 'low-stock',
    emoji: '⌚', color: '#DCFCE7',
    description: '健康管理・スポーツトラッキング・通知確認がすべてこれ一台。GPS内蔵で正確なランニング距離を記録できます。',
    specs: [
      { key: 'ディスプレイ', value: '1.4インチ AMOLED' },
      { key: 'バッテリー', value: '最大7日間' },
      { key: 'GPS', value: 'あり(内蔵)' },
      { key: '防水', value: '5ATM' },
      { key: 'センサー', value: '心拍・血中酸素・加速度' }
    ]
  },
  {
    id: 3, name: 'ノートPC UltraBook', category: 'パソコン',
    price: 98000, stock: '在庫あり', stockLevel: 'in-stock',
    emoji: '💻', color: '#FEF9C3',
    description: '薄型・軽量設計の高性能ノートPC。大容量バッテリーで外出先でも1日中使用可能。テレワーク・動画編集に最適です。',
    specs: [
      { key: 'CPU', value: 'Intel Core Ultra 5' },
      { key: 'メモリ', value: '16GB LPDDR5' },
      { key: 'ストレージ', value: '512GB SSD' },
      { key: 'ディスプレイ', value: '14インチ FHD IPS' },
      { key: '重量', value: '約1.2kg' }
    ]
  },
  {
    id: 4, name: 'ワイヤレス充電器 Pad', category: 'アクセサリ',
    price: 3980, stock: '在庫切れ', stockLevel: 'out-of-stock',
    emoji: '🔋', color: '#FFE4E6',
    description: '最大15W出力の急速ワイヤレス充電パッド。複数のQi規格対応デバイスを充電できます。薄型でデスクをすっきり保てます。',
    specs: [
      { key: '出力', value: '最大15W(Qi2対応)' },
      { key: '規格', value: 'Qi / Qi2' },
      { key: 'サイズ', value: '直径100mm' },
      { key: '厚さ', value: '約7mm' },
      { key: 'ケーブル長', value: '1.2m(USB-C)' }
    ]
  },
  {
    id: 5, name: 'スマートスピーカー Hub', category: 'スマートホーム',
    price: 8980, stock: '在庫あり', stockLevel: 'in-stock',
    emoji: '🔊', color: '#EDE9FE',
    description: '音声アシスタント搭載のスマートスピーカー。照明・家電を声だけでコントロール。360°全方向サウンドで部屋中に音楽を届けます。',
    specs: [
      { key: 'スピーカー', value: 'フルレンジ 40mm × 2' },
      { key: '音声アシスタント', value: '対応' },
      { key: 'スマートホーム', value: 'Matter / Zigbee対応' },
      { key: 'Wi-Fi', value: '2.4GHz / 5GHz 対応' },
      { key: '電源', value: 'ACアダプター(付属)' }
    ]
  }
];

// =========================================
// 商品カードグリッドを生成する
// =========================================
renderCards(PRODUCTS);

function renderCards(items) {
  var grid = document.getElementById('mdl-grid');
  items.forEach(function(item) {
    grid.appendChild(createCard(item));
  });
}

function createCard(item) {
  var card = document.createElement('div');
  card.className = 'mdl-card';

  // 絵文字エリア(カード上部のカラーブロック)
  var emojiArea = document.createElement('div');
  emojiArea.className = 'mdl-card-emoji-area';
  emojiArea.style.background = item.color;
  var emojiEl = document.createElement('span');
  emojiEl.setAttribute('aria-hidden', 'true');
  emojiEl.textContent = item.emoji;
  emojiArea.appendChild(emojiEl);

  // カード本体
  var body = document.createElement('div');
  body.className = 'mdl-card-body';

  var nameEl = document.createElement('p');
  nameEl.className = 'mdl-card-name';
  nameEl.textContent = item.name;

  var catBadge = document.createElement('span');
  catBadge.className = 'mdl-cat-badge';
  catBadge.textContent = item.category;

  var priceEl = document.createElement('p');
  priceEl.className = 'mdl-card-price';
  priceEl.textContent = '¥' + item.price.toLocaleString();

  var stockBadge = document.createElement('span');
  stockBadge.className = 'mdl-stock-badge mdl-stock-badge--' + item.stockLevel;
  stockBadge.textContent = item.stock;

  var btn = document.createElement('button');
  btn.className = 'mdl-detail-btn';
  btn.type = 'button';
  btn.textContent = '詳細を見る';
  btn.setAttribute('aria-label', item.name + 'の詳細を見る');
  btn.onclick = function() { openModal(item); };

  body.appendChild(nameEl);
  body.appendChild(catBadge);
  body.appendChild(priceEl);
  body.appendChild(stockBadge);
  body.appendChild(btn);

  card.appendChild(emojiArea);
  card.appendChild(body);
  return card;
}

// =========================================
// モーダルを開く
// =========================================
function openModal(item) {
  // 絵文字エリアの背景色を設定
  document.getElementById('mdl-img-area').style.background = item.color;
  document.getElementById('mdl-emoji').textContent = item.emoji;

  // テキストは textContent で設定(XSS対策)
  document.getElementById('mdl-title').textContent = item.name;
  document.getElementById('mdl-cat-badge').textContent = item.category;

  // 価格(税込10%)
  var priceWithTax = Math.floor(item.price * 1.1);
  document.getElementById('mdl-price').textContent =
    '¥' + item.price.toLocaleString() + '(税込 ¥' + priceWithTax.toLocaleString() + ')';

  // 在庫バッジ
  var stockBadge = document.getElementById('mdl-stock-badge');
  stockBadge.textContent = item.stock;
  stockBadge.className = 'mdl-stock-badge mdl-stock-badge--' + item.stockLevel;

  document.getElementById('mdl-desc').textContent = item.description;

  // スペックテーブルを構築
  var tbody = document.getElementById('mdl-specs-body');
  tbody.innerHTML = '';
  item.specs.forEach(function(spec) {
    var tr = document.createElement('tr');
    var th = document.createElement('th');
    th.textContent = spec.key;
    var td = document.createElement('td');
    td.textContent = spec.value;
    tr.appendChild(th);
    tr.appendChild(td);
    tbody.appendChild(tr);
  });

  // オーバーレイを表示してスクロールをロック
  document.getElementById('mdl-overlay').hidden = false;
  document.body.classList.add('mdl-scroll-lock');

  // × ボタンにフォーカスを移す(アクセシビリティ)
  document.getElementById('mdl-close-btn').focus();
}

// =========================================
// モーダルを閉じる
// =========================================
function closeModal() {
  document.getElementById('mdl-overlay').hidden = true;
  document.body.classList.remove('mdl-scroll-lock');
}

// × ボタンで閉じる
document.getElementById('mdl-close-btn').addEventListener('click', closeModal);

// ESCキーで閉じる
document.addEventListener('keydown', function(e) {
  if (e.key === 'Escape') closeModal();
});

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。データ件数の変更や表示項目の追加など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# モーダルダイアログ(商品詳細)作成依頼

## 概要
商品一覧カードから詳細モーダルを開くUIを実装してください。
商品データはJavaScript配列で定義し、カード一覧とモーダル詳細の両方に反映します。

## 要件
- 商品データをJavaScript配列で定義する(id・name・category・price・stock・emoji・color・description・specsを含む)
- 商品カードをCSSグリッドで表示する(auto-fillで3〜4カラム、スマホでは2カラム)
- 各カードに「詳細を見る」ボタンを設置し、クリックでモーダルを開く
- モーダルには:絵文字エリア(カラー背景)・商品名・カテゴリバッジ・価格(税込)・在庫バッジ・説明文・スペックテーブルを表示する
- モーダルは × ボタンのクリックでのみ閉じる(枠外クリックでは閉じない)
- ESCキーでも閉じられるようにする
- モーダル表示中はページのスクロールをロックする(body に overflow: hidden)

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要

## 動作詳細
商品カードをグリッド表示し、各カードの「詳細を見る」ボタンをクリックするとモーダルが開く。
モーダルはページ全体を覆うオーバーレイ(半透明の黒背景、position: fixed; inset: 0)の上に表示する。
右上の × ボタンをクリックするとモーダルを閉じる。ESCキーでも閉じる。
在庫ステータスは「在庫あり」「残りわずか」「在庫切れ」の3種類とし、それぞれ色違いのバッジで表示する。
商品の絵文字と背景色はデータに持たせ、カードとモーダルの両方に適用する。
モーダルの表示・非表示は hidden 属性のON/OFFで制御する。
テキストの出力はすべて textContent を使いXSS対策を徹底する。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。