Skip to content

Select

<aparte-select> — with 2 parts: <aparte-option>, <aparte-optgroup>

Dropdown select for aparté — a vanilla web component with optional grouping, a search filter and a keyboard-driven listbox.

The element is light DOM and takes <aparte-option> / <aparte-optgroup> children, in the order they should appear. On its first render it captures its children, keeps those two kinds and moves them into the role="listbox" container it builds, then rewrites its own innerHTML — so any other child is dropped, and a wrapper element around your options takes the options down with it: only DIRECT children are captured.

Children written later are picked up by a subtree MutationObserver and moved into that same container, and the keyboard highlight is re-asserted on the new elements. The move EMPTIES the container first, so a later write replaces the list instead of adding to it: write the whole list, not one option. Writing straight into .aparte-select-options skips the move (the observer then only re-asserts the highlight), and that is the path @aparte/plugin-model-selector takes to refresh a live list in place.

It is not a form control: no name, no multiple, no participation in form submission. It holds exactly one value and reports it through aparte-select-change.

The combobox is whichever element holds focus. Without searchable that is the trigger. With it, opening focuses the filter field, so the field carries role="combobox", aria-expanded, aria-controls and the roving aria-activedescendant, and the trigger is a role="button" — one combobox either way, and always the focused one.

The dropdown is position: fixed and placed from script so it escapes an overflow: hidden ancestor — which is why its stacking order is a variable (--aparte-select-z) rather than a fixed rule, and why an open dropdown does not scroll with the trigger.

Width Open in a tab
The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes.
<aparte-select placeholder="Pick a model" searchable value="gpt-4o-mini">
<aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option>
<aparte-option value="llama-3.1-8b">Llama 3.1 8B</aparte-option>
</aparte-select>
<script>
document.querySelector('aparte-select').addEventListener('aparte-select-change', (e) => {
console.log(e.detail.value, e.detail.label, e.detail.previousValue);
});
</script>

<aparte-option>

AparteOption

Option element for aparte-select dropdown.

One selectable row. Only meaningful inside <aparte-select> (directly, or nested in an <aparte-optgroup>): the parent owns selected outright — on its first render, and on every value change after it, it sets that attribute on the option whose value matches its own and strips it from all the others. So selected written by hand does not survive; set value on the select instead. Outside a select nothing selects it: it only styles the row and sets role="option" / aria-selected from its own attributes.

It is not an <option>. It carries no form value, disabled blocks the click and the keyboard walk but is not a form-disabled state, and when the value attribute is absent the trimmed text content is used as the value instead.

Keep the label one text node: the label property reads only the FIRST text node — that is what keeps the injected status dot out of it — so wrapping the label in an element makes label fall back to value. The select’s own trigger label and its search filter read the full textContent, so a wrapped label still displays and still matches.

data-status is a rendering hook, not state core interprets: any non-empty value appends an aria-hidden .aparte-status-dot span as the last child, and only ready, cached and not-downloaded have a colour in the stylesheet — anything else renders an uncoloured dot until you style it.

<aparte-select placeholder="Pick a model" value="gpt-4o-mini">
<aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option>
<aparte-option value="o3" disabled>o3 (no access)</aparte-option>
</aparte-select>

<aparte-optgroup>

AparteOptgroup

Option group element for aparte-select dropdown.

A labelled band of options inside <aparte-select>. Presentational only: the group holds no value, and collapsing it just sets display: none on its <aparte-option> descendants — they stay in the DOM, which is what keeps the select’s keyboard walk skipping them, since it works off display. The label header is inserted before the children and the loading row appended after them, so both live inside the group, and neither is hidden when it collapses: only the options are.

Two consequences of that worth knowing before you reach for it. The select’s search writes that same display property on every option in the select, so filtering can reveal matches inside a collapsed group. And the header is built once, on the first render that finds a label: changing label afterwards does not rewrite it — set the label before inserting the group, or replace the group.

loading is a display state, not a fetch. It appends a spinner row to the group and nothing else happens — the host still owns the request. “Fetch on expand” is driven by aparte-optgroup-toggle, whose detail.collapsed says which way the group just went; the attribute and the options’ display are already updated when it fires, since setting the attribute runs attributeChangedCallback synchronously.

