Skip to content

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.

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

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

Usage

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.

<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

SlotRequiredDescription
selectYesRoot container. Supports data-placeholder for placeholder text.
select-triggerYesButton that opens and closes the content panel.
select-valueNoDisplays the currently selected item's text.
select-contentYesPanel containing the list of items.
select-itemYesA selectable option. Requires data-value. Supports data-disabled.
select-item-textNoText content of the item, used for typeahead matching.
select-groupNoGroups related items together.
select-labelNoLabel for a group of items.
select-separatorNoVisual divider between items.

Generated Attributes

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

AttributeElement(s)Description
data-stateRoot"open" or "closed".
data-openRootPresent when the select is open.
data-closedRootPresent when the select is closed.
data-disabledRoot, triggerPresent when the select is disabled.
data-placeholderRootPresent when no value is selected and a placeholder exists.
data-selectedselect-itemPresent on the currently selected item.
data-highlightedselect-itemPresent on the currently highlighted item (keyboard focus).
data-disabledselect-itemPresent when the item is disabled.
aria-expandedTrigger"true" when open, "false" when closed.
aria-controlsTriggerPoints 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

EventDetailDescription
select:change{ value: string | null }Fired when the selected value changes.
select:open-change{ open: boolean }Fired when the open state changes.

Inbound

EventDetailDescription
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:

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)

OptionTypeDefaultDescription
defaultValuestringInitial selected value.
onValueChange(value: string | null) => voidCalled when the selected value changes.
defaultOpenbooleanfalseInitial open state.
onOpenChange(open: boolean) => voidCalled when the open state changes.
placeholderstringText displayed when no value is selected.
disabledbooleanfalseDisable the entire select.
requiredbooleanfalseMark the select as required (sets required on the native element).
namestringName 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.
sideOffsetnumber0Offset in pixels from the trigger.
alignOffsetnumber0Offset in pixels along the alignment axis.
avoidCollisionsbooleantrueFlip side and alignment to avoid viewport collisions.
collisionPaddingnumber0Padding from viewport edges when avoiding collisions.

Controller

MemberTypeDescription
valuestring | null (getter)Currently selected value. null when nothing is selected.
isOpenboolean (getter)Current open state.
select(value)(value: string) => voidSelect the item identified by value.
open()() => voidOpen the select menu.
close()() => voidClose the select menu.
toggle()() => voidToggle the select menu open state.
setOpen(open)(open: boolean) => voidSet the open state programmatically.
destroy()() => voidRemove all event listeners and clean up.

Controller

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

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.