Autocomplete

Material Design 3 autocomplete/combobox component with single and multiple selection support

Autocomplete

Autocomplete provides suggestions as users type, helping them quickly find and select from a list of options. @duskmoon-dev/core offers a complete Material Design 3 autocomplete/combobox implementation with support for single selection, multiple selection, and async loading.

Basic Usage

Basic Autocomplete

  • Option 1
  • Option 2
  • Option 3

Variants

Filled Autocomplete (Default)

The default autocomplete with a filled background:

Filled Autocomplete

  • United States
  • Canada
  • Mexico
  • United Kingdom

Outlined Autocomplete

Autocomplete with an outlined border:

Outlined Autocomplete

  • Product A
  • Product B
  • Product C

Selection States

Single Selection

Standard autocomplete with a single selected option:

Single Selection

  • JavaScript
  • TypeScript
  • Python
  • Java

Multiple Selection with Chips

Autocomplete supporting multiple selections displayed as chips:

Multiple Selection with Chips

JavaScript TypeScript
  • JavaScript
  • TypeScript
  • Python
  • Java

Options with Icons

Add icons to autocomplete options for better visual context:

Options with Icons

  • Document
  • Image
  • Audio

Grouped Options

Organize options into labeled groups:

Grouped Options

  • Productivity
  • Calendar
  • Notes
  • Tasks
  • Communication
  • Mail
  • Chat
  • Design
  • Figma
  • Sketch

States

Loading State

Display a loading indicator while fetching async data:

Loading State

Loading...

No Options

Show a message when no matching options are found:

No Options State

No results found

Focused Option

Keyboard navigation highlights the focused option:

Focused Option

  • Option 1
  • Option 2
  • Option 3

Disabled State

Non-interactive autocomplete:

Disabled State

The dropdown visibility is controlled by the autocomplete-dropdown-open class:

Dropdown Control

Best Practices

Accessibility

Autocomplete requires proper ARIA attributes for screen reader support:

Accessible Autocomplete

  • United States
  • Canada

Key accessibility requirements:

  • Use role="combobox" on the input
  • Add aria-autocomplete="list" to indicate filtering behavior
  • Use aria-controls to link input to dropdown
  • Update aria-expanded based on dropdown state
  • Set aria-activedescendant to the focused option ID
  • Use unique IDs for each option
  • Provide clear labels

Keyboard Navigation

Implement keyboard controls for better usability:

  • Arrow Down: Open dropdown and navigate to next option
  • Arrow Up: Navigate to previous option
  • Enter: Select focused option and close dropdown
  • Escape: Close dropdown without selecting
  • Tab: Move focus away and close dropdown
  • Home: Focus first option
  • End: Focus last option

Performance

For large datasets or async operations:

  1. Debounce search input: Wait for user to finish typing before filtering
  2. Virtualize long lists: Render only visible options
  3. Cache results: Store previously fetched data
  4. Limit results: Show only top N matches (e.g., 50 items)

User Experience

  • Show loading state for async operations
  • Display helpful “no results” messages
  • Pre-populate with recent or suggested items
  • Allow clearing selection easily
  • Support both keyboard and mouse interaction
  • Auto-highlight first matching option

Framework Examples

React

import { useState, useRef, useEffect } from 'react';

interface Option {
  value: string;
  label: string;
  icon?: React.ReactNode;
}

interface AutocompleteProps {
  options: Option[];
  placeholder?: string;
  variant?: 'filled' | 'outlined';
  onChange?: (value: string) => void;
}

