icon

Morainev0.5.0

Select
formsselect

Select

Dropdown select component with search and custom item rendering.

Import#

import { Select } from 'moraine'

Slot Structure#

Trigger control and a floating listbox with grouped options. The control or search input keeps focus while options provide selection, highlight, and active-descendant semantics.

Control#

control
├── leading (Icon, optional)
├── input
├── clear (IconButton, optional)
└── trigger (IconButton)

Listbox#

content (portal)
├── listbox
│ ├── item (×n)
│ │ ├── itemLabel
│ │ ├── itemDescription (optional)
│ │ └── itemTrailing (optional)
│ └── group (×n, optional)
│ └── label (optional)
└── empty (optional, no matches)

Examples#

Single Select#

Basic single selection with controlled value.

function SingleSelect() {
const FRUIT_OPTIONS: SelectT.Item[] = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Date', value: 'date' },
{ label: 'Elderberry', value: 'elderberry', disabled: true },
{ label: 'Forest', value: 'forest', icon: 'i-lucide:braces' },
]
const [singleValue, setSingleValue] = createSignal<SelectT.Value | null>(null)
return (
<div class="w-80 space-y-2">
<Select
options={FRUIT_OPTIONS}
value={singleValue()}
onChange={setSingleValue}
placeholder="Pick a fruit..."
/>
<p class="text-xs text-muted-foreground">Selected: {singleValue() ?? 'none'}</p>
</div>
)
}

Variants#

Visual style variants.

function Variants() {
const FRUIT_OPTIONS: SelectT.Item[] = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Date', value: 'date' },
{ label: 'Elderberry', value: 'elderberry', disabled: true },
{ label: 'Forest', value: 'forest', icon: 'i-lucide:braces' },
]
const VARIANTS = ['outline', 'subtle', 'ghost', 'none'] as const
return (
<div class="gap-3 grid w-80 sm:grid-cols-2">
<For each={VARIANTS}>
{(variant) => <Select options={FRUIT_OPTIONS} variant={variant} placeholder={variant} />}
</For>
</div>
)
}

Sizes#

From xs to xl.

function Sizes() {
const FRUIT_OPTIONS: SelectT.Item[] = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Date', value: 'date' },
{ label: 'Elderberry', value: 'elderberry', disabled: true },
{ label: 'Forest', value: 'forest', icon: 'i-lucide:braces' },
]
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const
return (
<div class="gap-3 grid w-[42rem] md:grid-cols-5 sm:grid-cols-3">
<For each={SIZES}>
{(size) => <Select options={FRUIT_OPTIONS} size={size} placeholder={`Size: ${size}`} />}
</For>
</div>
)
}

Disabled#

Non-interactive state.

function Disabled() {
const FRUIT_OPTIONS: SelectT.Item[] = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Date', value: 'date' },
{ label: 'Elderberry', value: 'elderberry', disabled: true },
{ label: 'Forest', value: 'forest', icon: 'i-lucide:braces' },
]
return (
<div class="w-80">
<Select options={FRUIT_OPTIONS} disabled value="apple" placeholder="Pick..." />
</div>
)
}

Searchable#

Type to filter options.

function Searchable() {
const FRUIT_OPTIONS: SelectT.Item[] = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Date', value: 'date' },
{ label: 'Elderberry', value: 'elderberry', disabled: true },
{ label: 'Forest', value: 'forest', icon: 'i-lucide:braces' },
]
return (
<div class="w-80">
<Select
options={FRUIT_OPTIONS}
search
leadingIcon="i-lucide-search"
placeholder="Search fruits..."
/>
</div>
)
}

Grouped Options#

Options organized in sections.

function GroupedOptions() {
const GROUPED_OPTIONS: SelectT.Item[] = [
{
label: 'Fruits',
children: [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
],
},
{
label: 'Vegetables',
children: [
{ label: 'Carrot', value: 'carrot' },
{ label: 'Broccoli', value: 'broccoli' },
{ label: 'Spinach', value: 'spinach' },
],
},
]
return (
<div class="w-80">
<Select options={GROUPED_OPTIONS} placeholder="Pick an item..." />
</div>
)
}

Infinite Scroll#

Scroll to the bottom to load more options.

function InfiniteScroll() {
function makeOptions(count: number, offset = 0): SelectT.Item[] {
return Array.from({ length: count }, (_, i) => ({
label: `Option ${offset + i + 1}`,
value: `opt-${offset + i + 1}`,
}))
}
const [infiniteOptions, setInfiniteOptions] = createSignal<SelectT.Item[]>(makeOptions(20))
const [loadingMore, setLoadingMore] = createSignal(false)
return (
<div class="w-80 space-y-2">
<Select
options={infiniteOptions()}
classes={{
listbox: 'max-h-100',
}}
onScrollBottom={() => {
if (loadingMore()) {
return
}
setLoadingMore(true)
setTimeout(() => {
const next = infiniteOptions().length
setInfiniteOptions((prev) => [...prev, ...makeOptions(10, next)])
setLoadingMore(false)
}, 1000)
}}
scrollBottomThreshold={30}
loading={loadingMore()}
placeholder="Scroll to load more..."
/>
<p class="text-xs text-muted-foreground">Total options: {infiniteOptions().length}</p>
</div>
)
}

Virtual Rendering#

