Skip to content

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.