Propel

Autocomplete

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

Enter a registry URL with optional tags.

Show code
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 { ChevronsUpDown, X } from "lucide-react";

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={X} />}
            />
          }
          trigger={
            <IconButton
              variant="ghost"
              size="md"
              aria-label="Open container image"
              icon={<Icon icon={ChevronsUpDown} />}
            />
          }
        />
        <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>
  );
}

Installation

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

Usage

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 { ChevronsUpDown, X } from "lucide-react";

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={X} />}
            />
          }
          trigger={
            <IconButton
              variant="ghost"
              size="md"
              aria-label="Open container image"
              icon={<Icon icon={ChevronsUpDown} />}
            />
          }
        />
        <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

Show code
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 { Search, X } from "lucide-react";

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={Search} tint="placeholder" />}
        placeholder="Search images"
        aria-label="Search images"
        clear={
          <IconButton
            variant="ghost"
            size="md"
            aria-label="Clear search"
            icon={<Icon icon={X} />}
          />
        }
      />
      <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.

Show code
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.

Show code
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteInputGroup,
  AutocompleteItem,
  AutocompleteList,
  AutocompleteStatus,
  useFilter,
} from "@makeplane/propel/components/autocomplete";
import { Icon } from "@makeplane/propel/components/icon";
import { Search } from "lucide-react";
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={Search} 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.

Show code
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteInputGroup,
  AutocompleteItem,
  AutocompleteList,
} from "@makeplane/propel/components/autocomplete";
import { Icon } from "@makeplane/propel/components/icon";
import { Search } from "lucide-react";

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={Search} 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.

Show code
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 { ChevronsUpDown } from "lucide-react";

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={ChevronsUpDown} />}
                />
              }
            />
            <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.

PropTypeDefaultDescription
childrenReactNodeThe autocomplete's anatomy — an `AutocompleteInput` and the `AutocompletePopup` of items.
itemsreadonly any[] | readonly Group<any>[] | readonly Value[]The items to be displayed in the list. Can be either a flat array of items or an array of groups with items. Items to display in the autocomplete list.

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).

PropTypeDefaultDescription
size(required)"md" | "lg" | "xl" | "2xl"Visual size of the input row: height, padding, and icon/text sizing. Required.
placeholderstringInput placeholder.
iconReactNodeDecorative leading element at the inline-start, e.g. `<Icon icon={Search} tint="placeholder" />`.
clearReactElement<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.
triggerReactElement<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.

PropTypeDefaultDescription
renderReactElement<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"bottomWhich side of the input the list opens toward.
sideOffsetnumber | OffsetFunction4Distance in px between the input and the list.
align"center" | "start" | "end"startAlignment of the list relative to the input along `side`.
alignOffsetnumber | OffsetFunction0Additional offset in px along the align axis — slides the list along the input's edge without changing `align`.
collisionPaddingPadding5Minimum gap in px between the list and the viewport edge it would otherwise touch.
collisionBoundaryBoundaryThe element or rect the list is confined to when avoiding collisions. Base UI's clipping ancestors when unset.
collisionAvoidanceCollisionAvoidanceWhat 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.
stickybooleanfalseKeep 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.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullPosition 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.
disableAnchorTrackingbooleanfalseStop 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 (`layout="plain"` — autocomplete rows carry no selection marker). Children flow through as the row's content.

PropTypeDefaultDescription
size(required)"md" | "lg" | "xl" | "2xl"Visual size of the row: height and text size. Required.