Component reference
ChoiceSelect
Single-choice listbox.
Preview
Grouped options
render() {
const value = signal("deepseek-flash");
return (
<ChoiceSelect
aria-label="Provider and model"
value={value}
onValueChange={(next) => {
value.value = next;
}}
options={[
{ value: "deepseek-flash", label: "DeepSeek-V41-Flash", group: "DeepSeek" },
{ value: "deepseek-pro", label: "DeepSeek-V4-Pro", group: "DeepSeek" },
{ value: "anthropic", label: "Claude", group: "Anthropic" },
]}
/>
);
}Installation
bun add meowtail
Usage
Taken from the examples on this page.
import { ChoiceSelect } from "meowtail";
<ChoiceSelect
id="short-choice"
aria-label="Short trigger choice"
options={[
{ value: "one", label: "First option" },
{ value: "two", label: "Second option" },
]}
/>Examples
The other stories that show this component.
Short trigger and long options
A matching trigger keeps its exact width; the natural popover and wide form follow their existing contracts.
render() {
return (
<Specimens>
<Specimen
label="Trigger width"
note="The first list matches its trigger; the second takes its natural width."
align="start"
>
<ChoiceSelect
id="short-choice"
aria-label="Short trigger choice"
options={[
{ value: "one", label: "First option" },
{ value: "two", label: "Second option" },
]}
/>
<ChoiceSelect
id="natural-choice"
aria-label="Natural width choice"
matchTriggerWidth={false}
options={[
{ value: "one", label: "First option" },
{ value: "two", label: "Second option" },
]}
/>
</Specimen>
<Specimen label="Long option names" width="narrow">
<Field label="Full option name" htmlFor="long-choice">
<ChoiceSelect
id="long-choice"
defaultValue="long"
options={[
{
value: "long",
label: "The project owner reviews the full change and verification record",
},
{
value: "path",
label: "workspace-very-long-source-reference-for-a-readable-selection",
},
]}
/>
</Field>
</Specimen>
</Specimens>
);
}Custom popup and keyboard
A popover carries single selection; arrow keys, character navigation, and native form validation stay continuous.
render() {
const feedback = signal("Configuration is only demoed locally; no content is published.");
const controlled = signal("draft");
return (
<form
className="workshop-stack workshop-constrained"
onSubmit={(event: SubmitEvent) => {
event.preventDefault();
const data = new FormData(event.currentTarget as HTMLFormElement);
feedback.value = `Submitted: ${data.get("delivery")} · ${data.get("environment")}`;
}}
>
<header className="workshop-composition-header">
<h2>Prepare delivery</h2>
<p>Options expand in place, and long names remain fully readable.</p>
</header>
<Field
label="Delivery target"
htmlFor="custom-delivery"
description="Arrow keys move, Enter confirms, Escape returns."
>
<ChoiceSelect
id="custom-delivery"
name="delivery"
defaultValue="review"
options={[
{ value: "draft", label: "Keep as a local draft", group: "Current project" },
{
value: "review",
label:
"Hand to the project owner to review all changes and the verification record",
group: "Current project",
},
{
value: "remote",
label: "Remote project · not connected",
group: "Other locations",
disabled: true,
},
{
value: "archived",
label: "File into the project archive",
group: "Other locations",
},
]}
/>
</Field>
<Field
label="Release environment"
htmlFor="custom-environment"
description="Required; native form validation applies on submit."
>
<ChoiceSelect
id="custom-environment"
name="environment"
required
placeholder="Select an environment…"
options={[
{ value: "local", label: "Local preview only" },
{ value: "staging", label: "Staging environment" },
]}
/>
</Field>
<Field label="Searchable model" htmlFor="searchable-model">
<ChoiceSelect
id="searchable-model"
searchable
searchPlaceholder="Search by model name or ID"
defaultValue=""
options={[
{ value: "", label: "Follow the runner default" },
{ value: "gpt-6-astra", label: "GPT-6-Astra · gpt-6-astra" },
{ value: "gpt-6-sol", label: "GPT-6-Sol · gpt-6-sol" },
{ value: "gpt-6-luna", label: "GPT-6-Luna · gpt-6-luna" },
]}
/>
</Field>
<Field
label="Controlled select"
htmlFor="custom-controlled"
description="A form reset keeps the caller's current selection."
>
<ChoiceSelect
id="custom-controlled"
name="controlled"
value={controlled}
onValueChange={(next) => {
controlled.value = next;
}}
options={[
{ value: "draft", label: "Keep draft" },
{ value: "review", label: "Submit for review" },
]}
/>
</Field>
<div className="workshop-actions">
<Button type="submit">Submit configuration</Button>
<Button variant="quiet" type="reset">
Restore defaults
</Button>
</div>
<Note role="status">{feedback}</Note>
</form>
);
}API reference
Form participation remains native; data fetching belongs to callers.
Props
| Prop | Type | Default |
|---|---|---|
optionsRequiredPlain arrays are fixed for the mount. A signal keeps the open list mounted. | MaybeSignal<readonly ChoiceOption[]> | – |
hidden | MaybeSignal<boolean> | – |
value | MaybeSignal<string> | – |
defaultValue | string | "" |
onValueChange | (value: string) => void | – |
name | string | – |
id | string | `choice-${++nextChoiceId}` |
disabled | boolean | false |
required | boolean | false |
placeholder | string | "Select an option" |
searchable | boolean | false |
searchPlaceholder | string | "Search options" |
emptyMessage | string | "No matching options" |
placement | "top" | "bottom" | "bottom" |
align | "start" | "end" | "start" |
matchTriggerWidth | boolean | true |
Also takes className, children and the attributes and events of its root element.
DOM parts
roottriggervalueindicatorlistgroupoptionnativevalidationsearchempty
Registry
The component is registered as choice-select in the meowtail component registry.