diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1ec268b89..29c318348b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
 ### Improvements
 - フォロー/フォロワーを非公開にできるように
 - インスタンスプロフィールレンダリング ready
+- 通知のリアクションアイコンをホバーで拡大できるように
 - メールアドレスのバリデーションを強化
 
 ### Bugfixes
diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue
index a2e714b4e2..ce1fa5b160 100644
--- a/src/client/components/notification.vue
+++ b/src/client/components/notification.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }">
+<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }" ref="elRef">
 	<div class="head">
 		<MkAvatar v-if="notification.user" class="icon" :user="notification.user"/>
 		<img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
@@ -14,7 +14,16 @@
 			<i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i>
 			<i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i>
 			<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
-			<XReactionIcon v-else-if="notification.type === 'reaction'" :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" :custom-emojis="notification.note.emojis" :no-style="true"/>
+			<XReactionIcon v-else-if="notification.type === 'reaction'"
+				:reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
+				:custom-emojis="notification.note.emojis"
+				:no-style="true"
+				@touchstart.passive="onReactionMouseover"
+				@mouseover="onReactionMouseover"
+				@mouseleave="onReactionMouseleave"
+				@touchend="onReactionMouseleave"
+				ref="reactionRef"
+			/>
 		</div>
 	</div>
 	<div class="tail">
@@ -59,10 +68,11 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, markRaw } from 'vue';
+import { defineComponent, ref, onMounted, onUnmounted } from 'vue';
 import { getNoteSummary } from '@/misc/get-note-summary';
 import XReactionIcon from './reaction-icon.vue';
 import MkFollowButton from './follow-button.vue';
+import XReactionTooltip from './reaction-tooltip.vue';
 import notePage from '@client/filters/note';
 import { userPage } from '@client/filters/user';
 import { i18n } from '@client/i18n';
