JAVASCRIPT

Building a Headless Select Component in Vue 3

Create a highly customizable and accessible headless select component in Vue 3, managing its state and behavior without imposing specific UI rendering.

// HeadlessSelect.vue
<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  options: { type: Array, required: true },
  modelValue: { type: [String, Number], default: null }
})

const emit = defineEmits(['update:modelValue'])

const isOpen = ref(false)
const selectedValue = ref(props.modelValue)
const activeOptionIndex = ref(-1)

watch(() => props.modelValue, (newVal) => {
  selectedValue.value = newVal
})

watch(selectedValue, (newVal) => {
  emit('update:modelValue', newVal)
  if (isOpen.value) {
    isOpen.value = false // Close dropdown on selection
  }
})

function toggle() {
  isOpen.value = !isOpen.value
  if (isOpen.value) {
    // Set active index to current selected item when opening
    activeOptionIndex.value = props.options.findIndex(opt => opt.value === selectedValue.value)
  } else {
    activeOptionIndex.value = -1 // Reset when closed
  }
}

function selectOption(option) {
  selectedValue.value = option.value
}

function handleKeyDown(event) {
  const maxIndex = props.options.length - 1
  switch (event.key) {
    case 'ArrowDown':
      event.preventDefault()
      if (!isOpen.value) isOpen.value = true
      activeOptionIndex.value = (activeOptionIndex.value < maxIndex) ? activeOptionIndex.value + 1 : 0
      break
    case 'ArrowUp':
      event.preventDefault()
      if (!isOpen.value) isOpen.value = true
      activeOptionIndex.value = (activeOptionIndex.value > 0) ? activeOptionIndex.value - 1 : maxIndex
      break
    case 'Enter':
    case ' ':
      event.preventDefault()
      if (isOpen.value && activeOptionIndex.value !== -1) {
        selectOption(props.options[activeOptionIndex.value])
      } else if (!isOpen.value) {
        toggle()
      }
      break
    case 'Escape':
      event.preventDefault()
      isOpen.value = false
      break
  }
}

</script>

<template>
  <div @keydown="handleKeyDown" tabindex="0" class="headless-select-wrapper">
    <slot
      :is-open="isOpen"
      :selected-value="selectedValue"
      :options="props.options"
      :active-option-index="activeOptionIndex"
      :toggle="toggle"
      :select-option="selectOption"
    />
  </div>
</template>

<!-- Usage in App.vue -->
<script setup>
import HeadlessSelect from './HeadlessSelect.vue'
import { ref } from 'vue'

const frameworks = ref([
  { label: 'Vue.js', value: 'vue' },
  { label: 'React', value: 'react' },
  { label: 'Angular', value: 'angular' },
])

const selectedFramework = ref('vue')
</script>

<template>
  <HeadlessSelect v-model="selectedFramework" :options="frameworks">
    <template #default="{ isOpen, selectedValue, toggle, options, selectOption, activeOptionIndex }">
      <div class="custom-select" :class="{ 'is-open': isOpen }">
        <button class="select-button" @click="toggle" aria-haspopup="listbox" :aria-expanded="isOpen">
          {{ options.find(o => o.value === selectedValue)?.label || 'Select...' }}
        </button>
        <ul v-if="isOpen" class="select-options" role="listbox">
          <li
            v-for="(option, index) in options"
            :key="option.value"
            @click="selectOption(option)"
            :class="{ 'is-active': activeOptionIndex === index, 'is-selected': selectedValue === option.value }"
            role="option"
            :aria-selected="selectedValue === option.value"
          >
            {{ option.label }}
          </li>
        </ul>
      </div>
    </template>
  </HeadlessSelect>
</template>

<style>
/* Example styling for usage component */
.headless-select-wrapper:focus {
  outline: none; /* remove default outline for the wrapper */
}

.custom-select {
  position: relative;
  width: 200px;
}

.select-button {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background-color: #fff;
  cursor: pointer;
  text-align: left;
}

.select-options {
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  background-color: #fff;
  border: 1px solid #eee;
  border-radius: 4px;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
  list-style: none;
  padding: 0;
  margin: 4px 0 0 0;
  z-index: 10;
}

.select-options li {
  padding: 8px 12px;
  cursor: pointer;
}

.select-options li:hover,
.select-options li.is-active {
  background-color: #f0f0f0;
}

.select-options li.is-selected {
  font-weight: bold;
  background-color: #e6f7ff;
}
</style>
How it works: This snippet illustrates how to build a 'headless' select component in Vue 3. A headless component manages the state and behavior (like opening/closing, selection, keyboard navigation) but renders no intrinsic UI elements. Instead, it exposes its internal state and methods via a default slot's scope, allowing the consumer to render any desired markup and styling. This approach provides maximum flexibility and accessibility control to the developer using the component, without being opinionated about its visual appearance. The `v-model` is used for bidirectional data binding, and `tabindex="0"` along with `handleKeyDown` ensures keyboard accessibility.

Need help integrating this into your project?

Our team of expert developers can help you build your custom application from scratch.

Hire DigitalCodeLabs