

<Preview name="GroupedAutocompleteExample" />

## Overview [#overview]

The **Autocomplete** component provides an interactive text input with real-time suggestions. Built on Base UI primitives, it supports filtering, custom rendering, grouping, async data loading, and multiple size variants.

## Usage [#usage]

```tsx
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
} from "@tilt-legal/cubitt-components/primitives";
```

```tsx
<Autocomplete items={tags}>
  <Label htmlFor="tags">Search tags</Label>
  <AutocompleteInput id="tags" placeholder="e.g. feature" />
  <AutocompleteContent>
    <AutocompleteEmpty>No tags found.</AutocompleteEmpty>
    <AutocompleteList>
      {(tag) => (
        <AutocompleteItem key={tag.id} value={tag.value}>
          {tag.value}
        </AutocompleteItem>
      )}
    </AutocompleteList>
  </AutocompleteContent>
</Autocomplete>
```

## Examples [#examples]

### Auto Highlight [#auto-highlight]

Automatically highlights the first matching item as you type.

<Tabs items="['Preview', 'Code']">
  <Tab value="Preview">
    <Preview name="AutoHighlightAutocompleteExample" />
  </Tab>

  <Tab value="Code">
    ```tsx
    import {
      Autocomplete,
      AutocompleteContent,
      AutocompleteEmpty,
      AutocompleteInput,
      AutocompleteItem,
      AutocompleteList,
    } from "@tilt-legal/cubitt-components/primitives";
    import { Label } from "@tilt-legal/cubitt-components/primitives";

    <Autocomplete items={tags} autoHighlight>
      <Label htmlFor="tags">Search tags</Label>
      <AutocompleteInput id="tags" placeholder="e.g. feature" className="mt-2" />
      <AutocompleteContent>
        <AutocompleteEmpty>No tags found.</AutocompleteEmpty>
        <AutocompleteList>
          {(tag) => (
            <AutocompleteItem key={tag.id} value={tag.value}>
              {tag.value}
            </AutocompleteItem>
          )}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>;
    ```
  </Tab>
</Tabs>

### Clear Button [#clear-button]

Add a clear button to quickly reset the input value.

<Tabs items="['Preview', 'Code']">
  <Tab value="Preview">
    <Preview name="ClearButtonAutocompleteExample" />
  </Tab>

  <Tab value="Code">
    ```tsx
    import {
      AutocompleteClear,
      AutocompleteContent,
      AutocompleteEmpty,
      AutocompleteInput,
      AutocompleteItem,
      AutocompleteList,
      AutocompleteWrapper,
    } from "@tilt-legal/cubitt-components/primitives";
    import { useState } from "react";
    import { Autocomplete } from "@tilt-legal/cubitt-components/primitives";
    import { Label } from "@tilt-legal/cubitt-components/primitives";

    const [value, setValue] = useState<string>("");
    const filteredItems = tags.filter((item) =>
      item.value.toLowerCase().includes(value.toLowerCase())
    );

    <Autocomplete
      value={value}
      onValueChange={setValue}
      items={filteredItems}
      itemToStringValue={(item) => item.value}
    >
      <Label className="flex flex-col gap-2">
        Search tags
        <AutocompleteWrapper>
          <AutocompleteInput placeholder="e.g. feature" />
          {value && <AutocompleteClear />}
        </AutocompleteWrapper>
      </Label>
      <AutocompleteContent>
        <AutocompleteEmpty>No tags found.</AutocompleteEmpty>
        <AutocompleteList>
          {(tag) => (
            <AutocompleteItem key={tag.id} value={tag}>
              {tag.value}
            </AutocompleteItem>
          )}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>;
    ```
  </Tab>
</Tabs>

### Grouped Items [#grouped-items]

Organize suggestions into categorized groups with labels.

