references/api.md
# Combobox API Reference
## Table of Contents
- [useCombobox hook](#usecombobox-hook)
- [Combobox (root)](#combobox-root)
- [Sub-components](#sub-components)
- [CSS variables & Styles API](#css-variables--styles-api)
- [Default styles and overriding them](#default-styles-and-overriding-them)
---
## useCombobox hook
```tsx
const combobox = useCombobox(options?: UseComboboxOptions);
```
**Options:**
```ts
interface UseComboboxOptions {
defaultOpened?: boolean;
opened?: boolean;
onOpenedChange?: (opened: boolean) => void;
onDropdownClose?: (eventSource: 'keyboard' | 'mouse' | 'unknown') => void;
onDropdownOpen?: (eventSource: 'keyboard' | 'mouse' | 'unknown') => void;
// onDropdownOpen runs after the dropdown is mounted: option elements can be queried in it
loop?: boolean; // Default: true — keyboard nav wraps at boundaries
scrollBehavior?: ScrollBehavior; // Default: 'instant'
}
```
**Returned store:**
```ts
interface ComboboxStore {
// Dropdown state
dropdownOpened: boolean;
openDropdown(eventSource?: 'keyboard' | 'mouse' | 'unknown'): void;
closeDropdown(eventSource?: 'keyboard' | 'mouse' | 'unknown'): void;
toggleDropdown(eventSource?: 'keyboard' | 'mouse' | 'unknown'): void;
// Option keyboard navigation
selectedOptionIndex: number;
getSelectedOptionIndex(): number; // -1 when nothing is selected
selectOption(index: number): void;
selectActiveOption(): string | null; // highlight the option with `active` prop (first option if none)
selectFirstOption(): string | null; // highlight the first option that is not disabled
selectNextOption(): string | null;
selectPreviousOption(): string | null;
resetSelectedOption(): void;
clickSelectedOption(): void;
// Re-syncs the internal highlight index with the DOM. It runs in a timeout, after the next render,
// so it can be called right before the state update that changes the list.
// No argument ('selected'): keep the index on the highlighted option, or reset it if that option is gone.
// 'active': move the index to the active option WITHOUT highlighting it; with scrollIntoView it scrolls to it.
updateSelectedOptionIndex(
target?: 'active' | 'selected' | number,
options?: { scrollIntoView?: boolean }
): void;
// Programmatic focus
searchRef: React.RefObject<HTMLInputElement | null>; // Combobox.Search input
targetRef: React.RefObject<HTMLElement | null>;
focusSearchInput(): void;
focusTarget(): void;
}
```
"Selected" option means the option highlighted by keyboard navigation (`data-combobox-selected`),
not the option that holds the current value.
### useVirtualizedCombobox
Store for lists where only some options are in the DOM. It takes the same open/close options as
`useCombobox` plus:
```ts
useVirtualizedCombobox({
totalOptionsCount: number; // number of options after filtering
getOptionId: (index: number) => string | null; // id of the option element, for aria-activedescendant
selectedOptionIndex: number; // highlighted index, kept in your state
setSelectedOptionIndex: (index: number) => void; // called by keyboard navigation
onSelectedOptionSubmit: (index: number) => void; // Enter on the highlighted option
activeOptionIndex?: number; // index of the option that holds the value
isOptionDisabled?: (index: number) => boolean;
})
```
The store only moves the index. You render the highlight (`selected` prop on `Combobox.Option`),
scroll the list (in `setSelectedOptionIndex`) and handle clicks (`onClick` on the option).
DOM-based helpers (`selectActiveOption`, `selectFirstOption`, `updateSelectedOptionIndex`) do not
apply: set the index in your state instead. Full example in patterns.md.
---
## Combobox (root)
```tsx
<Combobox
store={combobox} // required — ComboboxStore from useCombobox()
onOptionSubmit={fn} // (value: string, optionProps) => void
size="sm" // MantineSize | string, default: 'sm'
dropdownPadding={4} // any CSS padding value, 4px when not set
resetSelectionOnOptionHover // boolean — hovering an option removes the keyboard highlight
disabled // boolean — Popover prop, the dropdown cannot be opened
readOnly // boolean — blocks keyboard interactions on the target only;
// option clicks still call onOptionSubmit, and your own
// toggleDropdown() in onClick still opens: guard it yourself
floatingHeight="viewport" // dropdown fills the available viewport height, disables flip
// + all Popover props (position, offset, width, withinPortal, etc.)
/>
```
---
## Sub-components
### Combobox.Target
```tsx
<Combobox.Target
targetType="input" // 'button' | 'input', default: 'input'
withKeyboardNavigation // boolean, default: true
withAriaAttributes // boolean, default: true
withExpandedAttribute // boolean, default: false — adds role="combobox" and aria-expanded
autoComplete="off" // string
refProp="ref" // prop name used to pass the ref to the child
>
{/* single child — the trigger element */}
</Combobox.Target>
```
While the dropdown is open the target element has the `data-expanded` attribute (unless `withAriaAttributes={false}`): use it to style the open state.
Use `targetType="button"` when the trigger is a button: Space and Enter open the dropdown.
With the default `input` type they do not.
### Combobox.DropdownTarget
Marks the element used for dropdown positioning when separate from the keyboard events target. Used together with `Combobox.EventsTarget` in multi-select/pills patterns.
### Combobox.EventsTarget
Receives keyboard events for dropdown navigation. Used alongside `Combobox.DropdownTarget` when the typing input is nested inside the trigger (e.g. inside `PillsInput`).
### Combobox.Dropdown
```tsx
<Combobox.Dropdown hidden={false}>
{/* dropdown content */}
</Combobox.Dropdown>
```
Use `hidden` to hide the dropdown without unmounting it, for example when there are no options to show or the search query is empty. Prefer it to conditionally opening the store.
### Combobox.Options
```tsx
<Combobox.Options labelledBy="some-label-id">
{/* Combobox.Option or Combobox.Group elements */}
</Combobox.Options>
```
### Combobox.Option
```tsx
<Combobox.Option
value="react" // string | number | boolean | bigint, required
active={false} // marks the option that holds the current value: sets data-combobox-active,
// used by selectActiveOption(). Has no styles by default — render a check icon
// or style [data-combobox-active] yourself
selected={false} // sets data-combobox-selected (keyboard highlight) manually
disabled={false}
>
React
</Combobox.Option>
```
### Combobox.Search
Built-in search input for the dropdown, wired to keyboard navigation. Render it before `Combobox.Options`. Focus it with `combobox.focusSearchInput()` in `onDropdownOpen`.
```tsx
<Combobox.Search
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder="Search..."
withAriaAttributes // boolean, default: true
withKeyboardNavigation // boolean, default: true
// + all Input props
/>
```
### Combobox.Empty
```tsx
<Combobox.Empty>Nothing found</Combobox.Empty>
```
### Combobox.Group
`label` accepts any React node. A group with no options inside is hidden.
```tsx
<Combobox.Group label="Frontend">
<Combobox.Option value="react">React</Combobox.Option>
</Combobox.Group>
```
### Combobox.Header / Combobox.Footer
```tsx
<Combobox.Header>Custom header</Combobox.Header>
<Combobox.Footer>Custom footer</Combobox.Footer>
```
### Combobox.Chevron
```tsx
<Combobox.Chevron size="sm" error={error} color="blue" />
```
### Combobox.ClearButton
```tsx
<Combobox.ClearButton onClear={() => setValue(null)} />
```
Rendered with `tabIndex={-1}` and `aria-hidden`: it is a mouse-only shortcut for input triggers, where keyboard users clear the value by deleting the text. For a button trigger use `<CloseButton aria-label="Clear" />` in the `rightSection` instead, so that it is reachable by keyboard.
### Combobox.HiddenInput
```tsx
<Combobox.HiddenInput
value={value} // primitive, array of primitives, or null
valuesDivider="," // string, default: ','
name="myField"
form="myForm"
/>
```
---
## CSS variables & Styles API
### CSS variables (set on the `dropdown` element)
| Variable | Description |
|---|---|
| `--combobox-option-fz` | Option font size (driven by `size` prop) |
| `--combobox-option-padding` | Option padding (driven by `size` prop) |
| `--combobox-padding` | Dropdown padding (driven by `dropdownPadding`, default 4px) |
| `--combobox-floating-options-max-height` | Available height for options when `floatingHeight="viewport"` is set |
### Styles API selectors
| Selector | Element |
|---|---|
| `dropdown` | Dropdown container |
| `options` | Options listbox |
| `option` | Individual option |
| `search` | Search input |
| `empty` | Nothing found message |
| `header` | Dropdown header |
| `footer` | Dropdown footer |
| `group` | Group container |
| `groupLabel` | Group label text |
### Option data attributes
| Attribute | Meaning |
|---|---|
| `data-combobox-selected` | Option highlighted by keyboard navigation (styled by default) |
| `data-combobox-active` | Option with `active` prop (no default styles) |
| `data-combobox-disabled` | Disabled option |
---
## Default styles and overriding them
Pass `classNames` to `Combobox` to style any selector from the table above with a CSS module:
`<Combobox classNames={{ dropdown: classes.dropdown, option: classes.option }} />`.
Default styles use `:where()` selectors with zero specificity, so a plain class rule always wins.
| Element | Default styles |
|---|---|
| `dropdown` | `padding: var(--combobox-padding)` (4px). Border, background, radius and shadow come from Popover: set them with the `radius` and `shadow` props or in CSS |
| `option` | `padding: var(--combobox-option-padding)`, `font-size: var(--combobox-option-fz)`, `border-radius: var(--mantine-radius-default)`, transparent background, pointer cursor |
| `option[data-combobox-selected]` | `background-color: var(--mantine-primary-color-filled)`, white text |
| `option[data-combobox-disabled]` | `opacity: 0.35`, `cursor: not-allowed` |
| `option:hover` (not selected, not disabled) | light: `var(--mantine-color-gray-0)`, dark: `var(--mantine-color-dark-7)` |
| `option[data-combobox-active]` | no styles |
| `header`, `footer` | option padding and font size, negative inline margins that cancel the dropdown padding, 1px border on the side facing the options |
| `groupLabel` | dimmed, 85% of option font size, a line after the text |
| `empty` | centered, dimmed, option padding |
`size` (`xs` to `xl`) sets `--combobox-option-fz` to the theme font size and `--combobox-option-padding`
to `4px 8px`, `6px 10px`, `8px 12px`, `10px 16px`, `14px 20px`. Both variables are inherited, so
content inside an option can use them.
Popover props used most with a custom look: `width` (`'target'` by default, or a number),
`position` (`'bottom-start'`), `offset` (gap in px), `radius`, `shadow`,
`transitionProps={{ transition: 'pop-top-left', duration: 150 }}`, `withArrow`.
references/patterns.md
# Combobox Implementation Patterns
## Table of Contents
- [Basic select (button trigger)](#basic-select-button-trigger)
- [Searchable select (input trigger)](#searchable-select-input-trigger)
- [Multi-select with pills](#multi-select-with-pills)
- [Options with groups](#options-with-groups)
- [Custom option rendering](#custom-option-rendering)
- [Clear button](#clear-button)
- [Form integration (hidden input)](#form-integration-hidden-input)
- [Highlight the current value on open](#highlight-the-current-value-on-open)
- [Creatable option](#creatable-option)
- [Async search](#async-search)
- [Free-text input with suggestions](#free-text-input-with-suggestions)
- [Button inside an option](#button-inside-an-option)
- [Suggestions for a textarea](#suggestions-for-a-textarea)
- [Custom styles](#custom-styles)
- [Virtualized list](#virtualized-list)
- [Custom form input](#custom-form-input)
- [Search inside the dropdown](#search-inside-the-dropdown)
- [Dropdown that fits the viewport](#dropdown-that-fits-the-viewport)
- [Nothing found message](#nothing-found-message)
---
## Basic select (button trigger)
```tsx
function CustomSelect({ data }: { data: string[] }) {
const [value, setValue] = useState<string | null>(null);
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const options = data.map((item) => (
<Combobox.Option value={item} key={item} active={item === value}>
{item}
</Combobox.Option>
));
return (
<Combobox
store={combobox}
onOptionSubmit={(val) => {
setValue(val);
combobox.closeDropdown();
}}
>
<Combobox.Target targetType="button">
<InputBase
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
onClick={() => combobox.toggleDropdown()}
>
{value || <Input.Placeholder>Pick value</Input.Placeholder>}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>{options}</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
```
---
## Searchable select (input trigger)
Input acts as both trigger and search field. Filter options based on the typed value.
```tsx
function SearchableSelect({ data }: { data: string[] }) {
const [value, setValue] = useState<string | null>(null);
const [search, setSearch] = useState('');
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.selectFirstOption(),
});
// When the input holds the label of the selected value, show the full list instead of filtering by it
const shouldFilterOptions = data.every((item) => item !== search);
const filtered = shouldFilterOptions
? data.filter((item) => item.toLowerCase().includes(search.toLowerCase().trim()))
: data;
const options = filtered.length > 0
? filtered.map((item) => (
<Combobox.Option value={item} key={item}>{item}</Combobox.Option>
))
: <Combobox.Empty>Nothing found</Combobox.Empty>;
return (
<Combobox
store={combobox}
onOptionSubmit={(val) => {
setValue(val);
setSearch(val);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={search}
onChange={(e) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(e.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => {
combobox.closeDropdown();
setSearch(value || '');
}}
placeholder="Search value"
rightSectionPointerEvents="none"
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>{options}</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
```
---
## Multi-select with pills
Use `Combobox.DropdownTarget` for the outer container and `Combobox.EventsTarget` around the text input inside it.
```tsx
function MultiSelect({ data }: { data: string[] }) {
const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption() });
const [search, setSearch] = useState('');
const [value, setValue] = useState<string[]>([]);
const handleValueSelect = (val: string) =>
setValue((current) =>
current.includes(val) ? current.filter((v) => v !== val) : [...current, val]
);
const handleValueRemove = (val: string) =>
setValue((current) => current.filter((v) => v !== val));
const options = data
.filter((item) => item.toLowerCase().includes(search.trim().toLowerCase()))
.map((item) => (
<Combobox.Option value={item} key={item} active={value.includes(item)}>
<Group gap="sm">
{value.includes(item) ? <CheckIcon size={12} /> : null}
<span>{item}</span>
</Group>
</Combobox.Option>
));
const pills = value.map((item) => (
<Pill key={item} withRemoveButton onRemove={() => handleValueRemove(item)}>
{item}
</Pill>
));
return (
<Combobox store={combobox} onOptionSubmit={handleValueSelect}>
<Combobox.DropdownTarget>
<PillsInput onClick={() => combobox.openDropdown()}>
<Pill.Group>
{pills}
<Combobox.EventsTarget>
<PillsInput.Field
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
value={search}
placeholder="Search values"
onChange={(e) => {
combobox.updateSelectedOptionIndex();
setSearch(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === 'Backspace' && search.length === 0) {
e.preventDefault();
handleValueRemove(value[value.length - 1]);
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<Combobox.Options>
{options.length > 0 ? options : <Combobox.Empty>Nothing found</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
```
---
## Options with groups
```tsx
const groups = data.map((group) => (
<Combobox.Group label={group.group} key={group.group}>
{group.items.map((item) => (
<Combobox.Option value={item.value} key={item.value}>
{item.label}
</Combobox.Option>
))}
</Combobox.Group>
));
// Inside Combobox.Dropdown:
<Combobox.Options>{groups}</Combobox.Options>
```
---
## Custom option rendering
Render any content inside `Combobox.Option`:
```tsx
const options = data.map((item) => (
<Combobox.Option value={item.value} key={item.value}>
<Group>
<Avatar src={item.avatar} size="sm" />
<div>
<Text size="sm">{item.label}</Text>
<Text size="xs" c="dimmed">{item.email}</Text>
</div>
</Group>
</Combobox.Option>
));
```
---
## Clear button
```tsx
const rightSection = value ? (
<Combobox.ClearButton onClear={() => { setValue(null); setSearch(''); }} />
) : (
<Combobox.Chevron />
);
<InputBase
rightSection={rightSection}
rightSectionPointerEvents={value ? 'all' : 'none'}
/>
```
---
## Form integration (hidden input)
```tsx
// Single value
<Combobox.HiddenInput value={value} name="framework" />
// Multiple values (joined by comma by default)
<Combobox.HiddenInput value={selectedValues} name="frameworks" valuesDivider="," />
```
---
## Highlight the current value on open
Mark the option that holds the value with `active`, then highlight it and scroll it into view when
the dropdown opens:
```tsx
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => {
combobox.selectActiveOption(); // highlight
combobox.updateSelectedOptionIndex('active', { scrollIntoView: true }); // scroll to it
},
});
<Combobox.Option value={item} key={item} active={item === value}>
<Group gap="xs">
{item === value && <CheckIcon size={12} />}
{item}
</Group>
</Combobox.Option>
```
`updateSelectedOptionIndex('active')` alone does not show a highlight.
---
## Creatable option
Add a regular option with a reserved value and handle it in `onOptionSubmit`:
```tsx
const exactMatch = data.some((item) => item.toLowerCase() === search.trim().toLowerCase());
<Combobox
store={combobox}
onOptionSubmit={(val) => {
if (val === '$create') {
onCreate(search.trim());
} else {
onChange(val);
}
setSearch('');
}}
>
{/* target */}
<Combobox.Dropdown>
<Combobox.Options>
{options}
{!exactMatch && search.trim().length > 0 && (
<Combobox.Option value="$create">+ Create "{search.trim()}"</Combobox.Option>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
```
---
## Async search
Keep the request state next to the search value. Highlight the first option in an effect when
results arrive, hide the dropdown for an empty query with `hidden`, and keep focus in the input
when the user clicks Retry.
```tsx
const [search, setSearch] = useState('');
const [debounced] = useDebouncedValue(search, 300);
const [state, setState] = useState<{ status: 'idle' | 'loading' | 'error' | 'done'; items: User[] }>({
status: 'idle',
items: [],
});
const requestId = useRef(0);
const load = (query: string) => {
const id = ++requestId.current;
setState((current) => ({ ...current, status: 'loading' }));
searchUsers(query).then(
(items) => id === requestId.current && setState({ status: 'done', items }),
() => id === requestId.current && setState({ status: 'error', items: [] })
);
};
useEffect(() => {
if (debounced.trim()) {
load(debounced);
}
}, [debounced]);
useEffect(() => {
combobox.selectFirstOption(); // after results render; skips disabled options
}, [state.items]);
<Combobox store={combobox} onOptionSubmit={handleSubmit}>
<Combobox.Target>
<TextInput
value={search}
rightSection={state.status === 'loading' ? <Loader size="xs" /> : null}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.openDropdown();
}}
onBlur={() => combobox.closeDropdown()}
/>
</Combobox.Target>
<Combobox.Dropdown hidden={search.trim() === '' || state.status === 'idle'}>
<Combobox.Options>
{state.status === 'error' ? (
<Combobox.Empty>
Could not load users{' '}
<Anchor
component="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => load(debounced)}
>
Retry
</Anchor>
</Combobox.Empty>
) : state.status === 'done' && state.items.length === 0 ? (
<Combobox.Empty>No users found</Combobox.Empty>
) : (
state.items.map((user) => (
<Combobox.Option value={user.id} key={user.id} disabled={!user.active}>
{user.name}
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
```
---
## Free-text input with suggestions
For a search box the user must be able to submit text that matches no option. Do not highlight
anything automatically; Enter with no highlighted option is not handled by Combobox, so handle it
in your own `onKeyDown`, which runs before the built-in handling:
```tsx
<Combobox store={combobox} onOptionSubmit={handleOptionSubmit}>
<Combobox.Target>
<TextInput
value={query}
onChange={(event) => {
setQuery(event.currentTarget.value);
combobox.resetSelectedOption(); // typing removes the highlight
combobox.openDropdown();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && combobox.getSelectedOptionIndex() === -1) {
onSearch(query);
combobox.closeDropdown();
}
}}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
/>
</Combobox.Target>
<Combobox.Dropdown hidden={suggestions.length === 0 && query.trim() === ''}>
<Combobox.Options>
{suggestions}
{query.trim() !== '' && <Combobox.Option value="$search">Search for "{query}"</Combobox.Option>}
</Combobox.Options>
<Combobox.Footer>↑↓ to navigate · ↵ to select · esc to close</Combobox.Footer>
</Combobox.Dropdown>
</Combobox>
```
Unlike a select, do not restore the previous value on blur and do not call `selectFirstOption()`.
---
## Button inside an option
A button inside `Combobox.Option` (remove a recent item) must not submit the option or take focus:
```tsx
<Combobox.Option value={item} key={item}>
<Group justify="space-between" wrap="nowrap">
{item}
<CloseButton
size="sm"
aria-label={`Remove ${item}`}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onRemove(item);
}}
/>
</Group>
</Combobox.Option>
```
---
## Suggestions for a textarea
For @mentions or /commands the target is a `Textarea` and the dropdown opens from the text around
the caret, not from focus. Turn the built-in keyboard handling off so that arrows and Enter work
normally while the dropdown is closed:
```tsx
const combobox = useCombobox();
// trigger: { start, query } for the "@word" at the caret, or null. Compute it in onChange, onSelect and onClick.
useEffect(() => {
if (trigger && matches.length > 0) {
combobox.openDropdown();
combobox.selectFirstOption(); // after the options render
} else {
combobox.closeDropdown();
}
}, [trigger?.query, matches.length]);
<Combobox store={combobox} width={260} position="bottom-start" onOptionSubmit={insertMention}>
<Combobox.Target withKeyboardNavigation={false}>
<Textarea
value={value}
onChange={handleChange}
onBlur={() => combobox.closeDropdown()}
onKeyDown={(event) => {
if (!combobox.dropdownOpened) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
combobox.selectNextOption();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
combobox.selectPreviousOption();
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
combobox.clickSelectedOption();
} else if (event.key === 'Escape') {
combobox.closeDropdown();
}
}}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>{options}</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
```
In `insertMention`, replace the text from `trigger.start` to the caret and restore the caret with
`setSelectionRange` after the value updates. Clicking an option keeps focus in the textarea.
---
## Custom styles
Style the dropdown with `classNames` and a CSS module. Default option rules have zero specificity,
so these classes override them without `!important`. See "Default styles" in api.md for what you
are overriding.
```tsx
<Combobox
store={combobox}
size="md"
dropdownPadding={8}
radius={12}
shadow="lg"
offset={6}
transitionProps={{ transition: 'pop-top-left', duration: 150 }}
classNames={{ options: classes.options, option: classes.option }}
>
<Combobox.Target targetType="button">
<InputBase component="button" type="button" pointer className={classes.trigger} rightSection={<Combobox.Chevron />}>
{label}
</InputBase>
</Combobox.Target>
{/* dropdown */}
</Combobox>
```
```css
.options {
display: flex;
flex-direction: column;
gap: 4px;
}
.option {
border-radius: 8px;
/* keyboard highlight: replace the default primary background */
&[data-combobox-selected] {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
color: inherit;
}
/* option that holds the value */
&[data-combobox-active] {
background-color: var(--mantine-primary-color-light);
}
&[data-combobox-active][data-combobox-selected] {
background-color: var(--mantine-primary-color-light-hover);
}
&[data-combobox-disabled] {
opacity: 0.5;
}
}
/* open state: the target element gets data-expanded */
.trigger [data-expanded] {
border-color: var(--mantine-primary-color-filled);
}
```
---
## Virtualized list
For thousands of options render only the visible rows. Example with `@tanstack/react-virtual`:
```tsx
const ITEM_HEIGHT = 36;
function Demo({ data }: { data: { value: string; label: string }[] }) {
const [selectedOptionIndex, setSelectedOptionIndex] = useState(-1); // keyboard highlight
const [activeOptionIndex, setActiveOptionIndex] = useState(-1); // option that holds the value
const [value, setValue] = useState('');
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => scrollParent,
estimateSize: () => ITEM_HEIGHT,
overscan: 5,
});
const combobox = useVirtualizedCombobox({
onDropdownOpen: () => {
if (activeOptionIndex !== -1) {
setSelectedOptionIndex(activeOptionIndex);
requestAnimationFrame(() => virtualizer.scrollToIndex(activeOptionIndex, { align: 'auto' }));
}
},
totalOptionsCount: data.length,
getOptionId: (index) => `option-${data[index].value}`,
selectedOptionIndex,
activeOptionIndex,
setSelectedOptionIndex: (index) => {
setSelectedOptionIndex(index);
if (index !== -1) {
virtualizer.scrollToIndex(index, { align: 'auto' });
}
},
onSelectedOptionSubmit: handleSubmit,
});
function handleSubmit(index: number) {
setValue(data[index].value);
setActiveOptionIndex(index);
combobox.closeDropdown();
combobox.resetSelectedOption();
}
return (
<Combobox store={combobox} resetSelectionOnOptionHover={false} keepMounted>
<Combobox.Target targetType="button">
<InputBase component="button" type="button" pointer onClick={() => combobox.toggleDropdown()}>
{value || <Input.Placeholder>Pick a value</Input.Placeholder>}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
<ScrollArea.Autosize
mah={220}
type="scroll"
viewportRef={setScrollParent}
onMouseDown={(event) => event.preventDefault()}
>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<Combobox.Option
value={data[row.index].value}
key={data[row.index].value}
id={`option-${data[row.index].value}`}
active={row.index === activeOptionIndex}
selected={row.index === selectedOptionIndex}
onClick={() => handleSubmit(row.index)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: row.size,
transform: `translateY(${row.start}px)`,
}}
>
{data[row.index].label}
</Combobox.Option>
))}
</div>
</ScrollArea.Autosize>
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
```
- `keepMounted` keeps the scroll element in the DOM so that `scrollToIndex` works when the dropdown opens.
- With search, filter the data first and pass the filtered length as `totalOptionsCount`. After the
query changes, set `selectedOptionIndex` to `0` and call `virtualizer.scrollToIndex(0)`.
- When the value can be set from outside, derive `activeOptionIndex` from the value
(`data.findIndex(...)`) instead of storing it, so that opening scrolls to the right row.
---
## Custom form input
A select built on `Combobox` that works with `form.getInputProps` from `@mantine/form` accepts
`value`, `defaultValue`, `onChange`, `error`, `onFocus` and `onBlur`. Use `useUncontrolled` for the
value, and report blur only when focus leaves both the trigger and the dropdown search:
```tsx
function CountrySelect({ value, defaultValue, onChange, onFocus, onBlur, error, label, readOnly, ...others }: CountrySelectProps) {
const [current, setCurrent] = useUncontrolled<string | null>({ value, defaultValue, finalValue: null, onChange });
const combobox = useCombobox({
onDropdownOpen: () => combobox.focusSearchInput(),
onDropdownClose: () => combobox.resetSelectedOption(),
});
const isInside = (node: EventTarget | null) =>
node !== null && (node === combobox.targetRef.current || node === combobox.searchRef.current);
const handleBlur = (event: React.FocusEvent<HTMLElement>) => {
if (!isInside(event.relatedTarget)) {
onBlur?.(event);
}
};
return (
<Combobox
store={combobox}
onOptionSubmit={(val) => {
setCurrent(val);
combobox.closeDropdown();
combobox.focusTarget();
}}
>
<Combobox.Target targetType="button">
<InputBase
component="button"
type="button"
pointer
label={label}
error={error}
rightSection={<Combobox.Chevron />}
onClick={() => !readOnly && combobox.toggleDropdown()}
onFocus={onFocus}
onBlur={handleBlur}
{...others}
>
{current || <Input.Placeholder>Pick country</Input.Placeholder>}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search value={search} onChange={handleSearchChange} onBlur={handleBlur} />
<Combobox.Options>{options}</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
<CountrySelect label="Country" key={form.key('country')} {...form.getInputProps('country')} />
```
Spread the remaining props onto the trigger: `getInputProps` also passes `data-path`, which
`form.getInputNode` uses to find and focus the field.
`Combobox.Target` can also wrap a small button inside the `leftSection` of a `TextInput` (a country
code picker in a phone input): the dropdown is positioned relative to that button.
---
## Search inside the dropdown
Button trigger with `Combobox.Search` in the dropdown. The button opens the dropdown with its own
`onClick` and the search input handles the keyboard, so `targetType="button"` is not needed here and
`withAriaAttributes={false}` keeps combobox ARIA attributes off the button. Focus the search input when the dropdown
opens and return focus to the target when it closes. Call `combobox.updateSelectedOptionIndex()`
whenever the options list changes, otherwise keyboard navigation keeps the old index.
```tsx
const [search, setSearch] = useState('');
const [value, setValue] = useState<string | null>(null);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
combobox.focusTarget();
setSearch('');
},
onDropdownOpen: () => combobox.focusSearchInput(),
});
const options = data
.filter((item) => item.toLowerCase().includes(search.toLowerCase().trim()))
.map((item) => (
<Combobox.Option value={item} key={item}>
{item}
</Combobox.Option>
));
<Combobox
store={combobox}
width={250}
position="bottom-start"
onOptionSubmit={(val) => {
setValue(val);
combobox.closeDropdown();
}}
>
<Combobox.Target withAriaAttributes={false}>
<Button onClick={() => combobox.toggleDropdown()}>{value || 'Pick item'}</Button>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => {
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
placeholder="Search"
/>
<Combobox.Options>
{options.length > 0 ? options : <Combobox.Empty>Nothing found</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
```
If no custom option markup is needed, `<ComboboxPopover searchable data={data} />` does the same
without any of this code.
---
## Dropdown that fits the viewport
For long lists, let the dropdown take the available viewport height and scroll inside it:
```tsx
<Combobox store={combobox} floatingHeight="viewport" onOptionSubmit={handleSubmit}>
<Combobox.Target>{/* ... */}</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
<ScrollArea.Autosize mah="var(--combobox-floating-options-max-height)" type="scroll">
{options}
</ScrollArea.Autosize>
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
```
---
## Nothing found message
```tsx
<Combobox.Dropdown>
<Combobox.Options>
{filteredOptions.length > 0
? filteredOptions
: <Combobox.Empty>Nothing found for "{search}"</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
```
SKILL.md
---
name: mantine-combobox
description: >
Build custom dropdown/select/autocomplete/multiselect components using Mantine's Combobox
primitives. Use this skill when: (1) creating a new custom select-like component with
Combobox primitives, (2) building a searchable dropdown, (3) implementing a multi-select
or tags input variant, (4) customizing option rendering, (5) adding custom filtering logic,
or (6) any task involving useCombobox, Combobox.Target, Combobox.Option, or Combobox.Dropdown.
---
# Mantine Combobox Skill
Written for Mantine 9.x.
## Overview
`Combobox` provides low-level primitives for building any select-like UI. The built-in
`Select`, `MultiSelect`, `Autocomplete`, and `TagsInput` components are all built on top of it.
## Check for a ready-made component first
Build with `Combobox` primitives only when none of these fit:
| Need | Use |
|---|---|
| Standard select, multi-select, autocomplete or tags input | `Select`, `MultiSelect`, `Autocomplete`, `TagsInput` (all support `renderOption`) |
| Options dropdown attached to a button or any other element, no input | `ComboboxPopover` (data-driven: `data`, `value`, `onChange`, `searchable`, `multiple`) |
| Hierarchical options | `TreeSelect` (see below) |
| More than a few hundred options | `Combobox` with `useVirtualizedCombobox`, see the virtualized pattern |
```tsx
<ComboboxPopover data={['React', 'Angular', 'Vue']} value={value} onChange={setValue}>
<ComboboxPopover.Target>
<Button variant="default">{value || 'Select framework'}</Button>
</ComboboxPopover.Target>
</ComboboxPopover>
```
`ComboboxPopover` opens on target click by itself, marks the selected option with a check icon and
handles keyboard navigation. Useful props: `searchable`, `multiple` (value becomes `string[]`, the
dropdown stays open while picking), `allowDeselect={false}` (one option is always selected),
`nothingFoundMessage`, `renderOption`, `limit`, `maxDropdownHeight`, `name` (hidden input for forms),
`comboboxProps={{ width: 220, position: 'bottom-start' }}`. `data` supports `disabled` items and groups
like `Select`: `[{ group: 'Size', items: [{ value: 'xs', label: 'XS' }] }]`. It cannot render custom dropdown content (tabs, header, footer, create actions): use
`Combobox` primitives for that.
`TreeSelect` takes nested `data` (`{ value, label, children? }[]`, unique values) and renders an
input with an expandable tree in the dropdown. Props include `mode` (single, multiple, or checkbox
with parent-child cascade), `searchable`, `clearable`, `renderNode`. Build the tree from `Combobox`
primitives instead when you need a different layout: only leaves selectable, a flat list of search
results with full paths, or a button trigger. In that case render every visible row as a
`Combobox.Option` and decide in `onOptionSubmit` whether the row selects or expands.
## Core Workflow
### 1. Create the store
```tsx
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.selectFirstOption(),
});
```
### 2. Render structure
```tsx
<Combobox store={combobox} onOptionSubmit={handleSubmit}>
<Combobox.Target targetType="button">
<InputBase
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
onClick={() => combobox.toggleDropdown()}
>
{value || <Input.Placeholder>Pick value</Input.Placeholder>}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{options.map((item) => (
<Combobox.Option value={item} key={item}>{item}</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
```
### 3. Handle submit
```tsx
const handleSubmit = (val: string) => {
setValue(val);
combobox.closeDropdown();
};
```
## Target Types
| Scenario | Use |
|---|---|
| Button trigger (no text input) | `<Combobox.Target targetType="button">` |
| Input trigger | `<Combobox.Target>` (default) |
| Pills + separate input (multi-select) | `<Combobox.DropdownTarget>` + `<Combobox.EventsTarget>` |
## What Combobox does for you
- The target handles keyboard: ArrowUp/ArrowDown move the highlight and skip disabled options (and
open the dropdown when it is closed), Enter submits the highlighted option, Escape closes the
dropdown. With `targetType="button"`, Space and Enter also open it. Tab is not handled.
- Enter does nothing when no option is highlighted, so a free-text input can handle it itself.
- Nothing is highlighted until the user presses an arrow key, unless you call `selectFirstOption()`
or `selectActiveOption()`.
- Clicking an option does not move focus away from the input, and a disabled option never calls
`onOptionSubmit`.
- `Combobox.Option` forwards `data-*` attributes and event handlers to its element.
- `Combobox.Search` handles the same keys as the target. Your own `onKeyDown` on the target child or
on `Combobox.Search` is called first, then the built-in handling runs, so extra keys (PageUp,
ArrowRight) can be added there. To take the keyboard over completely (a textarea, where arrows
must move the caret), set `withKeyboardNavigation={false}` on `Combobox.Target` and call
`selectNextOption`, `selectPreviousOption` and `clickSelectedOption` yourself.
- Defaults: the dropdown is as wide as the target (`width="target"`), rendered in a portal, kept
mounted while closed, opens without animation, and hovering an option does not change the
keyboard highlight.
- The dropdown renders in a portal above modals and drawers and is not clipped by them. Inside a
`Modal`, Escape closes only the dropdown; a second Escape closes the modal. No extra code is needed.
- An option does not have to select a value: `onOptionSubmit` can toggle, expand a row or run an
action, and decides itself whether to close the dropdown.
- Other interactive content in the dropdown (buttons in `Combobox.Header`, a Retry link) is not
handled: add `onMouseDown={(event) => event.preventDefault()}` to it so the input keeps focus and
an `onBlur` handler does not close the dropdown.
Two words that are easy to mix up:
- **selected** option: the one highlighted by keyboard navigation (`data-combobox-selected`).
- **active** option: the one that holds the current value, marked with the `active` prop
(`data-combobox-active`). It has no styles by default: render a check icon.
## References
Read both before building anything beyond the basic select above:
- **[`references/patterns.md`](references/patterns.md)** — complete examples: searchable select (input trigger), free-text input with suggestions, suggestions for a textarea (mentions), custom styles with a CSS module, virtualized list for thousands of options, custom form input built on Combobox, search inside the dropdown, multi-select with pills, creatable option, async search with loading and error states, groups, custom option rendering, highlighting the current value on open, clear button, form integration, dropdown that fits the viewport
- **[`references/api.md`](references/api.md)** — `useCombobox` options and every store method with what it does, default styles and how to override them, `useVirtualizedCombobox`, all sub-component props, CSS variables, Styles API selectors
## Looking things up
If the references do not cover what you need, do not guess:
- If the Mantine MCP server (`@mantine/mcp-server`) is connected, use `search_docs` and `get_item_doc`
- Otherwise fetch `https://mantine.dev/llms.txt` and open the Combobox page. More than 50 complete examples are at `https://mantine.dev/combobox/`