diff --git a/src/assets/styles/tailwind.css b/src/assets/styles/tailwind.css
index ca2aebef253b0d81eed9aacf33f0f7f2bac87aa3..c410a57e26a81e1dd568fca9f63264533781a72a 100644
--- a/src/assets/styles/tailwind.css
+++ b/src/assets/styles/tailwind.css
@@ -176,4 +176,12 @@ thead .fc-day-selected:hover {
       @apply tw-pr-6;
     }
   }
+
+  .form-control:focus-within {
+    color: #495057;
+    background-color: #fff;
+    border-color: #80bdff;
+    outline: 0;
+    box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+  }
 }
diff --git a/src/components/generic/TagInput.vue b/src/components/generic/TagInput.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e4f87c871e2721c4b481f6cf8d5301fdb7c96844
--- /dev/null
+++ b/src/components/generic/TagInput.vue
@@ -0,0 +1,62 @@
+<template>
+  <div
+    class="form-control tw-flex tw-flex-wrap tw-gap-2 tw-h-auto tw-min-h-[46px] tw-cursor-text"
+    @click="inputEl?.focus?.()"
+  >
+    <span
+      v-for="(tag, index) in modelValue"
+      :key="index"
+      class="tw-py-1 tw-px-2 tw-flex tw-items-center tw-bg-gray-100 tw-flex-none tw-rounded tw-max-w-full"
+    >
+      <span class="tw-min-w-0 tw-truncate">{{ tag }}</span>
+      <button type="button" class="btn tw-text-xs tw-p-0" @click="removeTag(index)">
+        <icon-system-uicons-cross />
+      </button>
+    </span>
+
+    <input
+      ref="inputEl"
+      v-model="tagInputValue"
+      type="text"
+      class="tw-flex-1 tw-border-none focus:tw-outline-none tw-p-0 tw-m-0 tw-min-w-[150px]"
+      @keyup.enter.prevent="addTag"
+      @keyup.delete="maybeRemoveLastTag"
+      @click.stop
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { useUpdatableState } from '@/util'
+
+defineOptions({ compatConfig: { MODE: 3 } })
+
+const props = defineProps<{
+  modelValue: string[]
+}>()
+const emit = defineEmits<{
+  'update:modelValue': [string[]]
+}>()
+const value = useUpdatableState(
+  computed(() => props.modelValue),
+  (newValue) => emit('update:modelValue', newValue),
+)
+const tagInputValue = ref('')
+const inputEl = ref<HTMLInputElement>()
+
+function addTag() {
+  value.value.push(tagInputValue.value)
+  tagInputValue.value = ''
+}
+
+function removeTag(index: number) {
+  value.value.splice(index, 1)
+}
+
+function maybeRemoveLastTag() {
+  if (tagInputValue.value === '' && value.value.length > 0) {
+    value.value.splice(value.value.length - 1, 1)
+  }
+}
+</script>