<!-- Collapsed groups keep a long list readable; the label is the group's header. -->
<aparte-select placeholder="Pick a model">
<aparte-optgroup label="Ollama" collapsible collapsed>
<aparte-option value="ollama::llama3">Llama 3</aparte-option>
</aparte-optgroup>
<aparte-optgroup label="OpenRouter" collapsible>
<aparte-option value="openrouter::gpt-4o-mini">GPT-4o mini</aparte-option>
</aparte-optgroup>
</aparte-select>
AttributeDescription
valueThe selected option’s value.
placeholderShown while nothing is selected.
disabledBlocks opening the dropdown.
searchableAdds a filter field above the options. Read on the first render only.
openReflects (and controls) whether the dropdown is open.
PropertyTypeDescription
valuestring
openboolean
EventTypeDescription
aparte-select-openCustomEventThe dropdown opened. No detail.
aparte-select-closeCustomEventThe dropdown closed. No detail.
aparte-select-changeCustomEvent&lt;AparteSelectChangeDetail>The selection changed; carries the new value, its label and the previous value.
AttributeDescription
valueOption value
disabledDisabled state
selectedSelected state
data-statusFree-form status the host sets; styled, never read by core.
PropertyTypeDescription
valuestring
label (readonly)string
disabledboolean
selectedboolean
AttributeDescription
labelGroup label
collapsibleAdds the chevron and the click handler to the header — so it needs a label, and it is read only when that header is first built.
collapsedCollapsed state
loadingAppends a spinner row to the group; the options stay visible.
PropertyTypeDescription
labelstring
collapsible (readonly)boolean
collapsedboolean
loadingboolean
EventTypeDescription
aparte-optgroup-toggleCustomEvent&lt;AparteOptgroupToggleEventDetail>The group was collapsed or expanded.

Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.

VariableDefaultDescription
--aparte-select-bgvar(--aparte-surface-1)Trigger background. (The dropdown panel has its own token, --aparte-select-dropdown-bg, in both themes.)
--aparte-select-bordervar(--aparte-border-control)Border of the trigger and of the dropdown.
--aparte-select-border-hovervar(--aparte-primary)Trigger border on hover.
--aparte-radius-selectvar(--aparte-radius-md)Corner radius of the trigger and the dropdown — the theme’s knob, beside --aparte-radius-input and the others. (It used to exist twice, as this and a private --aparte-select-radius with a different value.)
--aparte-select-textvar(--aparte-text)Colour of the trigger label (and of the options).
--aparte-select-chevronvar(--aparte-text-muted)Colour of the chevron, which rotates 180° while open.
--aparte-select-dropdown-bgvar(--aparte-surface-1)Dropdown panel background, in both themes (--aparte-surface-1 is dark-aware). It used to be read in the light theme only, the dark rule taking the TRIGGER’s background instead — so a transparent trigger made the panel see-through in the dark.
--aparte-select-shadow0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16)Dropdown panel shadow.
--aparte-select-zvar(--aparte-z-dropdown)z-index of the dropdown. It is position: fixed, so this is the one knob that decides whether it lands above the rest of your page.
VariableDefaultDescription
--aparte-select-textvar(--aparte-text)Option text colour.
--aparte-select-option-hovervar(--aparte-surface-2)Background on hover, and for the keyboard-active row ([data-active]), which adds an inset --aparte-primary ring on top so the two are distinguishable.
--aparte-select-option-selectedcolor-mix(in srgb, var(--aparte-primary) var(--aparte-mark-tint), transparent)Background of the selected row. A tint by default: a solid accent fill with white text failed WCAG AA in both themes.
--aparte-select-option-selected-textvar(--aparte-select-text, var(--aparte-text))Text colour of the selected row. Set both this and the background to go back to a solid fill.
VariableDefaultDescription
--aparte-text-mutedColour of the group header label and of the loading row. The shared theme token: there is no optgroup-specific override, so restyling one group’s header means setting this on that element.

The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.

<aparte-select disabled=""></aparte-select>
el.addEventListener('aparte-select-change', (e) => use(e.detail));

Installation and the framework-specific traps: React · Vue · Svelte · Angular.