Autocomplete
CSS-first suggestion surfaces with a working local combobox example and native datalist alternative
Autocomplete
Core styles the input, suggestion surface and selection states. Applications own filtering, opening, keyboard navigation, selection and asynchronous data. No Core runtime is installed. For simple browser-owned suggestions, use the native datalist alternative below.
Imports
@import "@duskmoon-dev/core/base.css";
@import "@duskmoon-dev/core/themes/sunshine";
@import "@duskmoon-dev/core/components/autocomplete.css";
@import "@duskmoon-dev/core/components/form-group.css";
@import "@duskmoon-dev/core/components/button.css";
Aggregate: @duskmoon-dev/core; no-Tailwind: @duskmoon-dev/core/standalone.css.
The extensionless components/autocomplete entry exports generated css and constructable
styles. Individual imports need base.css and a theme. Use Moonlight for dark forms.
Basic Usage
This runnable single-input example includes its local application controller in the copyable
code. It accepts free text as well as suggestions and submits one country value. Suggestions
are local: there is no network request or implicit async search engine.
Basic Autocomplete
<form id="demo-autocomplete-form">
<div class="form-group">
<label for="demo-autocomplete-country" class="form-label">Country</label>
<div id="demo-autocomplete" class="autocomplete">
<div class="autocomplete-input-wrapper">
<input id="demo-autocomplete-country" name="country" type="text" class="autocomplete-input" placeholder="Search countries..." role="combobox" autocomplete="off" aria-autocomplete="list" aria-expanded="false" aria-controls="demo-autocomplete-list">
<button type="button" class="autocomplete-toggle" aria-label="Show suggestions" title="Show suggestions" aria-controls="demo-autocomplete-list" aria-expanded="false"><span aria-hidden="true">▾</span></button>
</div>
<div class="autocomplete-dropdown" hidden>
<ul id="demo-autocomplete-list" class="autocomplete-options" role="listbox" aria-label="Country suggestions">
<li id="demo-country-us" class="autocomplete-option" role="option" aria-selected="false">United States</li>
<li id="demo-country-ca" class="autocomplete-option" role="option" aria-selected="false">Canada</li>
<li id="demo-country-mx" class="autocomplete-option" role="option" aria-selected="false" aria-disabled="true">Mexico</li>
<li id="demo-country-uk" class="autocomplete-option" role="option" aria-selected="false">United Kingdom</li>
</ul>
<p class="autocomplete-no-results" role="status" hidden>No matching countries.</p>
</div>
</div>
</div>
<div class="form-actions"><button class="btn" type="reset">Reset country</button></div>
</form>
<script>
(() => {
const root = document.getElementById('demo-autocomplete');
const input = root.querySelector('input');
const toggle = root.querySelector('button');
const dropdown = root.querySelector('.autocomplete-dropdown');
const options = [...root.querySelectorAll('[role="option"]')];
const status = root.querySelector('[role="status"]');
let active = -1;
const available = () => options.filter(option => !option.hidden && option.getAttribute('aria-disabled') !== 'true');
function highlight(index) {
const visible = available();
active = index;
options.forEach(option => option.setAttribute('aria-selected', String(option === visible[index])));
if (visible[index]) {
input.setAttribute('aria-activedescendant', visible[index].id);
visible[index].scrollIntoView({ block: 'nearest' });
} else input.removeAttribute('aria-activedescendant');
}
function close() {
dropdown.hidden = true;
root.classList.remove('autocomplete-open');
input.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-expanded', 'false');
highlight(-1);
}
function open(query) {
if (input.matches(':disabled')) return;
options.forEach(option => option.hidden = !option.textContent.toLowerCase().includes(query.toLowerCase()));
status.hidden = options.some(option => !option.hidden);
dropdown.hidden = false;
root.classList.add('autocomplete-open');
input.setAttribute('aria-expanded', 'true');
toggle.setAttribute('aria-expanded', 'true');
highlight(-1);
}
function choose(option) {
if (option.hidden || option.getAttribute('aria-disabled') === 'true') return;
input.value = option.textContent.trim();
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
close();
}
input.addEventListener('input', () => open(input.value));
input.addEventListener('focus', () => open(input.value));
input.addEventListener('click', () => open(input.value));
toggle.addEventListener('click', () => {
const expand = dropdown.hidden;
input.focus();
if (expand) open(''); else close();
});
options.forEach(option => {
option.addEventListener('mousedown', event => event.preventDefault());
option.addEventListener('click', () => choose(option));
});
input.addEventListener('keydown', event => {
if (event.isComposing) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (dropdown.hidden) open(input.value);
const count = available().length;
if (count) highlight(event.key === 'ArrowDown' ? (active + 1) % count : (active < 0 ? count - 1 : (active - 1 + count) % count));
} else if (event.key === 'Enter' && !dropdown.hidden && available()[active]) {
event.preventDefault();
choose(available()[active]);
} else if (event.key === 'Escape') {
event.preventDefault();
close();
}
});
root.addEventListener('focusout', event => {
if (!root.contains(event.relatedTarget)) close();
});
input.form.addEventListener('reset', () => requestAnimationFrame(close));
input.form.addEventListener('submit', event => event.preventDefault());
})();
</script>Focus stays on the input while arrows move the active suggestion; Enter accepts it, Escape dismisses the surface, and Tab leaves the field normally. Home/End retain native caret behavior. Disabled suggestions are not selectable. Reset restores the native default value and closes the surface after the browser’s reset action. Without the example script, this remains an ordinary text field but has no custom suggestion interaction.
Native Suggestions
A native list/datalist needs no controller. The browser owns filtering, popup positioning
and keyboard behavior; the suggestion menu itself cannot be styled by Core. Suggestions do
not restrict the submitted value. Use a native Select when values must come from a fixed list.
Native city suggestions
<form>
<div class="form-group">
<label class="form-label" for="demo-autocomplete-city">City</label>
<div class="autocomplete">
<input id="demo-autocomplete-city" name="city" type="text" class="autocomplete-input" list="demo-city-suggestions" autocomplete="off">
<datalist id="demo-city-suggestions">
<option value="Beijing"></option>
<option value="Paris"></option>
<option value="Tokyo"></option>
</datalist>
</div>
</div>
</form>Disabled Fields
Use actual disabled attributes or a genuinely disabled fieldset. Visual classes alone do
not disable interaction or submission. An application should close an open custom popup
when disabling its control dynamically.
Disabled fieldset
<fieldset id="demo-autocomplete-disabled" class="fieldset" disabled>
<legend class="fieldset-legend">Unavailable selection</legend>
<div class="form-group">
<label class="form-label" for="demo-disabled-country">Unavailable country</label>
<div class="autocomplete">
<div class="autocomplete-input-wrapper">
<input id="demo-disabled-country" name="unavailable-country" type="text" class="autocomplete-input" value="Canada">
<button type="button" class="autocomplete-toggle" aria-label="Show unavailable suggestions"><span aria-hidden="true">▾</span></button>
</div>
</div>
</div>
</fieldset>Canonical Classes And Compatibility
| Canonical API | Retained aliases / presentation |
|---|---|
.autocomplete, .autocomplete-input |
Container and native text field |
.autocomplete-input-wrapper, .autocomplete-toggle |
Input-relative suggestion button |
.autocomplete-open on root |
.autocomplete-dropdown-open or .show on dropdown |
.autocomplete-dropdown, .autocomplete-options |
Popup surface and reset list layout |
.autocomplete-option[aria-selected="true"] |
.selected, .autocomplete-option-selected |
.highlighted |
.autocomplete-option-focused; application owns active descendant |
.autocomplete-option[aria-disabled="true"] |
.disabled; application prevents selection |
.autocomplete-group-header |
.autocomplete-group-label |
.autocomplete-no-results |
.autocomplete-no-options |
.autocomplete-tags, .autocomplete-tag, .autocomplete-tag-remove |
.autocomplete-chips, .autocomplete-chip, .autocomplete-chip-remove |
.autocomplete-clear, .autocomplete-clearable |
Appearance only; application clears value |
.autocomplete-loading, .autocomplete-highlight |
Application loading and match presentation |
.autocomplete-sm/lg, .autocomplete-outlined/ghost |
Size and appearance |
.autocomplete-{color} |
primary, secondary, tertiary, info, success, warning, error, accent, neutral, base |
Multiple selection, removable tags, asynchronous requests, cancellation, debounce,
virtualization and selection/value mapping need an application or framework adapter.
Existing tag styles remain available; Core does not imply a multi-select or fetching engine.
Use unique demo--prefixed IDs in docs, and unique IDs per instance in an application.
The inline script is scoped to this example, not a global initializer for every .autocomplete.