Moul UIBeta
Components

Autocomplete

Composable collection filter and suggestion coordinator connecting text inputs with menus, lists, tables, and tags.

React Aria

Autocomplete

An autocomplete enables users to search or filter a collection of items using a text input like SearchField or TextField. It coordinates filtering, virtual focus management, and keyboard navigation across collections such as lists, menus, tables, and tag groups.

Autocomplete vs. ComboBox

Use ComboBox to select one or more values from a predefined popup set. Use Autocomplete to filter an arbitrary collection (in-place or floating) or provide text suggestions and completions.

Interactive Preview

New ComponentCreate a clean typescript or stylex file
⌘N
Import RepositoryImport an existing repository from git
⌘O
Toggle Color ThemeToggle between dark and light themes
⌘T
Keyboard ShortcutsConfigure keybindings and chords
⌘K

Installation

import {
  Autocomplete,
  AutocompleteList,
  AutocompleteItem,
  AutocompleteSection,
  AutocompleteEmptyState,
  AutocompletePopover,
  useFilter,
} from '@moul-dev/ui';

Basic Usage

By default, <Autocomplete> provides a styled container and applies a smart case-insensitive contains filter across collection items:

import {
  Autocomplete,
  AutocompleteList,
  AutocompleteItem,
  AutocompleteSection,
  SearchField,
} from '@moul-dev/ui';

export function Example() {
  return (
    <Autocomplete>
      <SearchField
        aria-label="Filter commands"
        placeholder="Type to filter actions..."
      />
      <AutocompleteList aria-label="Actions">
        <AutocompleteSection title="Projects">
          <AutocompleteItem
            id="new"
            description="Create a new workspace component"
            shortcut="⌘N"
          >
            New Component
          </AutocompleteItem>
          <AutocompleteItem
            id="open"
            description="Open an existing project folder"
            shortcut="⌘O"
          >
            Open Project
          </AutocompleteItem>
        </AutocompleteSection>
        <AutocompleteSection title="Preferences">
          <AutocompleteItem
            id="theme"
            description="Toggle light or dark color scheme"
            shortcut="⌘T"
          >
            Toggle Theme
          </AutocompleteItem>
        </AutocompleteSection>
      </AutocompleteList>
    </Autocomplete>
  );
}

Floating Dropdown with Popover

Combine <Autocomplete> with <AutocompletePopover> to display filterable suggestions beneath an input upon focus:

import {
  Autocomplete,
  AutocompletePopover,
  AutocompleteList,
  AutocompleteItem,
  SearchField,
} from '@moul-dev/ui';
import { useState } from 'react';

export function PopoverExample() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <Autocomplete>
      <SearchField
        aria-label="Filter programming languages"
        placeholder="Search languages..."
        onFocus={() => setIsOpen(true)}
      />
      <AutocompletePopover
        isOpen={isOpen}
        onOpenChange={setIsOpen}
        isNonModal
      >
        <AutocompleteList
          aria-label="Languages"
          selectionMode="single"
          onSelectionChange={() => setIsOpen(false)}
          variant="borderless"
        >
          <AutocompleteItem id="ts" description="Typed JavaScript superset">
            TypeScript
          </AutocompleteItem>
          <AutocompleteItem id="js" description="Standard web scripting">
            JavaScript
          </AutocompleteItem>
          <AutocompleteItem id="rust" description="Safe systems programming">
            Rust
          </AutocompleteItem>
          <AutocompleteItem id="go" description="Cloud native concurrency">
            Go
          </AutocompleteItem>
        </AutocompleteList>
      </AutocompletePopover>
    </Autocomplete>
  );
}

Creatable with Popover

When filtering options in a dropdown yields no matches, you can turn the empty state into a creation action. By listening to the Return key (Enter) on the input and rendering an interactive creation trigger with <Kbd>↵</Kbd>, users can seamlessly create and select custom options:

import {
  Autocomplete,
  AutocompletePopover,
  AutocompleteList,
  AutocompleteItem,
  SearchField,
  Kbd,
  tokens,
} from '@moul-dev/ui';
import { useState } from 'react';