export function Autocomplete({
  options,
  placeholder = 'Search...',
  variant = 'filled',
  onChange
}: AutocompleteProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [search, setSearch] = useState('');
  const [focusedIndex, setFocusedIndex] = useState(-1);

  const filtered = options.filter(opt =>
    opt.label.toLowerCase().includes(search.toLowerCase())
  );

  const handleSelect = (option: Option) => {
    setSearch(option.label);
    setIsOpen(false);
    onChange?.(option.value);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setIsOpen(true);
        setFocusedIndex(prev =>
          prev < filtered.length - 1 ? prev + 1 : prev
        );
        break;
      case 'ArrowUp':
        e.preventDefault();
        setFocusedIndex(prev => prev > 0 ? prev - 1 : 0);
        break;
      case 'Enter':
        e.preventDefault();
        if (focusedIndex >= 0) {
          handleSelect(filtered[focusedIndex]);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

  return (
    <div className={`autocomplete ${variant === 'outlined' ? 'autocomplete-outlined' : ''}`}>
      <div className="autocomplete-input-wrapper">
        <input
          type="text"
          className="autocomplete-input"
          placeholder={placeholder}
          value={search}
          onChange={(e) => {
            setSearch(e.target.value);
            setIsOpen(true);
          }}
          onKeyDown={handleKeyDown}
          onFocus={() => setIsOpen(true)}
          aria-autocomplete="list"
          aria-expanded={isOpen}
        />
        <button
          className="autocomplete-toggle"
          onClick={() => setIsOpen(!isOpen)}
          aria-label="Toggle dropdown"
        >

        </button>
      </div>

      <div className={`autocomplete-dropdown ${isOpen ? 'autocomplete-dropdown-open' : ''}`}>
        {filtered.length > 0 ? (
          <ul className="autocomplete-options" role="listbox">
            {filtered.map((option, index) => (
              <li
                key={option.value}
                role="option"
                className={`autocomplete-option ${
                  index === focusedIndex ? 'autocomplete-option-focused' : ''
                }`}
                onClick={() => handleSelect(option)}
              >
                {option.icon && (
                  <span className="autocomplete-option-icon">{option.icon}</span>
                )}
                {option.label}
              </li>
            ))}
          </ul>
        ) : (
          <div className="autocomplete-no-options">No results found</div>
        )}
      </div>
    </div>
  );
}

// Usage
const countries = [
  { value: 'us', label: 'United States' },
  { value: 'ca', label: 'Canada' },
  { value: 'mx', label: 'Mexico' },
];

<Autocomplete
  options={countries}
  placeholder="Choose a country..."
  onChange={(value) => console.log('Selected:', value)}
/>

Vue

<template>
  <div :class="['autocomplete', { 'autocomplete-outlined': variant === 'outlined' }]">
    <div class="autocomplete-input-wrapper">
      <input
        v-model="search"
        type="text"
        class="autocomplete-input"
        :placeholder="placeholder"
        @focus="isOpen = true"
        @keydown="handleKeyDown"
        aria-autocomplete="list"
        :aria-expanded="isOpen"
      />
      <button
        class="autocomplete-toggle"
        @click="isOpen = !isOpen"
        aria-label="Toggle dropdown"
      >

      </button>
    </div>

    <div :class="['autocomplete-dropdown', { 'autocomplete-dropdown-open': isOpen }]">
      <ul v-if="filteredOptions.length > 0" class="autocomplete-options" role="listbox">
        <li
          v-for="(option, index) in filteredOptions"
          :key="option.value"
          role="option"
          :class="[
            'autocomplete-option',
            { 'autocomplete-option-focused': index === focusedIndex }
          ]"
          @click="handleSelect(option)"
        >
          {{ option.label }}
        </li>
      </ul>
      <div v-else class="autocomplete-no-options">
        No results found
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

const props = defineProps({
  options: {
    type: Array,
    required: true
  },
  placeholder: {
    type: String,
    default: 'Search...'
  },
  variant: {
    type: String,
    default: 'filled'
  }
});

const emit = defineEmits(['change']);

const search = ref('');
const isOpen = ref(false);
const focusedIndex = ref(-1);

const filteredOptions = computed(() =>
  props.options.filter(opt =>
    opt.label.toLowerCase().includes(search.value.toLowerCase())
  )
);

const handleSelect = (option) => {
  search.value = option.label;
  isOpen.value = false;
  emit('change', option.value);
};

const handleKeyDown = (e) => {
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      isOpen.value = true;
      focusedIndex.value = Math.min(
        focusedIndex.value + 1,
        filteredOptions.value.length - 1
      );
      break;
    case 'ArrowUp':
      e.preventDefault();
      focusedIndex.value = Math.max(focusedIndex.value - 1, 0);
      break;
    case 'Enter':
      e.preventDefault();
      if (focusedIndex.value >= 0) {
        handleSelect(filteredOptions.value[focusedIndex.value]);
      }
      break;
    case 'Escape':
      isOpen.value = false;
      break;
  }
};
</script>

