Components

Select

Compose trigger + listbox with form-compatible hidden input semantics.

Release Scope: Canonical Implementation: Core Site Visibility: Listed Adapters: React, Vanilla, Solid, Vue, Svelte Category: selection

1. Live Preview

vanilla live preview activates on the client; source HTML keeps a readable preview island and primitive contract.

vanilla select preview activates on the client.

2. Installation

Install adapter-specific dependencies first, then complete setup via the installation guide. installation docs

pnpm add @nake-ui/primitives @nake-ui/adapter-vanilla @nake-ui/theme-vanilla

3. Usage

Minimal runnable usage for the current adapter.

import "@nake-ui/theme-vanilla/styles.css";
import { bindSelect } from "@nake-ui/adapter-vanilla";

const binding = bindSelect(nodes, items, options);
binding.destroy();

4. Examples

Groups

Use native group/label/separator structure to organize select items.

import "@nake-ui/theme-vanilla/styles.css";
import { bindSelect } from "@nake-ui/adapter-vanilla";

const registrations = [
  { id: "apple", label: "Apple" },
  { id: "banana", label: "Banana" },
  { id: "carrot", label: "Carrot" },
  { id: "spinach", label: "Spinach" },
];

if (typeof window !== "undefined" && typeof document !== "undefined") {
  const binding = bindSelect(
    { trigger, content, options, hiddenInput },
    registrations,
    {
      id: "vanilla-select-groups-example-controller",
      defaultValue: null,
      name: "demo-select-groups",
      onValueChange(nextValue) {
        triggerLabel.textContent =
          nextValue === null ? "Select an item" : (options[nextValue]?.dataset.demoLabel ?? nextValue);
      }
    }
  );

  binding.destroy();
}

Disabled

Disable the select via controller disabled option.

Use the disabled prop to disable the select

import "@nake-ui/theme-vanilla/styles.css";
import { bindSelect } from "@nake-ui/adapter-vanilla";

if (typeof window !== "undefined" && typeof document !== "undefined") {
  const binding = bindSelect(
    { trigger, content, options, hiddenInput },
    registrations,
    {
      id: "vanilla-select-disabled-example-controller",
      defaultValue: null,
      name: "demo-select-disabled",
      disabled: true
    }
  );

  triggerLabel.textContent = "Select is disabled";
  binding.destroy();
}

Invalid

Mark data-invalid on the container and reflect aria-invalid on the trigger.

Please choose a valid fruit option.

import "@nake-ui/theme-vanilla/styles.css";
import { bindSelect } from "@nake-ui/adapter-vanilla";

if (typeof window !== "undefined" && typeof document !== "undefined") {
  const binding = bindSelect(
    { trigger, content, options, hiddenInput },
    registrations,
    {
      id: "vanilla-select-invalid-example-controller",
      defaultValue: null,
      name: "demo-select-invalid"
    }
  );

  trigger.setAttribute("aria-invalid", "true");
  trigger.setAttribute("aria-label", "Open select options");
  binding.destroy();
}

Custom

Render rich option content while keeping data-demo-label as readable semantics.

import "@nake-ui/theme-vanilla/styles.css";
import { bindSelect } from "@nake-ui/adapter-vanilla";

const registrations = [
  { id: "apple", label: "Apple" },
  { id: "banana", label: "Banana" },
  { id: "carrot", label: "Carrot" },
];

if (typeof window !== "undefined" && typeof document !== "undefined") {
  const binding = bindSelect(
    { trigger, content, options, hiddenInput },
    registrations,
    {
      id: "vanilla-select-custom-example-controller",
      defaultValue: null,
      name: "demo-select-custom"
    }
  );

  // keep a readable label in data-demo-label while option body stays fully custom
  trigger.setAttribute("aria-label", "Open select options");
  binding.destroy();
}

LabelComposition

Keep only Aspect, remove inner container wrappers, and center it directly.

import "@nake-ui/theme-vanilla/styles.css";
import { bindSelect } from "@nake-ui/adapter-vanilla";

const aspectRegistrations = [
  { id: "16:9", label: "16:9" },
  { id: "9:16", label: "9:16" },
  { id: "1:1", label: "1:1" },
];

function bindAspectSelect(root, registrations, controllerId, placeholder) {
  const trigger = root.querySelector("[data-demo-slot='trigger']");
  const triggerLabel = root.querySelector("[data-demo-slot='trigger-label']");
  const content = root.querySelector("[data-demo-slot='content']");
  const optionNodes = Object.fromEntries(
    Array.from(root.querySelectorAll("[data-demo-slot='option'][data-demo-id]")).map((node) => [
      node.getAttribute("data-demo-id"),
      node
    ])
  );
  const hiddenInput = root.querySelector("[data-demo-slot='hidden-input']");

  if (!trigger || !triggerLabel || !content) return null;

  const binding = bindSelect(
    { trigger, content, options: optionNodes, hiddenInput },
    registrations,
    {
      id: controllerId,
      defaultValue: null,
      name: hiddenInput?.name ?? controllerId,
      onValueChange(nextValue) {
        triggerLabel.textContent =
          nextValue === null ? placeholder : (optionNodes[nextValue]?.dataset.demoLabel ?? nextValue);
      }
    }
  );

  return binding;
}

