diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts
index 6884fdfdb6..32f7896cdd 100644
--- a/packages/backend/src/core/ChatService.ts
+++ b/packages/backend/src/core/ChatService.ts
@@ -33,6 +33,20 @@ const MAX_ROOM_MEMBERS = 30;
 const MAX_REACTIONS_PER_MESSAGE = 100;
 const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
 
+// TODO: ReactionServiceのやつと共通化
+function normalizeEmojiString(x: string) {
+	const match = emojiRegex.exec(x);
+	if (match) {
+		// 合字を含む1つの絵文字
+		const unicode = match[0];
+
+		// 異体字セレクタ除去
+		return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
+	} else {
+		throw new Error('invalid emoji');
+	}
+}
+
 @Injectable()
 export class ChatService {
 	constructor(
@@ -751,24 +765,10 @@ export class ChatService {
 	public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
 		let reaction;
 
-		// TODO: ReactionServiceのやつと共通化
-		function normalize(x: string) {
-			const match = emojiRegex.exec(x);
-			if (match) {
-				// 合字を含む1つの絵文字
-				const unicode = match[0];
-
-				// 異体字セレクタ除去
-				return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
-			} else {
-				throw new Error('invalid emoji');
-			}
-		}
-
 		const custom = reaction_.match(isCustomEmojiRegexp);
 
 		if (custom == null) {
-			reaction = normalize(reaction_);
+			reaction = normalizeEmojiString(reaction_);
 		} else {
 			const name = custom[1];
 			const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
@@ -827,6 +827,52 @@ export class ChatService {
 		}
 	}
 
+	@bindThis
+	public async unreact(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
+		let reaction;
+
+		const custom = reaction_.match(isCustomEmojiRegexp);
+
+		if (custom == null) {
+			reaction = normalizeEmojiString(reaction_);
+		} else { // 削除されたカスタム絵文字のリアクションを削除したいかもしれないので絵文字の存在チェックはする必要なし
+			const name = custom[1];
+			reaction = `:${name}:`;
+		}
+
+		// NOTE: 自分のリアクションを(あれば)削除するだけなので諸々の権限チェックは必要なし
+
+		const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId });
+
+		const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null;
+
+		await this.chatMessagesRepository.createQueryBuilder().update()
+			.set({
+				reactions: () => `array_remove("reactions", '${userId}/${reaction}')`,
+			})
+			.where('id = :id', { id: message.id })
+			.execute();
+
+		// TODO: 実際に削除が行われたときのみイベントを発行する
+
+		if (room) {
+			this.globalEventService.publishChatRoomStream(room.id, 'unreact', {
+				messageId: message.id,
+				user: await this.userEntityService.pack(userId),
+				reaction,
+			});
+		} else {
+			this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'unreact', {
+				messageId: message.id,
+				reaction,
+			});
+			this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'unreact', {
+				messageId: message.id,
+				reaction,
+			});
+		}
+	}
+
 	@bindThis
 	public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
 		const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 4da3b8bc78..f85d302774 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -167,6 +167,11 @@ export interface ChatEventTypes {
 		user?: Packed<'UserLite'>;
 		messageId: MiChatMessage['id'];
 	};
+	unreact: {
+		reaction: string;
+		user?: Packed<'UserLite'>;
+		messageId: MiChatMessage['id'];
+	};
 }
 
 export interface ReversiEventTypes {
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index c8cb87e0ab..34aaef3cc7 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -401,6 +401,7 @@ export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/creat
 export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js';
 export * as 'chat/messages/show' from './endpoints/chat/messages/show.js';
 export * as 'chat/messages/react' from './endpoints/chat/messages/react.js';
+export * as 'chat/messages/unreact' from './endpoints/chat/messages/unreact.js';
 export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js';
 export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js';
 export * as 'chat/messages/search' from './endpoints/chat/messages/search.js';
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts
new file mode 100644
index 0000000000..4eb25259fb
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+	},
+
+	errors: {
+		noSuchMessage: {
+			message: 'No such message.',
+			code: 'NO_SUCH_MESSAGE',
+			id: 'c39ea42f-e3ca-428a-ad57-390e0a711595',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		messageId: { type: 'string', format: 'misskey:id' },
+		reaction: { type: 'string' },
+	},
+	required: ['messageId', 'reaction'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			await this.chatService.unreact(ps.messageId, me.id, ps.reaction);
+		});
+	}
+}
diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue
index bc348c6fb1..3dae772db1 100644
--- a/packages/frontend/src/pages/chat/XMessage.vue
+++ b/packages/frontend/src/pages/chat/XMessage.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			:moveClass="prefer.s.animation ? $style.transition_reaction_move : ''"
 			tag="div" :class="$style.reactions"
 		>
-			<div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction">
+			<div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction" @click="onReactionClick(record)">
 				<MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/>
 				<MkReactionIcon
 					:withTooltip="true"
@@ -87,6 +87,15 @@ function react(ev: MouseEvent) {
 	});
 }
 
