# Autocomplete

A searchable text input that suggests and filters matching options as you type.

## Installation

```tsx
import {
  Autocomplete,
  AutocompleteInputGroup,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteList,
  AutocompleteItem,
} from "@makeplane/propel/components/autocomplete";
```
## Usage

```tsx
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteInputGroup,
  AutocompleteItem,
  AutocompleteList,
} from "@makeplane/propel/components/autocomplete";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@makeplane/propel/components/field";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { ChevronExpandOutline, CloseOutline } from "@makeplane/propel/icons";

const IMAGES = ["nginx:1.29-alpine", "node:22-slim", "postgres:18", "redis:8.2.2-alpine"];

export default function BasicDemo() {
  return (
    <Field name="containerImage">
      <Autocomplete items={IMAGES} mode="both" required>
        <FieldLabel size="lg" inset={false}>
          Container image
        </FieldLabel>
        <AutocompleteInputGroup
          size="lg"
          placeholder="e.g. docker.io/library/node:latest"
          clear={
            <IconButton
              variant="ghost"
              size="md"
              aria-label="Clear container image"
              icon={<Icon icon={CloseOutline} />}
            />
          }
          trigger={
            <IconButton
              variant="ghost"
              size="md"
              aria-label="Open container image"
              icon={<Icon icon={ChevronExpandOutline} />}
            />
          }
        />
        <FieldDescription size="lg">Enter a registry URL with optional tags.</FieldDescription>
        <AutocompleteContent>
          <AutocompleteEmpty>No matches</AutocompleteEmpty>
          <AutocompleteList>
            {(image: string) => (
              <AutocompleteItem key={image} value={image} size="lg">
                {image}
              </AutocompleteItem>
            )}
          </AutocompleteList>
        </AutocompleteContent>
        <FieldError size="lg" />
      </Autocomplete>
    </Field>
  );
}
```
## Examples

### Search

```tsx
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteInputGroup,
  AutocompleteItem,
  AutocompleteList,
} from "@makeplane/propel/components/autocomplete";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { SearchOutline, CloseOutline } from "@makeplane/propel/icons";

const IMAGES = ["nginx:1.29-alpine", "node:22-slim", "postgres:18", "redis:8.2.2-alpine"];

export default function SearchDemo() {
  return (
    <Autocomplete items={IMAGES} mode="both">
      <AutocompleteInputGroup
        size="lg"
        icon={<Icon icon={SearchOutline} tint="placeholder" />}
        placeholder="Search images"
        aria-label="Search images"
        clear={
          <IconButton
            variant="ghost"
            size="md"
            aria-label="Clear search"
            icon={<Icon icon={CloseOutline} />}
          />
        }
      />
      <AutocompleteContent>
        <AutocompleteEmpty>No matches</AutocompleteEmpty>
        <AutocompleteList>
          {(image: string) => (
            <AutocompleteItem key={image} value={image} size="lg">
              {image}
            </AutocompleteItem>
          )}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>
  );
}
```

### Grouped

Grouped suggestions render each group under a label; filtering drops empty groups and their headings automatically.

```tsx
import {
  Autocomplete,
  AutocompleteCollection,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteGroup,
  AutocompleteGroupLabel,
  AutocompleteInputGroup,
  AutocompleteItem,
  AutocompleteList,
} from "@makeplane/propel/components/autocomplete";

const GROUPED_LABELS = [
  { label: "Type", items: ["feature", "fix", "bug", "docs"] },
  { label: "Component", items: ["component: editor", "component: sidebar", "component: issues"] },
];

export default function GroupedDemo() {
  return (
    <Autocomplete items={GROUPED_LABELS}>
      <AutocompleteInputGroup size="lg" placeholder="e.g. feature" aria-label="Add label" />
      <AutocompleteContent>
        <AutocompleteEmpty>No matches</AutocompleteEmpty>
        <AutocompleteList>
          {(group: (typeof GROUPED_LABELS)[number]) => (
            <AutocompleteGroup key={group.label} items={group.items}>
              <AutocompleteGroupLabel>{group.label}</AutocompleteGroupLabel>
              <AutocompleteCollection>
                {(label: string) => (
                  <AutocompleteItem key={label} value={label} size="lg">
                    {label}
                  </AutocompleteItem>
                )}
              </AutocompleteCollection>
            </AutocompleteGroup>
          )}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>
  );
}
```

### Async

Hand filtering to the consumer with `filter={null}`, drive the lookup from `value`/`onValueChange`, and announce progress with `AutocompleteStatus`.

