Components
Select
Compose trigger + listbox with form-compatible hidden input semantics.
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.
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.
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.
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
triggervalue(optional, insidetrigger)contentgroup(optional, repeatable)label(optional, insidegroup)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
-
createSelectControllervalue -
SelectListboxControllertype -
SelectControllertype -
SelectControllerOptionstype -
SelectKeyboardResulttype -
SelectOptionRegistrationtype
Vanilla
-
bindSelectfunction -
SelectBindingOptionsinterface -
SelectElementsinterface -
SelectPositioningPlacementtype