Select
A native-first styled select control with v-model. The browser owns the dropdown, full keyboard
interaction, type-ahead, and all ARIA semantics — oriUI only styles the closed control and draws a
decorative chevron. It shares the form-control contract of Input and
Textarea: a built-in label, hint, error, and required asterisk, all
wired for accessibility. State is expressed through real attributes — native disabled and
aria-invalid="true" (not classes) — and arbitrary native attrs (name, autocomplete, …) fall
through to the <select> via inheritAttrs:false.
The examples are organised by layer: the class reference is the standalone
@oriui/css layer, and the Framework API is the @oriui/vue component. Every
example is live — flip its code between HTML (the standalone classes, also your htmx / Astro / Svelte /
plain-HTML usage), Vue; HTML is the default.
Classes
A select is a block wrapper plus single-class token utilities — one class repoints one token, no base
class needed. The ori-color_* accent drives the focus ring; the idle border is a neutral,
theme-aware blend. The Vue props in Framework API map 1:1 to these.
| Class | Type | Description |
|---|---|---|
ori-select | Block | Column wrapper (label, control, hint/error); carries the ori-color and ori-font-size utility classes. |
ori-color_* | Color | primary (default) · secondary · success · warn · danger · info · surface (focus ring accent). |
ori-select_* (size) | Size | xs · sm · md (default) · lg · xl · xxl (control height sugar on the wrapper). |
ori-size-radius_* | Radius | zero · xs · sm · md (default) · lg · xl · rounded (control corners, on the inner select element). |
ori-font-size_* | Font | xs · sm · md (default) · lg · xl · xxl (text scale, driven by size prop). |
ori-select__control | Part | The native select element; carries ori-size-radius utility. |
ori-select__chevron | Part | Decorative aria-hidden chevron (inline SVG), absolutely positioned at inset-inline-end. |
ori-select__label · ori-select__required · ori-select__control-wrap · ori-select__hint · ori-select__error | Part | label / required asterisk / control+chevron wrapper / helper text / error message (role=alert). |
ori-select_fluid | Layout | Stretches the wrapper to full width of its container. |
disabled · aria-invalid | State | real attributes, not classes |
Colors
The ori-color_* class controls the focus ring accent.
<!-- swap the color: ori-color_primary → _secondary / _success / _warn / _danger / _info -->
<div class="ori-select ori-color_danger">
<div class="ori-select__control-wrap">
<select class="ori-select__control ori-size-radius_md">
<option value="danger">Danger</option>
</select>
<span class="ori-select__chevron" aria-hidden="true"><!-- chevron SVG --></span>
</div>
</div>
Sizes
xs → xxl. The size drives both the control height (ori-select_* sugar on the wrapper) and the
text scale (ori-font-size_*).
<!-- ori-select_* drives height; ori-font-size_* scales the text; both use the same size token -->
<div class="ori-select ori-color_primary ori-select_xl ori-font-size_xl">
<div class="ori-select__control-wrap">
<select class="ori-select__control ori-size-radius_md">
<option value="xl">Extra large</option>
</select>
<span class="ori-select__chevron" aria-hidden="true"><!-- chevron SVG --></span>
</div>
</div>
Radius
From zero (square) through the default md to rounded (pill-shaped control).
<select class="ori-select__control ori-size-radius_zero">
…
</select>
Placeholder
A placeholder renders a disabled, non-reselectable first option that acts as a prompt. It carries
disabled so it cannot be chosen after the user has made a selection.
<div class="ori-select ori-color_primary">
<div class="ori-select__control-wrap">
<select class="ori-select__control ori-size-radius_md">
<option value="" disabled selected>Choose a country</option>
<option value="fr">France</option>
<option value="de">Germany</option>
<option value="jp">Japan</option>
</select>
<span class="ori-select__chevron" aria-hidden="true"><!-- chevron SVG --></span>
</div>
</div>
Options prop
Pass a flat array of { label, value, disabled? } objects. The component renders them as <option>
elements, with disabled items available but not selectable.
<select class="ori-select__control …">
<option value="" disabled selected>Pick a plan</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="ent" disabled>Enterprise (contact us)</option>
</select>
Default slot
Provide hand-written <option> / <optgroup> markup as the default slot. When the slot is used it
replaces the options-prop rendering; the placeholder option (if any) still renders before the slot.
<div class="ori-select ori-color_primary">
<div class="ori-select__control-wrap">
<select class="ori-select__control ori-size-radius_md">
<option value="" disabled selected>Pick a city</option>
<optgroup label="Europe">
<option value="par">Paris</option>
<option value="ber">Berlin</option>
</optgroup>
<optgroup label="Asia">
<option value="tok">Tokyo</option>
<option value="bkk">Bangkok</option>
</optgroup>
</select>
<span class="ori-select__chevron" aria-hidden="true"><!-- chevron SVG --></span>
</div>
</div>
States
disabled uses the real native attribute. invalid sets aria-invalid="true" and switches the
border and focus ring to the danger color.
<!-- disabled -->
<select class="ori-select__control …" disabled>
…
</select>
<!-- invalid -->
<select class="ori-select__control …" aria-invalid="true">
…
</select>
Label, hint & error
Like Input and Textarea, the select has built-in form
wiring — a label (rendered <label for>, no external label needed), a hint, an error
(role="alert", flips aria-invalid, wired through aria-describedby), and a required asterisk.
Where the order ships
Please choose a plan
<div class="ori-select ori-color_primary">
<label for="country" class="ori-select__label"
>Country<span class="ori-select__required" aria-hidden="true">*</span></label
>
<div class="ori-select__control-wrap">
<select id="country" required aria-describedby="country-hint" class="ori-select__control ori-size-radius_md">
<option value="fr">France</option>
<option value="de">Germany</option>
</select>
<span class="ori-select__chevron" aria-hidden="true"><!-- chevron SVG --></span>
</div>
<p id="country-hint" class="ori-select__hint">Where the order ships</p>
</div>
Common patterns
A shipping-address form row — color, size, radius, placeholder, and required fall through together.
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap">
<div class="ori-select ori-color_primary">
<div class="ori-select__control-wrap">
<select name="country" required class="ori-select__control ori-size-radius_md">
<option value="" disabled selected>Country</option>
<option value="fr">France</option>
<option value="de">Germany</option>
<option value="jp">Japan</option>
</select>
<span class="ori-select__chevron" aria-hidden="true"><!-- chevron SVG --></span>
</div>
</div>
<div class="ori-select ori-color_primary">
<div class="ori-select__control-wrap">
<select name="state" class="ori-select__control ori-size-radius_md">
<option value="" disabled selected>State / Province</option>
<option value="idf">Île-de-France</option>
<option value="bay">Bavaria</option>
</select>
<span class="ori-select__chevron" aria-hidden="true"><!-- chevron SVG --></span>
</div>
</div>
</div>
Accessibility
The accessibility contract holds across every layer — the standalone classes and the Vue component render the same attributes.
- The control is a native
<select>, so the browser provides the complete dropdown, full keyboard interaction (arrow keys, type-ahead, Home/End), and all ARIA semantics — no custom widget needed. - State is real attributes: native
disabledandaria-invalid="true"(not classes), styled with attribute selectors (:disabled,[aria-invalid='true']). - The built-in
labelrenders a<label for>bound to the control'suseId()-basedid, so the select is named without any external markup. (You can still supply an external<label for>using the control'sid, or passaria-label/aria-labelledbyfor labelless contexts — they fall through to the<select>.) hintanderrorare wired to the control viaaria-describedby;erroralso rendersrole="alert"and flips the control toaria-invalid="true".requiredsets the nativerequiredattribute.- The chevron is
aria-hidden="true"andpointer-events: none— it is never announced and never intercepts clicks or keyboard events. - The placeholder renders as a
disabled<option>so it cannot be re-selected once the user makes a choice. - Arbitrary native attrs (
name,required,autocomplete,aria-label,aria-labelledby, …) fall through viainheritAttrs: false+v-bind="$attrs"directly to the<select>element. - Focus is always visible:
:focusshows a tokenized border +box-shadowring reading--ori-color(the control sits on the page, not on a fill surface). Wheninvalid, the ring uses the danger color.
| Key | Action |
|---|---|
Tab / Shift+Tab | Moves focus to / away from the control. |
Space / Enter | Opens the dropdown (browser-native). |
Arrow Up / Down | Cycles through options; navigates the open list. |
Home / End | Jumps to the first / last option. |
| Type-ahead | Selects the first option beginning with typed char. |
Escape | Closes the dropdown without changing selection. |
Framework API
The props, events, slots, and attribute passthrough of the Vue component. The standalone CSS layer has no component API — its surface is the classes above. (Svelte bindings are planned.)
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ThemeColor | 'primary' | Accent that drives the focus-ring/border color via the ori-color utility (repoints --ori-color). |
describedby | string | — | Extra element id(s) appended to aria-describedby (e.g. a shared form note). |
disabled | boolean | false | Disables the control via the real native disabled attribute (styled with :disabled). |
error | string | — | Error message rendered below the control (role="alert"); flips the control to aria-invalid and wires aria-describedby. Replaces the hint. |
fluid | boolean | false | Stretches the control to the full width of its container. |
hint | string | — | Helper text below the control, wired via aria-describedby. Hidden while an error is shown. |
id | string | useId() | Id applied to the native <select>; defaults to an SSR-safe useId() so the label (or an external <label for>) can target it. |
invalid | boolean | false | Marks the control invalid via aria-invalid="true"; styled with a danger border and ring. (error also sets this.) |
label | string | — | Built-in <label> rendered above the control and wired to it via for. |
options | Array<{ label: string; value: string | number; disabled?: boolean }> | [] | Options rendered as <option> elements. Ignored when a default slot is provided. |
placeholder | string | — | Renders a disabled, selected-by-default first <option value=""> as a non-selectable prompt. |
radius | RadiusSize | 'md' | Border radius via the ori-size-radius_* single-class token (zero · xs · sm · md · lg · xl · rounded). |
required | boolean | false | Sets the native required attribute and renders a * after the label. |
size | ActionSize | 'md' | Control height (ori-select_* size sugar) and font-size (ori-font-size_*) (xs–xxl). |
Events & attributes
v-model binds to a string | number via defineModel<string | number>() — it reads the
modelValue prop and emits update:modelValue on change:
<OriSelect v-model="selected" :options="options" />
OriSelect sets inheritAttrs: false and spreads $attrs onto the underlying <select>, so native
attributes and listeners pass through directly to the field element — not the wrapper <div>:
- Attributes:
name,required,autocomplete,aria-label,aria-labelledby, … - Listeners:
@change,@focus,@blur, …
Slots
| Slot | Scope | Description |
|---|---|---|
default | none | Hand-written <option> / <optgroup> markup. When present it replaces the options-prop rendering; the placeholder option still renders before it. |