Meowland docs
Workbench

Component reference

ChoiceSelect

Single-choice listbox.

Preview

Grouped options

packages/meowtail/stories/choice-select.stories.tsxline 11tsx
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.

usage.tsxtsx
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.

packages/meowtail/stories/choice-select.stories.tsxline 37tsx
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.

packages/meowtail/stories/popover.stories.tsxline 318tsx
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

PropTypeDefault
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
booleanfalse
required
booleanfalse
placeholder
string"Select an option"
searchable
booleanfalse
searchPlaceholder
string"Search options"
emptyMessage
string"No matching options"
placement
"top" | "bottom""bottom"
align
"start" | "end""start"
matchTriggerWidth
booleantrue

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.