---
title: Select
description: "A custom select primitive with keyboard navigation, typeahead, scroll locking, and native form integration."
order: 17
tags: [primitives]
---

# Select

A primitive for building custom select menus. Supports single value selection, keyboard navigation (arrow keys, typeahead), scroll locking when open, Escape/outside-click dismissal, item groups and separators, disabled items, and automatic native `<select>` element generation for form submissions.

```ts
import { Select } from "@areia/slots";

document.querySelector("#root")!.innerHTML = `
  <div data-slot="select">
    <button data-slot="select-trigger" class="win95-button min-w-40 text-left">
      <span data-slot="select-value"></span>
    </button>
    <div data-slot="select-content" class="win95-menu">
      <div data-slot="select-group">
        <div data-slot="select-label" class="win95-label">Fruits</div>
        <div data-slot="select-item" data-value="apple" class="win95-menu-item">
          <span data-slot="select-item-text">Apple</span>
        </div>
        <div data-slot="select-item" data-value="banana" class="win95-menu-item">
          <span data-slot="select-item-text">Banana</span>
        </div>
        <div data-slot="select-separator" class="win95-separator"></div>
        <div
          data-slot="select-item"
          data-value="cherry"
          data-disabled
          class="win95-menu-item">
          <span data-slot="select-item-text">Cherry</span>
        </div>
      </div>
    </div>
  </div>
`;

const root = document.querySelector('[data-slot="select"]')!;
const controller = Select.createSelect(root, {
  placeholder: "Choose an option",
  onValueChange: (value) => console.log("selected:", value),
});
```

## Import

```ts
import { Select } from "@areia/slots";
```

## Usage

```ts
import { Select } from "@areia/slots";

// Auto-bind a single root
const root = document.querySelector('[data-slot="select"]');
const controller = Select.createSelect(root, {
  defaultValue: "apple",
  placeholder: "Choose a fruit",
  onValueChange: (value) => console.log("selected:", value),
  onOpenChange: (open) => console.log("open:", open),
});

// Auto-bind all unbound roots in scope
const controllers = Select.create();
```

## Expected Markup

The select expects a trigger containing a value display, and a content panel with items. Items are identified by `data-value`. Groups wrap sets of items with an optional label. Separators create visual dividers between items.

```html
<div data-slot="select">
  <button data-slot="select-trigger">
    <span data-slot="select-value"></span>
  </button>

  <div data-slot="select-content">
    <div data-slot="select-group">
      <div data-slot="select-label">Fruits</div>
      <div data-slot="select-item" data-value="apple">
        <span data-slot="select-item-text">Apple</span>
      </div>
      <div data-slot="select-item" data-value="banana">
        <span data-slot="select-item-text">Banana</span>
      </div>
      <div data-slot="select-separator"></div>
      <div
        data-slot="select-item"
        data-value="cherry"
        data-disabled
      >
        <span data-slot="select-item-text">Cherry</span>
      </div>
    </div>
  </div>
</div>
```

### Data Slots

| Slot               | Required | Description                                                           |
| ------------------ | -------- | --------------------------------------------------------------------- |
| `select`           | Yes      | Root container. Supports `data-placeholder` for placeholder text.     |
| `select-trigger`   | Yes      | Button that opens and closes the content panel.                       |
| `select-value`     | No       | Displays the currently selected item's text.                          |
| `select-content`   | Yes      | Panel containing the list of items.                                   |
| `select-item`      | Yes      | A selectable option. Requires `data-value`. Supports `data-disabled`. |
| `select-item-text` | No       | Text content of the item, used for typeahead matching.                |
| `select-group`     | No       | Groups related items together.                                        |
| `select-label`     | No       | Label for a group of items.                                           |
| `select-separator` | No       | Visual divider between items.                                         |

### Generated Attributes

The controller writes the following attributes on initialization and updates them as state changes:

| Attribute          | Element(s)    | Description                                                 |
| ------------------ | ------------- | ----------------------------------------------------------- |
| `data-state`       | Root          | `"open"` or `"closed"`.                                     |
| `data-open`        | Root          | Present when the select is open.                            |
| `data-closed`      | Root          | Present when the select is closed.                          |
| `data-disabled`    | Root, trigger | Present when the select is disabled.                        |
| `data-placeholder` | Root          | Present when no value is selected and a placeholder exists. |
| `data-selected`    | `select-item` | Present on the currently selected item.                     |
| `data-highlighted` | `select-item` | Present on the currently highlighted item (keyboard focus). |
| `data-disabled`    | `select-item` | Present when the item is disabled.                          |
| `aria-expanded`    | Trigger       | `"true"` when open, `"false"` when closed.                  |
| `aria-controls`    | Trigger       | Points to the content element `id`.                         |

### Native Form Integration