+function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) {
+	if (record.user.id === $i.id) {
+		misskeyApi('chat/messages/unreact', {
+			messageId: props.message.id,
+			reaction: record.reaction,
+		});
+	}
+}
+
 function showMenu(ev: MouseEvent) {
 	const menu: MenuItem[] = [];
 
diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue
index 8ffaf233cf..5938fd2688 100644
--- a/packages/frontend/src/pages/chat/room.vue
+++ b/packages/frontend/src/pages/chat/room.vue
@@ -170,6 +170,7 @@ async function initialize() {
 		connection.value.on('message', onMessage);
 		connection.value.on('deleted', onDeleted);
 		connection.value.on('react', onReact);
+		connection.value.on('unreact', onUnreact);
 	} else {
 		const [r, m] = await Promise.all([
 			misskeyApi('chat/rooms/show', { roomId: props.roomId }),
@@ -189,6 +190,7 @@ async function initialize() {
 		connection.value.on('message', onMessage);
 		connection.value.on('deleted', onDeleted);
 		connection.value.on('react', onReact);
+		connection.value.on('unreact', onUnreact);
 	}
 
 	window.document.addEventListener('visibilitychange', onVisibilitychange);
@@ -268,6 +270,16 @@ function onReact(ctx) {
 	}
 }
 
+function onUnreact(ctx) {
+	const message = messages.value.find(m => m.id === ctx.messageId);
+	if (message) {
+		const index = message.reactions.findIndex(r => r.reaction === ctx.reaction && r.user.id === ctx.user.id);
+		if (index !== -1) {
+			message.reactions.splice(index, 1);
+		}
+	}
+}
+
 function onIndicatorClick() {
 	showIndicator.value = false;
 }
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 2c97e4b12e..cc397e2270 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1001,6 +1001,12 @@ type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody
 // @public (undocumented)
 type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
 
+// @public (undocumented)
+type ChatMessagesUnreactRequest = operations['chat___messages___unreact']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesUnreactResponse = operations['chat___messages___unreact']['responses']['200']['content']['application/json'];
+
 // @public (undocumented)
 type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
 
@@ -1613,6 +1619,8 @@ declare namespace entities {
         ChatMessagesSearchResponse,
         ChatMessagesShowRequest,
         ChatMessagesShowResponse,
+        ChatMessagesUnreactRequest,
+        ChatMessagesUnreactResponse,
         ChatMessagesUserTimelineRequest,
         ChatMessagesUserTimelineResponse,
         ChatRoomsCreateRequest,
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index e5d0b94a10..ae97084116 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -1622,6 +1622,17 @@ declare module '../api.js' {
       credential?: string | null,
     ): Promise<SwitchCaseResponseType<E, P>>;
 
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/messages/unreact', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
     /**
      * No description provided.
      * 
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 1dfdb53320..26d22d273c 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -223,6 +223,8 @@ import type {
 	ChatMessagesSearchResponse,
 	ChatMessagesShowRequest,
 	ChatMessagesShowResponse,
+	ChatMessagesUnreactRequest,
+	ChatMessagesUnreactResponse,
 	ChatMessagesUserTimelineRequest,
 	ChatMessagesUserTimelineResponse,
 	ChatRoomsCreateRequest,
@@ -780,6 +782,7 @@ export type Endpoints = {
 	'chat/messages/room-timeline': { req: ChatMessagesRoomTimelineRequest; res: ChatMessagesRoomTimelineResponse };
 	'chat/messages/search': { req: ChatMessagesSearchRequest; res: ChatMessagesSearchResponse };
 	'chat/messages/show': { req: ChatMessagesShowRequest; res: ChatMessagesShowResponse };
+	'chat/messages/unreact': { req: ChatMessagesUnreactRequest; res: ChatMessagesUnreactResponse };
 	'chat/messages/user-timeline': { req: ChatMessagesUserTimelineRequest; res: ChatMessagesUserTimelineResponse };
 	'chat/rooms/create': { req: ChatRoomsCreateRequest; res: ChatRoomsCreateResponse };
 	'chat/rooms/delete': { req: ChatRoomsDeleteRequest; res: ChatRoomsDeleteResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 0e3ca7202e..6f3b2aa983 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -226,6 +226,8 @@ export type ChatMessagesSearchRequest = operations['chat___messages___search']['
 export type ChatMessagesSearchResponse = operations['chat___messages___search']['responses']['200']['content']['application/json'];
 export type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
 export type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
+export type ChatMessagesUnreactRequest = operations['chat___messages___unreact']['requestBody']['content']['application/json'];
+export type ChatMessagesUnreactResponse = operations['chat___messages___unreact']['responses']['200']['content']['application/json'];
 export type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
 export type ChatMessagesUserTimelineResponse = operations['chat___messages___user-timeline']['responses']['200']['content']['application/json'];
 export type ChatRoomsCreateRequest = operations['chat___rooms___create']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 3b52a70ccf..c91fedf2fa 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -1430,6 +1430,15 @@ export type paths = {
      */
     post: operations['chat___messages___show'];
   };
+  '/chat/messages/unreact': {
+    /**
+     * chat/messages/unreact
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___messages___unreact'];
+  };
   '/chat/messages/user-timeline': {
     /**
      * chat/messages/user-timeline
@@ -14424,6 +14433,61 @@ export type operations = {
       };
     };
   };
+  /**
+   * chat/messages/unreact
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___messages___unreact: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          messageId: string;
+          reaction: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
   /**
    * chat/messages/user-timeline
    * @description No description provided.