Appearance
Vue Select Component
A custom-styled select dropdown that hides a native <select> element underneath a styled overlay. Preserves full keyboard and screen-reader accessibility from the native element while allowing complete visual customization. Uses MutationObserver to keep a reactive options array in sync with DOM changes.
When to use
Use this component when you need full visual control over a select dropdown without sacrificing keyboard and screen-reader accessibility. It hides a native <select> under a styled overlay and uses MutationObserver to stay in sync with dynamically added or removed options.
The pattern
Layer a visible styled element on top of a real but invisible <select>. The native element handles keyboard navigation, focus, and assistive technology.
Basic usage
Pass options as <option> children and bind the selection with v-model.
vue
<Select
name="country"
label="Country"
v-model="form.country"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="mx">Mexico</option>
</Select>Custom selected display
Use the #selected slot to customize how the chosen value renders.
vue
<Select
name="status"
label="Status"
v-model="form.status"
>
<template #selected="{ value }">
<StatusBadge :status="value" />
</template>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>Implementing the Select component
A MutationObserver watches for <option> changes and syncs them into a reactive array for display. The selectedLabel computed derives display text from the synced options.
vue
<template>
<label
class="SelectContainer"
v-bind="{ class: className, style }"
>
<span class="Label">{{ label }}</span>
<span class="SelectValue">
<slot name="selected" :value="model">
{{ selectedLabel }}
</slot>
</span>
<select
class="SelectControl"
ref="select"
:name="name"
v-model="model"
@input="emit('input', $event.currentTarget.value)"
@change="emit('change', $event.currentTarget.value)"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
v-bind="$attrs"
>
<option v-if="blankOption" value=""></option>
<slot />
</select>
</label>
</template>
<script setup>
import {
computed, reactive, ref, unref, useAttrs, onMounted,
} from "vue";
const { class: className, style, ...$attrs } = useAttrs();
defineOptions({ inheritAttrs: false });
const model = defineModel({ default: "" });
const props = defineProps({
name: { type: String, default: "" },
label: { type: String, default: "" },
blankOption: { type: Boolean, default: true },
});
const emit = defineEmits(["input", "change", "focus", "blur"]);
defineExpose({ focus, blur });
const select = ref(null);
const options_ = reactive([]);
onMounted(() => {
const el = unref(select);
updateOptions();
new MutationObserver(updateOptions).observe(el, {
childList: true, subtree: true,
});
function updateOptions() {
const descriptors = [...el.querySelectorAll("option")]
.map((opt) => ({
value: opt.value,
label: opt.innerText,
}));
options_.splice(0, Infinity, ...descriptors);
}
});
const selectedLabel = computed(() => {
const value = String(model.value);
return options_.find((o) => o.value === value)?.label;
});
function focus() { unref(select)?.focus(); }
function blur() { unref(select)?.blur(); }
</script>Hidden native <select> overlay
The native <select> is set to opacity: 0 and layered over the styled display with CSS Grid so it captures all pointer and keyboard events. Screen readers interact with the real <select>, getting correct ARIA semantics for free.
Reactive option syncing with MutationObserver
The observer watches for <option> elements added or removed in the DOM. This handles dynamic options passed via the default slot without requiring a prop-based options array. Each mutation triggers a full resync of the reactive options_ array.
selectedLabel computed
The computed property derives the display text from the synced options array. When the model value changes, the computed re-evaluates and the styled overlay updates automatically.
blankOption and selected slot
The blankOption prop inserts an empty <option> at the top so the user can deselect. It defaults to true. The selected slot lets the parent customize how the selected value renders — for example, with an icon or formatted text.
Trade-offs
- MutationObserver overhead — Fires on every DOM mutation in the
<select>subtree. For static option lists this is unnecessary work, but the cost is negligible in practice. If options are known at setup time, a prop-based approach avoids the observer entirely. - Opacity layering — The invisible native
<select>must stay sized and positioned over the styled overlay. CSS Grid makes this reliable, but z-index conflicts or overflow clipping from parent containers can break the interaction layer. - No filtering or search — This is a styled native
<select>, not a combobox. For searchable dropdowns or multi-select, a different component with ARIA combobox roles is needed.