```tsx
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteInputGroup,
  AutocompleteItem,
  AutocompleteList,
  AutocompleteStatus,
  useFilter,
} from "@makeplane/propel/components/autocomplete";
import { Icon } from "@makeplane/propel/components/icon";
import { SearchOutline } from "@makeplane/propel/icons";
import * as React from "react";

const PROJECTS = ["Design system", "Marketing site", "Mobile app", "Platform API"];

export default function AsyncSearchDemo() {
  const [value, setValue] = React.useState("");
  const [results, setResults] = React.useState<readonly string[]>([]);
  const [searching, setSearching] = React.useState(false);
  const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const { contains } = useFilter();

  React.useEffect(() => () => clearTimeout(timeoutRef.current), []);

  return (
    <Autocomplete
      items={results}
      value={value}
      filter={null}
      onValueChange={(nextValue) => {
        setValue(nextValue);
        clearTimeout(timeoutRef.current);
        if (nextValue === "") {
          setSearching(false);
          setResults([]);
          return;
        }
        setSearching(true);
        timeoutRef.current = setTimeout(() => {
          setResults(PROJECTS.filter((project) => contains(project, nextValue)));
          setSearching(false);
        }, 300);
      }}
    >
      <AutocompleteInputGroup
        size="lg"
        icon={<Icon icon={SearchOutline} tint="placeholder" />}
        placeholder="Search projects"
        aria-label="Search projects"
      />
      <AutocompleteContent>
        <AutocompleteStatus>
          {searching
            ? "Searching…"
            : value !== "" && `${results.length} result${results.length === 1 ? "" : "s"}`}
        </AutocompleteStatus>
        <AutocompleteList>
          {(project: string) => (
            <AutocompleteItem key={project} value={project} size="lg">
              {project}
            </AutocompleteItem>
          )}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>
  );
}
```

### Fuzzy matching

A custom `filter` replaces the built-in contains match, so skipped-letter queries (e.g. `ngx`) still find their item.

```tsx
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteInputGroup,
  AutocompleteItem,
  AutocompleteList,
} from "@makeplane/propel/components/autocomplete";
import { Icon } from "@makeplane/propel/components/icon";
import { SearchOutline } from "@makeplane/propel/icons";

const IMAGES = ["nginx:1.29-alpine", "node:22-slim", "postgres:18", "redis:8.2.2-alpine"];

// A subsequence matcher: every query character must appear in the item, in order — so partial or
// skipped-letter queries ("ngx", "pstgrs") still find their item.
function fuzzyMatch(item: string, query: string): boolean {
  const needle = query.trim().toLowerCase();
  if (needle === "") {
    return true;
  }
  let matched = 0;
  for (const char of item.toLowerCase()) {
    if (char === needle[matched]) {
      matched += 1;
    }
    if (matched === needle.length) {
      return true;
    }
  }
  return false;
}

export default function FuzzyMatchingDemo() {
  return (
    <Autocomplete items={IMAGES} filter={fuzzyMatch}>
      <AutocompleteInputGroup
        size="lg"
        icon={<Icon icon={SearchOutline} tint="placeholder" />}
        placeholder="Search images"
        aria-label="Search images"
      />
      <AutocompleteContent>
        <AutocompleteEmpty>No matches</AutocompleteEmpty>
        <AutocompleteList>
          {(image: string) => (
            <AutocompleteItem key={image} value={image} size="lg">
              {image}
            </AutocompleteItem>
          )}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>
  );
}
```

### Sizes

`size` steps the input row's height, padding, and icon and text scale: `md`, `lg`, `xl`, or `2xl`.

```tsx
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteInputGroup,
  AutocompleteItem,
  AutocompleteList,
  type AutocompleteSize,
} from "@makeplane/propel/components/autocomplete";
import { Field, FieldLabel } from "@makeplane/propel/components/field";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { ChevronExpandOutline } from "@makeplane/propel/icons";

const IMAGES = ["nginx:1.29-alpine", "node:22-slim", "postgres:18", "redis:8.2.2-alpine"];

const SIZES: AutocompleteSize[] = ["md", "lg", "xl", "2xl"];

export default function SizesDemo() {
  return (
    <div className="flex flex-wrap items-end gap-3">
      {SIZES.map((size) => (
        <Field key={size} name={`containerImage-${size}`}>
          <Autocomplete items={IMAGES}>
            <FieldLabel size="lg" inset={false}>
              {size}
            </FieldLabel>
            <AutocompleteInputGroup
              size={size}
              placeholder="e.g. node:22-slim"
              trigger={
                <IconButton
                  variant="ghost"
                  size="md"
                  aria-label="Open container image"
                  icon={<Icon icon={ChevronExpandOutline} />}
                />
              }
            />
            <AutocompleteContent>
              <AutocompleteEmpty>No matches</AutocompleteEmpty>
              <AutocompleteList>
                {(image: string) => (
                  <AutocompleteItem key={image} value={image} size="lg">
                    {image}
                  </AutocompleteItem>
                )}
              </AutocompleteList>
            </AutocompleteContent>
          </Autocomplete>
        </Field>
      ))}
    </div>
  );
}
```
## API Reference