<Tabs items="['Preview', 'Code']">
  <Tab value="Preview">
    <Preview name="GroupedAutocompleteExample" />
  </Tab>

  <Tab value="Code">
    ```tsx
    import {
      Autocomplete,
      AutocompleteClear,
      AutocompleteCollection,
      AutocompleteContent,
      AutocompleteEmpty,
      AutocompleteGroup,
      AutocompleteGroupLabel,
      AutocompleteInput,
      AutocompleteItem,
      AutocompleteList,
      InputWrapper,
    } from "@tilt-legal/cubitt-components/primitives";
    import { Avatar, AvatarFallback, AvatarImage } from "@tilt-legal/cubitt-components/primitives";
    import { Label } from "@tilt-legal/cubitt-components/primitives";

    const [value, setValue] = React.useState("");
    const [open, setOpen] = React.useState(false);

    <Autocomplete
      items={filteredItems}
      value={value}
      onValueChange={setValue}
      open={open}
      onOpenChange={setOpen}
      itemToStringValue={(item) => item.name}
      filter={null}
    >
      <Label className="flex flex-col gap-2">
        Search users
        <InputWrapper>
          <AutocompleteInput placeholder="e.g. John, Developer, Marketing" />
          {value && <AutocompleteClear />}
        </InputWrapper>
      </Label>

      {open && (
        <AutocompleteContent className="pt-0">
          {filteredItems.length === 0 ? (
            <AutocompleteEmpty>No matching users found.</AutocompleteEmpty>
          ) : (
            <AutocompleteList className="p-0">
              {(group) => (
                <AutocompleteGroup
                  key={group.group}
                  items={group.items}
                  className="py-0"
                >
                  <AutocompleteGroupLabel className="sticky top-0 z-10 bg-background py-3">
                    {group.group}
                  </AutocompleteGroupLabel>
                  <AutocompleteCollection>
                    {(item) => (
                      <AutocompleteItem key={item.id} value={item}>
                        <Avatar className="size-9">
                          <AvatarImage src={item.avatar} alt={item.name} />
                          <AvatarFallback>
                            {item.name
                              .split(" ")
                              .map((n) => n[0])
                              .join("")}
                          </AvatarFallback>
                        </Avatar>
                        <div className="flex-1 min-w-0">
                          <div className="font-medium truncate">{item.name}</div>
                          <div className="text-sm text-muted-foreground truncate">
                            {item.position}
                          </div>
                        </div>
                      </AutocompleteItem>
                    )}
                  </AutocompleteCollection>
                </AutocompleteGroup>
              )}
            </AutocompleteList>
          )}
        </AutocompleteContent>
      )}
    </Autocomplete>;
    ```
  </Tab>
</Tabs>

### Async Search [#async-search]

Load suggestions dynamically from an API with loading states.

