Back to catalog

autocomplete-builder

Build autocomplete/combobox with keyboard navigation, async search, accessibility. Headless UI Combobox, Downshift. Keywords - autocomplete, combobox, typeahead, search suggestions, headless ui combobox, downshift, async autocomplete

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • Updated: 2025-11-22

Download the latest version (v1.0.0) from this page.

SKILL.md

---
name: autocomplete-builder
description: Build autocomplete/combobox with keyboard navigation, async search, accessibility. Headless UI Combobox, Downshift. Keywords - autocomplete, combobox, typeahead, search suggestions, headless ui combobox, downshift, async autocomplete
---

# Autocomplete Builder

## When to Use This Skill

Activate when:
- Building autocomplete search inputs
- Creating comboboxes with searchable dropdowns
- Need async data loading (search as you type)
- Want keyboard navigation (arrows, Enter, Escape)
- Implementing accessible autocomplete

## What This Skill Does

Creates autocomplete components with:
- Search as you type with debouncing
- Keyboard navigation (arrows, Enter, Esc)
- Async data loading
- Highlighted matching text
- Accessibility (ARIA, screen reader support)
- Loading and empty states

## Supported Technologies

**React**:
- Headless UI Combobox (recommended)
- Downshift
- React Select
- Custom implementation

**Vue 3**:
- Headless UI Vue Combobox
- Custom implementation

## Example: Headless UI Combobox (React)

```tsx
// components/Autocomplete.tsx
import { useState } from 'react';
import { Combobox } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
import { useDebounce } from 'use-debounce';

interface Option {
  id: number;
  name: string;
  email?: string;
}

interface AutocompleteProps {
  options: Option[];
  value: Option | null;
  onChange: (value: Option | null) => void;
  onSearch: (query: string) => void;
  isLoading?: boolean;
  placeholder?: string;
}

export function Autocomplete({
  options,
  value,
  onChange,
  onSearch,
  isLoading = false,
  placeholder = 'Search...',
}: AutocompleteProps) {
  const [query, setQuery] = useState('');
  const [debouncedQuery] = useDebounce(query, 300);

  // Trigger search when debounced query changes
  useEffect(() => {
    onSearch(debouncedQuery);
  }, [debouncedQuery, onSearch]);

  // Filter options locally
  const filteredOptions =
    query === ''
      ? options
      : options.filter((option) =>
          option.name.toLowerCase().includes(query.toLowerCase())
        );

  return (
    <Combobox value={value} onChange={onChange}>
      <div className="relative">
        <div className="relative w-full cursor-default overflow-hidden rounded-lg bg-white text-left border border-gray-300 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
          <Combobox.Input
            className="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0"
            displayValue={(option: Option | null) => option?.name || ''}
            onChange={(event) => setQuery(event.target.value)}
            placeholder={placeholder}
          />
          <Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
            <ChevronUpDownIcon className="h-5 w-5 text-gray-400" />
          </Combobox.Button>
        </div>

        <Combobox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm">
          {isLoading ? (
            <div className="relative cursor-default select-none py-2 px-4 text-gray-700">
              Loading...
            </div>
          ) : filteredOptions.length === 0 && query !== '' ? (
            <div className="relative cursor-default select-none py-2 px-4 text-gray-700">
              No results found.
            </div>
          ) : (
            filteredOptions.map((option) => (
              <Combobox.Option
                key={option.id}
                value={option}
                className={({ active }) =>
                  `relative cursor-pointer select-none py-2 pl-10 pr-4 ${
                    active ? 'bg-indigo-600 text-white' : 'text-gray-900'
                  }`
                }
              >
                {({ selected, active }) => (
                  <>
                    <span
                      className={`block truncate ${
                        selected ? 'font-medium' : 'font-normal'
                      }`}
                    >
                      {option.name}
                    </span>
                    {option.email && (
                      <span
                        className={`block text-sm ${
                          active ? 'text-indigo-200' : 'text-gray-500'
                        }`}
                      >
                        {option.email}
                      </span>
                    )}
                    {selected && (
                      <span
                        className={`absolute inset-y-0 left-0 flex items-center pl-3 ${
                          active ? 'text-white' : 'text-indigo-600'
                        }`}
                      >
                        <CheckIcon className="h-5 w-5" />
                      </span>
                    )}
                  </>
                )}
              </Combobox.Option>
            ))
          )}
        </Combobox.Options>
      </div>
    </Combobox>
  );
}

// Usage
function UserSelector() {
  const [selectedUser, setSelectedUser] = useState<Option | null>(null);
  const [users, setUsers] = useState<Option[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  const handleSearch = async (query: string) => {
    if (!query) {
      setUsers([]);
      return;
    }

    setIsLoading(true);
    try {
      const response = await fetch(`/api/users/search?q=${query}`);
      const data = await response.json();
      setUsers(data);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Autocomplete
      options={users}
      value={selectedUser}
      onChange={setSelectedUser}
      onSearch={handleSearch}
      isLoading={isLoading}
      placeholder="Search users..."
    />
  );
}
```

## Example: Multi-Select Autocomplete