@@ -72,6 +82,7 @@ export default defineComponent({
 	components: {
 		XReactionIcon, MkFollowButton
 	},
+
 	props: {
 		notification: {
 			type: Object,
@@ -88,60 +99,117 @@ export default defineComponent({
 			default: false,
 		},
 	},
-	data() {
+
+	setup(props) {
+		const elRef = ref<HTMLElement>(null);
+		const reactionRef = ref(null);
+
+		onMounted(() => {
+			let readObserver: IntersectionObserver = null;
+			let connection = null;
+
+			if (!props.notification.isRead) {
+				readObserver = new IntersectionObserver((entries, observer) => {
+					if (!entries.some(entry => entry.isIntersecting)) return;
+					os.stream.send('readNotification', {
+						id: props.notification.id
+					});
+					entries.map(({ target }) => observer.unobserve(target));
+				});
+
+				readObserver.observe(elRef.value);
+
+				connection = os.stream.useChannel('main');
+				connection.on('readAllNotifications', () => readObserver.unobserve(elRef.value));
+			}
+
+			onUnmounted(() => {
+				if (readObserver) readObserver.unobserve(elRef.value);
+				if (connection) connection.dispose();
+			});
+		});
+
+		const followRequestDone = ref(false);
+		const groupInviteDone = ref(false);
+
+		const acceptFollowRequest = () => {
+			followRequestDone.value = true;
+			os.api('following/requests/accept', { userId: props.notification.user.id });
+		};
+
+		const rejectFollowRequest = () => {
+			followRequestDone.value = true;
+			os.api('following/requests/reject', { userId: props.notification.user.id });
+		};
+
+		const acceptGroupInvitation = () => {
+			groupInviteDone.value = true;
+			os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id });
+		};
+
+		const rejectGroupInvitation = () => {
+			groupInviteDone.value = true;
+			os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id });
+		};
+
+		let isReactionHovering = false;
+		let reactionTooltipTimeoutId;
+
+		const onReactionMouseover = () => {
+			if (isReactionHovering) return;
+			isReactionHovering = true;
+			reactionTooltipTimeoutId = setTimeout(openReactionTooltip, 300);
+		};
+
+		const onReactionMouseleave = () => {
+			if (!isReactionHovering) return;
+			isReactionHovering = false;
+			clearTimeout(reactionTooltipTimeoutId);
+			closeReactionTooltip();
+		};
+
+		let changeReactionTooltipShowingState: () => void;
+
+		const openReactionTooltip = () => {
+			closeReactionTooltip();
+			if (!isReactionHovering) return;
+
+			const showing = ref(true);
+			os.popup(XReactionTooltip, {
+				showing,
+				reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction,
+				emojis: props.notification.note.emojis,
+				source: reactionRef.value.$el,
+			}, {}, 'closed');
+
+			changeReactionTooltipShowingState = () => {
+				showing.value = false;
+			};
+		};
+
+		const closeReactionTooltip = () => {
+			if (changeReactionTooltipShowingState != null) {
+				changeReactionTooltipShowingState();
+				changeReactionTooltipShowingState = null;
+			}
+		};
+
 		return {
 			getNoteSummary: (text: string) => getNoteSummary(text, i18n.locale),
-			followRequestDone: false,
-			groupInviteDone: false,
-			connection: null,
-			readObserver: null,
+			followRequestDone,
+			groupInviteDone,
+			notePage,
+			userPage,
+			acceptFollowRequest,
+			rejectFollowRequest,
+			acceptGroupInvitation,
+			rejectGroupInvitation,
+			onReactionMouseover,
+			onReactionMouseleave,
+			elRef,
+			reactionRef,
 		};
 	},
-
-	mounted() {
-		if (!this.notification.isRead) {
-			this.readObserver = new IntersectionObserver((entries, observer) => {
-				if (!entries.some(entry => entry.isIntersecting)) return;
-				os.stream.send('readNotification', {
-					id: this.notification.id
-				});
-				entries.map(({ target }) => observer.unobserve(target));
-			});
-
-			this.readObserver.observe(this.$el);
-
-			this.connection = markRaw(os.stream.useChannel('main'));
-			this.connection.on('readAllNotifications', () => this.readObserver.unobserve(this.$el));
-		}
-	},
-
-	beforeUnmount() {
-		if (!this.notification.isRead) {
-			this.readObserver.unobserve(this.$el);
-			this.connection.dispose();
-		}
-	},
-
-	methods: {
-		acceptFollowRequest() {
-			this.followRequestDone = true;
-			os.api('following/requests/accept', { userId: this.notification.user.id });
-		},
-		rejectFollowRequest() {
-			this.followRequestDone = true;
-			os.api('following/requests/reject', { userId: this.notification.user.id });
-		},
-		acceptGroupInvitation() {
-			this.groupInviteDone = true;
-			os.apiWithDialog('users/groups/invitations/accept', { invitationId: this.notification.invitation.id });
-		},
-		rejectGroupInvitation() {
-			this.groupInviteDone = true;
-			os.api('users/groups/invitations/reject', { invitationId: this.notification.invitation.id });
-		},
-		notePage,
-		userPage
-	}
 });
 </script>
 
diff --git a/src/client/components/reaction-tooltip.vue b/src/client/components/reaction-tooltip.vue
new file mode 100644
index 0000000000..93143cbe81
--- /dev/null
+++ b/src/client/components/reaction-tooltip.vue
@@ -0,0 +1,51 @@
+<template>
+<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340">
+	<div class="beeadbfb">
+		<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
+		<div class="name">{{ reaction.replace('@.', '') }}</div>
+	</div>
+</MkTooltip>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkTooltip from './ui/tooltip.vue';
+import XReactionIcon from './reaction-icon.vue';
+
+export default defineComponent({
+	components: {
+		MkTooltip,
+		XReactionIcon,
+	},
+	props: {
+		reaction: {
+			type: String,
+			required: true,
+		},
+		emojis: {
+			type: Array,
+			required: true,
+		},
+		source: {
+			required: true,
+		}
+	},
+	emits: ['closed'],
+})
+</script>
+
+<style lang="scss" scoped>
+.beeadbfb {
+	text-align: center;
+
+	> .icon {
+		display: block;
+		width: 60px;
+		margin: 0 auto;
+	}
+
+	> .name {
+		font-size: 0.9em;
+	}
+}
+</style>