<Tabs items="['Preview', 'Code']">
  <Tab value="Preview">
    <Preview name="AsyncSearchAutocompleteExample" />
  </Tab>

  <Tab value="Code">
    ```tsx
    import {
      Autocomplete,
      AutocompleteClear,
      AutocompleteContent,
      AutocompleteInput,
      AutocompleteItem,
      AutocompleteList,
      AutocompleteStatus,
      InputWrapper,
    } from "@tilt-legal/cubitt-components/primitives";
    import { Avatar, AvatarFallback, AvatarImage } from "@tilt-legal/cubitt-components/primitives";
    import { Label } from "@tilt-legal/cubitt-components/primitives";
    import {
      AutocompleteContent,
      AutocompleteEmpty,
      AutocompleteInput,
      AutocompleteItem,
      AutocompleteList,
      AutocompleteValue,
      Label,
    } from "@tilt-legal/cubitt-components/primitives";
    import {
      Loader2 } from "@tilt-legal/cubitt-icons/ui/outline";

    const [searchValue,
      setSearchValue] = React.useState("");
    const [isLoading,
      setIsLoading] = React.useState(false);
    const [searchResults,
      setSearchResults] = React.useState([]);

    React.useEffect(() => {
      if (!searchValue) {
        setSearchResults([]);
        return;
      }

      setIsLoading(true);
      const timeoutId = setTimeout(async () => {
        const results = await fetchDevelopers(searchValue);
        setSearchResults(results);
        setIsLoading(false);
      },
      300);

      return () => clearTimeout(timeoutId);
    },
      [searchValue]);

    <Autocomplete
      items={searchResults}
      value={searchValue}
      onValueChange={setSearchValue}
      itemToStringValue={(item) => item.name}
      filter={null}
    >
      <Label className="flex flex-col gap-2">
        Search developers
        <InputWrapper>
          <AutocompleteInput placeholder="e.g. John Smith,
      React,
      San Francisco" />
          {searchValue && <AutocompleteClear />}
        </InputWrapper>
      </Label>
      {searchValue && (
        <AutocompleteContent>
          <AutocompleteStatus>
            {isLoading ? (
              <div className="flex items-center gap-2">
                <Loader2 className="size-4 animate-spin" />
                Searching...
              </div>
            ) : (
              `${searchResults.length} results found`
            )}
          </AutocompleteStatus>
          <AutocompleteList>
            {(developer) => (
              <AutocompleteItem key={developer.id} value={developer}>
                <Avatar className="size-9">
                  <AvatarImage src={developer.avatar} alt={developer.name} />
                  <AvatarFallback>
                    {developer.name
                      .split(" ")
                      .map((n) => n[0])
                      .join("")}
                  </AvatarFallback>
                </Avatar>
                <div className="flex-1 min-w-0">
                  <div className="font-medium truncate">{developer.name}</div>
                  <div className="text-sm text-muted-foreground truncate">
                    {developer.role} • {developer.location}
                  </div>
                </div>
              </AutocompleteItem>
            )}
          </AutocompleteList>
        </AutocompleteContent>
      )}
    </Autocomplete>;
    ```
  </Tab>
</Tabs>

### Fuzzy Matching [#fuzzy-matching]

Advanced fuzzy search with highlighted matches using `match-sorter`.

<Tabs
  items="['Preview',
'Code']"
>
  <Tab value="Preview">
    <Preview name="FuzzyMatchingAutocompleteExample" />
  </Tab>

  <Tab value="Code">
    ```tsx
    import * as React from "react";
    import { Autocomplete } from "@tilt-legal/cubitt-components/primitives";
    import {
      AutocompleteContent,
      AutocompleteEmpty,
      AutocompleteInput,
      AutocompleteItem,
      AutocompleteList,
      Label,
      useAutocompleteFilter,
    } from "@tilt-legal/cubitt-components/primitives";
    import {
      matchSorter } from "match-sorter";

    export default function FuzzyMatchingExample() {
      return (
        <Autocomplete
          items={fuzzyItems}
          filter={fuzzyFilter}
          itemToStringValue={(item) => item.title}
        >
          <Label>
            Fuzzy search documentation
            <AutocompleteInput placeholder="e.g. React" className="mt-2" />
          </Label>

          <AutocompleteContent sideOffset={4}>
            <AutocompleteEmpty>
              No results found for "<AutocompleteValue />"
            </AutocompleteEmpty>

            <AutocompleteList>
              {(item) => (
                <AutocompleteItem key={item.title} value={item}>
                  <AutocompleteValue>
                    {(value) => (
                      <div className="flex flex-col gap-1">
                        <div className="font-medium">
                          {highlightText(item.title,
      value)}
                        </div>
                        <div className="text-sm text-muted-foreground">
                          {highlightText(item.description,
      value)}
                        </div>
                      </div>
                    )}
                  </AutocompleteValue>
                </AutocompleteItem>
              )}
            </AutocompleteList>
          </AutocompleteContent>
        </Autocomplete>
      );
    }

    function highlightText(text: string,
      query: string): React.ReactNode {
      const trimmed = query.trim();
      if (!trimmed) return text;

      const escaped = trimmed.slice(0,
      100).replace(/[.*+?^${}()|[\]\\]/g,
      "\\$&");
      const regex = new RegExp(`(${escaped})`,
      "gi");

      return text.split(regex).map((part,
      idx) =>
        regex.test(part) ? (
          <mark key={idx} className="bg-yellow-200 dark:bg-yellow-900">
            {part}
          </mark>
        ) : (
          part
        )
      );
    }

    function fuzzyFilter(item: unknown,
      query: string): boolean {
      if (!query) return true;

      const fuzzyItem = item as FuzzyItem;
      const results = matchSorter([fuzzyItem],
      query,
      {
        keys: [
          "title",
      "description",
      "category",
      { key: "title",
      threshold: matchSorter.rankings.CONTAINS },
      { key: "description",
      threshold: matchSorter.rankings.WORD_STARTS_WITH },
      ],
      });

      return results.length > 0;
    }

    interface FuzzyItem {
      title: string;
      description: string;
      category: string;
    }

    const fuzzyItems: FuzzyItem[] = [
      {
        title: "React Hooks Guide",
      description:
          "Learn how to use React Hooks like useState,
      useEffect,
      and custom hooks",
      category: "React",
      },
      // ... more items
    ];
    ```
  </Tab>
