diff --git a/src/components/ComboBox.vue b/src/components/ComboBox.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0ef465ee3a8f3a352a471ea198c4c1f29d0bd118
--- /dev/null
+++ b/src/components/ComboBox.vue
@@ -0,0 +1,238 @@
+<template>
+  <div ref="rootEl" class="tw-relative" @keyup.esc="close">
+    <div
+      class="tw-relative"
+      :class="inputContainerClass"
+      aria-haspopup="listbox"
+      :aria-expanded="isOpen"
+      :aria-controls="resultsId"
+      tabindex="-1"
+    >
+      <slot name="pre" />
+      <label
+        v-if="label"
+        :for="inputId"
+        class="tw-absolute tw-top-2 tw-left-8 tw-text-sm tw-font-bold tw-pointer-events-none"
+      >
+        {{ label }}
+      </label>
+      <span
+        v-if="!isOpen && modelValue"
+        class="tw-absolute tw-pointer-events-none tw-top-7 tw-left-8"
+      >
+        {{ getDisplayName(modelValue) }}
+      </span>
+      <kbd
+        v-if="keyboardShortcut && keyboardShortcutLabel !== undefined && !isTouch"
+        v-show="!isOpen"
+        :title="keyboardShortcutLabel"
+        class="tw-absolute tw-pointer-events-none tw-top-4 tw-right-8 tw-bg-black/20 tw-opacity-60 tw-p-2 tw-text-xs tw-rounded-lg tw-leading-none"
+      >
+        <template v-for="(token, index) in keyboardShortcutLabel.split(' ')" :key="index">
+          <span v-if="token !== '+'">{{ token }}</span>
+          <span v-else>&nbsp;</span>
+        </template>
+      </kbd>
+      <input
+        :id="inputId"
+        ref="inputEl"
+        v-model="query"
+        aria-autocomplete="list"
+        :aria-expanded="isOpen"
+        :aria-controls="resultsId"
+        :aria-activedescendant="activeResultId"
+        role="combobox"
+        tabindex="0"
+        type="text"
+        class="tw-pt-6 tw-pb-2 tw-px-8 md:tw-w-80 tw-text-inherit"
+        :class="inputClass"
+        @keydown.up.prevent
+        @keydown.down.prevent
+        @keydown.home.prevent
+        @keydown.end.prevent
+        @keyup="ensureOpen"
+        @keyup.down.prevent.stop="updateIndex(1)"
+        @keyup.up.prevent.stop="updateIndex(-1)"
+        @keyup.home.prevent.stop="updateIndex(Number.NEGATIVE_INFINITY)"
+        @keyup.end.prevent.stop="updateIndex(Number.POSITIVE_INFINITY)"
+        @keyup.enter.prevent.stop="selectChoice(choices[activeIndex])"
+        @focus="open"
+      />
+    </div>
+
+    <Transition
+      enter-active-class="tw-transition tw-duration-200 tw-ease-out"
+      enter-from-class="tw-translate-y-1 tw-opacity-0"
+      enter-to-class="tw-translate-y-0 tw-opacity-100"
+      leave-active-class="tw-transition tw-duration-150 tw-ease-in"
+      leave-from-class="tw-translate-y-0 tw-opacity-100"
+      leave-to-class="tw-translate-y-1 tw-opacity-0"
+    >
+      <div
+        v-if="isOpen"
+        :id="drawerId"
+        :class="[
+          drawerClass,
+          {
+            'tw-fixed tw-left-2 tw-right-2 tw-z-20 tw-mt-2 tw-bg-white tw-shadow-2xl tw-rounded tw-flex tw-flex-col tw-overflow-hidden': true,
+            'md:tw-absolute md:tw-top-full md:tw-left-auto md:tw-right-0 md:tw-w-fit md:tw-flex-row': true,
+          },
+        ]"
+      >
+        <div class="tw-order-2 tw-flex-none tw-p-6 tw-bg-gray-100 md:tw-w-80">
+          <ul
+            v-if="choices.length > 0"
+            ref="choicesEl"
+            role="listbox"
+            class="tw-max-h-96 tw-overflow-y-auto tw-block tw-p-0 tw-m-0"
+          >
+            <template v-for="(choice, index) in choices" :key="getKey(choice)">
+              <slot
+                :id="`${resultsId}-option-${index}`"
+                role="option"
+                tabindex="-1"
+                class="tw-py-2 tw-px-4 tw-rounded tw-block tw-cursor-pointer"
+                :class="{
+                  'tw-bg-aura-primary tw-text-white': activeIndex === index,
+                }"
+                :choice="choice"
+                :index="index"
+                :active-index="activeIndex"
+                @click="selectChoice(choice)"
+                @mouseenter="activeIndex = index"
+              />
+            </template>
+          </ul>
+
+          <slot v-else name="noData">
+            <p class="tw-font-bold">{{ noDataLabel }}</p>
+          </slot>
+        </div>
+
+        <div v-if="slots.filter" class="tw-order-1 tw-p-6 tw-flex tw-flex-col tw-gap-3">
+          <slot name="filter" :results-id="resultsId" />
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script lang="ts" setup>
+// TODO: implement proper typing once we upgrade to Vue 3.3.
+import { computed, nextTick, Ref, ref, useSlots, watchEffect } from 'vue'
+import { onClickOutside, useMediaQuery, whenever } from '@vueuse/core'
+import { useId } from '@/util'
+
+const props = withDefaults(
+  defineProps<{
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    modelValue: any
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    choices: any[]
+    keyboardShortcut?: Ref<boolean>
+    keyboardShortcutLabel?: string
+    label?: string
+    noDataLabel?: string
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    getDisplayName?: (obj: any) => string
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    getKey?: (obj: any) => string | number
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    inputClass?: any
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    inputContainerClass?: any
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    drawerClass?: any
+  }>(),
+  {
+    keyboardShortcut: undefined,
+    keyboardShortcutLabel: '',
+    label: '',
+    noDataLabel: '',
+    getDisplayName: (obj: { name: string }) => String(obj?.name),
+    getKey: (obj: { id: string | number }) => obj.id,
+    inputClass: undefined,
+    inputContainerClass: undefined,
+    drawerClass: undefined,
+  },
+)
+const emit = defineEmits<{
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  (e: 'update:modelValue', value: any): void
+  (e: 'search', value: string): void
+  (e: 'open'): void
+  (e: 'close'): void
+}>()
+const slots = useSlots()
+
+const query = ref('')
+const rootEl = ref<HTMLElement>()
+const inputEl = ref<HTMLInputElement>()
+const choicesEl = ref<HTMLUListElement>()
+const activeIndex = ref(0)
+const isOpen = ref(false)
+const isTouch = useMediaQuery('screen and (pointer: coarse)')
+const inputId = useId('combobox-input')
+const drawerId = useId('combobox-drawer')
+const resultsId = useId('combobox-results')
+const activeResultId = computed(() => `${resultsId.value}-option-${activeIndex.value}`)
+
+function open() {
+  isOpen.value = true
+  inputEl.value?.focus?.()
+  emit('open')
+}
+
+function close(event?: Event) {
+  if (event instanceof KeyboardEvent && isOpen.value) {
+    event.stopImmediatePropagation()
+    event.preventDefault()
+  }
+  isOpen.value = false
+  query.value = ''
+  activeIndex.value = 0
+  emit('close')
+}
+
+function ensureOpen(event: Event) {
+  if (!isOpen.value) {
+    event.preventDefault()
+    event.stopImmediatePropagation()
+    nextTick(open)
+  }
+}
+
+function updateIndex(modifier: number) {
+  const newIndex = activeIndex.value + modifier
+  activeIndex.value = Math.max(0, Math.min(newIndex, props.choices.length - 1))
+}
+
+function selectChoice(obj: unknown) {
+  emit('update:modelValue', obj)
+  nextTick(close)
+}
+
+watchEffect(() => {
+  emit('search', query.value)
+})
+
+watchEffect(() => {
+  const listItem = choicesEl.value?.children?.[activeIndex.value]
+  listItem?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'start' })
+})
+
+onClickOutside(rootEl, close)
+whenever(
+  computed(() => props.keyboardShortcut?.value),
+  open,
+)
+</script>
+
+<script lang="ts">
+export default {
+  compatConfig: {
+    MODE: 3,
+    ATTR_FALSE_VALUE: false,
+  },
+}
+</script>