Open demo

CSS code

HTML
<div><span class="lbl">Native datalist</span>
<form class="ac" role="search" aria-label="Destinations">
  <label class="ac-label" for="ac-city">Where to?</label>
  <div class="ac-field">
    <svg class="ac-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg>
    <input class="ac-input" id="ac-city" type="search" name="to" list="ac-cities" placeholder="Search European cities" aria-describedby="ac-city-hint">
  </div>
  <p class="ac-hint" id="ac-city-hint">Pick a suggestion or type any city.</p>
  <datalist id="ac-cities">
    <option value="Amsterdam, Netherlands"></option><option value="Barcelona, Spain"></option><option value="Berlin, Germany"></option>
    <option value="Bologna, Italy"></option><option value="Copenhagen, Denmark"></option><option value="Edinburgh, Scotland"></option>
    <option value="Lisbon, Portugal"></option><option value="Ljubljana, Slovenia"></option><option value="Lyon, France"></option>
    <option value="Porto, Portugal"></option><option value="Prague, Czechia"></option><option value="Seville, Spain"></option>
    <option value="Tallinn, Estonia"></option><option value="Valencia, Spain"></option><option value="Vienna, Austria"></option>
  </datalist>
</form></div>

<div><span class="lbl">Styled listbox (combobox)</span>
<form class="ac" role="search" aria-label="Train stations" data-ac>
  <label class="ac-label" for="ac-station">From station</label>
  <div class="ac-field">
    <svg class="ac-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
    <input class="ac-input" id="ac-station" type="search" name="from" value="Ber" placeholder="City or station name" autocomplete="off"
      role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="ac-station-list">
    <ul class="ac-list" id="ac-station-list" role="listbox" aria-label="Stations" hidden></ul>
  </div>
  <p class="sr-only" role="status"></p>
  <template><li class="ac-opt" role="option" aria-selected="false"><span class="ac-opt-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="14" rx="3"/><path d="M5 11h14M9 21l-2-4M15 21l2-4"/><circle cx="9" cy="14" r=".5"/><circle cx="15" cy="14" r=".5"/></svg></span><span class="ac-opt-name" data-name></span><small class="ac-opt-code" data-code></small></li></template>
</form></div>
CSS
/* Autocomplete search. A: a native <datalist> — the browser draws the suggestions, no JavaScript.
   B: an ARIA 1.2 combobox — the input owns a styled listbox, arrow keys move aria-activedescendant, Enter picks. */