</Tabs>

### Virtualized List [#virtualized-list]

Handle large datasets efficiently with `@tanstack/react-virtual`. This example demonstrates virtualization with 10,
000 items.

<Tabs
  items="['Preview',
'Code']"
>
  <Tab value="Preview">
    <Preview name="VirtualizedAutocompleteExample" />
  </Tab>

  <Tab value="Code">
    ```tsx
    import * as React from "react";
    import { Autocomplete } from "@tilt-legal/cubitt-components/primitives";
    import {
      AutocompleteClear,
      AutocompleteContent,
      AutocompleteEmpty,
      AutocompleteInput,
      AutocompleteItem,
      AutocompleteList,
      InputWrapper,
    } from "@tilt-legal/cubitt-components/primitives";
    import {
      useVirtualizer } from "@tanstack/react-virtual";

    export default function VirtualizedExample() {
      const [open,
      setOpen] = React.useState(false);
      const [searchValue,
      setSearchValue] = React.useState("");
      const scrollElementRef = React.useRef<HTMLDivElement | null>(null);

      const { contains } = useAutocompleteFilter({ sensitivity: "base" });

      const filteredItems = React.useMemo(() => {
        if (!searchValue) return virtualItems;
        return virtualItems.filter((item) => contains(item,
      searchValue));
      },
      [contains,
      searchValue]);

      const virtualizer = useVirtualizer({
        enabled: open,
      count: filteredItems.length,
      getScrollElement: () => scrollElementRef.current,
      estimateSize: () => 32,
      overscan: 20,
      paddingStart: 8,
      paddingEnd: 8,
      });

      const handleScrollElementRef = React.useCallback(
        (element: HTMLDivElement) => {
          scrollElementRef.current = element;
          if (element) virtualizer.measure();
        },
      [virtualizer]
      );

      const totalSize = virtualizer.getTotalSize();

      return (
        <Autocomplete
          items={filteredItems}
          open={open}
          onOpenChange={setOpen}
          value={searchValue}
          onValueChange={setSearchValue}
          filter={null}
        >
          <Label>
            Search 10,
      000 items (virtualized)
            <AutocompleteInput className="mt-2" />
          </Label>

          <AutocompleteContent sideOffset={4}>
            <AutocompleteEmpty>No items found.</AutocompleteEmpty>
            <AutocompleteList>
              {filteredItems.length > 0 && (
                <div
                  role="presentation"
                  ref={handleScrollElementRef}
                  className="max-h-[300px] overflow-auto"
                >
                  <div
                    role="presentation"
                    className="relative w-full"
                    style={{ height: `${totalSize}px` }}
                  >
                    {virtualizer.getVirtualItems().map((virtualItem) => {
                      const item = filteredItems[virtualItem.index];
                      if (!item) return null;

                      return (
                        <AutocompleteItem
                          key={virtualItem.key}
                          value={item}
                          className="absolute top-0 left-0 w-full"
                          style={{
                            height: `${virtualItem.size}px`,
      transform: `translateY(${virtualItem.start}px)`,
      }}
                        >
                          {item}
                        </AutocompleteItem>
                      );
                    })}
                  </div>
                </div>
              )}
            </AutocompleteList>
          </AutocompleteContent>
        </Autocomplete>
      );
    }

    const virtualItems = Array.from({ length: 10000 },
      (_,
      i) => {
      const indexLabel = String(i + 1).padStart(4,
      "0");
      return `Item ${indexLabel}`;
    });
    ```
  </Tab>
