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.
Use Multi Select when the user can choose multiple values from a list, with selected values rendered as removable tags/chips. For single selection, use Combobox (with search) or Select (without).
Pair with Field.* for label/description/error wiring in forms — wrap MultiSelect.Root in Field.Control. Field.Control flows the generated id, name, aria-describedby, aria-errormessage, and aria-invalid through to the focusable MultiSelect.Input via FieldControlContext.
Pick one or more.
1import { Field } from "@ngrok/mantle/field";2import { MultiSelect } from "@ngrok/mantle/multi-select";3 4<Field.Item name="fruits">5 <Field.Label>Fruits</Field.Label>6 <Field.Control>7 <MultiSelect.Root>8 <MultiSelect.Trigger>9 <MultiSelect.TagValues />10 <MultiSelect.Input placeholder="Select fruits..." />11 </MultiSelect.Trigger>12 <MultiSelect.Content>13 {fruits.map((value) => (14 <MultiSelect.Item key={value} value={value}>15 {value}16 </MultiSelect.Item>17 ))}18 </MultiSelect.Content>19 </MultiSelect.Root>20 </Field.Control>21 <Field.Description>Pick one or more.</Field.Description>22</Field.Item>;Or render the control on its own:
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 { Field, toErrorMessages } from "@ngrok/mantle/field";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 14type FormValues = z.infer<typeof formSchema>;15 16const [isPending, startTransition] = useTransition();17const formId = useId();18const [searchValue, setSearchValue] = useState("");19const defaultValues: FormValues = {20 favorites: ["Cherry"],21};22const form = useForm({23 defaultValues,24 validators: {25 onSubmit: formSchema,26 },27 onSubmit: ({ value }) => {28 window.alert(`Submitted: ${JSON.stringify(value, null, 2)}`);29 },30});31 32const filteredFruits = useMemo(33 () => matchSorter(["Apple", "Banana", "Cherry", "Grapes", "Orange", "Strawberry"], searchValue),34 [searchValue],35);36const filteredVeggies = useMemo(37 () => matchSorter(["Carrot", "Cucumber", "Lettuce", "Tomato", "Zucchini"], searchValue),38 [searchValue],39);40 41<Sheet.Root>42 <Sheet.Trigger asChild>43 <Button type="button" appearance="filled">44 Assign fruits45 </Button>46 </Sheet.Trigger>47 <Sheet.Content preferredWidth="sm:max-w-[560px]">48 <Sheet.Header>49 <Sheet.TitleGroup>50 <Sheet.Title>Assign fruits</Sheet.Title>51 <Sheet.Actions>52 <Sheet.CloseIconButton />53 </Sheet.Actions>54 </Sheet.TitleGroup>55 <Sheet.Description>56 Use TanStack Form to validate and submit a multi-select inside a sheet workflow.57 </Sheet.Description>58 </Sheet.Header>59 <form60 className="contents"61 id={formId}62 onSubmit={(event) => {63 event.preventDefault();64 event.stopPropagation();65 void form.handleSubmit();66 }}67 >68 <Sheet.Body>69 <form.Field name="favorites">70 {(field) => (71 <Field.Item name={field.name}>72 <Field.Label>Fruits</Field.Label>73 <Field.Control>74 <MultiSelect.Root75 selectedValue={field.state.value}76 setSelectedValue={field.handleChange}77 setOpen={() => {78 setSearchValue("");79 }}80 >81 <MultiSelect.Trigger onBlur={field.handleBlur}>82 <MultiSelect.TagValues />83 <MultiSelect.Input84 onValueChange={(value) => startTransition(() => setSearchValue(value))}85 placeholder="Select fruits and vegetables..."86 />87 </MultiSelect.Trigger>88 <MultiSelect.Content aria-busy={isPending}>89 {filteredFruits.length > 0 && (90 <MultiSelect.Group>91 <MultiSelect.GroupLabel>Fruits</MultiSelect.GroupLabel>92 {filteredFruits.map((value) => (93 <MultiSelect.Item key={value} value={value}>94 {value}95 </MultiSelect.Item>96 ))}97 </MultiSelect.Group>98 )}99 {filteredFruits.length > 0 && filteredVeggies.length > 0 && (100 <MultiSelect.Separator />101 )}102 {filteredVeggies.length > 0 && (103 <MultiSelect.Group>104 <MultiSelect.GroupLabel>Vegetables</MultiSelect.GroupLabel>105 {filteredVeggies.map((value) => (106 <MultiSelect.Item key={value} value={value}>107 {value}108 </MultiSelect.Item>109 ))}110 </MultiSelect.Group>111 )}112 {filteredFruits.length === 0 && filteredVeggies.length === 0 && (113 <MultiSelect.Empty>No results found</MultiSelect.Empty>114 )}115 </MultiSelect.Content>116 </MultiSelect.Root>117 </Field.Control>118 <Field.Errors messages={toErrorMessages(field.state.meta.errors)} />119 </Field.Item>120 )}121 </form.Field>122 </Sheet.Body>123 <Sheet.Footer>124 <Sheet.Close asChild>125 <Button type="button" appearance="outlined" priority="neutral">126 Cancel127 </Button>128 </Sheet.Close>129 <form.Subscribe selector={(state) => state.isDirty}>130 {(isDirty) => (131 <Button type="submit" form={formId} appearance="filled" disabled={!isDirty}>132 Save133 </Button>134 )}135 </form.Subscribe>136 </Sheet.Footer>137 </form>138 </Sheet.Content>139</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 with zod to wire selected values into form state. Pass field.state.value to selectedValue and field.handleChange to setSelectedValue.
1import { Button } from "@ngrok/mantle/button";2import { Field, toErrorMessages } from "@ngrok/mantle/field";3import { MultiSelect } from "@ngrok/mantle/multi-select";4import { useForm } from "@tanstack/react-form";5import { z } from "zod";6 7const fruits = ["Apple", "Banana", "Cherry", "Grapes", "Orange", "Strawberry"];8 9const formSchema = z.object({10 favorites: z.string().array().min(1, "Select at least one fruit."),11});12 13type FormValues = z.infer<typeof formSchema>;14 15function Example() {16 const defaultValues: FormValues = {17 favorites: [],18 };19 const form = useForm({20 defaultValues,21 validators: {22 onSubmit: formSchema,23 },24 onSubmit: ({ value }) => {25 // Handle form submission here26 },27 });28 29 return (30 <form31 className="space-y-4"32 onSubmit={(event) => {33 event.preventDefault();34 event.stopPropagation();35 void form.handleSubmit();36 }}37 >38 <form.Field name="favorites">39 {(field) => (40 <Field.Item name={field.name}>41 <Field.Label>Favorites</Field.Label>42 <Field.Control>43 <MultiSelect.Root44 selectedValue={field.state.value}45 setSelectedValue={field.handleChange}46 >47 <MultiSelect.Trigger onBlur={field.handleBlur}>48 <MultiSelect.TagValues />49 <MultiSelect.Input placeholder="Select fruits..." />50 </MultiSelect.Trigger>51 <MultiSelect.Content>52 {fruits.map((value) => (53 <MultiSelect.Item key={value} value={value}>54 {value}55 </MultiSelect.Item>56 ))}57 </MultiSelect.Content>58 </MultiSelect.Root>59 </Field.Control>60 <Field.Errors messages={toErrorMessages(field.state.meta.errors)} />61 </Field.Item>62 )}63 </form.Field>64 <Button type="submit" appearance="filled">65 Submit66 </Button>67 </form>68 );69}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}Compose the parts of a MultiSelect together to build your own:
MultiSelect.Root├── MultiSelect.Trigger│ ├── MultiSelect.TagValues│ └── MultiSelect.Input└── MultiSelect.Content ├── MultiSelect.Group │ ├── MultiSelect.GroupLabel │ ├── MultiSelect.GroupDescription │ └── MultiSelect.Item ├── MultiSelect.Separator ├── MultiSelect.Empty └── MultiSelect.ContentFooterThe 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.