diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index da21104704623c6d9b8c26b5623b2d63e5dc17b3..f6fcfaab97343827f23023d01d1a945a49bd11d2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -31,6 +31,7 @@ Added
 * Data template for `room` model used for haystack search indexing moved to core.
 * Support for two factor authentication via email codes and Webauthn.
 * GraphQL schema for Rooms
+* [Dev] UpdateIndicator Vue Component to display the status of interactive pages
 
 Changed
 ~~~~~~~
diff --git a/aleksis/core/frontend/components/generic/UpdateIndicator.vue b/aleksis/core/frontend/components/generic/UpdateIndicator.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0d23a2cab16a1135d2207aea074b4832520005eb
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/UpdateIndicator.vue
@@ -0,0 +1,85 @@
+<template>
+  <v-tooltip bottom>
+    <template #activator="{ on, attrs }">
+      <v-btn
+        right
+        icon
+        v-bind="attrs"
+        v-on="on"
+        @click="handleClick"
+        :loading="status === $options.UPDATING"
+      >
+        <v-icon v-if="status !== $options.UPDATING" :color="color">
+          {{ icon }}
+        </v-icon>
+      </v-btn>
+    </template>
+    <span>{{ text }}</span>
+  </v-tooltip>
+</template>
+
+<script>
+export default {
+  ERROR: "ERROR", // Something went wrong
+  SAVED: "SAVED", // Everything alright
+  UPDATING: "UPDATING", // We are sending something to the server
+  CHANGES: "CHANGES", // the user changed something, but it has not been saved yet
+  name: "UpdateIndicator",
+  emits: ["manual-update"],
+  props: {
+    status: {
+      type: String,
+      required: true,
+    },
+  },
+  computed: {
+    text() {
+      switch (this.status) {
+        case this.$options.SAVED:
+          return this.$t("status.saved");
+        case this.$options.UPDATING:
+          return this.$t("status.updating");
+        case this.$options.CHANGES:
+          return this.$t("status.changes");
+        default:
+          return this.$t("status.error");
+      }
+    },
+    color() {
+      switch (this.status) {
+        case this.$options.SAVED:
+          return "success";
+        case this.$options.CHANGES:
+          return "secondary";
+        case this.$options.UPDATING:
+          return "secondary";
+        default:
+          return "error";
+      }
+    },
+    icon() {
+      switch (this.status) {
+        case this.$options.SAVED:
+          return "$success";
+        case this.$options.CHANGES:
+          return "mdi-dots-horizontal";
+        default:
+          return "$warning";
+      }
+    },
+    isAbleToClick() {
+      return (
+        this.status === this.$options.CHANGES ||
+        this.status === this.$options.ERROR
+      );
+    },
+  },
+  methods: {
+    handleClick() {
+      if (this.isAbleToClick) {
+        emit('manual-update');
+      }
+    }
+  }
+};
+</script>
diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json
index 3adabc47bc60d1bfca2dfd74b0c4b31591c2ea88..ff9693d369fea8f9ce44e07d5cc1dd4b89aae1aa 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -224,5 +224,11 @@
   },
   "graphql": {
     "snackbar_error_message": "There was an error retrieving the page data. Please try again."
+  },
+  "status": {
+    "saved": "All changes are saved.",
+    "updating": "Changes are being synced.",
+    "changes": "You have unsaved changes.",
+    "error": "There has been an error while saving the latest changes."
   }
 }