</Tabs>

### Size Variants [#size-variants]

Three size variants: small,
medium (default),
and large.

<Tabs
  items="['Preview',
'Code']"
>
  <Tab value="Preview">
    <Preview name="SizeVariantsAutocompleteExample" />
  </Tab>

  <Tab value="Code">
    ```tsx
    import { Autocomplete } from "@tilt-legal/cubitt-components/primitives";
    import { Label } from "@tilt-legal/cubitt-components/primitives";

    // Small
    <Autocomplete items={filteredItems}>
      <InputWrapper>
        <AutocompleteInput size="sm" placeholder="e.g. feature" />
        {value && <AutocompleteClear />}
      </InputWrapper>
      <AutocompleteContent>
        <AutocompleteList>
          {(tag) => <AutocompleteItem key={tag.id} value={tag}>{tag.value}</AutocompleteItem>}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>

    // Medium (default)
    <Autocomplete items={filteredItems}>
      <InputWrapper>
        <AutocompleteInput placeholder="e.g. feature" />
        {value && <AutocompleteClear />}
      </InputWrapper>
      <AutocompleteContent>
        <AutocompleteList>
          {(tag) => <AutocompleteItem key={tag.id} value={tag}>{tag.value}</AutocompleteItem>}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>

    // Large
    <Autocomplete items={filteredItems}>
      <InputWrapper>
        <AutocompleteInput size="lg" placeholder="e.g. feature" />
        {value && <AutocompleteClear />}
      </InputWrapper>
      <AutocompleteContent>
        <AutocompleteList>
          {(tag) => <AutocompleteItem key={tag.id} value={tag}>{tag.value}</AutocompleteItem>}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>
    ```
  </Tab>
</Tabs>

## API Reference [#api-reference]

### Autocomplete [#autocomplete]

| Prop                | Type                                            | Description                                                           |
| ------------------- | ----------------------------------------------- | --------------------------------------------------------------------- |
| `items`             | `T[]`                                           | Array of items to display in the autocomplete list. **Required**      |
| `value`             | `string \| number \| string[]`                  | The controlled value of the input.                                    |
| `defaultValue`      | `string \| number \| string[]`                  | The default value when uncontrolled.                                  |
| `onValueChange`     | `(value: string \| number \| string[]) => void` | Callback fired when the input value changes.                          |
| `open`              | `boolean`                                       | The controlled open state of the popup.                               |
| `defaultOpen`       | `boolean`                                       | The default open state when uncontrolled. Defaults to `false`.        |
| `onOpenChange`      | `(open: boolean, eventDetails) => void`         | Callback fired when the open state changes.                           |
| `autoHighlight`     | `boolean`                                       | Automatically highlight the first matching item. Defaults to `false`. |
| `filter`            | `(item: T, value: string) => boolean \| null`   | Custom filter function. Set to `null` to disable filtering.           |
| `itemToStringValue` | `(item: T) => string`                           | Function to convert an item to a string value.                        |
| `name`              | `string`                                        | Identifies the field when a form is submitted.                        |
| `disabled`          | `boolean`                                       | Whether the autocomplete is disabled. Defaults to `false`.            |
| `readOnly`          | `boolean`                                       | Whether the autocomplete is read-only. Defaults to `false`.           |
| `className`         | `string`                                        | Additional CSS classes for the root element.                          |

