diff --git a/aleksis/core/frontend/components/generic/forms/RecurrenceField.vue b/aleksis/core/frontend/components/generic/forms/RecurrenceField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2643fcd962b20ae9c523b7a89381161fe79b108b
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/RecurrenceField.vue
@@ -0,0 +1,125 @@
+<script setup>
+import DateField from "./DateField.vue";
+</script>
+
+<template>
+  <div>
+    <v-row>
+      <v-col class="py-0">
+        <div>{{ $attrs.label }}</div>
+      </v-col>
+    </v-row>
+    <v-row>
+      <v-col v-if="!startDate" cols="4">
+        <date-field
+          v-model="start"
+          v-bind="{ ...$attrs }"
+          :label="$t('forms.recurrence.start')"
+          :disabled="!frequency"
+        />
+      </v-col>
+      <v-col :cols="startDate ? 8 : 4">
+        <v-select
+          v-model="frequency"
+          v-bind="{ ...$attrs }"
+          :label="$t('forms.recurrence.frequency')"
+          :items="rruleFrequencies"
+          item-value="freq"
+          :placeholder="$t('forms.recurrence.no_repeat')"
+          clearable
+        />
+      </v-col>
+      <v-col cols="4">
+        <date-field
+          v-model="until"
+          v-bind="{ ...$attrs }"
+          :label="$t('forms.recurrence.until')"
+          :disabled="!frequency"
+        />
+      </v-col>
+    </v-row>
+  </div>
+</template>
+
+<script>
+import { DateTime } from "luxon";
+import { RRule } from "rrule";
+
+export default {
+  name: "RecurrenceField",
+  data() {
+    return {
+      innerRecurrenceOptions: {},
+      rruleFrequencies: [
+        { freq: RRule.DAILY, text: this.$t("forms.recurrence.frequencies.daily") },
+        { freq: RRule.WEEKLY, text: this.$t("forms.recurrence.frequencies.weekly") },
+        { freq: RRule.MONTHLY, text: this.$t("forms.recurrence.frequencies.monthly") },
+        { freq: RRule.YEARLY, text: this.$t("forms.recurrence.frequencies.yearly") },
+      ],
+      test: 123,
+    };
+  },
+  props: {
+    value: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    startDate: {
+      type: DateTime,
+      required: false,
+      default: undefined,
+    },
+  },
+  computed: {
+    frequency: {
+      get() {
+        return this.innerRecurrenceOptions?.freq;
+      },
+      set(value) {
+        this.innerRecurrenceOptions.freq = value;
+        this.$emit("input", RRule.optionsToString(this.innerRecurrenceOptions));
+      },
+    },
+    start: {
+      get() {
+        return this.innerRecurrenceOptions?.dtstart ? DateTime.fromJSDate(this.innerRecurrenceOptions.dtstart).toISODate() : null;
+      },
+      set(value) {
+        const date = DateTime.fromISO(value).toUTC().toJSDate();
+
+        this.innerRecurrenceOptions.dtstart = date;
+        this.$emit("input", RRule.optionsToString(this.innerRecurrenceOptions));
+      },
+    },
+    until: {
+      get() {
+        return this.innerRecurrenceOptions?.until ? DateTime.fromJSDate(this.innerRecurrenceOptions.until).toISODate() : null;
+      },
+      set(value) {
+        const date = DateTime.fromISO(value).toUTC().toJSDate();
+
+        this.innerRecurrenceOptions.until = date;
+        this.$emit("input", RRule.optionsToString(this.innerRecurrenceOptions));
+      },
+    },
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(newValue) {
+        this.innerRecurrenceOptions = newValue ? RRule.parseString(newValue) : {};
+      },
+    },
+    startDate: {
+      deep: true,
+      immediate: true,
+      handler(newValue) {
+        Object.assign(this.innerRecurrenceOptions, { dtstart: newValue.toUTC().startOf("day").toJSDate() });
+      },
+    },
+  },
+};
+</script>
+
+<style scoped></style>