<!-- Usage -->
<Autocomplete
  :options="countries"
  placeholder="Choose a country..."
  @change="handleChange"
/>

Async Loading Example

// React example with async data fetching
import { useState, useEffect } from 'react';

interface AsyncAutocompleteProps {
  fetchOptions: (query: string) => Promise<Option[]>;
  placeholder?: string;
  debounceMs?: number;
}

export function AsyncAutocomplete({
  fetchOptions,
  placeholder = 'Search...',
  debounceMs = 300
}: AsyncAutocompleteProps) {
  const [search, setSearch] = useState('');
  const [options, setOptions] = useState<Option[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    if (!search) {
      setOptions([]);
      return;
    }

    const timer = setTimeout(async () => {
      setIsLoading(true);
      try {
        const results = await fetchOptions(search);
        setOptions(results);
      } catch (error) {
        console.error('Failed to fetch options:', error);
        setOptions([]);
      } finally {
        setIsLoading(false);
      }
    }, debounceMs);

    return () => clearTimeout(timer);
  }, [search, fetchOptions, debounceMs]);

  return (
    <div className="autocomplete">
      <div className="autocomplete-input-wrapper">
        <input
          type="text"
          className="autocomplete-input"
          placeholder={placeholder}
          value={search}
          onChange={(e) => {
            setSearch(e.target.value);
            setIsOpen(true);
          }}
        />
        <button className="autocomplete-toggle" onClick={() => setIsOpen(!isOpen)}>

        </button>
      </div>

      <div className={`autocomplete-dropdown ${isOpen ? 'autocomplete-dropdown-open' : ''}`}>
        {isLoading ? (
          <div className="autocomplete-loading">Loading...</div>
        ) : options.length > 0 ? (
          <ul className="autocomplete-options">
            {options.map(option => (
              <li key={option.value} className="autocomplete-option">
                {option.label}
              </li>
            ))}
          </ul>
        ) : search ? (
          <div className="autocomplete-no-options">No results found</div>
        ) : null}
      </div>
    </div>
  );
}

// Usage
<AsyncAutocomplete
  fetchOptions={async (query) => {
    const response = await fetch(`/api/search?q=${query}`);
    return response.json();
  }}
  placeholder="Search users..."
/>

API Reference

Class Names

ClassDescription
.autocompleteBase autocomplete container (required)
.autocomplete-outlinedOutlined variant with border
.autocomplete-disabledDisabled state styling
.autocomplete-input-wrapperContainer for input and toggle button
.autocomplete-inputText input field
.autocomplete-toggleDropdown toggle button
.autocomplete-dropdownDropdown container (hidden by default)
.autocomplete-dropdown-openMakes dropdown visible
.autocomplete-optionsList container for options
.autocomplete-optionIndividual option item
.autocomplete-option-selectedSelected option state
.autocomplete-option-focusedKeyboard-focused option state
.autocomplete-option-iconIcon within an option
.autocomplete-loadingLoading state indicator
.autocomplete-no-optionsEmpty state message
.autocomplete-group-labelGroup label for categorized options
.autocomplete-chipsContainer for selected chips (multi-select)
.autocomplete-chipIndividual chip for selected item
.autocomplete-chip-removeRemove button within a chip

HTML Attributes

Key ARIA attributes for accessibility:

AttributeElementDescription
role="combobox"InputIdentifies the autocomplete pattern
aria-autocomplete="list"InputIndicates list-based autocomplete
aria-controlsInputLinks to dropdown listbox ID
aria-expandedInputBoolean indicating dropdown state
aria-activedescendantInputID of currently focused option
role="listbox"DropdownIdentifies options container
role="option"OptionIdentifies selectable option
aria-multiselectableListboxAllows multiple selections
  • Input - Text input fields
  • Select - Dropdown selection
  • Chip - Selected items display
  • List - List items

See Also