Select
A select: a field-shaped trigger opens a floating listbox of options — with grouped sections, optional leading icons, a placeholder, and a trailing check marking the current value. The trigger wears the field look (a calm, ring-less accent border on focus); the popup is an elevated surface.
Basic
A Select.Trigger (holding a Select.Value and a Select.Icon) opens the Select.Popup. Pass items on Select.Root so Select.Value renders the chosen item's label rather than its raw value, and defaultValue for an initial selection.
"use client";
import { Select } from "@stridge/noctis";
const FRUIT = {
apple: "Apple",
banana: "Banana",
cherry: "Cherry",
grape: "Grape",
};
export default function SelectBasic() {
return (
<Select.Root items={FRUIT} defaultValue="apple">
<Select.Trigger aria-label="Fruit" className="w-48">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
<Select.Item value="cherry">Cherry</Select.Item>
<Select.Item value="grape">Grape</Select.Item>
</Select.Popup>
</Select.Root>
);
}
Sizes
Select.Root takes a size (md | lg, default md) shared with the trigger — it sets the trigger's control height, inline padding, and value type size off the shared, density-aware control scale, matching the rest of the field family. The popup rows keep one size, so the list reads the same comfortable density whichever trigger opens it.
"use client";
import { Select } from "@stridge/noctis";
const SPEED = { slow: "Slow", normal: "Normal", fast: "Fast" };
export default function SelectSizes() {
return (
<div className="flex items-center gap-4">
<Select.Root items={SPEED} defaultValue="normal" size="md">
<Select.Trigger aria-label="Speed (medium)" className="w-36">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Item value="slow">Slow</Select.Item>
<Select.Item value="normal">Normal</Select.Item>
<Select.Item value="fast">Fast</Select.Item>
</Select.Popup>
</Select.Root>
<Select.Root items={SPEED} defaultValue="normal" size="lg">
<Select.Trigger aria-label="Speed (large)" className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Item value="slow">Slow</Select.Item>
<Select.Item value="normal">Normal</Select.Item>
<Select.Item value="fast">Fast</Select.Item>
</Select.Popup>
</Select.Root>
</div>
);
}
Leading content
Give a Select.Item an icon — a glyph, a colour dot, or an avatar — and it sits in a reserved leading column so labels stay aligned whether or not a row has one. The selected row's check trails the row, so unselected labels stay flush to the leading edge. This is the status/priority/assignee-picker shape.
"use client";
import { Select } from "@stridge/noctis";
/** A status picker: every row carries a leading colour dot, the selected row a trailing check. */
const STATUS = [
{ value: "backlog", label: "Backlog", dot: "bg-chart-1" },
{ value: "todo", label: "Todo", dot: "bg-chart-2" },
{ value: "in-progress", label: "In progress", dot: "bg-chart-3" },
{ value: "in-review", label: "In review", dot: "bg-chart-4" },
{ value: "done", label: "Done", dot: "bg-chart-5" },
];
const ITEMS = Object.fromEntries(STATUS.map((s) => [s.value, s.label]));
export default function SelectWithIcons() {
return (
<Select.Root items={ITEMS} defaultValue="in-progress">
<Select.Trigger aria-label="Status" className="w-48">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{STATUS.map((s) => (
<Select.Item key={s.value} value={s.value} icon={<span className={`size-2.5 rounded-full ${s.dot}`} />}>
{s.label}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
);
}
Multiple
Pass multiple on Select.Root for a multi-select: picking a row toggles it without closing the popup, every selected row keeps its check, and Select.Value summarises the selection as a localized "N selected" in the trigger (override it with a child function for a custom display). Seed it with an array defaultValue.
"use client";
import { Select } from "@stridge/noctis";
const TOPPINGS = {
cheese: "Cheese",
pepperoni: "Pepperoni",
mushroom: "Mushroom",
onion: "Onion",
olive: "Olive",
};
export default function SelectMultiple() {
return (
<Select.Root items={TOPPINGS} multiple defaultValue={["cheese", "mushroom"]}>
<Select.Trigger aria-label="Toppings" className="w-56">
<Select.Value placeholder="Select toppings" />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Item value="cheese">Cheese</Select.Item>
<Select.Item value="pepperoni">Pepperoni</Select.Item>
<Select.Item value="mushroom">Mushroom</Select.Item>
<Select.Item value="onion">Onion</Select.Item>
<Select.Item value="olive">Olive</Select.Item>
</Select.Popup>
</Select.Root>
);
}
Placement
The popup opens in one of two placements. By default it overlays the trigger (item-aligned, so the selected row lines up over the trigger's value — the macOS-native feel; mouse input only, and auto-disabled when there isn't room). Pass alignItemWithTrigger={false} on Select.Popup to anchor it below the trigger instead, like a standard dropdown.
"use client";
import { Select } from "@stridge/noctis";
const VIEW = { board: "Board", list: "List", timeline: "Timeline" };
/** The two popup placements: overlay (item-aligned, paints over the trigger) and below (anchored under it). */
export default function SelectPlacement() {
return (
<div className="flex items-center gap-4">
<Select.Root items={VIEW} defaultValue="list">
<Select.Trigger aria-label="View (overlay)" className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Item value="board">Board</Select.Item>
<Select.Item value="list">List</Select.Item>
<Select.Item value="timeline">Timeline</Select.Item>
</Select.Popup>
</Select.Root>
<Select.Root items={VIEW} defaultValue="list">
<Select.Trigger aria-label="View (below)" className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup alignItemWithTrigger={false}>
<Select.Item value="board">Board</Select.Item>
<Select.Item value="list">List</Select.Item>
<Select.Item value="timeline">Timeline</Select.Item>
</Select.Popup>
</Select.Root>
</div>
);
}
Long lists
A long list caps at the available viewport height and scrolls. The list's scrollbar is hidden — sticky scroll arrows appear at the popup's top and bottom edges and scroll on hover (the Radix/macOS pattern), while the wheel, trackpad, and arrow keys scroll as usual. The keyboard highlight always stays in view.
"use client";
import { Select } from "@stridge/noctis";
const TIMEZONES = Array.from({ length: 25 }, (_, i) => {
const offset = i - 12;
const sign = offset >= 0 ? "+" : "-";
const label = `UTC${sign}${String(Math.abs(offset)).padStart(2, "0")}:00`;
return { value: label, label };
});
const ITEMS = Object.fromEntries(TIMEZONES.map((tz) => [tz.value, tz.label]));
export default function SelectScrollable() {
return (
<Select.Root items={ITEMS} defaultValue="UTC+00:00">
<Select.Trigger aria-label="Timezone" className="w-48">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{TIMEZONES.map((tz) => (
<Select.Item key={tz.value} value={tz.value}>
{tz.label}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
);
}
Groups
Wrap related options in a Select.Group with a Select.GroupLabel — the label is muted, non-interactive, and announced as the group's name — and divide groups with a Select.Separator.
"use client";
import { Select } from "@stridge/noctis";
const FOOD = {
apple: "Apple",
banana: "Banana",
carrot: "Carrot",
potato: "Potato",
};
export default function SelectGroups() {
return (
<Select.Root items={FOOD}>
<Select.Trigger aria-label="Food" className="w-48">
<Select.Value placeholder="Choose a food" />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Group>
<Select.GroupLabel>Fruit</Select.GroupLabel>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
</Select.Group>
<Select.Separator />
<Select.Group>
<Select.GroupLabel>Vegetable</Select.GroupLabel>
<Select.Item value="carrot">Carrot</Select.Item>
<Select.Item value="potato" disabled>
Potato
</Select.Item>
</Select.Group>
</Select.Popup>
</Select.Root>
);
}
In a field
Drop a Select straight into a Field.Root — its trigger auto-wires to the field, so the Field.Label, Field.Description, and Field.Error are associated for assistive tech and the trigger turns invalid (a danger border) when the field does. Use nativeLabel={false} on the label since it labels a button, mark the field required, and write the placeholder as a prompt to act ("Select a framework"), not a fake value.
Used to scaffold the starter project.
"use client";
import { Field, Select } from "@stridge/noctis";
const FRAMEWORK = {
next: "Next.js",
remix: "Remix",
astro: "Astro",
nuxt: "Nuxt",
};
export default function SelectField() {
return (
<Field.Root name="framework" className="w-full max-w-sm">
<Field.Label nativeLabel={false}>
Framework{" "}
<span aria-hidden="true" className="text-danger">
*
</span>
</Field.Label>
<Select.Root items={FRAMEWORK} required>
<Select.Trigger className="w-full">
<Select.Value placeholder="Select a framework" />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Item value="next">Next.js</Select.Item>
<Select.Item value="remix">Remix</Select.Item>
<Select.Item value="astro">Astro</Select.Item>
<Select.Item value="nuxt">Nuxt</Select.Item>
</Select.Popup>
</Select.Root>
<Field.Description>Used to scaffold the starter project.</Field.Description>
<Field.Error match="valueMissing">Please select a framework.</Field.Error>
</Field.Root>
);
}
Controlled
Drive the selection yourself with value + onValueChange on Select.Root (set value to null to clear it).
Selected: apple
"use client";
import { Button, Select } from "@stridge/noctis";
import { useState } from "react";
const FRUIT = { apple: "Apple", banana: "Banana", cherry: "Cherry" };
export default function SelectControlled() {
const [value, setValue] = useState<string | null>("apple");
return (
<div className="flex w-full max-w-xs flex-col items-start gap-3">
<Select.Root items={FRUIT} value={value} onValueChange={setValue}>
<Select.Trigger aria-label="Fruit" className="w-full">
<Select.Value placeholder="Select a fruit" />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
<Select.Item value="cherry">Cherry</Select.Item>
</Select.Popup>
</Select.Root>
<p className="text-sm text-secondary">
Selected: <span className="font-medium text-foreground">{value ?? "none"}</span>
</p>
<Button variant="link" size="sm" className="self-start" onClick={() => setValue(null)}>
Reset
</Button>
</div>
);
}
Select or Combobox?
Reach for Select when the options are a short, fixed list (roughly ten or fewer) the user picks from — it shows only the chosen value and never accepts typed text. When the list is long or the user needs to filter by typing, reach for Combobox instead.
Keyboard
| Key | Action |
|---|---|
| Enter / Space / ↓ / ↑ | On the trigger: open the popup, highlighting the selected option (or the first / last). |
| Alt + ↓ | Open the popup without moving the highlight. |
| ↓ / ↑ | Move the highlight to the next / previous option. |
| PageDown / PageUp | Jump the highlight by a page (about ten options). |
| Home / End | First / last option. |
| Enter / Space | Select the highlighted option and close the popup, returning focus to the trigger. |
| Alt + ↑ | Select the highlighted option and close the popup. |
| Esc | Close the popup without changing the value; return focus to the trigger. |
| Tab | Close the popup and move focus on. |
| Characters | Typeahead — jump to the option whose label matches what you type; a non-matching key never moves the highlight, and repeats of one character cycle the matches. |
Accessibility
- The trigger follows the APG select-only combobox pattern: it is a
role="combobox"button witharia-expanded, focus stays on it (the popup is arole="listbox"ofrole="option"s driven byaria-activedescendant), and the chosen option carriesaria-selected. - Disabled options stay reachable by the keyboard and are announced as disabled — they just can't be selected. Removing them from navigation would hide why a choice is unavailable from screen-reader users.
- The scroll arrows are a pointer affordance (they scroll on hover and never render for touch), so they are
aria-hidden— keyboard users scroll with the arrow keys. - In a
Field, the trigger wiresaria-describedbyto the description and error, takesaria-invalidwhile invalid, andaria-requiredwhen the field isrequired; the visual required marker is decorative (aria-hidden). - Multiple selects convey their count through the trigger's "N selected" summary, and every selected option keeps
aria-selected. - The chevron, the leading column, and the popup alignment are all direction-aware, so the select mirrors under RTL by construction.
Anatomy
Compose a select from its parts. Select.Root owns the value and open state (it accepts every Base UI Select.Root prop — value, defaultValue, onValueChange, items, multiple, readOnly, required, modal, plus the name/form props — and adds size and an additive invalid override).
Select.Root— owns the value and open state and shares the controlsize,multiple, andinvalid; renders no element of its own.Select.Trigger— the field-shaped control that opens the popup; reads the root'ssizeand paints the danger border while invalid, the quiet chrome whilereadOnly.Select.Value— renders the selected label inside the trigger (or a localized "N selected" undermultiple), falling back toplaceholder.Select.Icon— the trailing chevron marking the control as a select.Select.Backdrop— the modal scrim; rendered for you bySelect.Popuponly when the select ismodal(off by default, so the page keeps scrolling while the popup is open), transparent.Select.Popup— the floating, elevated, animated listbox. Props:side(defaultbottom),align(defaultstart),sideOffset,alignItemWithTrigger(defaulttrue),collisionPadding.Select.ScrollUpArrow+Select.ScrollDownArrow— the sticky scroll affordances; rendered for you bySelect.Popup, appearing only while the list can scroll.Select.List— the scrollable list inside the popup; rendered for you.Select.Item— a selectable option carrying itsvalue, plus an optional leadingiconanddescription, with a trailing check on the selected row.Select.ItemIcon/Select.ItemText/Select.ItemDescription/Select.ItemIndicator— the row's leading column, label, muted second line, and selected-check; rendered for you bySelect.Item(via itsicon/descriptionprops), composed by hand only for custom inner structure.Select.Group+Select.GroupLabel— a labelled section of related options.Select.Separator— a presentational hairline between groups.
Every rendered part carries a data-slot (select-trigger, select-value, select-icon, select-backdrop, select-popup, select-scroll-up-arrow, select-scroll-down-arrow, select-list, select-item, select-item-icon, select-item-text, select-item-description, select-item-indicator, select-group, select-group-label, select-separator) for host-side styling — pair it with the Base UI state attributes (data-open, data-placeholder, data-invalid, data-readonly, data-highlighted, data-selected, data-disabled).
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every select in that region retunes — e.g. .dense { --noctis-select-item-height: 1.75rem; } tightens every popup opened beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through.