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.
1import { MultiSelect } from "@ngrok/mantle/multi-select";2import { matchSorter } from "match-sorter";3import { useMemo, useState, useTransition } from "react";4 5const fruits = [6 "Apple",7 "Banana",8 "Blueberry",9 "Cherry",10 "Grapes",11 "Kiwi",12 "Lemon",13 "Mango",14 "Orange",15 "Peach",16 "Pear",17 "Pineapple",18 "Strawberry",19 "Watermelon",20];21 22function Example() {23 const [isPending, startTransition] = useTransition();24 const [searchValue, setSearchValue] = useState("");25 const matches = useMemo(() => matchSorter(fruits, searchValue), [searchValue]);26 27 return (28 <MultiSelect.Root29 setOpen={() => {30 setSearchValue("");31 }}32 >33 <MultiSelect.Trigger>34 <MultiSelect.TagValues />35 <MultiSelect.Input36 onValueChange={(value) => startTransition(() => setSearchValue(value))}37 placeholder="Select fruits..."38 />39 </MultiSelect.Trigger>40 <MultiSelect.Content aria-busy={isPending}>41 {matches.map((value) => (42 <MultiSelect.Item key={value} value={value}>43 {value}44 </MultiSelect.Item>45 ))}46 {matches.length === 0 && <MultiSelect.Empty>No results found</MultiSelect.Empty>}47 </MultiSelect.Content>48 </MultiSelect.Root>49 );50}Use selectedValue and setSelectedValue to control the selected values.
1const [selected, setSelected] = useState(["Apple", "Cherry"]);2 3<MultiSelect.Root4 selectedValue={selected}5 setOpen={() => {6 setSearchValue("");7 }}8 setSelectedValue={setSelected}9>10 <MultiSelect.Trigger>11 <MultiSelect.TagValues />12 <MultiSelect.Input13 onValueChange={(value) => startTransition(() => setSearchValue(value))}14 placeholder="Select fruits..."15 />16 </MultiSelect.Trigger>17 <MultiSelect.Content>18 {matches.map((value) => (19 <MultiSelect.Item key={value} value={value}>20 {value}21 </MultiSelect.Item>22 ))}23 </MultiSelect.Content>24</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.
1import { Button } from "@ngrok/mantle/button";2import { Label } from "@ngrok/mantle/label";3import { MultiSelect } from "@ngrok/mantle/multi-select";4import { Sheet } from "@ngrok/mantle/sheet";5import { useForm } from "@tanstack/react-form";6import { matchSorter } from "match-sorter";7import { useId, useMemo, useState, useTransition } from "react";8import { z } from "zod";9 10const formSchema = z.object({11 favorites: z.string().array().min(1, "Select at least one fruit."),12});13 14const [isPending, startTransition] = useTransition();15const formId = useId();16const [searchValue, setSearchValue] = useState("");17const form = useForm({18 defaultValues: {19 favorites: ["Cherry"],20 },21 validators: {22 onChange: formSchema,23 onSubmit: formSchema,24 },25 onSubmit: ({ value }) => {26 window.alert(`Submitted: ${JSON.stringify(value, null, 2)}`);27 },28});29 30const filteredFruits = useMemo(31 () => matchSorter(["Apple", "Banana", "Cherry", "Grapes", "Orange", "Strawberry"], searchValue),32 [searchValue],33);34const filteredVeggies = useMemo(35 () => matchSorter(["Carrot", "Cucumber", "Lettuce", "Tomato", "Zucchini"], searchValue),36 [searchValue],37);38 39<Sheet.Root>40 <Sheet.Trigger asChild>41 <Button type="button" appearance="filled">42 Assign fruits43 </Button>44 </Sheet.Trigger>45 <Sheet.Content preferredWidth="sm:max-w-[560px]">46 <Sheet.Header>47 <Sheet.TitleGroup>48 <Sheet.Title>Assign fruits</Sheet.Title>49 <Sheet.Actions>50 <Sheet.CloseIconButton />51 </Sheet.Actions>52 </Sheet.TitleGroup>53 <Sheet.Description>54 Use TanStack Form to validate and submit a multi-select inside a sheet workflow.55 </Sheet.Description>56 </Sheet.Header>57 <form58 className="contents"59 id={formId}60 onSubmit={(event) => {61 event.preventDefault();62 event.stopPropagation();63 void form.handleSubmit();64 }}65 >66 <Sheet.Body>67 <form.Field name="favorites">68 {(field) => (69 <div className="space-y-1.5">70 <Label htmlFor={field.name}>Fruits</Label>71 <MultiSelect.Root72 selectedValue={field.state.value}73 setSelectedValue={field.handleChange}74 setOpen={() => {75 setSearchValue("");76 }}77 >78 <MultiSelect.Trigger79 onBlur={field.handleBlur}80 validation={field.state.meta.errors.length > 0 ? "error" : false}81 >82 <MultiSelect.TagValues />83 <MultiSelect.Input84 id={field.name}85 onValueChange={(value) => startTransition(() => setSearchValue(value))}86 placeholder="Select fruits and vegetables..."87 />88 </MultiSelect.Trigger>89 <MultiSelect.Content aria-busy={isPending}>90 {filteredFruits.length > 0 && (91 <MultiSelect.Group>92 <MultiSelect.GroupLabel>Fruits</MultiSelect.GroupLabel>93 {filteredFruits.map((value) => (94 <MultiSelect.Item key={value} value={value}>95 {value}96 </MultiSelect.Item>97 ))}98 </MultiSelect.Group>99 )}100 {filteredFruits.length > 0 && filteredVeggies.length > 0 && (101 <MultiSelect.Separator />102 )}103 {filteredVeggies.length > 0 && (104 <MultiSelect.Group>105 <MultiSelect.GroupLabel>Vegetables</MultiSelect.GroupLabel>106 {filteredVeggies.map((value) => (107 <MultiSelect.Item key={value} value={value}>108 {value}109 </MultiSelect.Item>110 ))}111 </MultiSelect.Group>112 )}113 {filteredFruits.length === 0 && filteredVeggies.length === 0 && (114 <MultiSelect.Empty>No results found</MultiSelect.Empty>115 )}116 </MultiSelect.Content>117 </MultiSelect.Root>118 </div>119 )}120 </form.Field>121 </Sheet.Body>122 <Sheet.Footer>123 <Sheet.Close asChild>124 <Button type="button">Cancel</Button>125 </Sheet.Close>126 <form.Subscribe selector={(state) => state.isDirty}>127 {(isDirty) => (128 <Button type="submit" form={formId} appearance="filled" disabled={!isDirty}>129 Save130 </Button>131 )}132 </form.Subscribe>133 </Sheet.Footer>134 </form>135 </Sheet.Content>136</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.
1const [selected, setSelected] = useState(["apple", "banana", "cherry"]);2 3<MultiSelect.Root4 selectedValue={selected}5 setOpen={() => {6 setSearchValue("");7 }}8 setSelectedValue={setSelected}9>10 <MultiSelect.Trigger>11 <MultiSelect.TagValues lockedValues={["apple", "cherry"]} />12 <MultiSelect.Input13 onValueChange={(value) => startTransition(() => setSearchValue(value))}14 placeholder="Select fruits..."15 />16 </MultiSelect.Trigger>17 <MultiSelect.Content>18 <MultiSelect.Item value="apple">apple</MultiSelect.Item>19 <MultiSelect.Item value="banana">banana</MultiSelect.Item>20 <MultiSelect.Item value="cherry">cherry</MultiSelect.Item>21 </MultiSelect.Content>22</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.
1const [selected, setSelected] = useState(["Apple", "Cherry"]);2const [options, setOptions] = useState(fruits);3const matches = useMemo(() => matchSorter(options, searchValue), [options, searchValue]);4const showCreate =5 searchValue.trim() !== "" &&6 !options.some((o) => o.toLowerCase() === searchValue.trim().toLowerCase());7 8<MultiSelect.Root9 selectedValue={selected}10 setOpen={() => {11 setSearchValue("");12 }}13 setSelectedValue={(values) => {14 setSelected(values);15 startTransition(() => setSearchValue(""));16 }}17>18 <MultiSelect.Trigger>19 <MultiSelect.TagValues />20 <MultiSelect.Input21 onValueChange={(value) => startTransition(() => setSearchValue(value))}22 placeholder="Select or create fruits..."23 />24 </MultiSelect.Trigger>25 <MultiSelect.Content>26 {matches.map((value) => (27 <MultiSelect.Item key={value} value={value}>28 {value}29 </MultiSelect.Item>30 ))}31 {showCreate && (32 <MultiSelect.Item33 value={searchValue.trim()}34 onClick={() => setOptions((prev) => [...prev, searchValue.trim()])}35 >36 Create "{searchValue.trim()}"37 </MultiSelect.Item>38 )}39 {matches.length === 0 && !showCreate && <MultiSelect.Empty>No results found</MultiSelect.Empty>}40 </MultiSelect.Content>41</MultiSelect.Root>;Use MultiSelect.Group, MultiSelect.GroupLabel, and MultiSelect.Separator to organize items.
1<MultiSelect.Root2 setOpen={() => {3 setSearchValue("");4 }}5>6 <MultiSelect.Trigger>7 <MultiSelect.TagValues />8 <MultiSelect.Input9 onValueChange={(value) => startTransition(() => setSearchValue(value))}10 placeholder="Select items..."11 />12 </MultiSelect.Trigger>13 <MultiSelect.Content>14 <MultiSelect.Group>15 <MultiSelect.GroupLabel>Fruits</MultiSelect.GroupLabel>16 <MultiSelect.Item value="Apple">Apple</MultiSelect.Item>17 <MultiSelect.Item value="Banana">Banana</MultiSelect.Item>18 </MultiSelect.Group>19 <MultiSelect.Separator />20 <MultiSelect.Group>21 <MultiSelect.GroupLabel>Vegetables</MultiSelect.GroupLabel>22 <MultiSelect.Item value="Carrot">Carrot</MultiSelect.Item>23 <MultiSelect.Item value="Lettuce">Lettuce</MultiSelect.Item>24 </MultiSelect.Group>25 </MultiSelect.Content>26</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.
1import { Button } from "@ngrok/mantle/button";2import { Label } from "@ngrok/mantle/label";3import { MultiSelect } from "@ngrok/mantle/multi-select";4import { useForm } from "@tanstack/react-form";5import { matchSorter } from "match-sorter";6import { useMemo, useState, useTransition } from "react";7import { z } from "zod";8 9const formSchema = z.object({10 favorites: z.string().array().min(1, "Select at least one item."),11});12 13type FormValues = z.infer<typeof formSchema>;14 15function Example() {16 const [isPending, startTransition] = useTransition();17 const [searchValue, setSearchValue] = useState("");18 const filteredFruits = useMemo(19 () => matchSorter(["Apple", "Banana", "Cherry", "Grapes", "Orange", "Strawberry"], searchValue),20 [searchValue],21 );22 const filteredVeggies = useMemo(23 () => matchSorter(["Carrot", "Cucumber", "Lettuce", "Tomato", "Zucchini"], searchValue),24 [searchValue],25 );26 const form = useForm({27 defaultValues: {28 favorites: [],29 } satisfies FormValues as FormValues,30 validators: {31 onChange: formSchema,32 onSubmit: formSchema,33 },34 onSubmit: ({ value }) => {35 // Handle form submission here36 },37 });38 39 return (40 <form41 className="space-y-4"42 onSubmit={(event) => {43 event.preventDefault();44 event.stopPropagation();45 void form.handleSubmit();46 }}47 >48 <form.Field name="favorites">49 {(field) => (50 <div className="space-y-1">51 <Label htmlFor={field.name}>Favorites</Label>52 <MultiSelect.Root53 selectedValue={field.state.value}54 setOpen={() => {55 setSearchValue("");56 }}57 setSelectedValue={(values) => {58 field.handleChange(values);59 }}60 >61 <MultiSelect.Trigger62 onBlur={field.handleBlur}63 validation={field.state.meta.errors.length > 0 ? "error" : false}64 >65 <MultiSelect.TagValues />66 <MultiSelect.Input67 id={field.name}68 onValueChange={(value) => startTransition(() => setSearchValue(value))}69 placeholder="Select fruits and vegetables..."70 />71 </MultiSelect.Trigger>72 <MultiSelect.Content aria-busy={isPending}>73 {filteredFruits.length > 0 && (74 <MultiSelect.Group>75 <MultiSelect.GroupLabel>Fruits</MultiSelect.GroupLabel>76 {filteredFruits.map((value) => (77 <MultiSelect.Item key={value} value={value}>78 {value}79 </MultiSelect.Item>80 ))}81 </MultiSelect.Group>82 )}83 {filteredFruits.length > 0 && filteredVeggies.length > 0 && (84 <MultiSelect.Separator />85 )}86 {filteredVeggies.length > 0 && (87 <MultiSelect.Group>88 <MultiSelect.GroupLabel>Vegetables</MultiSelect.GroupLabel>89 {filteredVeggies.map((value) => (90 <MultiSelect.Item key={value} value={value}>91 {value}92 </MultiSelect.Item>93 ))}94 </MultiSelect.Group>95 )}96 {filteredFruits.length === 0 && filteredVeggies.length === 0 && (97 <MultiSelect.Empty>No results found</MultiSelect.Empty>98 )}99 </MultiSelect.Content>100 </MultiSelect.Root>101 {field.state.meta.errors.map((error) => (102 <p key={error?.message} className="text-sm leading-4 text-danger-600">103 {error?.message}104 </p>105 ))}106 </div>107 )}108 </form.Field>109 <form.Subscribe selector={(state) => state.isDirty}>110 {(isDirty) => (111 <Button type="submit" appearance="filled" disabled={!isDirty}>112 Submit113 </Button>114 )}115 </form.Subscribe>116 </form>117 );118}Use the validation prop on MultiSelect.Trigger to show validation states.
1<MultiSelect.Root>2 <MultiSelect.Trigger validation="error">3 <MultiSelect.TagValues />4 <MultiSelect.Input placeholder="Error state..." />5 </MultiSelect.Trigger>6 <MultiSelect.Content>7 <MultiSelect.Item value="Apple">Apple</MultiSelect.Item>8 </MultiSelect.Content>9</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
1import { Anchor } from "@ngrok/mantle/anchor";2import { Button } from "@ngrok/mantle/button";3import { MultiSelect } from "@ngrok/mantle/multi-select";4import { matchSorter } from "match-sorter";5import { useMemo, useState, useTransition } from "react";6 7const regionalAliases = [8 { value: "global", popCount: "All PoPs" },9 { value: "united-states", popCount: "2 PoPs" },10 { value: "european-union", popCount: "1 PoP" },11];12 13const pointsOfPresence = [14 { value: "sg-sin-1", location: "Asia / Pacific (Singapore)" },15 { value: "au-syd-1", location: "Australia (Sydney)" },16 { value: "de-fra-1", location: "European Union (Frankfurt)" },17 { value: "in-mum-1", location: "India (Mumbai)" },18 { value: "jp-tokyo-1", location: "Japan (Tokyo)" },19 { value: "br-sao-1", location: "South America (São Paulo)" },20 { value: "us-ohio-1", location: "United States (Ohio)" },21 { value: "us-cal-1", location: "United States (California)" },22];23 24const dedicatedIPs = [25 { ip: "52.191.171.57", description: "this is a helpful description that is exceedingly lengthy" },26 { ip: "40.95.110.217", description: "this is a helpful description" },27 { ip: "63.243.178.35", description: "" },28];29 30function Example() {31 const [isPending, startTransition] = useTransition();32 const [searchValue, setSearchValue] = useState("");33 const [selected, setSelected] = useState(["global"]);34 const filteredAliases = useMemo(35 () => matchSorter(regionalAliases, searchValue, { keys: ["value"] }),36 [searchValue],37 );38 const filteredPops = useMemo(39 () => matchSorter(pointsOfPresence, searchValue, { keys: ["value"] }),40 [searchValue],41 );42 const filteredDedicatedIPs = useMemo(43 () => matchSorter(dedicatedIPs, searchValue, { keys: ["ip"] }),44 [searchValue],45 );46 47 return (48 <div className="flex w-full max-w-lg flex-col gap-1.5">49 <p className="text-strong text-sm font-medium">Resolves to</p>50 <MultiSelect.Root51 selectedValue={selected}52 setOpen={() => {53 setSearchValue("");54 }}55 setSelectedValue={setSelected}56 >57 <MultiSelect.Trigger>58 <MultiSelect.TagValues />59 <MultiSelect.Input60 onValueChange={(value) => startTransition(() => setSearchValue(value))}61 placeholder="Select regions..."62 />63 {/* Input is flex-1 so this sibling is pushed to the right */}64 <Anchor className="shrink-0 whitespace-nowrap text-xs" href="#">65 Requires Upgrade66 </Anchor>67 </MultiSelect.Trigger>68 <MultiSelect.Content aria-busy={isPending}>69 {filteredAliases.length > 0 && (70 <MultiSelect.Group>71 <MultiSelect.GroupLabel>Regional Aliases</MultiSelect.GroupLabel>72 <MultiSelect.GroupDescription>73 Include all points of presence that are geographically within the region.74 </MultiSelect.GroupDescription>75 {filteredAliases.map(({ value, popCount }) => (76 <MultiSelect.Item key={value} value={value}>77 <span className="flex min-w-0 flex-1 items-center justify-between gap-2">78 <span className="font-mono">{value}</span>79 <span className="text-muted font-sans text-xs font-normal">{popCount}</span>80 </span>81 </MultiSelect.Item>82 ))}83 </MultiSelect.Group>84 )}85 {filteredAliases.length > 0 && filteredPops.length > 0 && <MultiSelect.Separator />}86 {filteredPops.length > 0 && (87 <MultiSelect.Group>88 <MultiSelect.GroupLabel>Points of presence</MultiSelect.GroupLabel>89 <MultiSelect.GroupDescription>90 If you've included a region, you cannot include PoPs that are within it.91 </MultiSelect.GroupDescription>92 {filteredPops.map(({ value, location }) => (93 <MultiSelect.Item key={value} value={value} disabled>94 <span className="flex min-w-0 flex-1 items-center justify-between gap-2">95 <span className="font-mono">{value}</span>96 <span className="text-muted font-sans text-xs font-normal">{location}</span>97 </span>98 </MultiSelect.Item>99 ))}100 </MultiSelect.Group>101 )}102 {(filteredAliases.length > 0 || filteredPops.length > 0) &&103 filteredDedicatedIPs.length > 0 && <MultiSelect.Separator />}104 {filteredDedicatedIPs.length > 0 && (105 <MultiSelect.Group>106 <MultiSelect.GroupLabel>Dedicated IPs</MultiSelect.GroupLabel>107 {filteredDedicatedIPs.map(({ ip, description }) => (108 <MultiSelect.Item key={ip} value={ip} disabled>109 <span className="flex min-w-0 flex-1 flex-col">110 <span className="font-mono">{ip}</span>111 {description && (112 <span className="text-muted font-sans text-xs font-normal">113 {description}114 </span>115 )}116 </span>117 </MultiSelect.Item>118 ))}119 </MultiSelect.Group>120 )}121 {filteredAliases.length === 0 &&122 filteredPops.length === 0 &&123 filteredDedicatedIPs.length === 0 && (124 <MultiSelect.Empty>No results found</MultiSelect.Empty>125 )}126 <MultiSelect.ContentFooter>127 <div className="flex items-center justify-between gap-3 bg-accent-600/10 px-3 py-3">128 <p className="text-accent-600 text-sm font-medium">129 Upgrade your plan to specify regions, PoPs, and dedicated IPs.130 </p>131 <Button appearance="filled" className="shrink-0" asChild>132 <Link133 to={{134 pathname: href("/billing/choose-a-plan"),135 search: "?plan=paygo",136 }}137 >138 Upgrade to Pay-as-you-go139 </Link>140 </Button>141 </div>142 </MultiSelect.ContentFooter>143 </MultiSelect.Content>144 </MultiSelect.Root>145 </div>146 );147}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.