The controller automatically generates a hidden `<select>` element appended to the root. This native element stays in sync with the selected value so that the custom select participates in standard form submissions. The `name` option controls the `name` attribute on this hidden element.

## Events

All events are dispatched from the root element (`data-slot="select"`).

### Outbound

| Event                | Detail                      | Description                            |
| -------------------- | --------------------------- | -------------------------------------- |
| `select:change`      | `{ value: string \| null }` | Fired when the selected value changes. |
| `select:open-change` | `{ open: boolean }`         | Fired when the open state changes.     |

### Inbound

| Event           | Detail              | Description                                                  |
| --------------- | ------------------- | ------------------------------------------------------------ |
| `select:select` | `{ value: string }` | Dispatch on the root to select an item by its `data-value`.  |
| `select:open`   | `{ open: boolean }` | Dispatch on the root to programmatically set the open state. |

Send `select:select` on the root element to set the value without a controller reference:

```ts
const root = document.querySelector('[data-slot="select"]');
root?.dispatchEvent(
  new CustomEvent("select:select", {
    bubbles: true,
    detail: { value: "banana" },
  }),
);
```

## API Reference

### Options

`Select.createSelect(root, options)`

| Option             | Type                              | Default          | Description                                                                 |
| ------------------ | --------------------------------- | ---------------- | --------------------------------------------------------------------------- |
| `defaultValue`     | `string`                          | —                | Initial selected value.                                                     |
| `onValueChange`    | `(value: string \| null) => void` | —                | Called when the selected value changes.                                     |
| `defaultOpen`      | `boolean`                         | `false`          | Initial open state.                                                         |
| `onOpenChange`     | `(open: boolean) => void`         | —                | Called when the open state changes.                                         |
| `placeholder`      | `string`                          | —                | Text displayed when no value is selected.                                   |
| `disabled`         | `boolean`                         | `false`          | Disable the entire select.                                                  |
| `required`         | `boolean`                         | `false`          | Mark the select as required (sets `required` on the native element).        |
| `name`             | `string`                          | —                | Name attribute for the hidden native `<select>`.                            |
| `position`         | `"item-aligned" \| "popper"`      | `"item-aligned"` | Positioning strategy. `"item-aligned"` aligns content to the selected item. |
| `side`             | `"top" \| "bottom"`               | `"bottom"`       | Preferred side relative to the trigger.                                     |
| `align`            | `"start" \| "center" \| "end"`    | `"start"`        | Preferred alignment relative to the trigger.                                |
| `sideOffset`       | `number`                          | `0`              | Offset in pixels from the trigger.                                          |
| `alignOffset`      | `number`                          | `0`              | Offset in pixels along the alignment axis.                                  |
| `avoidCollisions`  | `boolean`                         | `true`           | Flip side and alignment to avoid viewport collisions.                       |
| `collisionPadding` | `number`                          | `0`              | Padding from viewport edges when avoiding collisions.                       |

### Controller

| Member          | Type                      | Description                                                |
| --------------- | ------------------------- | ---------------------------------------------------------- |
| `value`         | `string \| null` (getter) | Currently selected value. `null` when nothing is selected. |
| `isOpen`        | `boolean` (getter)        | Current open state.                                        |
| `select(value)` | `(value: string) => void` | Select the item identified by `value`.                     |
| `open()`        | `() => void`              | Open the select menu.                                      |
| `close()`       | `() => void`              | Close the select menu.                                     |
| `toggle()`      | `() => void`              | Toggle the select menu open state.                         |
| `setOpen(open)` | `(open: boolean) => void` | Set the open state programmatically.                       |
| `destroy()`     | `() => void`              | Remove all event listeners and clean up.                   |

## Controller

The controller is returned by `Select.createSelect()` and provides imperative control over the select's state.

```ts
const controller = Select.createSelect(root, {
  placeholder: "Choose a fruit",
});

// Read current value
console.log(controller.value); // "apple" | null

// Check if open
console.log(controller.isOpen); // true | false

// Select an item programmatically
controller.select("banana");

// Open / close / toggle
controller.open();
controller.close();
controller.toggle();
controller.setOpen(true);

// Tear down
controller.destroy();
```

### Behavior

- **Scroll locking**: When the select is open, body scroll is locked to prevent background scrolling.
- **Escape dismiss**: Pressing `Escape` closes the select menu.
- **Outside click dismiss**: Clicking outside the select content closes the menu.
- **Keyboard navigation**: Arrow keys move highlight between items. `Enter` or `Space` selects the highlighted item. `Home` and `End` jump to the first or last item.
- **Typeahead**: Typing characters while the select is open jumps to items matching the typed prefix.
- **Disabled items**: Items with `data-disabled` are not selectable and are skipped during keyboard navigation.
- **Hidden native select**: A hidden `<select>` element is maintained in sync for standard form submissions. The `name` and `required` options control its attributes.
