オートコンプリート

入力に応じて候補を絞り込み一致部分をハイライト表示。矢印キー・Enter・Escのキーボード操作に対応したアクセシブルなコンボボックスです。

#javascript#form#combobox

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<div class="mb-find">
  <header class="mb-find__head">
    <span class="mb-logo">☕ MOON BREW</span>
    <h2 class="mb-find__title">お近くの店舗をさがす</h2>
    <p class="mb-find__sub">エリア名を入力して候補から選んでください</p>
  </header>

  <div class="ac-box">
    <span class="ac-pin">📍</span>
    <input id="ac-input" class="ac-input" type="text" role="combobox"
           aria-expanded="false" aria-autocomplete="list"
           placeholder="例: 中目黒 / 鎌倉 …" autocomplete="off">
    <ul id="ac-list" class="ac-list" role="listbox" hidden></ul>
  </div>

  <p id="ac-picked" class="ac-picked">店舗を選択するとアクセス情報を表示します</p>
</div>
CSS
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  background: radial-gradient(130% 100% at 50% 0%, #3a2817, #2b1d12);
  color: #2b1d12;
}

.mb-find {
  width: min(420px, 92vw);
  padding: 24px 26px 26px;
  background: #f5ede1;
  border-radius: 18px;
  box-shadow: 0 24px 56px -22px rgba(0, 0, 0, 0.6);
}
.mb-logo { font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; color: #a96e26; }
.mb-find__title { margin: 7px 0 4px; font-size: 1.15rem; font-weight: 800; }
.mb-find__sub { margin: 0 0 16px; font-size: 0.78rem; color: #8a6c45; }

/* 検索ボックス */
.ac-box { position: relative; }
.ac-pin {
  position: absolute; left: 13px; top: 50%; transform: translateY(-50%);
  font-size: 0.95rem; pointer-events: none;
}
.ac-input {
  width: 100%;
  padding: 12px 14px 12px 38px;
  font-size: 0.95rem; color: #2b1d12;
  background: #fff;
  border: 2px solid #e3d4ba; border-radius: 12px;
  outline: none;
  transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.ac-input:focus { border-color: #c98a3b; box-shadow: 0 0 0 4px rgba(201, 138, 59, 0.18); }

/* 候補リスト */
.ac-list {
  position: absolute; left: 0; right: 0; top: calc(100% + 6px);
  margin: 0; padding: 6px; list-style: none; z-index: 5;
  background: #fff;
  border: 1px solid #e3d4ba; border-radius: 12px;
  box-shadow: 0 18px 36px -16px rgba(43, 29, 18, 0.5);
  max-height: 184px; overflow-y: auto;
}
.ac-item {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 10px; border-radius: 9px; cursor: pointer;
  font-size: 0.9rem;
}
.ac-item:hover, .ac-item.active { background: #f3e6d2; }
.ac-flag { font-size: 1rem; }
.ac-item mark { background: #f6dca8; color: #a96e26; border-radius: 3px; padding: 0 1px; }
.ac-empty { padding: 12px 10px; font-size: 0.85rem; color: #9a7e57; }

.ac-picked {
  margin: 16px 0 0; font-size: 0.84rem; color: #8a6c45;
  padding: 11px 13px; background: #efe2cd; border-radius: 10px;
  border-left: 3px solid #c7ad86;
}
.ac-picked.has { color: #2b1d12; border-left-color: #c98a3b; background: #f6ecda; }
JavaScript
// 店舗候補データ(エリア名・絵文字・アクセス)
const DATA = [
  ["中目黒店", "🌿", "東急東横線 徒歩3分 / 川沿いテラス席あり"],
  ["代官山店", "📖", "代官山駅 徒歩5分 / 書架併設の静かな2階席"],
  ["鎌倉店", "⛩️", "鎌倉駅西口 徒歩7分 / 古民家を改装した一軒家"],
  ["吉祥寺店", "🌳", "井の頭公園口 徒歩4分 / 緑を望む大窓席"],
  ["横浜元町店", "⚓", "元町・中華街駅 徒歩6分 / 港の見えるカウンター"],
  ["京都三条店", "🍵", "三条駅 徒歩5分 / 町家造りの坪庭テラス"],
  ["札幌大通店", "❄️", "大通駅 徒歩2分 / 薪ストーブのある冬季限定席"],
  ["福岡天神店", "🌸", "天神駅 徒歩4分 / 屋上ガーデンラウンジ"],
];

const input = document.getElementById("ac-input");
const list = document.getElementById("ac-list");
const picked = document.getElementById("ac-picked");
let activeIndex = -1;
let current = [];

// HTMLエスケープ(XSS防止)
function esc(s) {
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}

// 一致部分を<mark>で囲む
function highlight(name, query) {
  const i = name.indexOf(query);
  if (i < 0 || !query) return esc(name);
  return esc(name.slice(0, i)) + "<mark>" + esc(name.slice(i, i + query.length)) + "</mark>" + esc(name.slice(i + query.length));
}

function closeList() {
  list.hidden = true;
  list.innerHTML = "";
  input.setAttribute("aria-expanded", "false");
  activeIndex = -1;
}

function render(query) {
  current = query ? DATA.filter(([name]) => name.includes(query)) : DATA;
  list.innerHTML = "";
  activeIndex = -1;

  if (current.length === 0) {
    const li = document.createElement("li");
    li.className = "ac-empty";
    li.textContent = "該当する店舗がありません";
    list.appendChild(li);
  } else {
    current.forEach(([name, flag], idx) => {
      const li = document.createElement("li");
      li.className = "ac-item";
      li.setAttribute("role", "option");
      li.dataset.index = idx;
      li.innerHTML = `<span class="ac-flag">${flag}</span><span>${highlight(name, query)}</span>`;
      li.addEventListener("mousedown", (e) => { e.preventDefault(); choose(idx); });
      list.appendChild(li);
    });
  }
  list.hidden = false;
  input.setAttribute("aria-expanded", "true");
}

// 候補を確定してアクセス情報を表示
function choose(idx) {
  const item = current[idx];
  if (!item) return;
  input.value = item[0];
  picked.textContent = `${item[1]} ${item[0]} — ${item[2]}`;
  picked.classList.add("has");
  closeList();
}

// アクティブ候補のハイライト更新
function setActive(idx) {
  const items = [...list.querySelectorAll(".ac-item")];
  if (items.length === 0) return;
  activeIndex = (idx + items.length) % items.length;
  items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
  items[activeIndex].scrollIntoView({ block: "nearest" });
}

if (input && list && picked) {
  input.addEventListener("input", () => render(input.value.trim()));
  input.addEventListener("focus", () => render(input.value.trim()));

  // キーボード操作
  input.addEventListener("keydown", (e) => {
    if (list.hidden) return;
    if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex + 1); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1); }
    else if (e.key === "Enter" && activeIndex >= 0) { e.preventDefault(); choose(activeIndex); }
    else if (e.key === "Escape") { closeList(); }
  });

  // 外側クリックで閉じる
  document.addEventListener("click", (e) => {
    if (!e.target.closest(".ac-box")) closeList();
  });
}

コード

HTML
<div class="stage">
  <div class="ac-card">
    <h2 class="ac-title">国を検索</h2>

    <div class="ac-box">
      <span class="ac-search">🔍</span>
      <!-- combobox パターンで補完候補を提示 -->
      <input id="ac-input" class="ac-input" type="text" role="combobox"
             aria-autocomplete="list" aria-expanded="false" aria-controls="ac-list"
             placeholder="国名を入力…" autocomplete="off">
      <ul id="ac-list" class="ac-list" role="listbox" hidden></ul>
    </div>

    <p class="ac-picked" id="ac-picked">候補から選択してください</p>
  </div>
</div>
CSS
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  color: #1f2937;
}

.stage { width: 100%; padding: 22px; display: grid; place-items: center; }

.ac-card {
  width: min(380px, 92vw);
  padding: 26px 24px;
  background: #fff;
  border-radius: 18px;
  box-shadow: 0 24px 56px -26px rgba(30, 41, 59, 0.4);
}

.ac-title { margin: 0 0 16px; font-size: 1.1rem; color: #1e293b; }

.ac-box { position: relative; }
.ac-search {
  position: absolute;
  left: 13px; top: 50%;
  transform: translateY(-50%);
  font-size: 0.95rem;
  opacity: 0.55;
  pointer-events: none;
}
.ac-input {
  width: 100%;
  padding: 12px 14px 12px 40px;
  font-size: 0.95rem;
  color: #1e293b;
  background: #f1f5f9;
  border: 2px solid #e2e8f0;
  border-radius: 11px;
  outline: none;
  transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
.ac-input:focus { border-color: #6366f1; background: #fff; box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.16); }

/* 候補リスト */
.ac-list {
  list-style: none;
  margin: 6px 0 0;
  padding: 6px;
  position: absolute;
  left: 0; right: 0;
  max-height: 192px;
  overflow-y: auto;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  box-shadow: 0 18px 40px -18px rgba(30, 41, 59, 0.45);
  z-index: 5;
}
.ac-item {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 11px;
  font-size: 0.88rem;
  border-radius: 8px;
  cursor: pointer;
}
.ac-item:hover,
.ac-item.active { background: #eef2ff; }
.ac-flag { font-size: 1.1rem; }
/* 入力に一致した部分をハイライト */
.ac-item mark { background: #fde68a; color: inherit; border-radius: 3px; padding: 0 1px; }

.ac-empty { padding: 11px; font-size: 0.82rem; color: #94a3b8; text-align: center; }

.ac-picked { margin: 16px 0 0; font-size: 0.84rem; color: #64748b; font-weight: 600; }
.ac-picked.has { color: #4f46e5; }

@media (prefers-reduced-motion: reduce) {
  .ac-input { transition: none; }
}
JavaScript
// 候補データ(国名+絵文字フラグ)
const DATA = [
  ["日本", "🇯🇵"], ["アメリカ", "🇺🇸"], ["イギリス", "🇬🇧"], ["フランス", "🇫🇷"],
  ["ドイツ", "🇩🇪"], ["イタリア", "🇮🇹"], ["スペイン", "🇪🇸"], ["カナダ", "🇨🇦"],
  ["オーストラリア", "🇦🇺"], ["ブラジル", "🇧🇷"], ["インド", "🇮🇳"], ["中国", "🇨🇳"],
  ["韓国", "🇰🇷"], ["タイ", "🇹🇭"], ["シンガポール", "🇸🇬"], ["スイス", "🇨🇭"],
  ["スウェーデン", "🇸🇪"], ["オランダ", "🇳🇱"], ["メキシコ", "🇲🇽"], ["エジプト", "🇪🇬"],
];

const input = document.getElementById("ac-input");
const list = document.getElementById("ac-list");
const picked = document.getElementById("ac-picked");
let activeIndex = -1;
let current = [];

// HTMLエスケープ(XSS防止)
function esc(s) {
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}

// 一致部分を<mark>で囲む
function highlight(name, query) {
  const i = name.indexOf(query);
  if (i < 0 || !query) return esc(name);
  return esc(name.slice(0, i)) + "<mark>" + esc(name.slice(i, i + query.length)) + "</mark>" + esc(name.slice(i + query.length));
}

function closeList() {
  list.hidden = true;
  list.innerHTML = "";
  input.setAttribute("aria-expanded", "false");
  activeIndex = -1;
}

function render(query) {
  current = query ? DATA.filter(([name]) => name.includes(query)) : DATA;
  list.innerHTML = "";
  activeIndex = -1;

  if (current.length === 0) {
    const li = document.createElement("li");
    li.className = "ac-empty";
    li.textContent = "該当なし";
    list.appendChild(li);
  } else {
    current.forEach(([name, flag], idx) => {
      const li = document.createElement("li");
      li.className = "ac-item";
      li.setAttribute("role", "option");
      li.dataset.index = idx;
      li.innerHTML = `<span class="ac-flag">${flag}</span><span>${highlight(name, query)}</span>`;
      li.addEventListener("mousedown", (e) => { e.preventDefault(); choose(idx); });
      list.appendChild(li);
    });
  }
  list.hidden = false;
  input.setAttribute("aria-expanded", "true");
}

// 候補を確定
function choose(idx) {
  const item = current[idx];
  if (!item) return;
  input.value = item[0];
  picked.textContent = `選択中: ${item[1]} ${item[0]}`;
  picked.classList.add("has");
  closeList();
}

// アクティブ候補のハイライト更新
function setActive(idx) {
  const items = [...list.querySelectorAll(".ac-item")];
  if (items.length === 0) return;
  activeIndex = (idx + items.length) % items.length;
  items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
  items[activeIndex].scrollIntoView({ block: "nearest" });
}

if (input && list && picked) {
  input.addEventListener("input", () => render(input.value.trim()));
  input.addEventListener("focus", () => render(input.value.trim()));

  // キーボード操作
  input.addEventListener("keydown", (e) => {
    if (list.hidden) return;
    if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex + 1); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1); }
    else if (e.key === "Enter" && activeIndex >= 0) { e.preventDefault(); choose(activeIndex); }
    else if (e.key === "Escape") { closeList(); }
  });

  // 外側クリックで閉じる
  document.addEventListener("click", (e) => {
    if (!e.target.closest(".ac-box")) closeList();
  });
}

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「オートコンプリート」の効果を追加してください。

# 追加してほしい効果
オートコンプリート(フォーム & 入力)
入力に応じて候補を絞り込み一致部分をハイライト表示。矢印キー・Enter・Escのキーボード操作に対応したアクセシブルなコンボボックスです。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<div class="stage">
  <div class="ac-card">
    <h2 class="ac-title">国を検索</h2>

    <div class="ac-box">
      <span class="ac-search">🔍</span>
      <!-- combobox パターンで補完候補を提示 -->
      <input id="ac-input" class="ac-input" type="text" role="combobox"
             aria-autocomplete="list" aria-expanded="false" aria-controls="ac-list"
             placeholder="国名を入力…" autocomplete="off">
      <ul id="ac-list" class="ac-list" role="listbox" hidden></ul>
    </div>

    <p class="ac-picked" id="ac-picked">候補から選択してください</p>
  </div>
</div>

【CSS】
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  color: #1f2937;
}

.stage { width: 100%; padding: 22px; display: grid; place-items: center; }

.ac-card {
  width: min(380px, 92vw);
  padding: 26px 24px;
  background: #fff;
  border-radius: 18px;
  box-shadow: 0 24px 56px -26px rgba(30, 41, 59, 0.4);
}

.ac-title { margin: 0 0 16px; font-size: 1.1rem; color: #1e293b; }

.ac-box { position: relative; }
.ac-search {
  position: absolute;
  left: 13px; top: 50%;
  transform: translateY(-50%);
  font-size: 0.95rem;
  opacity: 0.55;
  pointer-events: none;
}
.ac-input {
  width: 100%;
  padding: 12px 14px 12px 40px;
  font-size: 0.95rem;
  color: #1e293b;
  background: #f1f5f9;
  border: 2px solid #e2e8f0;
  border-radius: 11px;
  outline: none;
  transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
.ac-input:focus { border-color: #6366f1; background: #fff; box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.16); }

/* 候補リスト */
.ac-list {
  list-style: none;
  margin: 6px 0 0;
  padding: 6px;
  position: absolute;
  left: 0; right: 0;
  max-height: 192px;
  overflow-y: auto;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  box-shadow: 0 18px 40px -18px rgba(30, 41, 59, 0.45);
  z-index: 5;
}
.ac-item {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 11px;
  font-size: 0.88rem;
  border-radius: 8px;
  cursor: pointer;
}
.ac-item:hover,
.ac-item.active { background: #eef2ff; }
.ac-flag { font-size: 1.1rem; }
/* 入力に一致した部分をハイライト */
.ac-item mark { background: #fde68a; color: inherit; border-radius: 3px; padding: 0 1px; }

.ac-empty { padding: 11px; font-size: 0.82rem; color: #94a3b8; text-align: center; }

.ac-picked { margin: 16px 0 0; font-size: 0.84rem; color: #64748b; font-weight: 600; }
.ac-picked.has { color: #4f46e5; }

@media (prefers-reduced-motion: reduce) {
  .ac-input { transition: none; }
}

【JavaScript】
// 候補データ(国名+絵文字フラグ)
const DATA = [
  ["日本", "🇯🇵"], ["アメリカ", "🇺🇸"], ["イギリス", "🇬🇧"], ["フランス", "🇫🇷"],
  ["ドイツ", "🇩🇪"], ["イタリア", "🇮🇹"], ["スペイン", "🇪🇸"], ["カナダ", "🇨🇦"],
  ["オーストラリア", "🇦🇺"], ["ブラジル", "🇧🇷"], ["インド", "🇮🇳"], ["中国", "🇨🇳"],
  ["韓国", "🇰🇷"], ["タイ", "🇹🇭"], ["シンガポール", "🇸🇬"], ["スイス", "🇨🇭"],
  ["スウェーデン", "🇸🇪"], ["オランダ", "🇳🇱"], ["メキシコ", "🇲🇽"], ["エジプト", "🇪🇬"],
];

const input = document.getElementById("ac-input");
const list = document.getElementById("ac-list");
const picked = document.getElementById("ac-picked");
let activeIndex = -1;
let current = [];

// HTMLエスケープ(XSS防止)
function esc(s) {
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}

// 一致部分を<mark>で囲む
function highlight(name, query) {
  const i = name.indexOf(query);
  if (i < 0 || !query) return esc(name);
  return esc(name.slice(0, i)) + "<mark>" + esc(name.slice(i, i + query.length)) + "</mark>" + esc(name.slice(i + query.length));
}

function closeList() {
  list.hidden = true;
  list.innerHTML = "";
  input.setAttribute("aria-expanded", "false");
  activeIndex = -1;
}

function render(query) {
  current = query ? DATA.filter(([name]) => name.includes(query)) : DATA;
  list.innerHTML = "";
  activeIndex = -1;

  if (current.length === 0) {
    const li = document.createElement("li");
    li.className = "ac-empty";
    li.textContent = "該当なし";
    list.appendChild(li);
  } else {
    current.forEach(([name, flag], idx) => {
      const li = document.createElement("li");
      li.className = "ac-item";
      li.setAttribute("role", "option");
      li.dataset.index = idx;
      li.innerHTML = `<span class="ac-flag">${flag}</span><span>${highlight(name, query)}</span>`;
      li.addEventListener("mousedown", (e) => { e.preventDefault(); choose(idx); });
      list.appendChild(li);
    });
  }
  list.hidden = false;
  input.setAttribute("aria-expanded", "true");
}

// 候補を確定
function choose(idx) {
  const item = current[idx];
  if (!item) return;
  input.value = item[0];
  picked.textContent = `選択中: ${item[1]} ${item[0]}`;
  picked.classList.add("has");
  closeList();
}

// アクティブ候補のハイライト更新
function setActive(idx) {
  const items = [...list.querySelectorAll(".ac-item")];
  if (items.length === 0) return;
  activeIndex = (idx + items.length) % items.length;
  items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
  items[activeIndex].scrollIntoView({ block: "nearest" });
}

if (input && list && picked) {
  input.addEventListener("input", () => render(input.value.trim()));
  input.addEventListener("focus", () => render(input.value.trim()));

  // キーボード操作
  input.addEventListener("keydown", (e) => {
    if (list.hidden) return;
    if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex + 1); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1); }
    else if (e.key === "Enter" && activeIndex >= 0) { e.preventDefault(); choose(activeIndex); }
    else if (e.key === "Escape") { closeList(); }
  });

  // 外側クリックで閉じる
  document.addEventListener("click", (e) => {
    if (!e.target.closest(".ac-box")) closeList();
  });
}

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。