Ariakit
/

Radix Select with Combobox

Rendering a searchable Radix UI Select component with a text field that enables typeahead & autocomplete features using the primitive Ariakit Combobox components.

import {
} from "@ariakit/react";
import * as RadixSelect from "@radix-ui/react-select";
import { matchSorter } from "match-sorter";
import {
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { CheckIcon, ChevronUpDownIcon, SearchIcon } from "./icons.tsx";
import { languages } from "./languages.ts";
import "./style.css";
function useRadixSelectOpenState() {
const [open, setOpen] = useState(false);
const isWindowResizingRef = useRef(false);
useEffect(() => {
let resetTimer = 0;
const onResize = () => {
isWindowResizingRef.current = true;
// Browsers may run microtasks between event listeners, so clear the
// marker in the next task, after Radix handles this event.
window.clearTimeout(resetTimer);
resetTimer = window.setTimeout(() => {
isWindowResizingRef.current = false;
}, 0);
};
// Mark the resize before Radix handles it, but let the event reach Floating
// UI so the popover can reposition.
window.addEventListener("resize", onResize, true);
return () => {
window.removeEventListener("resize", onResize, true);
window.clearTimeout(resetTimer);
};
}, []);
const onOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen && isWindowResizingRef.current) return;
setOpen(nextOpen);
}, []);
return { open, setOpen, onOpenChange };
}
export default function Example() {
const { open, setOpen, onOpenChange } = useRadixSelectOpenState();
const [value, setValue] = useState("");
const [searchValue, setSearchValue] = useState("");
const matches = useMemo(() => {
if (!searchValue) return languages;
const keys = ["label", "value"];
const matches = matchSorter(languages, searchValue, { keys });
// Radix Select does not work if we don't render the selected item, so we
// make sure to include it in the list of matches.
const selectedLanguage = languages.find((lang) => lang.value === value);
if (selectedLanguage && !matches.includes(selectedLanguage)) {
matches.push(selectedLanguage);
}
return matches;
}, [searchValue, value]);
return (
<RadixSelect.Root
value={value}
onValueChange={setValue}
open={open}
onOpenChange={onOpenChange}
>
open={open}
setOpen={setOpen}
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
>
<RadixSelect.Trigger aria-label="Language" className="select">
<RadixSelect.Value placeholder="Select a language" />
<RadixSelect.Icon className="select-icon">
<ChevronUpDownIcon />
</RadixSelect.Icon>
</RadixSelect.Trigger>
<RadixSelect.Content
role="dialog"
aria-label="Languages"
position="popper"
className="popover"
sideOffset={4}
alignOffset={-16}
>
<div className="combobox-wrapper">
<div className="combobox-icon">
<SearchIcon />
</div>
placeholder="Search languages"
className="combobox"
// Radix infers outside focus from captured focus/blur order.
// Ariakit's virtual blur can arrive after focus and close the
// Select, and SelectContent has no outside-interaction escape
// hatch, so disable virtual blur here.
// https://github.com/ariakit/ariakit/pull/3269
onBlurCapture={(event) => {
event.preventDefault();
event.stopPropagation();
}}
/>
</div>
<ComboboxList className="listbox">
{matches.map(({ label, value }) => (
<RadixSelect.Item
key={value}
value={value}
asChild
className="item"
>
<RadixSelect.ItemText>{label}</RadixSelect.ItemText>
<RadixSelect.ItemIndicator className="item-indicator">
<CheckIcon />
</RadixSelect.ItemIndicator>
</RadixSelect.Item>
))}
</RadixSelect.Content>
</RadixSelect.Root>
);
}

Components

Explore the Ariakit components used in this example:

Basic structure

<RadixSelect.Root>
<RadixSelect.Trigger />
<RadixSelect.Content>
<Combobox />
<RadixSelect.Item asChild>
</RadixSelect.Item>
</RadixSelect.Content>
</RadixSelect.Root>

Sharing state between Ariakit and Radix UI

We can share state between Ariakit and Radix UI by passing our own open state to both Radix's Root and Ariakit's ComboboxProvider:

const { open, setOpen, onOpenChange } = useRadixSelectOpenState();
<RadixSelect.Root open={open} onOpenChange={onOpenChange}>
<ComboboxProvider open={open} setOpen={setOpen}>

You can learn more about this Ariakit feature in the guide:

Keeping the Select open when the viewport resizes

Radix Select closes its popup when the window resizes. On mobile browsers, showing the on-screen keyboard may resize the window and immediately close the popup. Since this example uses Radix's position="popper" mode, its Floating UI positioning already reacts to viewport changes. We can keep this controlled Select open while the resize event is being handled and let the popup reposition instead:

function useRadixSelectOpenState() {
const [open, setOpen] = useState(false);
const isWindowResizingRef = useRef(false);
useEffect(() => {
let resetTimer = 0;
const onResize = () => {
isWindowResizingRef.current = true;
window.clearTimeout(resetTimer);
resetTimer = window.setTimeout(() => {
isWindowResizingRef.current = false;
}, 0);
};
window.addEventListener("resize", onResize, true);
return () => {
window.removeEventListener("resize", onResize, true);
window.clearTimeout(resetTimer);
};
}, []);
const onOpenChange = useCallback((nextOpen) => {
if (!nextOpen && isWindowResizingRef.current) return;
setOpen(nextOpen);
}, []);
return { open, setOpen, onOpenChange };
}

The capture listener marks the current resize before Radix handles it without stopping propagation, so Floating UI and other resize listeners still run. The timer clears the marker in the next task because browsers may run microtasks between event listeners.

This intentionally keeps the popup open for every window resize, including virtual keyboards, orientation changes, and ordinary resizing. Browsers don't expose a reliable way to distinguish those causes.

Filtering options

The Ariakit Combobox component doesn't dictate how you filter the items. It focuses solely on the ComboboxItem elements you render. Consequently, you can render items conditionally based on the value state.

We use the setValue callback in combination with React.startTransition to update our search value state without blocking the UI:

const [searchValue, setSearchValue] = useState("");
setValue={(value) => {
React.startTransition(() => {
setSearchValue(value);
});
}}
>

You're free to use any matching algorithm or library to filter the items. In this example, we use match-sorter:

const matches = useMemo(() => {
return matchSorter(languages, searchValue, {
keys: ["label", "value"],
});
}, [languages, searchValue]);

Rendering SelectItem as ComboboxItem

To get the items to function as both a Radix SelectItem and an Ariakit ComboboxItem, we have to combine the two components:

<RadixSelect.Item value="en" asChild>
<RadixSelect.ItemText>English</RadixSelect.ItemText>
</RadixSelect.Item>

More examples

Stay tuned

Join 1,000+ subscribers and receive monthly tips & updates on new Ariakit content.

No spam. Unsubscribe anytime. Read latest issue