A typeahead multi-select combobox that allows users to select multiple values with filtering. Selected values are displayed as removable tags. Built on top of ariakit Combobox with full keyboard support and WAI-ARIA Combobox Pattern compliance.
import { MultiSelect } from "@ngrok/mantle/multi-select";
import { matchSorter } from "match-sorter";
import { useMemo, useState, useTransition } from "react";
const fruits = [
"Apple",
"Banana",
"Blueberry",
"Cherry",
"Grapes",
"Kiwi",
"Lemon",
"Mango",
"Orange",
"Peach",
"Pear",
"Pineapple",
"Strawberry",
"Watermelon",
];
function Example() {
const [isPending, startTransition] = useTransition();
const [searchValue, setSearchValue] = useState("");
const matches = useMemo(() => matchSorter(fruits, searchValue), [searchValue]);
return (
<MultiSelect.Root
setOpen={() => {
setSearchValue("");
}}
>
<MultiSelect.Trigger>
<MultiSelect.TagValues />
<MultiSelect.Input
onValueChange={(value) => startTransition(() => setSearchValue(value))}
placeholder="Select fruits..."
/>
</MultiSelect.Trigger>
<MultiSelect.Content aria-busy={isPending}>
{matches.map((value) => (
<MultiSelect.Item key={value} value={value}>
{value}
</MultiSelect.Item>
))}
{matches.length === 0 && <MultiSelect.Empty>No results found</MultiSelect.Empty>}
</MultiSelect.Content>
</MultiSelect.Root>
);
}Use selectedValue and setSelectedValue to control the selected values.
const [selected, setSelected] = useState(["Apple", "Cherry"]);
<MultiSelect.Root
selectedValue={selected}
setOpen={() => {
setSearchValue("");
}}
setSelectedValue={setSelected}
>
<MultiSelect.Trigger>
<MultiSelect.TagValues />
<MultiSelect.Input
onValueChange={(value) => startTransition(() => setSearchValue(value))}
placeholder="Select fruits..."
/>
</MultiSelect.Trigger>
<MultiSelect.Content>
{matches.map((value) => (
<MultiSelect.Item key={value} value={value}>
{value}
</MultiSelect.Item>
))}
</MultiSelect.Content>
</MultiSelect.Root>;Use @tanstack/react-form to manage the selected values when a side panel needs validation and submit handling. MultiSelect.Content will automatically scope its portal to the surrounding sheet so the default modal behavior works without extra configuration.
import { Button } from "@ngrok/mantle/button";
import { Label } from "@ngrok/mantle/label";
import { MultiSelect } from "@ngrok/mantle/multi-select";
import { Sheet } from "@ngrok/mantle/sheet";
import { useForm } from "@tanstack/react-form";
import { matchSorter } from "match-sorter";
import { useId, useMemo, useState, useTransition } from "react";
import { z } from "zod";
const formSchema = z.object({
favorites: z.string().array().min(1, "Select at least one fruit."),
});
const [isPending, startTransition] = useTransition();
const formId = useId();
const [searchValue, setSearchValue] = useState("");
const form = useForm({
defaultValues: {
favorites: ["Cherry"],
},
validators: {
onChange: formSchema,
onSubmit: formSchema,
},
onSubmit: ({ value }) => {
window.alert(`Submitted: ${JSON.stringify(value, null, 2)}`);
},
});
const filteredFruits = useMemo(
() => matchSorter(["Apple", "Banana", "Cherry", "Grapes", "Orange", "Strawberry"], searchValue),
[searchValue],
);
const filteredVeggies = useMemo(
() => matchSorter(["Carrot", "Cucumber", "Lettuce", "Tomato", "Zucchini"], searchValue),
[searchValue],
);
<Sheet.Root>
<Sheet.Trigger asChild>
<Button type="button" appearance="filled">
Assign fruits
</Button>
</Sheet.Trigger>
<Sheet.Content preferredWidth="sm:max-w-[560px]">
<Sheet.Header>
<Sheet.TitleGroup>
<Sheet.Title>Assign fruits</Sheet.Title>
<Sheet.Actions>
<Sheet.CloseIconButton />
</Sheet.Actions>
</Sheet.TitleGroup>
<Sheet.Description>
Use TanStack Form to validate and submit a multi-select inside a sheet workflow.
</Sheet.Description>
</Sheet.Header>
<form
className="contents"
id={formId}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<Sheet.Body>
<form.Field name="favorites">
{(field) => (
<div className="space-y-1.5">
<Label htmlFor={field.name}>Fruits</Label>
<MultiSelect.Root
selectedValue={field.state.value}
setSelectedValue={field.handleChange}
setOpen={() => {
setSearchValue("");
}}
>
<MultiSelect.Trigger
onBlur={field.handleBlur}
validation={field.state.meta.errors.length > 0 ? "error" : false}
>
<MultiSelect.TagValues />
<MultiSelect.Input
id={field.name}
onValueChange={(value) => startTransition(() => setSearchValue(value))}
placeholder="Select fruits and vegetables..."
/>
</MultiSelect.Trigger>
<MultiSelect.Content aria-busy={isPending}>
{filteredFruits.length > 0 && (
<MultiSelect.Group>
<MultiSelect.GroupLabel>Fruits</MultiSelect.GroupLabel>
{filteredFruits.map((value) => (
<MultiSelect.Item key={value} value={value}>
{value}
</MultiSelect.Item>
))}
</MultiSelect.Group>
)}
{filteredFruits.length > 0 && filteredVeggies.length > 0 && (
<MultiSelect.Separator />
)}
{filteredVeggies.length > 0 && (
<MultiSelect.Group>
<MultiSelect.GroupLabel>Vegetables</MultiSelect.GroupLabel>
{filteredVeggies.map((value) => (
<MultiSelect.Item key={value} value={value}>
{value}
</MultiSelect.Item>
))}
</MultiSelect.Group>
)}
{filteredFruits.length === 0 && filteredVeggies.length === 0 && (
<MultiSelect.Empty>No results found</MultiSelect.Empty>
)}
</MultiSelect.Content>
</MultiSelect.Root>
</div>
)}
</form.Field>
</Sheet.Body>
<Sheet.Footer>
<Sheet.Close asChild>
<Button type="button">Cancel</Button>
</Sheet.Close>
<form.Subscribe selector={(state) => state.isDirty}>
{(isDirty) => (
<Button type="submit" form={formId} appearance="filled" disabled={!isDirty}>
Save
</Button>
)}
</form.Subscribe>
</Sheet.Footer>
</form>
</Sheet.Content>
</Sheet.Root>;Use lockedValues on MultiSelect.TagValues to pin specific tags that cannot be removed. Locked tags display a lock icon instead of an ✕ and shake when the user tries to remove them. Locked values also cannot be deselected by clicking them in the popover.
const [selected, setSelected] = useState(["apple", "banana", "cherry"]);
<MultiSelect.Root
selectedValue={selected}
setOpen={() => {
setSearchValue("");
}}
setSelectedValue={setSelected}
>
<MultiSelect.Trigger>
<MultiSelect.TagValues lockedValues={["apple", "cherry"]} />
<MultiSelect.Input
onValueChange={(value) => startTransition(() => setSearchValue(value))}
placeholder="Select fruits..."
/>
</MultiSelect.Trigger>
<MultiSelect.Content>
<MultiSelect.Item value="apple">apple</MultiSelect.Item>
<MultiSelect.Item value="banana">banana</MultiSelect.Item>
<MultiSelect.Item value="cherry">cherry</MultiSelect.Item>
</MultiSelect.Content>
</MultiSelect.Root>;Render a dynamic "Create …" item when the search text doesn't match any existing option. When selected, add the new value to both your options list and the selection. No component changes needed — consumers own the items rendered in Content.
const [selected, setSelected] = useState(["Apple", "Cherry"]);
const [options, setOptions] = useState(fruits);
const matches = useMemo(() => matchSorter(options, searchValue), [options, searchValue]);
const showCreate =
searchValue.trim() !== "" &&
!options.some((o) => o.toLowerCase() === searchValue.trim().toLowerCase());
<MultiSelect.Root
selectedValue={selected}
setOpen={() => {
setSearchValue("");
}}
setSelectedValue={(values) => {
setSelected(values);
startTransition(() => setSearchValue(""));
}}
>
<MultiSelect.Trigger>
<MultiSelect.TagValues />
<MultiSelect.Input
onValueChange={(value) => startTransition(() => setSearchValue(value))}
placeholder="Select or create fruits..."
/>
</MultiSelect.Trigger>
<MultiSelect.Content>
{matches.map((value) => (
<MultiSelect.Item key={value} value={value}>
{value}
</MultiSelect.Item>
))}
{showCreate && (
<MultiSelect.Item
value={searchValue.trim()}
onClick={() => setOptions((prev) => [...prev, searchValue.trim()])}
>
Create "{searchValue.trim()}"
</MultiSelect.Item>
)}
{matches.length === 0 && !showCreate && <MultiSelect.Empty>No results found</MultiSelect.Empty>}
</MultiSelect.Content>
</MultiSelect.Root>;Use MultiSelect.Group, MultiSelect.GroupLabel, and MultiSelect.Separator to organize items.
<MultiSelect.Root
setOpen={() => {
setSearchValue("");
}}
>
<MultiSelect.Trigger>
<MultiSelect.TagValues />
<MultiSelect.Input
onValueChange={(value) => startTransition(() => setSearchValue(value))}
placeholder="Select items..."
/>
</MultiSelect.Trigger>
<MultiSelect.Content>
<MultiSelect.Group>
<MultiSelect.GroupLabel>Fruits</MultiSelect.GroupLabel>
<MultiSelect.Item value="Apple">Apple</MultiSelect.Item>
<MultiSelect.Item value="Banana">Banana</MultiSelect.Item>
</MultiSelect.Group>
<MultiSelect.Separator />
<MultiSelect.Group>
<MultiSelect.GroupLabel>Vegetables</MultiSelect.GroupLabel>
<MultiSelect.Item value="Carrot">Carrot</MultiSelect.Item>
<MultiSelect.Item value="Lettuce">Lettuce</MultiSelect.Item>
</MultiSelect.Group>
</MultiSelect.Content>
</MultiSelect.Root>Use @tanstack/react-form to wire the multi-select into a form with validation. Pass field.state.value to selectedValue, field.handleChange to setSelectedValue, and the validation prop to MultiSelect.Trigger to reflect field errors.
import { Button } from "@ngrok/mantle/button";
import { Label } from "@ngrok/mantle/label";
import { MultiSelect } from "@ngrok/mantle/multi-select";
import { useForm } from "@tanstack/react-form";
import { matchSorter } from "match-sorter";
import { useMemo, useState, useTransition } from "react";
import { z } from "zod";
const formSchema = z.object({
favorites: z.string().array().min(1, "Select at least one item."),
});
type FormValues = z.infer<typeof formSchema>;
function Example() {
const [isPending, startTransition] = useTransition();
const [searchValue, setSearchValue] = useState("");
const filteredFruits = useMemo(
() => matchSorter(["Apple", "Banana", "Cherry", "Grapes", "Orange", "Strawberry"], searchValue),
[searchValue],
);
const filteredVeggies = useMemo(
() => matchSorter(["Carrot", "Cucumber", "Lettuce", "Tomato", "Zucchini"], searchValue),
[searchValue],
);
const form = useForm({
defaultValues: {
favorites: [],
} satisfies FormValues as FormValues,
validators: {
onChange: formSchema,
onSubmit: formSchema,
},
onSubmit: ({ value }) => {
// Handle form submission here
},
});
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="favorites">
{(field) => (
<div className="space-y-1">
<Label htmlFor={field.name}>Favorites</Label>
<MultiSelect.Root
selectedValue={field.state.value}
setOpen={() => {
setSearchValue("");
}}
setSelectedValue={(values) => {
field.handleChange(values);
}}
>
<MultiSelect.Trigger
onBlur={field.handleBlur}
validation={field.state.meta.errors.length > 0 ? "error" : false}
>
<MultiSelect.TagValues />
<MultiSelect.Input
id={field.name}
onValueChange={(value) => startTransition(() => setSearchValue(value))}
placeholder="Select fruits and vegetables..."
/>
</MultiSelect.Trigger>
<MultiSelect.Content aria-busy={isPending}>
{filteredFruits.length > 0 && (
<MultiSelect.Group>
<MultiSelect.GroupLabel>Fruits</MultiSelect.GroupLabel>
{filteredFruits.map((value) => (
<MultiSelect.Item key={value} value={value}>
{value}
</MultiSelect.Item>
))}
</MultiSelect.Group>
)}
{filteredFruits.length > 0 && filteredVeggies.length > 0 && (
<MultiSelect.Separator />
)}
{filteredVeggies.length > 0 && (
<MultiSelect.Group>
<MultiSelect.GroupLabel>Vegetables</MultiSelect.GroupLabel>
{filteredVeggies.map((value) => (
<MultiSelect.Item key={value} value={value}>
{value}
</MultiSelect.Item>
))}
</MultiSelect.Group>
)}
{filteredFruits.length === 0 && filteredVeggies.length === 0 && (
<MultiSelect.Empty>No results found</MultiSelect.Empty>
)}
</MultiSelect.Content>
</MultiSelect.Root>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-sm leading-4 text-danger-600">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
<form.Subscribe selector={(state) => state.isDirty}>
{(isDirty) => (
<Button type="submit" appearance="filled" disabled={!isDirty}>
Submit
</Button>
)}
</form.Subscribe>
</form>
);
}Use the validation prop on MultiSelect.Trigger to show validation states.
<MultiSelect.Root>
<MultiSelect.Trigger validation="error">
<MultiSelect.TagValues />
<MultiSelect.Input placeholder="Error state..." />
</MultiSelect.Trigger>
<MultiSelect.Content>
<MultiSelect.Item value="Apple">Apple</MultiSelect.Item>
</MultiSelect.Content>
</MultiSelect.Root>A real-world example with three groups (regional aliases, PoPs, dedicated IPs), disabled items, secondary metadata per item, a stacked item description layout, a trailing link in the trigger, and a sticky footer CTA inside the content.
Resolves to
import { Anchor } from "@ngrok/mantle/anchor";
import { Button } from "@ngrok/mantle/button";
import { MultiSelect } from "@ngrok/mantle/multi-select";
import { matchSorter } from "match-sorter";
import { useMemo, useState, useTransition } from "react";
const regionalAliases = [
{ value: "global", popCount: "All PoPs" },
{ value: "united-states", popCount: "2 PoPs" },
{ value: "european-union", popCount: "1 PoP" },
];
const pointsOfPresence = [
{ value: "sg-sin-1", location: "Asia / Pacific (Singapore)" },
{ value: "au-syd-1", location: "Australia (Sydney)" },
{ value: "de-fra-1", location: "European Union (Frankfurt)" },
{ value: "in-mum-1", location: "India (Mumbai)" },
{ value: "jp-tokyo-1", location: "Japan (Tokyo)" },
{ value: "br-sao-1", location: "South America (São Paulo)" },
{ value: "us-ohio-1", location: "United States (Ohio)" },
{ value: "us-cal-1", location: "United States (California)" },
];
const dedicatedIPs = [
{ ip: "52.191.171.57", description: "this is a helpful description that is exceedingly lengthy" },
{ ip: "40.95.110.217", description: "this is a helpful description" },
{ ip: "63.243.178.35", description: "" },
];
function Example() {
const [isPending, startTransition] = useTransition();
const [searchValue, setSearchValue] = useState("");
const [selected, setSelected] = useState(["global"]);
const filteredAliases = useMemo(
() => matchSorter(regionalAliases, searchValue, { keys: ["value"] }),
[searchValue],
);
const filteredPops = useMemo(
() => matchSorter(pointsOfPresence, searchValue, { keys: ["value"] }),
[searchValue],
);
const filteredDedicatedIPs = useMemo(
() => matchSorter(dedicatedIPs, searchValue, { keys: ["ip"] }),
[searchValue],
);
return (
<div className="flex w-full max-w-lg flex-col gap-1.5">
<p className="text-strong text-sm font-medium">Resolves to</p>
<MultiSelect.Root
selectedValue={selected}
setOpen={() => {
setSearchValue("");
}}
setSelectedValue={setSelected}
>
<MultiSelect.Trigger>
<MultiSelect.TagValues />
<MultiSelect.Input
onValueChange={(value) => startTransition(() => setSearchValue(value))}
placeholder="Select regions..."
/>
{/* Input is flex-1 so this sibling is pushed to the right */}
<Anchor className="shrink-0 whitespace-nowrap text-xs" href="#">
Requires Upgrade
</Anchor>
</MultiSelect.Trigger>
<MultiSelect.Content aria-busy={isPending}>
{filteredAliases.length > 0 && (
<MultiSelect.Group>
<MultiSelect.GroupLabel>Regional Aliases</MultiSelect.GroupLabel>
<MultiSelect.GroupDescription>
Include all points of presence that are geographically within the region.
</MultiSelect.GroupDescription>
{filteredAliases.map(({ value, popCount }) => (
<MultiSelect.Item key={value} value={value}>
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="font-mono">{value}</span>
<span className="text-muted font-sans text-xs font-normal">{popCount}</span>
</span>
</MultiSelect.Item>
))}
</MultiSelect.Group>
)}
{filteredAliases.length > 0 && filteredPops.length > 0 && <MultiSelect.Separator />}
{filteredPops.length > 0 && (
<MultiSelect.Group>
<MultiSelect.GroupLabel>Points of presence</MultiSelect.GroupLabel>
<MultiSelect.GroupDescription>
If you've included a region, you cannot include PoPs that are within it.
</MultiSelect.GroupDescription>
{filteredPops.map(({ value, location }) => (
<MultiSelect.Item key={value} value={value} disabled>
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="font-mono">{value}</span>
<span className="text-muted font-sans text-xs font-normal">{location}</span>
</span>
</MultiSelect.Item>
))}
</MultiSelect.Group>
)}
{(filteredAliases.length > 0 || filteredPops.length > 0) &&
filteredDedicatedIPs.length > 0 && <MultiSelect.Separator />}
{filteredDedicatedIPs.length > 0 && (
<MultiSelect.Group>
<MultiSelect.GroupLabel>Dedicated IPs</MultiSelect.GroupLabel>
{filteredDedicatedIPs.map(({ ip, description }) => (
<MultiSelect.Item key={ip} value={ip} disabled>
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-mono">{ip}</span>
{description && (
<span className="text-muted font-sans text-xs font-normal">
{description}
</span>
)}
</span>
</MultiSelect.Item>
))}
</MultiSelect.Group>
)}
{filteredAliases.length === 0 &&
filteredPops.length === 0 &&
filteredDedicatedIPs.length === 0 && (
<MultiSelect.Empty>No results found</MultiSelect.Empty>
)}
<MultiSelect.ContentFooter>
<div className="flex items-center justify-between gap-3 bg-accent-600/10 px-3 py-3">
<p className="text-accent-600 text-sm font-medium">
Upgrade your plan to specify regions, PoPs, and dedicated IPs.
</p>
<Button appearance="filled" className="shrink-0" asChild>
<Link
to={{
pathname: href("/billing/choose-a-plan"),
search: "?plan=paygo",
}}
>
Upgrade to Pay-as-you-go
</Link>
</Button>
</div>
</MultiSelect.ContentFooter>
</MultiSelect.Content>
</MultiSelect.Root>
</div>
);
}The MultiSelect components are built on top of ariakit Combobox.
Root component for the multi-select. Provides state management for selecting multiple values with typeahead filtering.
All props from ariakit ComboboxProvider (typed to string[] for multi-select), plus:
The trigger container for the multi-select. Wraps the input and selected value tags in a styled container that looks like a form input.
Renders the selected values as removable tags. Place this inside MultiSelect.Trigger, followed by MultiSelect.Input.
TagRenderProps:
The combobox input for filtering items. Place this inside MultiSelect.Trigger, after MultiSelect.TagValues.
All props from ariakit Combobox. Key props:
The default tag component rendered inside MultiSelect.TagValues for each selected value. Displays the value label with a remove button and full keyboard navigation support.
Use this when building a custom TagValues-like component and you want the default tag chrome with consistent styling.
Also accepts all standard span element props (except children). The onKeyDown prop is supported and is called after the internal locked-key handling.
Renders a popover that contains multi-select content.
All props from ariakit ComboboxPopover, plus:
Renders a selectable item with a checkbox indicator inside a MultiSelect.Content.
All props from ariakit ComboboxItem, plus:
Renders a group for MultiSelect.Item elements. Optionally, a MultiSelect.GroupLabel can be rendered as a child to provide a label for the group.
All props from ariakit ComboboxGroup, plus:
Renders a label in a multi-select group. Should be wrapped with MultiSelect.Group so the aria-labelledby is correctly set on the group element.
All props from ariakit ComboboxGroupLabel, plus:
Renders a descriptive paragraph inside a MultiSelect.Group, placed after MultiSelect.GroupLabel. Provides additional context about the group's items.
Standard p element props.
Renders a separator between MultiSelect.Items or MultiSelect.Groups.
All props from Separator.
Renders a message when no items match the current filter.
Standard div element props.
Renders a sticky footer inside MultiSelect.Content that stays pinned to the bottom edge of the content area. Place it as the last child of MultiSelect.Content.
Standard div element props.