Siteplane UI

Autocomplete

Suggest completions while allowing free text.

Markdown
"use client";

import { SearchIcon } from "lucide-react";
import {
  Autocomplete,
  AutocompleteEmpty,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
} from "@/siteplane/components/autocomplete";

const projects = [
  "Website redesign",
  "Mobile app launch",
  "Billing migration",
  "Design system",
  "Customer onboarding",
  "Quarterly planning",
];

export default function Example() {
  return (
    <div className="w-full max-w-sm">
      <Autocomplete items={projects}>
        <AutocompleteInput
          aria-label="Search projects"
          placeholder="Search projects…"
          startAddon={<SearchIcon />}
        />
        <AutocompletePopup>
          <AutocompleteEmpty>No matching projects.</AutocompleteEmpty>
          <AutocompleteList>
            {(project: string) => (
              <AutocompleteItem key={project} value={project}>
                {project}
              </AutocompleteItem>
            )}
          </AutocompleteList>
        </AutocompletePopup>
      </Autocomplete>
    </div>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/autocomplete

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/button @siteplane/form @siteplane/input

Field is installed with Input.

Usage

import {
  Autocomplete,
  AutocompleteInput,
  AutocompletePopup,
  AutocompleteEmpty,
  AutocompleteList,
  AutocompleteItem,
} from "@/siteplane/components/autocomplete";

<Autocomplete items={["Design"]}>
  <AutocompleteInput aria-label="Workspace" />
  <AutocompletePopup>
    <AutocompleteEmpty>No matching workspaces.</AutocompleteEmpty>
    <AutocompleteList>
      {(item: string) => (
        <AutocompleteItem key={item} value={item}>
          {item}
        </AutocompleteItem>
      )}
    </AutocompleteList>
  </AutocompletePopup>
</Autocomplete>;

Composition

Autocomplete
├── AutocompleteInput
└── AutocompletePopup
    ├── AutocompleteStatus (optional)
    ├── AutocompleteEmpty
    └── AutocompleteList → AutocompleteItem

Controlled value and clear

The clear button updates the controlled value. Type a non-matching query to see the empty result.

"use client";

import {
  Autocomplete,
  AutocompleteEmpty,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
} from "@/siteplane/components/autocomplete";
import { useState } from "react";

export default function Example() {
  const [value, setValue] = useState("");
  return (
    <div className="w-full max-w-sm">
      <Autocomplete
        items={["Design", "Engineering", "Research"]}
        value={value}
        onValueChange={setValue}
      >
        <AutocompleteInput
          aria-label="Workspace"
          placeholder="Find workspace"
          showClear
        />
        <AutocompletePopup>
          <AutocompleteEmpty>No matching workspaces.</AutocompleteEmpty>
          <AutocompleteList>
            {(item: string) => (
              <AutocompleteItem key={item} value={item}>
                {item}
              </AutocompleteItem>
            )}
          </AutocompleteList>
        </AutocompletePopup>
      </Autocomplete>
    </div>
  );
}

Grouped suggestions

Pass groups as items and render each with AutocompleteGroup, AutocompleteGroupLabel and AutocompleteCollection. itemToStringValue turns an object item into the text that fills the input and drives filtering.

"use client";

import { Fragment } from "react";
import {
  Autocomplete,
  AutocompleteCollection,
  AutocompleteEmpty,
  AutocompleteGroup,
  AutocompleteGroupLabel,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
  AutocompleteSeparator,
} from "@/siteplane/components/autocomplete";

type Tag = { id: string; label: string };

const tagGroups: { value: string; items: Tag[] }[] = [
  {
    value: "Status",
    items: [
      { id: "open", label: "Open" },
      { id: "in-progress", label: "In progress" },
      { id: "blocked", label: "Blocked" },
      { id: "done", label: "Done" },
    ],
  },
  {
    value: "Priority",
    items: [
      { id: "low", label: "Low" },
      { id: "medium", label: "Medium" },
      { id: "high", label: "High" },
      { id: "urgent", label: "Urgent" },
    ],
  },
  {
    value: "Team",
    items: [
      { id: "design", label: "Design" },
      { id: "frontend", label: "Frontend" },
      { id: "backend", label: "Backend" },
      { id: "support", label: "Support" },
    ],
  },
];

export default function Example() {
  return (
    <div className="w-full max-w-sm">
      <Autocomplete
        items={tagGroups}
        itemToStringValue={(tag: Tag) => tag.label}
      >
        <AutocompleteInput
          aria-label="Search tags"
          placeholder="Search tags…"
        />
        <AutocompletePopup>
          <AutocompleteEmpty>No tags found.</AutocompleteEmpty>
          <AutocompleteList>
            {(group: (typeof tagGroups)[number], index: number) => (
              <Fragment key={group.value}>
                <AutocompleteGroup items={group.items}>
                  <AutocompleteGroupLabel>{group.value}</AutocompleteGroupLabel>
                  <AutocompleteCollection>
                    {(tag: Tag) => (
                      <AutocompleteItem key={tag.id} value={tag}>
                        {tag.label}
                      </AutocompleteItem>
                    )}
                  </AutocompleteCollection>
                </AutocompleteGroup>
                {index < tagGroups.length - 1 && <AutocompleteSeparator />}
              </Fragment>
            )}
          </AutocompleteList>
        </AutocompletePopup>
      </Autocomplete>
    </div>
  );
}

Limit results

Set limit to cap how many suggestions the list shows, and use AutocompleteStatus to say how many more match. useAutocompleteFilter returns the matching function the list uses by default, so the count stays in sync.

"use client";

import { useMemo, useState } from "react";
import {
  Autocomplete,
  AutocompleteEmpty,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
  AutocompleteStatus,
  useAutocompleteFilter,
} from "@/siteplane/components/autocomplete";

const limit = 6;

const skills = [
  "Accessibility",
  "Brand design",
  "Copywriting",
  "Customer research",
  "Data analysis",
  "Illustration",
  "Interaction design",
  "Motion design",
  "Product strategy",
  "Prototyping",
  "Usability testing",
  "User interviews",
  "Visual design",
  "Workshop facilitation",
];

export default function Example() {
  const [value, setValue] = useState("");
  const { contains } = useAutocompleteFilter();
  const matchCount = useMemo(
    () => skills.filter((skill) => contains(skill, value)).length,
    [contains, value],
  );
  const moreCount = Math.max(0, matchCount - limit);

  return (
    <div className="w-full max-w-sm">
      <Autocomplete
        items={skills}
        limit={limit}
        onValueChange={setValue}
        value={value}
      >
        <AutocompleteInput
          aria-label="Add a skill"
          placeholder="Add a skill…"
        />
        <AutocompletePopup>
          <AutocompleteEmpty>No matching skills.</AutocompleteEmpty>
          <AutocompleteList>
            {(skill: string) => (
              <AutocompleteItem key={skill} value={skill}>
                {skill}
              </AutocompleteItem>
            )}
          </AutocompleteList>
          {moreCount > 0 && (
            <AutocompleteStatus>
              {moreCount} more — keep typing to narrow the list
            </AutocompleteStatus>
          )}
        </AutocompletePopup>
      </Autocomplete>
    </div>
  );
}

Variants and disabled

variant belongs to the input. Disabled prevents input interaction.

"use client";

import {
  Autocomplete,
  AutocompleteEmpty,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
} from "@/siteplane/components/autocomplete";
import { Field, FieldLabel } from "@/siteplane/components/field";

const workspaces = ["Design", "Engineering", "Research"];

const inputs: {
  disabled?: boolean;
  label: string;
  variant?: "default" | "elevated" | "inset";
}[] = [
  { label: "default", variant: "default" },
  { label: "elevated", variant: "elevated" },
  { label: "inset", variant: "inset" },
  { label: "disabled", disabled: true },
];

export default function Example() {
  return (
    <div className="grid w-full max-w-sm gap-4">
      {inputs.map(({ disabled, label, variant }) => (
        <Field disabled={disabled} key={label}>
          <FieldLabel>Workspace, {label}</FieldLabel>
          <Autocomplete items={workspaces}>
            <AutocompleteInput placeholder="Find workspace" variant={variant} />
            <AutocompletePopup>
              <AutocompleteEmpty>No matching workspaces.</AutocompleteEmpty>
              <AutocompleteList>
                {(item: string) => (
                  <AutocompleteItem key={item} value={item}>
                    {item}
                  </AutocompleteItem>
                )}
              </AutocompleteList>
            </AutocompletePopup>
          </Autocomplete>
        </Field>
      ))}
    </div>
  );
}

Sizes

size belongs to the input. Omit it to inherit the provider's control size.

"use client";

import {
  Autocomplete,
  AutocompleteEmpty,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
} from "@/siteplane/components/autocomplete";
import { Field, FieldLabel } from "@/siteplane/components/field";

const workspaces = ["Design", "Engineering", "Research"];

const sizes = [
  { size: "sm", label: "small" },
  { size: "md", label: "medium" },
  { size: "lg", label: "large" },
  { size: "xl", label: "extra large" },
] as const;

export default function Example() {
  return (
    <div className="grid w-full max-w-sm gap-4">
      {sizes.map(({ size, label }) => (
        <Field key={size}>
          <FieldLabel>Workspace, {label}</FieldLabel>
          <Autocomplete items={workspaces}>
            <AutocompleteInput placeholder="Find workspace" size={size} />
            <AutocompletePopup>
              <AutocompleteEmpty>No matching workspaces.</AutocompleteEmpty>
              <AutocompleteList>
                {(item: string) => (
                  <AutocompleteItem key={item} value={item}>
                    {item}
                  </AutocompleteItem>
                )}
              </AutocompleteList>
            </AutocompletePopup>
          </Autocomplete>
        </Field>
      ))}
    </div>
  );
}

Form integration

Place the autocomplete in a Field with a name; the submitted value is the typed text, which can differ from every suggestion.

"use client";

import {
  Autocomplete,
  AutocompleteEmpty,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
} from "@/siteplane/components/autocomplete";
import { Button } from "@/siteplane/components/button";
import { Field, FieldLabel } from "@/siteplane/components/field";
import { Form } from "@/siteplane/components/form";

const jobTitles = [
  "Product designer",
  "Product manager",
  "Frontend engineer",
  "Backend engineer",
  "Data analyst",
  "Customer success manager",
];

export default function Example() {
  return (
    <Form
      className="grid w-full max-w-sm gap-3"
      onSubmit={(event) => {
        event.preventDefault();
      }}
    >
      <Field name="jobTitle">
        <FieldLabel>Job title</FieldLabel>
        <Autocomplete items={jobTitles}>
          <AutocompleteInput placeholder="e.g. Product designer" />
          <AutocompletePopup>
            <AutocompleteEmpty>No suggestions.</AutocompleteEmpty>
            <AutocompleteList>
              {(title: string) => (
                <AutocompleteItem key={title} value={title}>
                  {title}
                </AutocompleteItem>
              )}
            </AutocompleteList>
          </AutocompletePopup>
        </Autocomplete>
      </Field>
      <Button type="submit">Save</Button>
    </Form>
  );
}

API reference

Component properties
PropTypeDefaultDescription
AutocompleteInput.sizesm | md | lg | xl | numberprovider / lgVisual size, or numeric native input size.
AutocompleteInput.variantdefault | elevated | insetdefaultInput surface.
AutocompleteInput.showTriggerbooleanfalseShow popup toggle.
AutocompleteInput.showClearbooleanfalseShow clear action.
AutocompleteInput.startAddonReactNodeunsetLeading content.
AutocompleteInput.triggerProps / clearPropsTrigger.Props / Clear.PropsunsetConfigure generated action buttons.
AutocompletePopup.side / alignPositioner side / alignmentbottom / startPreferred position.
AutocompletePopup.sideOffset / alignOffsetoffsets4 / unsetGap and alignment correction.
AutocompletePopup.anchorPositioner anchortrigger/inputCustom anchor.
AutocompletePopup.portalPropsPortal.PropsunsetPortal options.
value / defaultValuestringuncontrolledEditable text, including arbitrary text.
onValueChange(value, eventDetails) => voidunsetValue callback.
onOpenChange(open, eventDetails) => voidunsetPopup visibility callback.
AutocompleteInput.unstyledbooleanfalseRemove the input surface.

Autocomplete resets uncontrolled text when its associated native form resets. It does not add selected-item or multiple-selection semantics. useAutocompleteFilter is the foundation filtering hook. Input accepts foundation render and native ref contracts, unlike ComboboxInput’s narrower API.

AutocompleteList renders the filtered collection; Item represents a suggestion; Empty displays no results; Status provides status feedback. AutocompleteGroup, AutocompleteGroupLabel, AutocompleteSeparator and AutocompleteRow structure options. AutocompleteValue renders the current value; AutocompleteCollection supplies foundation collection composition. AutocompleteTrigger and AutocompleteClear are independently exported actions; their default render uses kit Buttons. Popup includes Portal and Positioner; AutocompleteList adds the ScrollArea. AutocompletePrimitive exports the namespace. Remaining root item-to-string/filter, keyboard, validation, disabled, readOnly, form, events and refs follow Base UI Autocomplete. Styled className props are strings. No independent fetch or loading service is supplied.

AutocompleteTrigger and AutocompleteClear accept their corresponding foundation props and default to a ghost Button with field-action profile and icon-xs size. Supply an accessible name when composing either action separately. AutocompleteGroup, AutocompleteGroupLabel, AutocompleteRow, AutocompleteSeparator, AutocompleteValue, and AutocompleteCollection accept their matching foundation part props with kit styling; they add no owned state API.

Read the installed source.

Usage notes

Arrow keys navigate suggestions, Enter accepts and Escape dismisses. Keep a visible label in a production form. For async data, the application owns fetching, stale-result handling and pending/error status; render those states through Status and the surrounding field. See Choosing selection controls.