diff --git a/.changeset/fine-dragons-scream.md b/.changeset/fine-dragons-scream.md
new file mode 100644
index 0000000000..b1373e25f3
--- /dev/null
+++ b/.changeset/fine-dragons-scream.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Added `SearchAutocomplete` and `SearchAutocompleteItem` components for building accessible search-with-results patterns. Built on React Aria's Autocomplete with keyboard navigation and screen reader support. Designed for async/external search results with a configurable popover width.
+
+**Affected components:** SearchAutocomplete, SearchAutocompleteItem
diff --git a/docs-ui/src/app/components/search-autocomplete/components.tsx b/docs-ui/src/app/components/search-autocomplete/components.tsx
new file mode 100644
index 0000000000..4052ee0e3f
--- /dev/null
+++ b/docs-ui/src/app/components/search-autocomplete/components.tsx
@@ -0,0 +1,156 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ SearchAutocomplete,
+ SearchAutocompleteItem,
+} from '../../../../../packages/ui/src/components/SearchAutocomplete/SearchAutocomplete';
+import { PluginHeader } from '../../../../../packages/ui/src/components/PluginHeader/PluginHeader';
+import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex';
+import { Text } from '../../../../../packages/ui/src/components/Text/Text';
+import { MemoryRouter } from 'react-router-dom';
+
+const fruits = [
+ { id: 'apple', name: 'Apple', description: 'A round fruit' },
+ { id: 'banana', name: 'Banana', description: 'A yellow curved fruit' },
+ { id: 'blueberry', name: 'Blueberry', description: 'A small blue berry' },
+ { id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' },
+ { id: 'grape', name: 'Grape', description: 'Grows in clusters on vines' },
+ { id: 'lemon', name: 'Lemon', description: 'A sour yellow citrus fruit' },
+ { id: 'mango', name: 'Mango', description: 'A tropical stone fruit' },
+ { id: 'orange', name: 'Orange', description: 'A citrus fruit' },
+ { id: 'peach', name: 'Peach', description: 'A fuzzy stone fruit' },
+ {
+ id: 'strawberry',
+ name: 'Strawberry',
+ description: 'A red fruit with seeds on its surface',
+ },
+];
+
+export const WithItems = () => {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ );
+};
+
+export const WithRichContent = () => {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ {fruit.description}
+
+
+ ))}
+
+ );
+};
+
+export const InHeader = () => {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ >
+ }
+ />
+
+ );
+};
+
+export const WithSelection = () => {
+ const [inputValue, setInputValue] = useState('');
+ const [selected, setSelected] = useState(null);
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+
+ {filtered.map(fruit => (
+ {
+ setSelected(fruit.name);
+ setInputValue('');
+ }}
+ >
+ {fruit.name}
+
+ ))}
+
+ Last selected: {selected ?? 'none'}
+
+ );
+};
diff --git a/docs-ui/src/app/components/search-autocomplete/page.mdx b/docs-ui/src/app/components/search-autocomplete/page.mdx
new file mode 100644
index 0000000000..ecb58f3377
--- /dev/null
+++ b/docs-ui/src/app/components/search-autocomplete/page.mdx
@@ -0,0 +1,95 @@
+import { PropsTable } from '@/components/PropsTable';
+import { Snippet } from '@/components/Snippet';
+import { ReactAriaLink } from '@/components/ReactAriaLink';
+import {
+ searchAutocompletePropDefs,
+ searchAutocompleteItemPropDefs,
+} from './props-definition';
+import {
+ usage,
+ defaultSnippet,
+ withRichContent,
+ inHeader,
+ withSelection,
+} from './snippets';
+import {
+ WithItems,
+ WithRichContent,
+ InHeader,
+ WithSelection,
+} from './components';
+import { PageTitle } from '@/components/PageTitle';
+import { Theming } from '@/components/Theming';
+import { ChangelogComponent } from '@/components/ChangelogComponent';
+import { CodeBlock } from '@/components/CodeBlock';
+import {
+ SearchAutocompleteDefinition,
+ SearchAutocompleteItemDefinition,
+} from '../../../utils/definitions';
+
+export const reactAriaUrls = {
+ autocomplete: 'https://react-aria.adobe.com/Autocomplete',
+};
+
+
+
+} code={defaultSnippet} />
+
+## Usage
+
+
+
+## API reference
+
+### SearchAutocomplete
+
+
+
+
+
+### SearchAutocompleteItem
+
+Individual result item within the autocomplete list.
+
+
+
+## Examples
+
+### With rich content
+
+Here's a view when items include a title and description.
+
+}
+ code={withRichContent}
+/>
+
+### In header
+
+Use `popoverWidth` to control the dropdown width when placed in constrained layouts like `PluginHeader`.
+
+} code={inHeader} />
+
+### With selection
+
+Use `onAction` on items to handle selection and reset the input.
+
+}
+ code={withSelection}
+/>
+
+
+
+
+
+
diff --git a/docs-ui/src/app/components/search-autocomplete/props-definition.tsx b/docs-ui/src/app/components/search-autocomplete/props-definition.tsx
new file mode 100644
index 0000000000..7ddaea61e2
--- /dev/null
+++ b/docs-ui/src/app/components/search-autocomplete/props-definition.tsx
@@ -0,0 +1,97 @@
+import {
+ classNamePropDefs,
+ stylePropDefs,
+ type PropDef,
+} from '@/utils/propDefs';
+import { Chip } from '@/components/Chip';
+
+export const searchAutocompletePropDefs: Record = {
+ 'aria-label': {
+ type: 'string',
+ description: 'Accessible label for the search input.',
+ },
+ 'aria-labelledby': {
+ type: 'string',
+ description: 'ID of the element that labels the search input.',
+ },
+ inputValue: {
+ type: 'string',
+ description: 'The current input value (controlled).',
+ },
+ onInputChange: {
+ type: 'enum',
+ values: ['(value: string) => void'],
+ description: 'Handler called when the input value changes.',
+ },
+ placeholder: {
+ type: 'string',
+ default: 'Search',
+ description:
+ 'Placeholder text shown when the input is empty. Also used as the accessible label when neither aria-label nor aria-labelledby is provided.',
+ },
+ size: {
+ type: 'enum',
+ values: ['small', 'medium'],
+ default: 'small',
+ responsive: true,
+ description: (
+ <>
+ Visual size of the input. Use small for inline or dense
+ layouts, medium for standalone fields.
+ >
+ ),
+ },
+ isLoading: {
+ type: 'boolean',
+ default: 'false',
+ description:
+ 'Whether results are currently loading. Dims existing results and announces loading state to screen readers.',
+ },
+ popoverWidth: {
+ type: 'string',
+ description:
+ 'Width of the results popover. Accepts any CSS width value. Matches the input width when not set.',
+ },
+ popoverPlacement: {
+ type: 'enum',
+ values: ['bottom start', 'bottom end', 'top start', 'top end'],
+ default: 'bottom start',
+ description: 'Placement of the results popover relative to the input.',
+ },
+ defaultOpen: {
+ type: 'boolean',
+ default: 'false',
+ description: 'Whether the results popover is open by default.',
+ },
+ children: {
+ type: 'enum',
+ values: ['ReactNode'],
+ description: 'The result items to render inside the autocomplete.',
+ },
+ ...classNamePropDefs,
+ ...stylePropDefs,
+};
+
+export const searchAutocompleteItemPropDefs: Record = {
+ id: {
+ type: 'string',
+ description: 'Unique identifier for the item.',
+ },
+ textValue: {
+ type: 'string',
+ description:
+ 'Plain text value used for keyboard navigation and accessibility.',
+ },
+ onAction: {
+ type: 'enum',
+ values: ['() => void'],
+ description: 'Handler called when the item is selected.',
+ },
+ children: {
+ type: 'enum',
+ values: ['ReactNode'],
+ required: true,
+ description: 'Content to render inside the item.',
+ },
+ ...classNamePropDefs,
+};
diff --git a/docs-ui/src/app/components/search-autocomplete/snippets.ts b/docs-ui/src/app/components/search-autocomplete/snippets.ts
new file mode 100644
index 0000000000..fe799ff828
--- /dev/null
+++ b/docs-ui/src/app/components/search-autocomplete/snippets.ts
@@ -0,0 +1,139 @@
+export const usage = `import { SearchAutocomplete, SearchAutocompleteItem } from '@backstage/ui';
+
+
+ {items.map(item => (
+
+ {item.name}
+
+ ))}
+`;
+
+export const defaultSnippet = `const fruits = [
+ { id: 'apple', name: 'Apple' },
+ { id: 'banana', name: 'Banana' },
+ { id: 'cherry', name: 'Cherry' },
+ { id: 'grape', name: 'Grape' },
+ { id: 'orange', name: 'Orange' },
+];
+
+function Example() {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ );
+}`;
+
+export const withRichContent = `const fruits = [
+ { id: 'apple', name: 'Apple', description: 'A round fruit' },
+ { id: 'banana', name: 'Banana', description: 'A yellow curved fruit' },
+ { id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' },
+];
+
+function Example() {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ {fruit.description}
+
+
+ ))}
+
+ );
+}`;
+
+export const inHeader = `
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ >
+ }
+/>`;
+
+export const withSelection = `function Example() {
+ const [inputValue, setInputValue] = useState('');
+ const [selected, setSelected] = useState(null);
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+
+ {filtered.map(fruit => (
+ {
+ setSelected(fruit.name);
+ setInputValue('');
+ }}
+ >
+ {fruit.name}
+
+ ))}
+
+ Last selected: {selected ?? 'none'}
+
+ );
+}`;
diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts
index 753473ab64..fc1f841b55 100644
--- a/docs-ui/src/utils/data.ts
+++ b/docs-ui/src/utils/data.ts
@@ -85,6 +85,10 @@ export const components: Page[] = [
title: 'RadioGroup',
slug: 'radio-group',
},
+ {
+ title: 'SearchAutocomplete',
+ slug: 'search-autocomplete',
+ },
{
title: 'SearchField',
slug: 'search-field',
diff --git a/docs-ui/src/utils/types.ts b/docs-ui/src/utils/types.ts
index c2c383796f..670bee3f05 100644
--- a/docs-ui/src/utils/types.ts
+++ b/docs-ui/src/utils/types.ts
@@ -23,6 +23,7 @@ export type Component =
| 'password-field'
| 'radio-group'
| 'scrollarea'
+ | 'search-autocomplete'
| 'searchfield'
| 'select'
| 'skeleton'
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index 09454cecc9..b254b4f773 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -2074,6 +2074,85 @@ export type RowRenderFn = (params: {
index: number;
}) => ReactNode;
+// @public (undocumented)
+export function SearchAutocomplete(
+ props: SearchAutocompleteProps,
+): JSX_2.Element;
+
+// @public
+export const SearchAutocompleteDefinition: {
+ readonly styles: {
+ readonly [key: string]: string;
+ };
+ readonly classNames: {
+ readonly root: 'bui-SearchAutocomplete';
+ readonly searchField: 'bui-SearchAutocompleteSearchField';
+ readonly searchFieldInput: 'bui-SearchAutocompleteInput';
+ readonly searchFieldClear: 'bui-SearchAutocompleteClear';
+ readonly popover: 'bui-SearchAutocompletePopover';
+ readonly inner: 'bui-SearchAutocompleteInner';
+ readonly listBox: 'bui-SearchAutocompleteListBox';
+ readonly loadingState: 'bui-SearchAutocompleteLoadingState';
+ readonly emptyState: 'bui-SearchAutocompleteEmptyState';
+ };
+ readonly propDefs: {
+ readonly 'aria-label': {};
+ readonly 'aria-labelledby': {};
+ readonly size: {
+ readonly dataAttribute: true;
+ readonly default: 'small';
+ };
+ readonly placeholder: {
+ readonly default: 'Search';
+ };
+ readonly inputValue: {};
+ readonly onInputChange: {};
+ readonly popoverWidth: {};
+ readonly popoverPlacement: {};
+ readonly children: {};
+ readonly isLoading: {};
+ readonly defaultOpen: {};
+ readonly className: {};
+ readonly style: {};
+ };
+};
+
+// @public (undocumented)
+export function SearchAutocompleteItem(
+ props: SearchAutocompleteItemProps,
+): JSX_2.Element;
+
+// @public (undocumented)
+export type SearchAutocompleteItemOwnProps = {
+ children: ReactNode;
+ className?: string;
+};
+
+// @public (undocumented)
+export interface SearchAutocompleteItemProps
+ extends SearchAutocompleteItemOwnProps,
+ Omit {}
+
+// @public (undocumented)
+export type SearchAutocompleteOwnProps = {
+ inputValue?: string;
+ onInputChange?: (value: string) => void;
+ size?: 'small' | 'medium' | Partial>;
+ 'aria-label'?: string;
+ 'aria-labelledby'?: string;
+ placeholder?: string;
+ popoverWidth?: string;
+ popoverPlacement?: PopoverProps_2['placement'];
+ children?: ReactNode;
+ isLoading?: boolean;
+ defaultOpen?: boolean;
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+// @public (undocumented)
+export interface SearchAutocompleteProps extends SearchAutocompleteOwnProps {}
+
// @public (undocumented)
export const SearchField: ForwardRefExoticComponent<
SearchFieldProps & RefAttributes
diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css
new file mode 100644
index 0000000000..ca6de73402
--- /dev/null
+++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@layer tokens, base, components, utilities;
+
+@layer components {
+ .bui-SearchAutocomplete {
+ display: flex;
+ align-items: center;
+ border-radius: var(--bui-radius-2);
+ background-color: var(--bui-bg-neutral-1);
+ transition: box-shadow 0.2s ease-in-out;
+ font-family: var(--bui-font-regular);
+
+ &:has([data-focused]) {
+ box-shadow: inset 0 0 0 1px var(--bui-ring);
+ }
+
+ &[data-size='small'] {
+ --search-autocomplete-item-height: 2rem;
+ height: 2rem;
+ }
+
+ &[data-size='medium'] {
+ --search-autocomplete-item-height: 2.5rem;
+ height: 2.5rem;
+ }
+ }
+
+ .bui-SearchAutocompleteSearchField {
+ display: flex;
+ align-items: center;
+ flex: 1;
+
+ & > div {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ }
+
+ & svg {
+ flex: 0 0 auto;
+ color: var(--bui-fg-primary);
+ margin-inline-start: var(--bui-space-2);
+
+ .bui-SearchAutocomplete[data-size='small'] & {
+ width: 1rem;
+ height: 1rem;
+ }
+
+ .bui-SearchAutocomplete[data-size='medium'] & {
+ width: 1.25rem;
+ height: 1.25rem;
+ }
+ }
+ }
+
+ .bui-SearchAutocompleteInput {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ padding: 0;
+ padding-inline: var(--bui-space-2);
+ border: none;
+ background-color: transparent;
+ font-size: var(--bui-font-size-3);
+ font-family: var(--bui-font-regular);
+ font-weight: var(--bui-font-weight-regular);
+ color: var(--bui-fg-primary);
+ width: 100%;
+ height: 100%;
+ outline: none;
+
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-decoration {
+ -webkit-appearance: none;
+ }
+
+ &::placeholder {
+ color: var(--bui-fg-secondary);
+ }
+ }
+
+ .bui-SearchAutocompleteClear {
+ flex: 0 0 auto;
+ display: grid;
+ place-content: center;
+ background-color: transparent;
+ border: none;
+ padding: 0;
+ margin: 0;
+ cursor: pointer;
+ color: var(--bui-fg-secondary);
+ transition: color 0.2s ease-in-out;
+ width: var(--search-autocomplete-item-height);
+ height: var(--search-autocomplete-item-height);
+
+ &:hover {
+ color: var(--bui-fg-primary);
+ }
+
+ & svg {
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+
+ .bui-SearchAutocompletePopover {
+ --menu-border-radius: var(--bui-radius-2);
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--bui-shadow);
+ border: 1px solid var(--bui-border-1);
+ border-radius: var(--menu-border-radius);
+ background: var(--bui-bg-app);
+ color: var(--bui-fg-primary);
+ outline: none;
+ transition: transform 200ms, opacity 200ms;
+ min-height: 0;
+ overflow: hidden;
+
+ &[data-entering],
+ &[data-exiting] {
+ transform: var(--origin);
+ opacity: 0;
+ }
+
+ &[data-placement='top'] {
+ --origin: translateY(8px);
+ }
+
+ &[data-placement='bottom'] {
+ --origin: translateY(-8px);
+ }
+ }
+
+ .bui-SearchAutocompleteInner {
+ border-radius: var(--menu-border-radius);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .bui-SearchAutocompleteListBox {
+ max-height: inherit;
+ box-sizing: border-box;
+ overflow: auto;
+ min-width: 150px;
+ outline: none;
+ padding-block: var(--bui-space-1);
+
+ &[data-stale] {
+ opacity: 0.6;
+ }
+ }
+
+ .bui-SearchAutocompleteItem {
+ padding-inline: var(--bui-space-1);
+ display: block;
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ &[data-focus-visible] {
+ outline: 2px solid var(--bui-ring);
+ outline-offset: -2px;
+ }
+
+ &[data-focused] .bui-SearchAutocompleteItemContent,
+ &[data-hovered] .bui-SearchAutocompleteItemContent {
+ background: var(--bui-bg-neutral-2);
+ color: var(--bui-fg-primary);
+ }
+ }
+
+ .bui-SearchAutocompleteItemContent {
+ display: flex;
+ flex-direction: column;
+ gap: var(--bui-space-1);
+ min-height: 2rem;
+ padding-inline: var(--bui-space-2);
+ padding-block: var(--bui-space-2);
+ border-radius: var(--bui-radius-2);
+ outline: none;
+ cursor: default;
+ color: var(--bui-fg-primary);
+ font-size: var(--bui-font-size-3);
+ }
+
+ .bui-SearchAutocompleteLoadingState,
+ .bui-SearchAutocompleteEmptyState {
+ padding-inline: var(--bui-space-3);
+ padding-block: var(--bui-space-2);
+ color: var(--bui-fg-secondary);
+ font-size: var(--bui-font-size-3);
+ font-family: var(--bui-font-regular);
+ font-weight: var(--bui-font-weight-regular);
+ }
+}
diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx
new file mode 100644
index 0000000000..1a044a9da8
--- /dev/null
+++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx
@@ -0,0 +1,445 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/* eslint-disable no-restricted-syntax */
+
+import preview from '../../../../../.storybook/preview';
+import { useState, useEffect } from 'react';
+import {
+ SearchAutocomplete,
+ SearchAutocompleteItem,
+} from './SearchAutocomplete';
+import { PluginHeader } from '../PluginHeader';
+import { Flex } from '../Flex';
+import { Text } from '../Text';
+import { ButtonIcon } from '../ButtonIcon';
+import { RiCactusLine } from '@remixicon/react';
+import { MemoryRouter } from 'react-router-dom';
+
+const meta = preview.meta({
+ title: 'Backstage UI/SearchAutocomplete',
+ component: SearchAutocomplete,
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['small', 'medium'],
+ },
+ placeholder: {
+ control: 'text',
+ },
+ popoverWidth: {
+ control: 'text',
+ },
+ },
+});
+
+const fruits = [
+ { id: 'apple', name: 'Apple', description: 'A round fruit' },
+ { id: 'banana', name: 'Banana', description: 'A yellow curved fruit' },
+ { id: 'blueberry', name: 'Blueberry', description: 'A small blue berry' },
+ { id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' },
+ { id: 'grape', name: 'Grape', description: 'Grows in clusters on vines' },
+ { id: 'lemon', name: 'Lemon', description: 'A sour yellow citrus fruit' },
+ { id: 'mango', name: 'Mango', description: 'A tropical stone fruit' },
+ { id: 'orange', name: 'Orange', description: 'A citrus fruit' },
+ { id: 'peach', name: 'Peach', description: 'A fuzzy stone fruit' },
+ {
+ id: 'strawberry',
+ name: 'Strawberry',
+ description: 'A red fruit with seeds on its surface',
+ },
+];
+
+export const WithItems = meta.story({
+ args: {
+ 'aria-label': 'Search fruits',
+ placeholder: 'Search fruits...',
+ style: { maxWidth: '300px' },
+ },
+ render: function Render(args) {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ );
+ },
+});
+
+export const WithRichContent = meta.story({
+ args: {
+ 'aria-label': 'Search fruits',
+ placeholder: 'Search fruits...',
+ style: { maxWidth: '300px' },
+ },
+ render: function Render(args) {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ {fruit.description}
+
+
+ ))}
+
+ );
+ },
+});
+
+export const WithAsyncItems = meta.story({
+ args: {
+ 'aria-label': 'Search fruits',
+ placeholder: 'Search fruits...',
+ style: { maxWidth: '300px' },
+ },
+ render: function Render(args) {
+ const [inputValue, setInputValue] = useState('');
+ const [items, setItems] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ if (!inputValue) {
+ setItems([]);
+ return undefined;
+ }
+
+ setIsLoading(true);
+ const timeout = setTimeout(() => {
+ setItems(
+ fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ ),
+ );
+ setIsLoading(false);
+ }, 2000);
+
+ return () => clearTimeout(timeout);
+ }, [inputValue]);
+
+ return (
+
+ {items.map(item => (
+
+ {item.name}
+
+ {item.description}
+
+
+ ))}
+
+ );
+ },
+});
+
+export const WithSelection = meta.story({
+ args: {
+ 'aria-label': 'Search fruits',
+ placeholder: 'Search fruits...',
+ style: { maxWidth: '300px' },
+ },
+ render: function Render(args) {
+ const [inputValue, setInputValue] = useState('');
+ const [selected, setSelected] = useState(null);
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+
+ {filtered.map(fruit => (
+ {
+ setSelected(fruit.name);
+ setInputValue('');
+ }}
+ >
+ {fruit.name}
+
+ ))}
+
+ Last selected: {selected ?? 'none'}
+
+ );
+ },
+});
+
+export const Sizes = meta.story({
+ args: {
+ 'aria-label': 'Search',
+ placeholder: 'Search...',
+ },
+ render: function Render(args) {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+
+ );
+ },
+});
+
+export const InHeader = meta.story({
+ args: {
+ 'aria-label': 'Search',
+ placeholder: 'Search...',
+ },
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+ render: function Render(args) {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ }
+ size="small"
+ variant="secondary"
+ />
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ }
+ size="small"
+ variant="secondary"
+ />
+ >
+ }
+ />
+ );
+ },
+});
+
+export const WithPopoverWidth = meta.story({
+ args: {
+ 'aria-label': 'Search fruits',
+ placeholder: 'Search fruits...',
+ popoverWidth: '500px',
+ style: { maxWidth: '300px' },
+ },
+ render: function Render(args) {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ {fruit.description}
+
+
+ ))}
+
+ );
+ },
+});
+
+export const OpenByDefault = meta.story({
+ args: {
+ 'aria-label': 'Search fruits',
+ placeholder: 'Search fruits...',
+ defaultOpen: true,
+ style: { maxWidth: '300px' },
+ },
+ render: function Render(args) {
+ const [inputValue, setInputValue] = useState('ap');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ {fruit.description}
+
+
+ ))}
+
+ );
+ },
+});
+
+export const OpenWithLoading = meta.story({
+ args: {
+ 'aria-label': 'Search fruits',
+ placeholder: 'Search fruits...',
+ defaultOpen: true,
+ isLoading: true,
+ inputValue: 'ap',
+ style: { maxWidth: '300px' },
+ },
+ render: function Render(args) {
+ return (
+ {}}>
+ {fruits.slice(0, 3).map(fruit => (
+
+ {fruit.name}
+
+ {fruit.description}
+
+
+ ))}
+
+ );
+ },
+});
diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx
new file mode 100644
index 0000000000..d8c4640e26
--- /dev/null
+++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx
@@ -0,0 +1,204 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Children, useRef } from 'react';
+import { useInteractOutside } from '@react-aria/interactions';
+import {
+ Autocomplete,
+ SearchField as RASearchField,
+ Input,
+ Button as RAButton,
+ Popover as RAPopover,
+ ListBox,
+ ListBoxItem,
+ OverlayTriggerStateContext,
+} from 'react-aria-components';
+import { useOverlayTriggerState } from '@react-stately/overlays';
+import { RiSearch2Line, RiCloseCircleLine } from '@remixicon/react';
+import { useDefinition } from '../../hooks/useDefinition';
+import {
+ SearchAutocompleteDefinition,
+ SearchAutocompleteItemDefinition,
+} from './definition';
+import { Box } from '../Box';
+import { BgReset } from '../../hooks/useBg';
+import { VisuallyHidden } from '../VisuallyHidden';
+
+import type {
+ SearchAutocompleteProps,
+ SearchAutocompleteItemProps,
+} from './types';
+
+const SearchAutocompleteEmptyState = () => {
+ const { ownProps } = useDefinition(SearchAutocompleteDefinition, {});
+ return No results found.
;
+};
+
+/** @public */
+export function SearchAutocomplete(props: SearchAutocompleteProps) {
+ const { ownProps, dataAttributes } = useDefinition(
+ SearchAutocompleteDefinition,
+ props,
+ );
+ const {
+ classes,
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledBy,
+ inputValue,
+ onInputChange,
+ placeholder,
+ popoverWidth,
+ popoverPlacement = 'bottom start',
+ children,
+ isLoading,
+ defaultOpen,
+ style,
+ } = ownProps;
+
+ const triggerRef = useRef(null);
+ const popoverRef = useRef(null);
+ const hasValue = !!inputValue;
+ const hasChildren = Children.count(children) > 0;
+
+ let liveMessage = '';
+ if (isLoading) {
+ liveMessage = 'Searching';
+ } else if (hasValue && !hasChildren) {
+ liveMessage = 'No results found';
+ }
+ const overlayState = useOverlayTriggerState({ defaultOpen });
+
+ // Close on interact outside — same pattern as ComboBox.
+ // isNonModal disables useOverlay's built-in useInteractOutside,
+ // so we add our own that filters out clicks on the trigger.
+ useInteractOutside({
+ ref: popoverRef,
+ onInteractOutside: e => {
+ const target = e.target as Element;
+ if (triggerRef.current?.contains(target)) {
+ return;
+ }
+ overlayState.close();
+ },
+ isDisabled: !overlayState.isOpen,
+ });
+
+ return (
+
+ {
+ onInputChange?.(value);
+ if (value) {
+ overlayState.open();
+ } else {
+ overlayState.close();
+ }
+ }}
+ >
+ {
+ if (e.key === 'Enter' && !overlayState.isOpen && hasValue) {
+ e.preventDefault();
+ overlayState.open();
+ }
+ }}
+ >
+
+
+ {/* isNonModal keeps the page interactive while the popover is open,
+ required for virtual focus (aria-activedescendant) to work correctly */}
+
+
+
+
+ isLoading ? (
+ Searching...
+ ) : (
+
+ )
+ }
+ onAction={() => {
+ overlayState.close();
+ }}
+ >
+ {children}
+
+
+
+
+
+ {liveMessage}
+
+
+
+ );
+}
+
+/** @public */
+export function SearchAutocompleteItem(props: SearchAutocompleteItemProps) {
+ const { ownProps, restProps } = useDefinition(
+ SearchAutocompleteItemDefinition,
+ props,
+ );
+ const { classes, children } = ownProps;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/components/SearchAutocomplete/definition.ts b/packages/ui/src/components/SearchAutocomplete/definition.ts
new file mode 100644
index 0000000000..4a2e47881d
--- /dev/null
+++ b/packages/ui/src/components/SearchAutocomplete/definition.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { defineComponent } from '../../hooks/useDefinition';
+import type {
+ SearchAutocompleteOwnProps,
+ SearchAutocompleteItemOwnProps,
+} from './types';
+import styles from './SearchAutocomplete.module.css';
+
+/**
+ * Component definition for SearchAutocomplete.
+ * @public
+ */
+export const SearchAutocompleteDefinition =
+ defineComponent()({
+ styles,
+ classNames: {
+ root: 'bui-SearchAutocomplete',
+ searchField: 'bui-SearchAutocompleteSearchField',
+ searchFieldInput: 'bui-SearchAutocompleteInput',
+ searchFieldClear: 'bui-SearchAutocompleteClear',
+ popover: 'bui-SearchAutocompletePopover',
+ inner: 'bui-SearchAutocompleteInner',
+ listBox: 'bui-SearchAutocompleteListBox',
+ loadingState: 'bui-SearchAutocompleteLoadingState',
+ emptyState: 'bui-SearchAutocompleteEmptyState',
+ },
+ propDefs: {
+ 'aria-label': {},
+ 'aria-labelledby': {},
+ size: { dataAttribute: true, default: 'small' },
+ placeholder: { default: 'Search' },
+ inputValue: {},
+ onInputChange: {},
+ popoverWidth: {},
+ popoverPlacement: {},
+ children: {},
+ isLoading: {},
+ defaultOpen: {},
+ className: {},
+ style: {},
+ },
+ });
+
+/** @internal */
+export const SearchAutocompleteItemDefinition =
+ defineComponent()({
+ styles,
+ classNames: {
+ root: 'bui-SearchAutocompleteItem',
+ itemContent: 'bui-SearchAutocompleteItemContent',
+ },
+ propDefs: {
+ children: {},
+ className: {},
+ },
+ });
diff --git a/packages/ui/src/components/SearchAutocomplete/index.ts b/packages/ui/src/components/SearchAutocomplete/index.ts
new file mode 100644
index 0000000000..8e15383008
--- /dev/null
+++ b/packages/ui/src/components/SearchAutocomplete/index.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export {
+ SearchAutocomplete,
+ SearchAutocompleteItem,
+} from './SearchAutocomplete';
+export { SearchAutocompleteDefinition } from './definition';
+export type {
+ SearchAutocompleteProps,
+ SearchAutocompleteItemProps,
+ SearchAutocompleteOwnProps,
+ SearchAutocompleteItemOwnProps,
+} from './types';
diff --git a/packages/ui/src/components/SearchAutocomplete/types.ts b/packages/ui/src/components/SearchAutocomplete/types.ts
new file mode 100644
index 0000000000..a5920d370c
--- /dev/null
+++ b/packages/ui/src/components/SearchAutocomplete/types.ts
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type {
+ ListBoxItemProps as AriaListBoxItemProps,
+ PopoverProps as AriaPopoverProps,
+} from 'react-aria-components';
+import type { ReactNode } from 'react';
+import type { Breakpoint } from '../../types';
+
+/** @public */
+export type SearchAutocompleteOwnProps = {
+ /**
+ * The current value of the search input (controlled).
+ */
+ inputValue?: string;
+
+ /**
+ * Handler called when the search input value changes.
+ */
+ onInputChange?: (value: string) => void;
+
+ /**
+ * The size of the search input.
+ * @defaultValue 'small'
+ */
+ size?: 'small' | 'medium' | Partial>;
+
+ /**
+ * Accessible label for the search input.
+ */
+ 'aria-label'?: string;
+
+ /**
+ * ID of the element that labels the search input.
+ */
+ 'aria-labelledby'?: string;
+
+ /**
+ * The placeholder text for the search input.
+ */
+ placeholder?: string;
+
+ /**
+ * Width of the results popover. Accepts any CSS width value.
+ * When not set, the popover matches the input width.
+ */
+ popoverWidth?: string;
+
+ /**
+ * Placement of the results popover relative to the input.
+ * @defaultValue 'bottom start'
+ */
+ popoverPlacement?: AriaPopoverProps['placement'];
+
+ /**
+ * The result items to render inside the autocomplete.
+ */
+ children?: ReactNode;
+
+ /**
+ * Whether results are currently loading.
+ * When true, displays a loading indicator and announces the loading state to screen readers.
+ */
+ isLoading?: boolean;
+
+ /**
+ * Whether the results popover is open by default.
+ */
+ defaultOpen?: boolean;
+
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+/** @public */
+export interface SearchAutocompleteProps extends SearchAutocompleteOwnProps {}
+
+/** @public */
+export type SearchAutocompleteItemOwnProps = {
+ children: ReactNode;
+ className?: string;
+};
+
+/** @public */
+export interface SearchAutocompleteItemProps
+ extends SearchAutocompleteItemOwnProps,
+ Omit {}
diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts
index a07fff7462..79c5a8b884 100644
--- a/packages/ui/src/definitions.ts
+++ b/packages/ui/src/definitions.ts
@@ -52,6 +52,10 @@ export { MenuDefinition } from './components/Menu/definition';
export { PasswordFieldDefinition } from './components/PasswordField/definition';
export { PopoverDefinition } from './components/Popover/definition';
export { RadioGroupDefinition } from './components/RadioGroup/definition';
+export {
+ SearchAutocompleteDefinition,
+ SearchAutocompleteItemDefinition,
+} from './components/SearchAutocomplete/definition';
export { SearchFieldDefinition } from './components/SearchField/definition';
export { SelectDefinition } from './components/Select/definition';
export { SkeletonDefinition } from './components/Skeleton/definition';
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 290ae2a80f..2bf478f5fb 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -51,6 +51,7 @@ export * from './components/PasswordField';
export * from './components/Tooltip';
export * from './components/Menu';
export * from './components/Popover';
+export * from './components/SearchAutocomplete';
export * from './components/SearchField';
export * from './components/Link';
export * from './components/Select';