.ac{--ac-ink:#0f172a;--ac-text:#334155;--ac-muted:#5b6477;--ac-line:#7c889c;--ac-accent:#2563eb;--ac-mark:#1d4ed8;
  --ac-ring:rgba(37,99,235,.22);--ac-active:#eff6ff;position:relative;max-width:520px;color:var(--ac-ink)}
.ac *{box-sizing:border-box}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.ac-label{display:block;margin:0 0 7px;font-size:.9rem;font-weight:600}
.ac-field{position:relative}
.ac-ico{position:absolute;left:15px;top:25px;width:19px;height:19px;margin-top:-9.5px;color:var(--ac-muted);pointer-events:none}
.ac-input{width:100%;height:50px;margin:0;padding:0 16px 0 45px;font:inherit;font-size:1rem;color:inherit;background:#fff;
  border:1px solid var(--ac-line);border-radius:12px;-webkit-appearance:none;appearance:none;transition:border-color .15s,box-shadow .15s}
.ac-input::placeholder{color:var(--ac-muted);opacity:1}
.ac-input::-webkit-calendar-picker-indicator{display:none!important}
.ac-input:focus{outline:none;border-color:var(--ac-accent);box-shadow:0 0 0 1px var(--ac-accent),0 0 0 5px var(--ac-ring)}
.ac-hint{margin:8px 0 0;font-size:.85rem;color:var(--ac-muted)}
/* the listbox */
.ac-list{position:absolute;left:0;right:0;top:calc(100% + 8px);z-index:20;max-height:330px;margin:0;padding:6px;overflow:auto;list-style:none;
  background:#fff;border:1px solid #cbd5e1;border-radius:14px;box-shadow:0 22px 44px -14px rgba(15,23,42,.28)}
.ac-list[hidden]{display:none}
.ac-opt{display:flex;align-items:center;gap:12px;padding:9px 10px;border-radius:9px;color:var(--ac-text);cursor:pointer}
.ac-opt:hover{background:#f8fafc}
.ac-opt[aria-selected="true"]{background:var(--ac-active);color:var(--ac-ink);box-shadow:inset 3px 0 0 var(--ac-accent)}
.ac-opt-ico{display:grid;place-items:center;flex:none;width:32px;height:32px;border-radius:8px;background:#f1f5f9;color:#475569}
.ac-opt[aria-selected="true"] .ac-opt-ico{background:#dbeafe;color:var(--ac-mark)}
.ac-opt-ico svg{width:17px;height:17px}
.ac-opt-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ac-opt mark{background:none;color:var(--ac-mark);font-weight:700}
.ac-opt-code{flex:none;padding:1px 7px;border-radius:6px;background:#f1f5f9;color:#475569;font-size:.72rem;font-weight:700;letter-spacing:.05em}
@media (prefers-reduced-motion:reduce){.ac-input{transition:none}}
JavaScript
// Combobox with a listbox popup (WAI-ARIA 1.2). Focus stays in the input; arrow keys move aria-activedescendant,
// Enter picks, Escape closes, and a polite status line announces how many stations match.
(() => {
  const box = document.querySelector('[data-ac]');
  if (!box) return;
  const input = box.querySelector('[role="combobox"]');
  const list = box.querySelector('[role="listbox"]');
  const status = box.querySelector('[role="status"]');
  const tpl = box.querySelector('template');
  const stations = [['Amsterdam Centraal','NL'],['Antwerpen-Centraal','BE'],['Barcelona Sants','ES'],['Basel SBB','CH'],['Bergamo','IT'],
    ['Bergen','NO'],['Berlin Hauptbahnhof','DE'],['Berlin Ostbahnhof','DE'],['Berlin Südkreuz','DE'],['Bern','CH'],['Bologna Centrale','IT'],
    ['Bordeaux Saint-Jean','FR'],['Brussels-Midi','BE'],['Budapest Keleti','HU'],['Copenhagen Central','DK'],['Hamburg Hauptbahnhof','DE'],
    ['Lyon Part-Dieu','FR'],['Milano Centrale','IT'],['Paris Gare de Lyon','FR'],['Paris Nord','FR'],['Wien Hauptbahnhof','AT'],['Zürich HB','CH']];
  let active = -1;

  function setActive(i) {
    const opts = list.querySelectorAll('[role="option"]');
    active = i;
    opts.forEach((o, n) => o.setAttribute('aria-selected', String(n === i)));
    if (i > -1) { input.setAttribute('aria-activedescendant', opts[i].id); opts[i].scrollIntoView({ block: 'nearest' }); }
    else input.removeAttribute('aria-activedescendant');
  }
  function setOpen(on) {
    list.hidden = !on;
    input.setAttribute('aria-expanded', String(on));
    if (!on) setActive(-1);
  }
  function render(announce = true) {
    const q = input.value.trim().toLowerCase();
    const hits = q ? stations.filter(([name]) => name.toLowerCase().includes(q)).slice(0, 6) : [];
    list.replaceChildren(...hits.map(([name, code], n) => {
      const li = tpl.content.firstElementChild.cloneNode(true);
      const at = name.toLowerCase().indexOf(q);
      const mark = document.createElement('mark');
      mark.textContent = name.slice(at, at + q.length);
      li.id = 'ac-opt-' + n;
      li.dataset.value = name;
      li.querySelector('[data-name]').append(name.slice(0, at), mark, name.slice(at + q.length));
      li.querySelector('[data-code]').textContent = code;
      return li;
    }));
    setActive(-1);
    setOpen(hits.length > 0);
    if (announce) status.textContent = !q ? '' : hits.length ? hits.length + ' stations found' : 'No stations found';
  }
  function pick(li) {
    input.value = li.dataset.value;
    setOpen(false);
    status.textContent = '';
  }

  input.addEventListener('input', () => render());
  input.addEventListener('keydown', e => {
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      if (list.hidden) render();
      const n = list.children.length;
      if (!n) return;
      const step = e.key === 'ArrowDown' ? 1 : -1;
      setActive(active < 0 ? (step > 0 ? 0 : n - 1) : (active + step + n) % n);
    } else if (e.key === 'Enter' && !list.hidden && active > -1) {
      e.preventDefault();
      pick(list.children[active]);
    } else if (e.key === 'Escape' && !list.hidden) {
      e.preventDefault();
      setOpen(false);
    }
  });
  list.addEventListener('mousedown', e => e.preventDefault()); // keep focus in the input
  list.addEventListener('click', e => { const li = e.target.closest('[role="option"]'); if (li) pick(li); });
  input.addEventListener('blur', () => setOpen(false));
  document.addEventListener('click', e => { if (!box.contains(e.target)) setOpen(false); });

  render(false); // demo: show suggestions for the prefilled "Ber". Remove this line (and value="Ber") for an empty field.
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Native datalist</span>
<form class="ac" role="search" aria-label="Destinations">
  <label class="form-label" for="ac-city">Where to?</label>
  <div class="position-relative">
    <svg class="ac-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg>
    <input class="form-control" id="ac-city" type="search" name="to" list="ac-cities" placeholder="Search European cities" aria-describedby="ac-city-hint">
  </div>
  <div class="form-text" id="ac-city-hint">Pick a suggestion or type any city.</div>
  <datalist id="ac-cities">
    <option value="Amsterdam, Netherlands"></option><option value="Barcelona, Spain"></option><option value="Berlin, Germany"></option>
    <option value="Bologna, Italy"></option><option value="Copenhagen, Denmark"></option><option value="Edinburgh, Scotland"></option>
    <option value="Lisbon, Portugal"></option><option value="Ljubljana, Slovenia"></option><option value="Lyon, France"></option>
    <option value="Porto, Portugal"></option><option value="Prague, Czechia"></option><option value="Seville, Spain"></option>
    <option value="Tallinn, Estonia"></option><option value="Valencia, Spain"></option><option value="Vienna, Austria"></option>
  </datalist>
</form></div>

<div><span class="lbl">Styled listbox (combobox)</span>
<form class="ac" role="search" aria-label="Train stations" data-ac>
  <label class="form-label" for="ac-station">From station</label>
  <div class="position-relative">
    <svg class="ac-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
    <input class="form-control" id="ac-station" type="search" name="from" value="Ber" placeholder="City or station name" autocomplete="off"
      role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="ac-station-list">
    <ul class="list-group" id="ac-station-list" role="listbox" aria-label="Stations" hidden></ul>
  </div>
  <p class="visually-hidden" role="status"></p>
  <template><li class="list-group-item list-group-item-action d-flex align-items-center" role="option" aria-selected="false"><span class="ac-opt-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="14" rx="3"/><path d="M5 11h14M9 21l-2-4M15 21l2-4"/><circle cx="9" cy="14" r=".5"/><circle cx="15" cy="14" r=".5"/></svg></span><span class="ac-opt-name" data-name></span><small class="badge" data-code></small></li></template>
</form></div>
CSS
/* Bootstrap .form-control with a native <datalist>, and a .list-group styled as the listbox of an ARIA 1.2 combobox. */
.ac{--ac-ink:#0f172a;--ac-text:#334155;--ac-muted:#5b6477;--ac-line:#7c889c;--ac-accent:#2563eb;--ac-mark:#1d4ed8;
  --ac-ring:rgba(37,99,235,.22);--ac-active:#eff6ff;position:relative;max-width:520px;color:var(--ac-ink)}
.ac .form-label{display:block;margin-bottom:7px;font-size:.9rem;font-weight:600}
.ac-ico{position:absolute;left:15px;top:25px;z-index:1;width:19px;height:19px;margin-top:-9.5px;color:var(--ac-muted);pointer-events:none}
.ac .form-control{height:50px;padding:0 16px 0 45px;font-size:1rem;color:var(--ac-ink);border-color:var(--ac-line);border-radius:12px;
  -webkit-appearance:none;appearance:none}
.ac .form-control::placeholder{color:var(--ac-muted);opacity:1}
.ac .form-control:focus{border-color:var(--ac-accent);box-shadow:0 0 0 1px var(--ac-accent),0 0 0 5px var(--ac-ring)}
.ac .form-text{margin-top:8px;font-size:.85rem;color:var(--ac-muted)}
/* the listbox */
.ac .list-group{--bs-list-group-border-width:0;--bs-list-group-item-padding-x:10px;--bs-list-group-item-padding-y:9px;--bs-list-group-color:var(--ac-text);
  --bs-list-group-action-color:var(--ac-text);--bs-list-group-action-hover-color:var(--ac-text);--bs-list-group-action-hover-bg:#f8fafc;
  position:absolute;left:0;right:0;top:calc(100% + 8px);z-index:20;max-height:330px;padding:6px;overflow:auto;
  background:#fff;border:1px solid #cbd5e1;border-radius:14px;box-shadow:0 22px 44px -14px rgba(15,23,42,.28)}
.ac .list-group[hidden]{display:none}
.ac .list-group-item{gap:12px;border-radius:9px;cursor:pointer}
.ac .list-group-item[aria-selected="true"]{background:var(--ac-active);color:var(--ac-ink);box-shadow:inset 3px 0 0 var(--ac-accent)}
.ac-opt-ico{display:grid;place-items:center;flex:none;width:32px;height:32px;border-radius:8px;background:#f1f5f9;color:#475569}
.list-group-item[aria-selected="true"] .ac-opt-ico{background:#dbeafe;color:var(--ac-mark)}
.ac-opt-ico svg{width:17px;height:17px}
.ac-opt-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ac .list-group-item mark{padding:0;background:none;color:var(--ac-mark);font-weight:700}
.ac .badge{padding:1px 7px;border-radius:6px;background:#f1f5f9;color:#475569;font-size:.72rem;font-weight:700;letter-spacing:.05em;line-height:1.5}
JavaScript
// Combobox with a listbox popup (WAI-ARIA 1.2). Focus stays in the input; arrow keys move aria-activedescendant,
// Enter picks, Escape closes, and a polite status line announces how many stations match.
(() => {
  const box = document.querySelector('[data-ac]');
  if (!box) return;
  const input = box.querySelector('[role="combobox"]');
  const list = box.querySelector('[role="listbox"]');
  const status = box.querySelector('[role="status"]');
  const tpl = box.querySelector('template');
  const stations = [['Amsterdam Centraal','NL'],['Antwerpen-Centraal','BE'],['Barcelona Sants','ES'],['Basel SBB','CH'],['Bergamo','IT'],
    ['Bergen','NO'],['Berlin Hauptbahnhof','DE'],['Berlin Ostbahnhof','DE'],['Berlin Südkreuz','DE'],['Bern','CH'],['Bologna Centrale','IT'],
    ['Bordeaux Saint-Jean','FR'],['Brussels-Midi','BE'],['Budapest Keleti','HU'],['Copenhagen Central','DK'],['Hamburg Hauptbahnhof','DE'],
    ['Lyon Part-Dieu','FR'],['Milano Centrale','IT'],['Paris Gare de Lyon','FR'],['Paris Nord','FR'],['Wien Hauptbahnhof','AT'],['Zürich HB','CH']];
  let active = -1;

  function setActive(i) {
    const opts = list.querySelectorAll('[role="option"]');
    active = i;
    opts.forEach((o, n) => o.setAttribute('aria-selected', String(n === i)));
    if (i > -1) { input.setAttribute('aria-activedescendant', opts[i].id); opts[i].scrollIntoView({ block: 'nearest' }); }
    else input.removeAttribute('aria-activedescendant');
  }
  function setOpen(on) {
    list.hidden = !on;
    input.setAttribute('aria-expanded', String(on));
    if (!on) setActive(-1);
  }
  function render(announce = true) {
    const q = input.value.trim().toLowerCase();
    const hits = q ? stations.filter(([name]) => name.toLowerCase().includes(q)).slice(0, 6) : [];
    list.replaceChildren(...hits.map(([name, code], n) => {
      const li = tpl.content.firstElementChild.cloneNode(true);
      const at = name.toLowerCase().indexOf(q);
      const mark = document.createElement('mark');
      mark.textContent = name.slice(at, at + q.length);
      li.id = 'ac-opt-' + n;
      li.dataset.value = name;
      li.querySelector('[data-name]').append(name.slice(0, at), mark, name.slice(at + q.length));
      li.querySelector('[data-code]').textContent = code;
      return li;
    }));
    setActive(-1);
    setOpen(hits.length > 0);
    if (announce) status.textContent = !q ? '' : hits.length ? hits.length + ' stations found' : 'No stations found';
  }
  function pick(li) {
    input.value = li.dataset.value;
    setOpen(false);
    status.textContent = '';
  }

  input.addEventListener('input', () => render());
  input.addEventListener('keydown', e => {
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      if (list.hidden) render();
      const n = list.children.length;
      if (!n) return;
      const step = e.key === 'ArrowDown' ? 1 : -1;
      setActive(active < 0 ? (step > 0 ? 0 : n - 1) : (active + step + n) % n);
    } else if (e.key === 'Enter' && !list.hidden && active > -1) {
      e.preventDefault();
      pick(list.children[active]);
    } else if (e.key === 'Escape' && !list.hidden) {
      e.preventDefault();
      setOpen(false);
    }
  });
  list.addEventListener('mousedown', e => e.preventDefault()); // keep focus in the input
  list.addEventListener('click', e => { const li = e.target.closest('[role="option"]'); if (li) pick(li); });
  input.addEventListener('blur', () => setOpen(false));
  document.addEventListener('click', e => { if (!box.contains(e.target)) setOpen(false); });

  render(false); // demo: show suggestions for the prefilled "Ber". Remove this line (and value="Ber") for an empty field.
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6477] uppercase">Native datalist</span>
<form class="relative max-w-[520px] text-ac-ink" role="search" aria-label="Destinations">
  <label class="mb-[7px] block text-[.9rem] font-semibold" for="ac-city">Where to?</label>
  <div class="relative">
    <svg class="pointer-events-none absolute top-[25px] left-[15px] size-[19px] -translate-y-1/2 text-ac-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg>
    <input class="[&::-webkit-calendar-picker-indicator]:hidden! h-[50px] w-full appearance-none rounded-xl border border-ac-line bg-white pr-4 pl-[45px] text-base text-ac-ink transition-[border-color,box-shadow] duration-150 placeholder:text-ac-muted focus:border-ac-accent focus:shadow-[0_0_0_1px_var(--color-ac-accent),0_0_0_5px_rgba(37,99,235,.22)] focus:outline-none motion-reduce:transition-none" id="ac-city" type="search" name="to" list="ac-cities" placeholder="Search European cities" aria-describedby="ac-city-hint">
  </div>
  <p class="mt-2 text-[.85rem] text-ac-muted" id="ac-city-hint">Pick a suggestion or type any city.</p>
  <datalist id="ac-cities">
    <option value="Amsterdam, Netherlands"></option><option value="Barcelona, Spain"></option><option value="Berlin, Germany"></option>
    <option value="Bologna, Italy"></option><option value="Copenhagen, Denmark"></option><option value="Edinburgh, Scotland"></option>
    <option value="Lisbon, Portugal"></option><option value="Ljubljana, Slovenia"></option><option value="Lyon, France"></option>
    <option value="Porto, Portugal"></option><option value="Prague, Czechia"></option><option value="Seville, Spain"></option>
    <option value="Tallinn, Estonia"></option><option value="Valencia, Spain"></option><option value="Vienna, Austria"></option>
  </datalist>
</form></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6477] uppercase">Styled listbox (combobox)</span>
<form class="relative max-w-[520px] text-ac-ink" role="search" aria-label="Train stations" data-ac>
  <label class="mb-[7px] block text-[.9rem] font-semibold" for="ac-station">From station</label>
  <div class="relative">
    <svg class="pointer-events-none absolute top-[25px] left-[15px] size-[19px] -translate-y-1/2 text-ac-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
    <input class="h-[50px] w-full appearance-none rounded-xl border border-ac-line bg-white pr-4 pl-[45px] text-base text-ac-ink transition-[border-color,box-shadow] duration-150 placeholder:text-ac-muted focus:border-ac-accent focus:shadow-[0_0_0_1px_var(--color-ac-accent),0_0_0_5px_rgba(37,99,235,.22)] focus:outline-none motion-reduce:transition-none" id="ac-station" type="search" name="from" value="Ber" placeholder="City or station name" autocomplete="off"
      role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="ac-station-list">
    <ul class="absolute top-[calc(100%+8px)] right-0 left-0 z-20 max-h-[330px] overflow-auto rounded-[14px] border border-[#cbd5e1] bg-white p-1.5 shadow-[0_22px_44px_-14px_rgba(15,23,42,.28)] [&[hidden]]:hidden" id="ac-station-list" role="listbox" aria-label="Stations" hidden></ul>
  </div>
  <p class="sr-only" role="status"></p>
  <template><li class="group flex cursor-pointer items-center gap-3 rounded-[9px] px-2.5 py-[9px] text-ac-text hover:bg-[#f8fafc] aria-selected:bg-ac-active aria-selected:text-ac-ink aria-selected:shadow-[inset_3px_0_0_var(--color-ac-accent)] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-ac-mark" role="option" aria-selected="false"><span class="grid size-8 flex-none place-items-center rounded-lg bg-[#f1f5f9] text-[#475569] group-aria-selected:bg-[#dbeafe] group-aria-selected:text-ac-mark" aria-hidden="true"><svg class="size-[17px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="14" rx="3"/><path d="M5 11h14M9 21l-2-4M15 21l2-4"/><circle cx="9" cy="14" r=".5"/><circle cx="15" cy="14" r=".5"/></svg></span><span class="min-w-0 flex-1 truncate" data-name></span><small class="flex-none rounded-md bg-[#f1f5f9] px-[7px] py-px text-[.72rem] font-bold tracking-[.05em] text-[#475569]" data-code></small></li></template>
</form></div>
CSS
@theme {
  --color-ac-ink: #0f172a;
  --color-ac-text: #334155;
  --color-ac-muted: #5b6477;
  --color-ac-line: #7c889c;
  --color-ac-accent: #2563eb;
  --color-ac-mark: #1d4ed8;
  --color-ac-active: #eff6ff;
}
JavaScript
// Combobox with a listbox popup (WAI-ARIA 1.2). Focus stays in the input; arrow keys move aria-activedescendant,
// Enter picks, Escape closes, and a polite status line announces how many stations match.
(() => {
  const box = document.querySelector('[data-ac]');
  if (!box) return;
  const input = box.querySelector('[role="combobox"]');
  const list = box.querySelector('[role="listbox"]');
  const status = box.querySelector('[role="status"]');
  const tpl = box.querySelector('template');
  const stations = [['Amsterdam Centraal','NL'],['Antwerpen-Centraal','BE'],['Barcelona Sants','ES'],['Basel SBB','CH'],['Bergamo','IT'],
    ['Bergen','NO'],['Berlin Hauptbahnhof','DE'],['Berlin Ostbahnhof','DE'],['Berlin Südkreuz','DE'],['Bern','CH'],['Bologna Centrale','IT'],
    ['Bordeaux Saint-Jean','FR'],['Brussels-Midi','BE'],['Budapest Keleti','HU'],['Copenhagen Central','DK'],['Hamburg Hauptbahnhof','DE'],
    ['Lyon Part-Dieu','FR'],['Milano Centrale','IT'],['Paris Gare de Lyon','FR'],['Paris Nord','FR'],['Wien Hauptbahnhof','AT'],['Zürich HB','CH']];
  let active = -1;

  function setActive(i) {
    const opts = list.querySelectorAll('[role="option"]');
    active = i;
    opts.forEach((o, n) => o.setAttribute('aria-selected', String(n === i)));
    if (i > -1) { input.setAttribute('aria-activedescendant', opts[i].id); opts[i].scrollIntoView({ block: 'nearest' }); }
    else input.removeAttribute('aria-activedescendant');
  }
  function setOpen(on) {
    list.hidden = !on;
    input.setAttribute('aria-expanded', String(on));
    if (!on) setActive(-1);
  }
  function render(announce = true) {
    const q = input.value.trim().toLowerCase();
    const hits = q ? stations.filter(([name]) => name.toLowerCase().includes(q)).slice(0, 6) : [];
    list.replaceChildren(...hits.map(([name, code], n) => {
      const li = tpl.content.firstElementChild.cloneNode(true);
      const at = name.toLowerCase().indexOf(q);
      const mark = document.createElement('mark');
      mark.textContent = name.slice(at, at + q.length);
      li.id = 'ac-opt-' + n;
      li.dataset.value = name;
      li.querySelector('[data-name]').append(name.slice(0, at), mark, name.slice(at + q.length));
      li.querySelector('[data-code]').textContent = code;
      return li;
    }));
    setActive(-1);
    setOpen(hits.length > 0);
    if (announce) status.textContent = !q ? '' : hits.length ? hits.length + ' stations found' : 'No stations found';
  }
  function pick(li) {
    input.value = li.dataset.value;
    setOpen(false);
    status.textContent = '';
  }

  input.addEventListener('input', () => render());
  input.addEventListener('keydown', e => {
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      if (list.hidden) render();
      const n = list.children.length;
      if (!n) return;
      const step = e.key === 'ArrowDown' ? 1 : -1;
      setActive(active < 0 ? (step > 0 ? 0 : n - 1) : (active + step + n) % n);
    } else if (e.key === 'Enter' && !list.hidden && active > -1) {
      e.preventDefault();
      pick(list.children[active]);
    } else if (e.key === 'Escape' && !list.hidden) {
      e.preventDefault();
      setOpen(false);
    }
  });
  list.addEventListener('mousedown', e => e.preventDefault()); // keep focus in the input
  list.addEventListener('click', e => { const li = e.target.closest('[role="option"]'); if (li) pick(li); });
  input.addEventListener('blur', () => setOpen(false));
  document.addEventListener('click', e => { if (!box.contains(e.target)) setOpen(false); });

  render(false); // demo: show suggestions for the prefilled "Ber". Remove this line (and value="Ber") for an empty field.
})();

About this set

Two ways to suggest results while someone types. The native datalist version connects a search input to a datalist of fifteen European cities through the list attribute, so the browser draws and handles the suggestions itself with no JavaScript at all; use it when plain text suggestions are enough. The styled listbox version is a combobox following the WAI-ARIA 1.2 pattern for a train station search: the input has role=”combobox”, aria-expanded and aria-controls pointing at a listbox, and each suggestion is an option with the typed letters highlighted in a mark element and a country code on the right. About seventy lines of vanilla JavaScript filter the list, move aria-activedescendant with the arrow keys, pick with Enter or a click, close on Escape and announce the number of matches through a polite status message. Focus never leaves the input. Options are cloned from a template element, so each version keeps its own classes while the script stays the same. The demo loads with Ber typed in to show the open list.

What’s included

  • Native datalist version with no JavaScript
  • Styled combobox following the WAI-ARIA 1.2 listbox pattern
  • Arrow keys, Enter and Escape, with focus kept in the input
  • Match count announced through a role="status" message
  • Options cloned from a template, so one script serves all three versions

Accessibility

Both inputs have visible labels and sit in forms with role="search". The combobox exposes aria-expanded, aria-controls, aria-autocomplete="list" and aria-activedescendant, and the active option is marked with aria-selected and a visible highlight plus a left accent bar rather than colour alone. A visually hidden status line announces how many stations match. The field shows an accent focus ring and its transition is removed under prefers-reduced-motion.

Customise it

Set --ac-accent, --ac-mark and --ac-active on .ac for the ring, highlighted letters and active row, and replace the stations array in the script with your own data or a fetch call. Bootstrap styles the listbox as a .list-group; in Tailwind edit the --color-ac-* theme colours and the classes inside the template element.