### AutocompleteInput [#autocompleteinput]

| Prop        | Type                   | Description                            |
| ----------- | ---------------------- | -------------------------------------- |
| `size`      | `"sm" \| "md" \| "lg"` | Size of the input. Defaults to `"md"`. |
| `className` | `string`               | Additional CSS classes for the input.  |

### AutocompleteClear [#autocompleteclear]

Button to clear the input value.

| Prop        | Type        | Description                                  |
| ----------- | ----------- | -------------------------------------------- |
| `children`  | `ReactNode` | Custom icon/content for the clear button.    |
| `className` | `string`    | Additional CSS classes for the clear button. |

### AutocompleteWrapper [#autocompletewrapper]

Container for the input and clear button. Styled like `InputWrapper` and automatically serves as the anchor for popup positioning.

| Prop        | Type     | Description                               |
| ----------- | -------- | ----------------------------------------- |
| `className` | `string` | Additional CSS classes for the container. |

### AutocompleteContent [#autocompletecontent]

Wrapper for the popup content, portal, and positioner.

| Prop           | Type                           | Description                                                 |
| -------------- | ------------------------------ | ----------------------------------------------------------- |
| `align`        | `"start" \| "center" \| "end"` | Alignment of the popup. Defaults to `"start"`.              |
| `side`         | `"top" \| "bottom"`            | Side of the input to display popup. Defaults to `"bottom"`. |
| `sideOffset`   | `number`                       | Offset from the input in pixels. Defaults to `4`.           |
| `alignOffset`  | `number`                       | Offset along the alignment axis. Defaults to `0`.           |
| `showBackdrop` | `boolean`                      | Whether to show a backdrop overlay. Defaults to `false`.    |
| `className`    | `string`                       | Additional CSS classes for the popup.                       |

### AutocompleteList [#autocompletelist]

Container for autocomplete items.

| Prop        | Type                        | Description                          |
| ----------- | --------------------------- | ------------------------------------ |
| `children`  | `(item: T) => ReactElement` | Render function for each item.       |
| `className` | `string`                    | Additional CSS classes for the list. |

### AutocompleteItem [#autocompleteitem]

Individual selectable option.

| Prop        | Type     | Description                          |
| ----------- | -------- | ------------------------------------ |
| `value`     | `T`      | The item value.                      |
| `className` | `string` | Additional CSS classes for the item. |

### AutocompleteGroup [#autocompletegroup]

Groups related items together.

| Prop        | Type     | Description                           |
| ----------- | -------- | ------------------------------------- |
| `items`     | `T[]`    | Array of items in this group.         |
| `className` | `string` | Additional CSS classes for the group. |

### AutocompleteGroupLabel [#autocompletegrouplabel]

Label for a group of items.

| Prop        | Type     | Description                                 |
| ----------- | -------- | ------------------------------------------- |
| `className` | `string` | Additional CSS classes for the group label. |

### AutocompleteCollection [#autocompletecollection]

Nested collection for rendering grouped items.

| Prop       | Type                        | Description                    |
| ---------- | --------------------------- | ------------------------------ |
| `children` | `(item: T) => ReactElement` | Render function for each item. |

### AutocompleteEmpty [#autocompleteempty]

Message displayed when no items match the search.

| Prop        | Type     | Description                                 |
| ----------- | -------- | ------------------------------------------- |
| `className` | `string` | Additional CSS classes for the empty state. |

### AutocompleteStatus [#autocompletestatus]

Status message displayed above the list (e.g., loading, results count).

| Prop        | Type     | Description                            |
| ----------- | -------- | -------------------------------------- |
| `className` | `string` | Additional CSS classes for the status. |
