diff --git a/packages/frontend/src/components/MkNotificationToast.vue b/packages/frontend/src/components/MkNotificationToast.vue
deleted file mode 100644
index 39e8373e37..0000000000
--- a/packages/frontend/src/components/MkNotificationToast.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<template>
-<div class="mk-notification-toast" :style="{ zIndex }">
-	<Transition :name="$store.state.animation ? 'notification-toast' : ''" appear @after-leave="$emit('closed')">
-		<XNotification v-if="showing" :notification="notification" class="notification _acrylic"/>
-	</Transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import XNotification from '@/components/MkNotification.vue';
-import * as os from '@/os';
-
-defineProps<{
-	notification: any; // TODO
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-
-const zIndex = os.claimZIndex('high');
-let showing = $ref(true);
-
-onMounted(() => {
-	window.setTimeout(() => {
-		showing = false;
-	}, 6000);
-});
-</script>
-
-<style lang="scss" scoped>
-.notification-toast-enter-active, .notification-toast-leave-active {
-	transition: opacity 0.3s, transform 0.3s !important;
-}
-.notification-toast-enter-from, .notification-toast-leave-to {
-	opacity: 0;
-	transform: translateX(-250px);
-}
-
-.mk-notification-toast {
-	position: fixed;
-	left: 0;
-	width: 250px;
-	top: 32px;
-	padding: 0 32px;
-	pointer-events: none;
-	container-type: inline-size;
-
-	@media (max-width: 700px) {
-		top: initial;
-		bottom: 112px;
-		padding: 0 16px;
-	}
-
-	@media (max-width: 500px) {
-		bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
-		padding: 0 8px;
-	}
-
-	> .notification {
-		height: 100%;
-		box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
-		border-radius: 8px;
-		overflow: hidden;
-	}
-}
-</style>
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 7f3fc0e4af..0333e20d0a 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -9,6 +9,10 @@
 
 <XUpload v-if="uploads.length > 0"/>
 
+<TransitionGroup :name="$store.state.animation ? 'notification' : ''" tag="div" class="notifications">
+	<XNotification v-for="notification in notifications" :key="notification.id" :notification="notification" class="notification"/>
+</TransitionGroup>
+
 <XStreamIndicator/>
 
 <div v-if="pendingApiRequestsCount > 0" id="wait"></div>
@@ -19,8 +23,10 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
+import { defineAsyncComponent, nextTick } from 'vue';
+import * as misskey from 'misskey-js';
 import { swInject } from './sw-inject';
+import XNotification from './notification.vue';
 import { popup, popups, pendingApiRequestsCount } from '@/os';
 import { uploads } from '@/scripts/upload';
 import * as sound from '@/scripts/sound';
@@ -33,7 +39,9 @@ const XUpload = defineAsyncComponent(() => import('./upload.vue'));
 
 const dev = _DEV_;
 
-const onNotification = notification => {
+let notifications = $ref<misskey.entities.Notification[]>([]);
+
+function onNotification(notification) {
 	if ($i.mutingNotificationTypes.includes(notification.type)) return;
 
 	if (document.visibilityState === 'visible') {
@@ -41,13 +49,18 @@ const onNotification = notification => {
 			id: notification.id,
 		});
 
-		popup(defineAsyncComponent(() => import('@/components/MkNotificationToast.vue')), {
-			notification,
-		}, {}, 'closed');
+		notifications.unshift(notification);
+		window.setTimeout(() => {
+			if (notifications.length > 3) notifications.pop();
+		}, 500);
+
+		window.setTimeout(() => {
+			notifications = notifications.filter(x => x.id !== notification.id);
+		}, 6000);
 	}
 
 	sound.play('notification');
-};
+}
 
 if ($i) {
 	const connection = stream.useChannel('main', null, 'UI');
@@ -60,6 +73,53 @@ if ($i) {
 }
 </script>
 
+<style lang="scss" scoped>
+.notification-move, .notification-enter-active, .notification-leave-active {
+	transition: opacity 0.3s, transform 0.3s !important;
+}
+.notification-enter-from, .notification-leave-to {
+	opacity: 0;
+	transform: translateX(-250px);
+}
+
+.notifications {
+	position: fixed;
+	z-index: 3900000;
+	left: 0;
+	width: 250px;
+	top: 32px;
+	padding: 0 32px;
+	pointer-events: none;
+	container-type: inline-size;
+
+	> .notification {
+		& + .notification {
+			margin-top: 8px;
+		}
+	}
+
+	@media (max-width: 700px) {
+		top: initial;
+		bottom: 112px;
+		padding: 0 16px;
+		display: flex;
+		flex-direction: column-reverse;
+
+		> .notification {
+			& + .notification {
+				margin-top: 0;
+				margin-bottom: 8px;
+			}
+		}
+	}
+
+	@media (max-width: 500px) {
+		bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
+		padding: 0 8px;
+	}
+}
+</style>
+
 <style lang="scss">
 @keyframes dev-ticker-blink {
 	0% { opacity: 1; }
diff --git a/packages/frontend/src/ui/_common_/notification.vue b/packages/frontend/src/ui/_common_/notification.vue
new file mode 100644
index 0000000000..1f9c675a15
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/notification.vue
@@ -0,0 +1,24 @@
+<template>
+<div :class="$style.root">
+	<XNotification :notification="notification" class="notification _acrylic"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import XNotification from '@/components/MkNotification.vue';
+
+defineProps<{
+	notification: misskey.entities.Notification;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+	box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+	border-radius: 8px;
+	overflow: clip;
+	contain: content;
+}
+</style>