Import useListVirtualizer from moraine/utils to render only visible entries. Pass its virtualRender to Select and forward scrollToItem to its scrollToIndex method so keyboard highlighting can reveal off-screen options.

Install the adapter’s optional peer dependency before using it:

Terminal window
bun add @tanstack/virtual-core
function Virtualization() {
const virtualizer = useListVirtualizer<
SelectT.VirtualEntry<string>,
HTMLDivElement,
HTMLDivElement
>({
estimateSize: (entry) => (entry.type === 'label' ? 30 : 32),
getItemKey: (entry) => entry.key,
overscan: 8,
})
return (
<div class="w-80">
<Select
options={OPTIONS}
placeholder="Pick one of 10,000 options..."
virtualRender={virtualizer.virtualRender}
scrollToItem={(_, entryIndex) => virtualizer.scrollToIndex(entryIndex)}
classes={{ listbox: 'h-80 max-h-80' }}
/>
</div>
)
}

API Reference#

Attributes#

Slotroot3 attributes
Select root that owns open state, value display, and popup positioning.

Data Attributes

3
Data AttributeDescription
data-disabled
Present when the component or item is disabled.
data-invalid
Present when the field has a validation error.
data-required
Present when the field is required.

Props#

PropTypeDefaultDescription
classClassValue
Class applied to the component root or trigger element.
classesSelectT.Classes | undefined
closeIconIconT.Name
Icon kept for API compatibility; Select has no clear action.
defaultOpenboolean | undefinedfalse
Initial open state.
defaultSearchValuestring | undefined
Default search value.
defaultValueTItem | null | undefined
The default value of the input (uncontrolled).
disabledboolean | undefinedfalse
Whether the input is disabled.
emptyRenderComponentOrElement<SelectT.EmptyRenderProps<TItem>> | undefined
Custom renderer for the empty state when current filtered result has no matches.
filterOptionboolean | "startsWith" | "endsWith" | "contains" | ((inputValue: string, option: SelectT.Item<TItem>) => boolean) | undefinedtrue
Filter function or boolean. `false` disables filtering.
gutternumber | undefined0
Gap (px) between the control and popup content.
idstring | undefined
The ID of the input element.
itemProps((option: SelectT.Item<TItem> & BaseSelectT.OptionRenderState) => ElementProps<HTMLDivElement> | undefined) | undefined
Additional attributes for an option row.
labelRenderComponentOrElement<SelectT.LabelRenderProps<TItem>> | undefined
Custom renderer for the option label text.
leadingIconIconT.Name
Icon shown before the input/value area.
listboxPropsElementProps<HTMLDivElement> | undefined
Additional attributes for the listbox element.
loadingboolean | undefined
Whether the select is in a loading state.
loadingIconIconT.Nameicon-loading
Icon shown during loading state.
namestring | undefined
The name of the input element, used for form submission.
onChange((value: NoInfer<TItem | null>) => void) | undefined
Called when the selection changes.
onOpenChange((open: boolean) => void) | undefined
Called whenever the popup open state changes.
onScrollBottom(() => void) | undefined
Called when the listbox is scrolled to bottom. Useful for infinite loading scenarios. Make sure to set `overflowPadding` and `scrollBottomThreshold` appropriately to ensure the callback is triggered at the right time.
onSearch((value: string) => void) | undefined
Called when the search input changes.
openboolean | undefined
Controlled open state.
optionRenderComponentOrElement<SelectT.OptionRenderProps<TItem>> | undefined
Custom renderer for each option in the dropdown. Passes `null` for empty state.
optionsSelectT.Item<TItem>[] | undefined
Available options.
overflowPaddingnumber | undefined4
Padding (px) used when calculating popup overflow and viewport collision.
placeholderstring | undefined
Placeholder text shown when no value is selected.
refJSX.HTMLElementTags["div"] extends { ref?: infer Ref; } ? Ref : never | undefined
requiredboolean | undefinedfalse
Whether the input is required.
scrollBottomThresholdnumber | undefined20
Distance (px) from the bottom at which onScrollBottom fires.
scrollToItem((item: SelectT.Item<TItem>, entryIndex: number) => void) | undefined
Scrolls a highlighted option into view using its flattened entry index.
searchboolean | undefinedfalse
Enable search input.
searchMaxLengthnumber | undefined
Maximum search text length applied on final commit.
searchValuestring | undefined
Controlled search value.
size"xs" | "sm" | "md" | "lg" | "xl" | undefined
styleJSX.CSSProperties | undefined
stylesSelectT.Styles | undefined
trailingIconIconT.Nameicon-chevron-down
Icon for the dropdown trigger.
valueTItem | null | undefined
The current value of the input (controlled).
variant"none" | "outline" | "ghost" | "subtle" | undefined
virtualRenderComponent<SelectT.VirtualRenderProps<TItem>> | undefined
Renders flattened group labels and options through a virtualization layer.

Items#

PropTypeDefaultDescription
childrenOmit<BaseSelectT.Item<SelectT.Value>, "children">[] | undefined
One-layer child options for grouped select.
descriptionstring | JSX.Element
Description shown below the label.
disabledboolean | undefined
Whether the option is disabled.
iconIconT.Name
Icon shown next to the label.
keystring | undefined
Text key used for filtering and matching; set this when `label` is not a string.
labelstring | JSX.Element
Label to display for the option, or the option group title.
valueSelectT.Value | undefined
Value of the option.