オートコンプリート
入力に応じて候補を絞り込み一致部分をハイライト表示。矢印キー・Enter・Escのキーボード操作に対応したアクセシブルなコンボボックスです。
ライブデモ
使用例(お題: カフェ 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。