### Autocomplete

The autocomplete Root — Base UI's context/state provider (renders no element of its own). A
behavior-only role, so it lives in `components` (rules 1a, 2); the styled parts live in
`elements/autocomplete` and are grafted onto Base UI behavior here.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `readonly Value[] \| readonly Group<Value>[]` | — | The items to be displayed in the list. Can be either a flat array of items or an array of groups with items. Nullish entries are not supported: remove them from the data before passing it. Items to display in the autocomplete list. |
| `children` | `ReactNode` | — | The autocomplete's anatomy — an `AutocompleteInput` and the `AutocompletePopup` of items. |

### AutocompleteInputGroup

The ready-made autocomplete input row: grafts Base UI's `InputGroup` onto the styled bordered
frame and Base UI's `Input` onto the styled text field, laying out an optional leading `icon`
slot and the consumer-provided `clear`/`trigger` controls. All remaining props pass through to
the input (the element that carries the combobox behavior and accessible name).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` (required) | `"md" \| "lg" \| "xl" \| "2xl"` | — | Visual size of the input row: height, padding, and icon/text sizing. Required. |
| `placeholder` | `string` | — | Input placeholder. |
| `icon` | `ReactNode` | — | Decorative leading element at the inline-start, e.g. `<Icon icon={Search} tint="placeholder" />`. |
| `clear` | `ReactElement<unknown, string \| JSXElementConstructor<any>>` | — | The clear control (e.g. an `IconButton`), grafted onto Base UI's `Clear` behavior. It carries its own — localizable — `aria-label`; the group bakes no label or glyph. |
| `trigger` | `ReactElement<unknown, string \| JSXElementConstructor<any>>` | — | The popup-trigger control (e.g. an `IconButton`), grafted onto Base UI's `Trigger` behavior. It carries its own — localizable — `aria-label`; the group bakes no label or glyph. |

### AutocompleteContent

The autocomplete list surface: Base UI portal + positioner + popup grafted onto Propel styling.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `side` | `"top" \| "bottom" \| "left" \| "right" \| "inline-end" \| "inline-start"` | `bottom` | Which side of the input the list opens toward. |
| `sideOffset` | `number \| OffsetFunction` | `4` | Distance in px between the input and the list. |
| `align` | `"center" \| "start" \| "end"` | `start` | Alignment of the list relative to the input along `side`. |
| `alignOffset` | `number \| OffsetFunction` | `0` | Additional offset in px along the align axis — slides the list along the input's edge without changing `align`. |
| `collisionPadding` | `Padding` | `16` | Minimum gap in px between the list and the viewport edge it would otherwise touch. |
| `collisionBoundary` | `Boundary` | — | The element or rect the list is confined to when avoiding collisions. Base UI's clipping ancestors when unset. |
| `collisionAvoidance` | `CollisionAvoidance` | — | What to do when the list would overflow the boundary, set per axis: flip to the other side, shift along it, or stay put. Base UI flips the side and shifts the alignment when unset. |
| `sticky` | `boolean` | `false` | Keep the list on screen after the input scrolls out of view, instead of following it out. |
| `positionMethod` | `"absolute" \| "fixed"` | `"absolute"` | Which CSS `position` the positioner uses. `fixed` escapes overflow-clipping ancestors, e.g. an input inside a sticky header. |
| `anchor` | `Element \| VirtualElement \| RefObject<Element \| null> \| (() => Element \| VirtualElement \| null) \| null` | — | Position against this element instead of the input — an element, a ref, a virtual element, or a function returning one. Geometry only; the input keeps its role and interactions. |
| `disableAnchorTracking` | `boolean` | `false` | Stop tracking layout shifts of the anchor — position once, on open. |

### AutocompleteItem

The ready-made autocomplete option row: grafts Base UI's `Autocomplete.Item` behavior onto the
shared listbox row (autocomplete rows carry no selection marker). Children flow through as the
row's content.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` (required) | `"md" \| "lg" \| "xl" \| "2xl"` | — | Visual size of the row: height and text size. Required. |
