Select
Select is a composable, Windows 98 styled dropdown built from small parts, following the shadcn/ui structure. Compose a SelectTrigger (with a SelectValue) and a SelectContent of SelectItems. For a plain browser dropdown with native form and mobile semantics, use SelectNative.
Composition
Use the following composition to build a Select:
Select
├── SelectTrigger
│ └── SelectValue
└── SelectContent
├── SelectItem
├── SelectSeparator
└── SelectGroup
├── SelectLabel
├── SelectItem
└── SelectItemProps
Select
The root. Owns selection state and provides context to the parts. Renders no visible element of its own (plus a hidden form input when name is set).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | Selected value (controlled). |
defaultValue | string | - | Initial selected value (uncontrolled). |
onValueChange | (value: string) => void | - | Called with the next value on selection. |
open | boolean | - | Popup open state (controlled). |
defaultOpen | boolean | false | Initial popup open state (uncontrolled). |
onOpenChange | (open: boolean) => void | - | Called when the popup opens or closes. |
disabled | boolean | false | Disables the trigger and prevents opening. |
name | string | - | Name for the hidden form input. Omit for UI-only selects. |
required | boolean | - | Marks the hidden form input as required. |
SelectTrigger
The button that opens the listbox, rendering the Windows 98 arrow affordance. Accepts native button props (including id, className).
SelectValue
Shows the selected item’s label inside the trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | ReactNode | - | Shown when no value is selected. |
SelectContent
The portalled popup listbox. Handles positioning, keyboard navigation, typeahead, the custom scrollbar, and dismissal. Accepts native ul props.
| Prop | Type | Default | Description |
|---|---|---|---|
maxHeight | number | string | - | Upper bound for the listbox height before it scrolls. |
SelectItem
A selectable option. Its plain-text children double as the display + typeahead label.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | The value committed when the item is chosen. |
disabled | boolean | false | Prevents selection of this item. |
textValue | string | - | Label used for display/typeahead when children is not a plain string (e.g. contains an icon). |
SelectGroup, SelectLabel, SelectSeparator
SelectGroup wraps related items and is labelled by its SelectLabel. SelectSeparator draws a divider between items or groups.
Examples
Custom select
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@murasaki-io/react98'
export function SelectBasicDemo(): React.ReactElement {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 260 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label htmlFor="select-color">Color:</label>
<Select name="color" defaultValue="red">
<SelectTrigger id="select-color" className="w-[180px]">
<SelectValue placeholder="Select a color" />
</SelectTrigger>
<SelectContent>
<SelectItem value="red">Red</SelectItem>
<SelectItem value="green">Green</SelectItem>
<SelectItem value="blue">Blue</SelectItem>
<SelectItem value="yellow">Yellow</SelectItem>
</SelectContent>
</Select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label htmlFor="select-disabled">Disabled:</label>
<Select name="disabled-example" defaultValue="none" disabled>
<SelectTrigger id="select-disabled" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No options</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}Groups and labels
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@murasaki-io/react98'
export function SelectGroupedDemo(): React.ReactElement {
return (
<Select name="animal" defaultValue="cat">
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select an animal" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Pets</SelectLabel>
<SelectItem value="cat">Cat</SelectItem>
<SelectItem value="dog">Dog</SelectItem>
<SelectItem value="hamster">Hamster</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Wild</SelectLabel>
<SelectItem value="lion">Lion</SelectItem>
<SelectItem value="wolf">Wolf</SelectItem>
<SelectItem value="fox" disabled>Fox (unavailable)</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
)
}Native select
import { SelectNative } from '@murasaki-io/react98'
export function SelectNativeDemo(): React.ReactElement {
return (
<SelectNative name="size" label="Size:" defaultValue="medium">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</SelectNative>
)
}Scrollable options
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@murasaki-io/react98'
const countryOptions = [
{ value: 'cn', label: 'China' },
{ value: 'us', label: 'United States' },
{ value: 'jp', label: 'Japan' },
{ value: 'de', label: 'Germany' },
{ value: 'fr', label: 'France' },
{ value: 'gb', label: 'United Kingdom' },
{ value: 'ca', label: 'Canada' },
{ value: 'au', label: 'Australia' },
{ value: 'kr', label: 'South Korea' },
{ value: 'br', label: 'Brazil' },
{ value: 'in', label: 'India' },
{ value: 'ru', label: 'Russia' },
{ value: 'it', label: 'Italy' },
{ value: 'mx', label: 'Mexico' },
]
export function SelectScrollableDemo(): React.ReactElement {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label htmlFor="select-country">Country:</label>
<Select name="country" defaultValue="cn">
<SelectTrigger id="select-country" className="w-[220px]">
<SelectValue placeholder="Select a country" />
</SelectTrigger>
<SelectContent maxHeight={96}>
{countryOptions.map(option => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}Viewport edge detection
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@murasaki-io/react98'
import { EdgeTestStage } from '../../../components/edge-test-stage'
const OPTIONS = [
{ value: 'alpha', label: 'Alpha' },
{ value: 'bravo', label: 'Bravo' },
{ value: 'charlie', label: 'Charlie' },
{ value: 'delta', label: 'Delta' },
{ value: 'echo', label: 'Echo' },
{ value: 'foxtrot', label: 'Foxtrot' },
{ value: 'golf', label: 'Golf' },
{ value: 'hotel', label: 'Hotel' },
{ value: 'india', label: 'India' },
{ value: 'juliet', label: 'Juliet' },
]
function SelectSlot({ id }: { id: string, label: string }): React.ReactElement {
return (
<Select name={`select-${id}`} defaultValue="alpha">
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent maxHeight={160}>
{OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function SelectEdgeTestDemo(): React.ReactElement {
return (
<EdgeTestStage
height={420}
SlotComponent={SelectSlot}
/>
)
}Native select
SelectNative wraps the browser’s <select> element for cases that need real native form behavior or the native mobile picker. It takes label, name, and standard select props, with native <option> children.
ARIA
The trigger is a combobox with aria-haspopup="listbox", aria-expanded, and aria-controls. The popup is a listbox of options that expose aria-selected and aria-disabled. Groups use role="group" associated with their SelectLabel through aria-labelledby.
Keyboard
| Key | Behavior |
|---|---|
Enter / Space / ArrowDown / ArrowUp | Opens the select. |
ArrowUp / ArrowDown | Moves through options while open. |
Home / End | Jumps to the first or last option. |
| Printable characters | Moves focus to the next option whose label starts with the typed buffer. |
Enter / Space | Selects the focused option. |
Escape | Closes without selecting. |
Tab | Closes and moves focus onward. |
SSR
Select is a client component through the package root. Initial selected values should be stable between server and client to avoid hydration drift.