```tsx
// components/MultiSelectAutocomplete.tsx
import { useState } from 'react';
import { Combobox } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/20/solid';

interface MultiSelectProps {
  options: Option[];
  selected: Option[];
  onChange: (values: Option[]) => void;
}

export function MultiSelectAutocomplete({
  options,
  selected,
  onChange,
}: MultiSelectProps) {
  const [query, setQuery] = useState('');

  const filteredOptions =
    query === ''
      ? options
      : options.filter((option) =>
          option.name.toLowerCase().includes(query.toLowerCase())
        );

  const handleSelect = (value: Option[]) => {
    onChange(value);
    setQuery('');
  };

  const handleRemove = (id: number) => {
    onChange(selected.filter((item) => item.id !== id));
  };

  return (
    <Combobox value={selected} onChange={handleSelect} multiple>
      <div className="relative">
        {/* Selected items */}
        {selected.length > 0 && (
          <div className="flex flex-wrap gap-2 mb-2">
            {selected.map((item) => (
              <span
                key={item.id}
                className="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-sm text-indigo-800"
              >
                {item.name}
                <button
                  onClick={() => handleRemove(item.id)}
                  className="hover:text-indigo-600"
                >
                  <XMarkIcon className="h-4 w-4" />
                </button>
              </span>
            ))}
          </div>
        )}

        <Combobox.Input
          className="w-full rounded-md border border-gray-300 py-2 px-3 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
          onChange={(event) => setQuery(event.target.value)}
          value={query}
          placeholder="Search to add..."
        />

        <Combobox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5">
          {filteredOptions.map((option) => (
            <Combobox.Option
              key={option.id}
              value={option}
              disabled={selected.some((item) => item.id === option.id)}
              className={({ active, disabled }) =>
                `relative cursor-pointer select-none py-2 px-4 ${
                  disabled
                    ? 'text-gray-400 cursor-not-allowed'
                    : active
                    ? 'bg-indigo-600 text-white'
                    : 'text-gray-900'
                }`
              }
            >
              {option.name}
            </Combobox.Option>
          ))}
        </Combobox.Options>
      </div>
    </Combobox>
  );
}
```

## Example: Highlighted Text

```tsx
// components/HighlightedText.tsx
function highlightMatch(text: string, query: string) {
  if (!query) return text;

  const regex = new RegExp(`(${query})`, 'gi');
  const parts = text.split(regex);

  return parts.map((part, index) =>
    regex.test(part) ? (
      <mark key={index} className="bg-yellow-200 font-semibold">
        {part}
      </mark>
    ) : (
      <span key={index}>{part}</span>
    )
  );
}

// Usage in Combobox.Option
<span className="block truncate">
  {highlightMatch(option.name, query)}
</span>
```

## Example: Vue 3 Autocomplete

```vue
<!-- components/Autocomplete.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
  Combobox,
  ComboboxInput,
  ComboboxOptions,
  ComboboxOption,
  ComboboxButton,
} from '@headlessui/vue';
import { useDebounceFn } from '@vueuse/core';

interface Option {
  id: number;
  name: string;
}

const props = defineProps<{
  options: Option[];
  modelValue: Option | null;
}>();

const emit = defineEmits<{
  'update:modelValue': [value: Option | null];
  search: [query: string];
}>();

const query = ref('');

const debouncedSearch = useDebounceFn((value: string) => {
  emit('search', value);
}, 300);

const filteredOptions = computed(() => {
  if (query.value === '') return props.options;
  return props.options.filter((option) =>
    option.name.toLowerCase().includes(query.value.toLowerCase())
  );
});

const handleInput = (event: Event) => {
  const value = (event.target as HTMLInputElement).value;
  query.value = value;
  debouncedSearch(value);
};
</script>

<template>
  <Combobox
    :model-value="modelValue"
    @update:model-value="emit('update:modelValue', $event)"
  >
    <div class="relative">
      <ComboboxInput
        :display-value="(option: Option | null) => option?.name || ''"
        @input="handleInput"
        class="w-full rounded-md border border-gray-300 py-2 px-3 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
        placeholder="Search..."
      />

      <ComboboxOptions
        class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5"
      >
        <div
          v-if="filteredOptions.length === 0 && query !== ''"
          class="relative cursor-default select-none py-2 px-4 text-gray-700"
        >
          No results found.
        </div>

        <ComboboxOption
          v-for="option in filteredOptions"
          :key="option.id"
          :value="option"
          v-slot="{ active, selected }"
          class="relative cursor-pointer select-none py-2 px-4"
          :class="active ? 'bg-indigo-600 text-white' : 'text-gray-900'"
        >
          <span :class="selected ? 'font-semibold' : 'font-normal'">
            {{ option.name }}
          </span>
        </ComboboxOption>
      </ComboboxOptions>
    </div>
  </Combobox>
</template>
```

## Best Practices

1. **Debounce search** - Wait 300ms before API call
2. **Keyboard navigation** - Arrow keys, Enter, Escape
3. **Loading state** - Show "Loading..." while fetching
4. **Empty state** - Show "No results" when nothing found
5. **Highlight matches** - Bold matching text
6. **Accessibility** - Use ARIA attributes (Headless UI handles this)
7. **Clear button** - Allow clearing selection
8. **Min query length** - Require 2-3 chars before search

## Accessibility Checklist

✅ **ARIA attributes** - `aria-autocomplete`, `aria-expanded`
✅ **Keyboard navigation** - Arrow keys, Enter, Escape
✅ **Screen reader** - Announce results and selection
✅ **Focus management** - Focus input on open

## Integration with Other Skills

- **api-integration-layer** - Async data loading with React Query
- **form-builder-with-validation** - Use in forms
- **search-filter-builder** - Combine with filters

---

**Version**: 1.0.0
**Category**: Frontend Extended
**Complexity**: Medium