Select
A Select is a form control that lets users pick one or more options from a dropdown list. It is built on top of the Popover component and supports sections, search filtering, multi-select, and keyboard navigation out of the box.
Key Characteristics
- Flexible Data: Provide options via the
itemsprop or compose them declaratively withSelect.SectionandSelect.Item. - Multi-Select: Toggle
multipleto allow selecting more than one option, with chip-based value display. - Searchable: Enable the
searchprop to let users filter options by typing. - Accessible: Full keyboard navigation and focus management built in.
Import
There are 4 select-related components:
Select: The main select component.SelectItem: An individual option.SelectSection: A group of options with an optional title.SelectDivider: A visual separator between sections.
import { Select, SelectItem, SelectSection, SelectDivider } from '@andrejground/lab';
Compound pattern is also supported:
Select.ItemSelect.SectionSelect.Divider
Customization
Add your styles and your component is ready to be used.
- MySelect.tsx
- MySelect.module.scss
import { OptionItem, Select, SelectProps } from '@andrejground/lab';
import styles from './MySelect.module.scss';
type Props<T extends OptionItem> = SelectProps<T>;
function MySelect<T extends OptionItem>(props: Props<T>) {
return (
<Select
{...props}
classNames={{
popover: { content: styles.popoverContent },
trigger: {
base: styles.triggerBase,
valueChip: styles.triggerValueChip,
},
section: { title: styles.sectionTitle },
item: { base: styles.itemBase },
}}
/>
);
}
MySelect.Section = Select.Section;
MySelect.Item = Select.Item;
MySelect.Divider = Select.Divider;
export default MySelect;
.popoverContent {
background-color: var(--background-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.triggerBase {
background-color: var(--background-color);
color: var(--text-color);
border-color: var(--border-color);
}
.sectionTitle {
background-color: var(--section-bg-color);
}
.itemBase {
&:hover,
&:active,
&:focus-visible,
&:focus-within {
background-color: var(--item-hover-bg-color);
}
}
.triggerValueChip {
background-color: var(--chip-bg-color);
border-color: transparent;
outline-color: transparent;
border-radius: 4px;
color: inherit;
}
Usage
- Preview
- Code
import { Select, type OptionItem } from '@andrejground/lab';
const ITEMS: OptionItem[] = [
{ value: 'cat', text: 'Cat' },
{ value: 'dog', text: 'Dog' },
{ value: 'rabbit', text: 'Rabbit' },
{ value: 'mouse', text: 'Mouse' },
{ value: 'snake', text: 'Snake' },
];
export default function App() {
return (
<Select
items={ITEMS}
placeholder="Select an animal"
label="Favorite animal"
openOnLabelClick
/>
);
}
Sections
Use Select.Section and Select.Item to group options with titles and descriptions.
- Preview
- Code
import { Select, type OptionItem } from '@andrejground/lab';
const SELECT_ITEMS: { section: string; items: OptionItem[] }[] = [
{
section: 'Animals',
items: [
{ value: 'cat', text: 'Cat', description: 'A small domesticated carnivorous mammal' },
{ value: 'dog', text: 'Dog', description: 'A domesticated carnivorous mammal' },
{ value: 'rabbit', text: 'Rabbit', description: 'A small domesticated herbivorous mammal' },
],
},
{
section: 'Cars',
items: [
{ value: 'toyota', text: 'Toyota', description: 'A Japanese automotive manufacturer' },
{ value: 'bmw', text: 'BMW', description: 'A German automotive manufacturer' },
{ value: 'honda', text: 'Honda', description: 'A Japanese automotive manufacturer' },
],
},
];
export default function App() {
return (
<Select
label="Grouped items"
openOnLabelClick
placeholder="Select an item"
>
{SELECT_ITEMS.map(({ section, items }, index) => (
<Select.Section
title={section}
key={section}
showDivider={index !== SELECT_ITEMS.length - 1}
>
{items.map((item) => (
<Select.Item key={item.value} {...item}>
{item.text}
</Select.Item>
))}
</Select.Section>
))}
</Select>
);
}
Multiple
Enable multiple to allow selecting more than one option. Selected values appear as removable chips.
- Preview
- Code
import { Select, type OptionItem } from '@andrejground/lab';
const ITEMS: OptionItem[] = [
{ value: 'cat', text: 'Cat' },
{ value: 'dog', text: 'Dog' },
{ value: 'rabbit', text: 'Rabbit' },
{ value: 'mouse', text: 'Mouse' },
{ value: 'snake', text: 'Snake' },
{ value: 'bird', text: 'Bird' },
{ value: 'fish', text: 'Fish' },
];
export default function App() {
return (
<Select
items={ITEMS}
placeholder="Select animals"
label="Multiple selection"
openOnLabelClick
multiple
/>
);
}
Searchable
Enable search to let users filter options by typing in the trigger input.
- Preview
- Code
import { Select, type OptionItem } from '@andrejground/lab';
const ITEMS: OptionItem[] = [
{ value: 'cat', text: 'Cat' },
{ value: 'dog', text: 'Dog' },
{ value: 'rabbit', text: 'Rabbit' },
{ value: 'mouse', text: 'Mouse' },
{ value: 'snake', text: 'Snake' },
{ value: 'bird', text: 'Bird' },
{ value: 'fish', text: 'Fish' },
];
export default function App() {
return (
<Select
items={ITEMS}
placeholder="Search animals"
label="Searchable select"
openOnLabelClick
search
/>
);
}
Add New Option
Enable isAddNewOption together with search to let users add custom options that don't exist in the list. When the search query doesn't match any existing option, an "Add <value>" action appears.
- Preview
- Code
import { Select, type OptionItem } from '@andrejground/lab';
const ITEMS: OptionItem[] = [
{ value: 'cat', text: 'Cat' },
{ value: 'dog', text: 'Dog' },
{ value: 'rabbit', text: 'Rabbit' },
{ value: 'mouse', text: 'Mouse' },
{ value: 'snake', text: 'Snake' },
{ value: 'bird', text: 'Bird' },
{ value: 'fish', text: 'Fish' },
];
export default function App() {
return (
<Select
items={ITEMS}
placeholder="Search or add animals"
label="Add new option"
openOnLabelClick
search
isAddNewOption
/>
);
}
Multi-Select with Search
Combine multiple, search, and popOnSelection for an autocomplete-style multi-select. Selected options are removed from the dropdown list.
- Preview
- Code
import { Select, type OptionItem } from '@andrejground/lab';
const ITEMS: OptionItem[] = [
{ value: 'cat', text: 'Cat' },
{ value: 'dog', text: 'Dog' },
{ value: 'rabbit', text: 'Rabbit' },
{ value: 'mouse', text: 'Mouse' },
{ value: 'snake', text: 'Snake' },
{ value: 'bird', text: 'Bird' },
{ value: 'fish', text: 'Fish' },
];
export default function App() {
return (
<Select
items={ITEMS}
placeholder="Search animals"
label="Multi-select with search"
openOnLabelClick
multiple
search
popOnSelection
/>
);
}
Truncation Off
With all truncate options set to false, long option text, descriptions, and selected value chips wrap naturally into multiple lines.
- Preview
- Code
import { Select, type OptionItem } from '@andrejground/lab';
const ITEMS: OptionItem[] = [
{
value: 'long-1',
text: 'A very long option label that should wrap into multiple lines',
description: 'This is a very detailed description that explains the option in great length and should also wrap',
},
{
value: 'long-2',
text: 'Another extremely verbose option text that keeps going and going',
description: 'Yet another long description that provides extensive context about this particular option',
},
{
value: 'long-3',
text: 'One more ridiculously long option name to demonstrate wrapping behavior',
description: 'A comprehensive description that goes on to explain every single detail about this item',
},
];
export default function App() {
return (
<Select
items={ITEMS}
placeholder="Select items"
label="Truncation off (text wraps)"
openOnLabelClick
multiple
truncate={{
valueText: false,
valueChipText: false,
itemText: false,
itemDescription: false,
sectionTitle: false,
}}
/>
);
}
Truncation On
With all truncate options set to true, long text is clipped to a single line with an ellipsis - for item text, item descriptions, value chips, and the selected value display.
- Preview
- Code
import { Select, type OptionItem } from '@andrejground/lab';
const ITEMS: OptionItem[] = [
{
value: 'long-1',
text: 'A very long option label that should be truncated with an ellipsis',
description: 'This is a very detailed description that explains the option in great length and should also be truncated',
},
{
value: 'long-2',
text: 'Another extremely verbose option text that keeps going and going',
description: 'Yet another long description that provides extensive context about this particular option',
},
{
value: 'long-3',
text: 'One more ridiculously long option name to demonstrate truncation behavior',
description: 'A comprehensive description that goes on to explain every single detail about this item',
},
];
export default function App() {
return (
<Select
items={ITEMS}
placeholder="Select items"
label="Truncation on (text truncated)"
openOnLabelClick
multiple
truncate={{
valueText: true,
valueChipText: true,
itemText: true,
itemDescription: true,
sectionTitle: true,
}}
/>
);
}
Async & Infinite Scroll
Use infiniteScrollProps to load more options as the user scrolls. Combine with search, onSearchChange, and debounceCallback for a full async autocomplete experience.
- Preview
- Code
import React from 'react';
import { Select, debounceCallback, type OptionItem } from '@andrejground/lab';
// Example hook that fetches paginated data
function usePokemonList() {
const [items, setItems] = React.useState<OptionItem[]>([]);
const [hasMore, setHasMore] = React.useState(true);
const [isLoading, setIsLoading] = React.useState(false);
const [offset, setOffset] = React.useState(0);
const limit = 8;
const loadPokemon = React.useCallback(async (currentOffset: number) => {
setIsLoading(true);
const res = await fetch(
`https://pokeapi.co/api/v2/pokemon?offset=${currentOffset}&limit=${limit}`,
);
const json = await res.json();
setHasMore(json.next !== null);
setItems((prev) => {
const loaded = json.results.map((p) => ({ text: p.name, value: p.url }));
return [...new Map([...prev, ...loaded].map((i) => [i.value, i])).values()];
});
setIsLoading(false);
}, []);
React.useEffect(() => { loadPokemon(0); }, []);
const onLoadMore = React.useCallback(
({ search, newOffset = offset } = {}) => {
const next = newOffset + limit;
setOffset(next);
loadPokemon(next);
},
[offset, loadPokemon],
);
return { items, hasMore, isLoading, onLoadMore };
}
export default function App() {
const { items, isLoading, onLoadMore, hasMore } = usePokemonList();
const { callback: debouncedSearch } = debounceCallback(
(searchQuery?: string) => onLoadMore({ newOffset: 0, search: searchQuery }),
500,
);
return (
<Select
openOnLabelClick
items={items}
truncate={{
itemText: true,
valueChipText: true,
itemDescription: true,
sectionTitle: true,
valueText: true,
}}
multiple
label="Async infinite scroll"
search
popOnSelection
onSearchChange={debouncedSearch}
placeholder="Select pokémons"
infiniteScrollProps={{
onLoadMore: (searchVal) => onLoadMore({ search: searchVal }),
hasMore,
isLoading,
}}
/>
);
}
Controlled
- Preview
- Code
falseimport React from 'react';
import { Select, type OptionItem } from '@andrejground/lab';
const ITEMS: OptionItem[] = [
{ value: 'cat', text: 'Cat' },
{ value: 'dog', text: 'Dog' },
{ value: 'rabbit', text: 'Rabbit' },
{ value: 'mouse', text: 'Mouse' },
{ value: 'snake', text: 'Snake' },
];
export default function App() {
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<Select
items={ITEMS}
placeholder="Select an animal"
label="Controlled select"
openOnLabelClick
isOpen={isOpen}
onOpenChange={setIsOpen}
/>
<br />
<div>
Open: <code>{`${isOpen}`}</code>
</div>
</>
);
}
API
Select Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((item: T) => ReactNode) | - | Select content using composition pattern or render function |
trigger | ReactNode | - | Element that triggers the select |
items | OptionItem[] | - | The list of selectable options |
value | T[] | - | The currently selected options (controlled mode) |
defaultValue | T[] | - | The initially selected options (uncontrolled mode) |
placeholder | string | "Select" | Placeholder text shown when no value is selected |
multiple | boolean | false | Enables multi-select mode |
search | boolean | ((items: T[]) => T[]) | - | Enables search filtering; pass a function for custom filtering |
label | ReactNode | - | Label displayed above or beside the select |
isRequired | boolean | - | Marks the select as required and shows an asterisk |
fullWidth | boolean | false | Makes the select take the full width of its container |
isOpen | boolean | - | Controls the select open state (controlled mode) |
isDisabled | boolean | - | Disables the select trigger |
isLoading | boolean | - | Shows a loading indicator inside the select |
autoFocus | "first-item" | "last-item" | "menu" | "none" | "menu" | Controls which item receives focus when the select opens |
size | PopoverSize | "trigger" | Controls the width of the select popover |
backdrop | Backdrop | - | The backdrop style displayed behind the select |
shouldFlip | boolean | true | Flips placement when there is not enough space |
shouldBlockScroll | boolean | false | Blocks page scroll when the select is open |
shouldCloseOnScroll | boolean | false | Closes the select when the page is scrolled |
shouldCloseOnClickOutside | boolean | true | Closes the select when clicking outside of it |
shouldCloseOnEsc | boolean | true | Closes the select when the Escape key is pressed |
shouldCloseOnSelection | boolean | - | Whether selecting an item closes the select |
showArrow | boolean | false | Shows an arrow pointing toward the trigger |
offset | number | - | Distance in pixels between the select and the trigger |
growContent | boolean | - | Allows the select content to grow beyond the trigger width |
openOnLabelClick | boolean | - | Opens the select when the label is clicked |
popOnSelection | boolean | - | Removes selected items from the options list |
isAddNewOption | boolean | - | Shows "Add <value>" option when no results are found |
addNewLabel | string | "Add" | Custom label prefix for the "Add new" option |
clearSearchOnSelection | boolean | true | Clears the search input when an option is selected |
topContent | ReactNode | - | Content rendered above the listbox |
bottomContent | ReactNode | - | Content rendered below the listbox |
caret | ReactNode | - | Custom caret element replacing the default indicator |
showCaret | boolean | - | Shows or hides the caret indicator |
truncate | SelectTruncate | { itemText: false, valueChipText: true, itemDescription: false, sectionTitle: true, valueText: true } | Controls text truncation for various select parts |
description | ReactNode | - | Helper text displayed below the select |
errorMessage | ReactNode | - | Error message displayed below the select |
noResultsMessage | ReactNode | - | Message displayed when search yields no results |
renderOption | RenderOption | - | Custom render function for each option in the listbox |
renderValue | (selectedItems: T[]) => ReactNode | - | Custom render function for the selected value display |
infiniteScrollProps | InfiniteScrollProps | - | Configuration for infinite scrolling within the listbox |
onSelectionChange | (value: T[], action: "added" | "removed", item: T) => void | - | Callback fired when the selection changes |
onClose | (selectedItems?: T[]) => void | - | Callback fired when the select popover closes |
onSearchChange | (searchQuery: string) => void | - | Callback fired when the search query changes |
onAddNewOption | (newOption: T) => void | - | Callback fired when a new option is added |
onOpen | () => void | - | Callback fired when the select opens |
onClickOutside | () => void | - | Callback fired when clicking outside the select |
onOpenChange | (isOpen: boolean) => void | - | Callback fired when the open state changes |
onTriggerFocus | () => void | - | Callback fired when the trigger receives focus |
onTriggerBlur | () => void | - | Callback fired when the trigger loses focus |
triggerWrapper | boolean | - | Wraps the trigger in a span instead of using Slot |
fullWidthTriggerWrapper | boolean | - | Makes the trigger wrapper take full width |
focusTrapProps | { trapFocus?: boolean; autoFocus?: boolean } | { trapFocus: true, autoFocus: autoFocus === "none" } | Configuration for focus trap behavior |
classNames | SelectClassNames | - | Custom class names for the select slots |
Select.Item Props
Extends OptionItem (value, text, textContent, description, disabled).
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Item content |
value | string | number | required | Unique identifier for the option |
text | string | required | Display text for the option |
shouldCloseOnSelection | boolean | true | Whether selecting this item closes the select |
disabled | boolean | - | Disables the item |
showDisabledStyles | boolean | true | Shows disabled visual styles without disabling interaction |
startContent | ReactNode | - | Content rendered before the item text |
endContent | ReactNode | - | Content rendered after the item text |
description | ReactNode | - | Additional description displayed below the option text |
onAddNewOption | (newOption: T) => void | - | Callback fired when a new option is added via this item |
truncate | SelectItemTruncate | - | Override global truncate settings for this item |
classNames | SelectItemClassNames | - | Custom class names for the item slots |
Select.Section Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Section content |
title | ReactNode | - | Title displayed above the section |
isStickyTitle | boolean | true | Keeps the section title visible while the section scrolls |
showDivider | boolean | - | Shows a visual divider below this section |
truncate | SelectSectionTruncate | - | Override global truncate settings for this section |
classNames | SelectSectionClassNames | - | Custom class names for the section slots |
Select.Divider Props
| Prop | Type | Default | Description |
|---|---|---|---|
classNames | SelectDividerClassNames | - | Custom class names for the divider slots |