export function CreatablePopoverExample() {
  const [isOpen, setIsOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [selectedKey, setSelectedKey] = useState<string | null>(null);
  const [items, setItems] = useState([
    { id: 'react', name: 'React', description: 'Component-based UI library' },
    { id: 'vue', name: 'Vue', description: 'Progressive JavaScript framework' },
    { id: 'svelte', name: 'Svelte', description: 'Cybernetically enhanced web apps' },
    { id: 'solid', name: 'Solid', description: 'Simple and performant reactivity' },
    { id: 'angular', name: 'Angular', description: 'Enterprise frontend platform' },
  ]);

  const trimmedQuery = query.trim();
  const matchingItems = items.filter((item) =>
    item.name.toLowerCase().includes(trimmedQuery.toLowerCase())
  );
  const isNoResults = trimmedQuery.length > 0 && matchingItems.length === 0;

  const handleCreate = (name: string) => {
    const clean = name.trim();
    if (!clean) return;

    const id = clean.toLowerCase().replace(/\s+/g, '-');
    const existing = items.find(
      (item) => item.id === id || item.name.toLowerCase() === clean.toLowerCase()
    );

    if (!existing) {
      const newItem = {
        id,
        name: clean,
        description: 'User-created framework',
      };
      setItems((prev) => [...prev, newItem]);
      setSelectedKey(id);
    } else {
      setSelectedKey(existing.id);
    }

    setQuery('');
    setIsOpen(false);
  };

  return (
    <Autocomplete>
      <SearchField
        aria-label="Filter or create framework"
        placeholder="Search or create framework..."
        value={query}
        onChange={(val) => {
          setQuery(val);
          if (!isOpen) setIsOpen(true);
        }}
        onFocus={() => setIsOpen(true)}
        onKeyDown={(e) => {
          if (e.key === 'Enter' && isNoResults) {
            e.preventDefault();
            handleCreate(query);
          }
        }}
      />
      <AutocompletePopover
        isOpen={isOpen}
        onOpenChange={setIsOpen}
        isNonModal
      >
        <AutocompleteList
          aria-label="Frameworks"
          selectionMode="single"
          selectedKeys={selectedKey ? [selectedKey] : []}
          onSelectionChange={(keys) => {
            const [first] = Array.from(keys);
            if (first) setSelectedKey(String(first));
            setIsOpen(false);
          }}
          variant="borderless"
          renderEmptyState={() => (
            <div className="p-1">
              <button
                type="button"
                onClick={() => handleCreate(query)}
                className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded cursor-pointer transition-colors text-left hover:bg-[var(--moul-color-bg-subtle,#f4f4f5)] dark:hover:bg-neutral-800"
                style={{ color: tokens.colorFg }}
              >
                <span className="flex items-center gap-2 truncate">
                  <span
                    className="w-4 h-4 flex items-center justify-center rounded-full text-xs font-bold shrink-0"
                    style={{
                      backgroundColor: tokens.colorPrimary50,
                      color: tokens.colorPrimary700,
                    }}
                  >
                    +
                  </span>
                  <span className="truncate">
                    Create <strong className="font-semibold">&ldquo;{trimmedQuery}&rdquo;</strong>
                  </span>
                </span>
                <span className="flex items-center gap-1 shrink-0 text-xs text-neutral-400">
                  <span className="text-[10px]">Press</span>
                  <Kbd>↵</Kbd>
                </span>
              </button>
            </div>
          )}
        >
          {items.map((item) => (
            <AutocompleteItem
              key={item.id}
              id={item.id}
              description={item.description}
            >
              {item.name}
            </AutocompleteItem>
          ))}
        </AutocompleteList>
      </AutocompletePopover>
    </Autocomplete>
  );
}

TagGroup Filtering

<Autocomplete> is not restricted to listboxes — it seamlessly coordinates with collections like <TagGroup>:

React
Vue
Svelte
Solid
Angular
Astro
Next.js
Remix
Waku
import { Autocomplete, SearchField, TagGroup, Tag } from '@moul-dev/ui';

export function TagFilteringExample() {
  return (
    <Autocomplete>
      <SearchField
        aria-label="Filter tags"
        placeholder="Filter technology tags..."
      />
      <TagGroup
        aria-label="Technologies"
        selectionMode="multiple"
        renderEmptyState={() => 'No matching tags found.'}
      >
        <Tag id="react">React</Tag>
        <Tag id="stylex">StyleX</Tag>
        <Tag id="typescript">TypeScript</Tag>
        <Tag id="waku">Waku</Tag>
        <Tag id="vite">Vite</Tag>
      </TagGroup>
    </Autocomplete>
  );
}

For server-side querying or API search, set mode="async" (or pass filter={null}) to bypass client-side filtering while retaining keyboard coordination:

import {
  Autocomplete,
  AutocompleteList,
  AutocompleteItem,
  SearchField,
} from '@moul-dev/ui';
import { useState, useEffect } from 'react';

export function AsyncExample() {
  const [query, setQuery] = useState('');
  const [items, setItems] = useState<{ id: string; name: string }[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    let active = true;
    setIsLoading(true);
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((res) => res.json())
      .then((data) => {
        if (active) setItems(data.items);
      })
      .finally(() => {
        if (active) setIsLoading(false);
      });
    return () => {
      active = false;
    };
  }, [query]);

  return (
    <Autocomplete
      mode="async"
      inputValue={query}
      onInputChange={setQuery}
    >
      <SearchField aria-label="Server Search" placeholder="Search database..." />
      <AutocompleteList aria-label="Results">
        {items.map((item) => (
          <AutocompleteItem key={item.id} id={item.id}>
            {item.name}
          </AutocompleteItem>
        ))}
      </AutocompleteList>
    </Autocomplete>
  );
}

Custom Styling with StyleX Tokens

Every component in the Autocomplete family accepts compile-time StyleX overrides via the style prop. Import tokens from @moul-dev/ui to build custom variants that respond seamlessly to theme variables and density scales:

import {
  Autocomplete,
  AutocompleteList,
  AutocompleteItem,
  SearchField,
  tokens,
} from '@moul-dev/ui';
import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
  container: {
    maxWidth: '460px',
    gap: tokens.spacing3,
  },
  list: {
    maxHeight: '260px',
    borderRadius: tokens.radiusLg,
    borderColor: tokens.colorBorderSubtle,
    boxShadow: tokens.shadowMd,
  },
  customItem: {
    borderRadius: tokens.radiusMd,
    paddingBlock: tokens.spacing2,
  },
});

export function StyledAutocomplete() {
  return (
    <Autocomplete style={styles.container}>
      <SearchField
        aria-label="Filter options"
        placeholder="Filter custom list..."
      />
      <AutocompleteList aria-label="Options" style={styles.list}>
        <AutocompleteItem id="1" style={styles.customItem}>
          First Option
        </AutocompleteItem>
        <AutocompleteItem id="2" style={styles.customItem}>
          Second Option
        </AutocompleteItem>
      </AutocompleteList>
    </Autocomplete>
  );
}

Props

Autocomplete

Prop

Type

AutocompleteList

Prop

Type

AutocompleteItem

Prop

Type

AutocompletePopover

Prop

Type

On this page