Propel

Autocomplete

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

Basic

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 magnitude="md" inset={false}>
          Container image
        </FieldLabel>
        <AutocompleteInputGroup
          magnitude="md"
          placeholder="e.g. docker.io/library/node:latest"
          clear={
            <IconButton
              prominence="ghost"
              tone="neutral"
              magnitude="md"
              aria-label="Clear container image"
              icon={<Icon icon={X} />}
            />
          }
          trigger={
            <IconButton
              prominence="ghost"
              tone="neutral"
              magnitude="md"
              aria-label="Open container image"
              icon={<Icon icon={ChevronsUpDown} />}
            />
          }
        />
        <FieldDescription magnitude="md">Enter a registry URL with optional tags.</FieldDescription>
        <AutocompleteContent>
          <AutocompleteEmpty>No matches</AutocompleteEmpty>
          <AutocompleteList>
            {(image: string) => (
              <AutocompleteItem key={image} value={image} magnitude="md">
                {image}
              </AutocompleteItem>
            )}
          </AutocompleteList>
        </AutocompleteContent>
        <FieldError magnitude="md" />
      </Autocomplete>
    </Field>
  );
}
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
        magnitude="md"
        icon={<Icon icon={Search} tint="placeholder" />}
        placeholder="Search images"
        aria-label="Search images"
        clear={
          <IconButton
            prominence="ghost"
            tone="neutral"
            magnitude="md"
            aria-label="Clear search"
            icon={<Icon icon={X} />}
          />
        }
      />
      <AutocompleteContent>
        <AutocompleteEmpty>No matches</AutocompleteEmpty>
        <AutocompleteList>
          {(image: string) => (
            <AutocompleteItem key={image} value={image} magnitude="md">
              {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 magnitude="md" 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} magnitude="md">
                    {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
        magnitude="md"
        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} magnitude="md">
              {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
        magnitude="md"
        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} magnitude="md">
              {image}
            </AutocompleteItem>
          )}
        </AutocompleteList>
      </AutocompleteContent>
    </Autocomplete>
  );
}

Installation

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

Props

Autocomplete

PropTypeRequiredDescription
childrenReactNodeNoThe autocomplete's anatomy — an `AutocompleteInput` and the `AutocompletePopup` of items.

AutocompleteInputGroup

PropTypeRequiredDescription
magnitude"sm" | "md" | "lg" | "xl"YesVisual size of the input row: height, padding, and icon/text sizing. Required.
iconReactNodeNoDecorative leading element at the inline-start, e.g. `<Icon icon={Search} tint="placeholder" />`.
clearReactElement<unknown, string | JSXElementConstructor<any>>NoThe 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>>NoThe 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.

AutocompleteItem

PropTypeRequiredDescription
magnitude"sm" | "md" | "lg"YesVisual size of the row: height and text size. Required.