if (typeof window !== "undefined" && typeof document !== "undefined") {
  bindAspectSelect(aspectRoot, aspectRegistrations, "vanilla-select-editor-aspect-controller", "16:9");
}

Scrollable

Keep long option lists scrollable with max-height and overflow on content.

Long option lists can scroll inside Select.Content.

import "@nake-ui/theme-vanilla/styles.css";
import { bindSelect } from "@nake-ui/adapter-vanilla";

if (typeof window !== "undefined" && typeof document !== "undefined") {
  content.style.maxHeight = "12rem";
  content.style.overflow = "auto";

  const binding = bindSelect(
    { trigger, content, options, hiddenInput },
    registrations,
    {
      id: "vanilla-select-scrollable-example-controller",
      defaultValue: null,
      name: "demo-select-scrollable"
    }
  );

  trigger.setAttribute("aria-label", "Open scrollable select options");
  binding.destroy();
}

Single Option

When the only option is already selected, activating it again still closes the Select content.

import "@nake-ui/theme-vanilla/styles.css";
import { bindSelect } from "@nake-ui/adapter-vanilla";

const option = { id: "only-option", label: "Only option" };

if (typeof window !== "undefined" && typeof document !== "undefined") {
  const binding = bindSelect(
    { trigger, content, options, hiddenInput },
    [option],
    {
      id: "vanilla-select-single-option-example-controller",
      defaultValue: option.id,
      name: "demo-select-single-option"
    }
  );

  trigger.setAttribute("aria-label", "Open single-option select");
  binding.destroy();
}

5. RTL

The same structure running inside a `dir="rtl"` container.

vanilla select preview activates on the client.

6. Closure Coverage

Complete

Closure Score: 100% (19/19)

Required Checks

  • schemaRegistered
  • specDocumented
  • reactMapper
  • siteExampleCoverage
  • siteA11yCoverage
  • siteVisible
  • schemaValidatorCoverage
  • schemaAriaCoverage
  • specValidatorRulesSection
  • specTestChecklistSection
  • specSsrHydrationSection
  • reactSsrHydrationCoverage
  • reactRenderLevelSsrCoverage
  • solidSsrCoverage
  • coreImplemented
  • primitiveUnitTest
  • domContractSnapshot
  • vanillaBinding
  • adapterParitySnapshot

All required closure checks are currently satisfied.

7. Purpose / Non-goals

Compose trigger + listbox with form-compatible hidden input semantics.

  • Native <select> visual emulation
  • Styling responsibility for trigger/content

8. Anatomy & Slot

  • trigger
  • value (optional, inside trigger)
  • content
  • group (optional, repeatable)
  • label (optional, inside group)
  • separator (optional, repeatable)
  • option (repeatable)
  • hidden-input (optional)

9. DOM Contract

<button data-ui="select" data-slot="trigger" aria-controls="select-content"></button>
<div id="select-content" data-slot="content" role="listbox" hidden>
  <div data-slot="group" role="group">
    <div data-slot="label">Fruits</div>
    <div data-slot="option" role="option" aria-selected="true">Apple</div>
  </div>
  <div data-slot="separator" role="separator" aria-orientation="horizontal"></div>
</div>
<input type="hidden" data-slot="hidden-input" name="field" value="apple" />

10. State & Events

State Model

  • value controlled/uncontrolled
  • open controlled/uncontrolled

Event Model

  • trigger toggles popover/listbox
  • option click or Enter commits
  • Escape closes
  • grouped options keep the same commit semantics as flat options

11. Keyboard & Focus

  • trigger opens via Enter/Space/Arrow
  • listbox navigation handles option focus

12. ARIA

  • trigger: aria-haspopup=listbox, aria-expanded, aria-controls
  • options: aria-selected
  • content: role=listbox
  • group: role=group, separator: role=separator
  • invalid state should be reflected on trigger via aria-invalid

13. Validator Rules

  • state.invalid-default-value
  • aria.broken-controls-linkage
  • structure.invalid-slot-placement
  • structure.unexpected-child-slot

14. Adapter Notes

Vanilla

  • Primary binder: bindSelect.
  • Binds explicit DOM nodes and should be cleaned up on unmount.

15. Example Mapping

16. API

Core

  • createSelectController value
  • SelectListboxController type
  • SelectController type
  • SelectControllerOptions type
  • SelectKeyboardResult type
  • SelectOptionRegistration type

Vanilla

  • bindSelect function
  • SelectBindingOptions interface
  • SelectElements interface
  • SelectPositioningPlacement type