diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -45,6 +45,8 @@
   matrix-room-member-list-model.cpp
   matrix-event-reader.cpp
   matrix-event-reader-list-model.cpp
+  matrix-event-reaction-list-model.cpp
+  matrix-event-reaction.cpp
   matrix-event.cpp
   matrix-event-list.cpp
   matrix-link.cpp
@@ -167,6 +169,7 @@
   EventViewCompact.qml
   EventSourceView.qml
   EventHistoryView.qml
+  EventReactions.qml
   Bubble.qml
   MediaFileMenu.qml
   KazvIOMenu.qml
diff --git a/src/contents/ui/Bubble.qml b/src/contents/ui/Bubble.qml
--- a/src/contents/ui/Bubble.qml
+++ b/src/contents/ui/Bubble.qml
@@ -22,6 +22,7 @@
   property var menuContent: []
   property var shouldDisplayTime: displayTime || (!compactMode && !event.isLocalEcho)
   property var eventPinned: event.eventId && pinnedEvents.eventIds.includes(event.eventId)
+  readonly property var eventReactions: event.reactions()
 
   topPadding: 0
   bottomPadding: 0
@@ -179,6 +180,13 @@
         id: children
         data: upper.children
       }
+
+      Kazv.EventReactions {
+        objectName: 'eventReactions'
+        visible: !compactMode
+        Layout.fillWidth: true
+        reactions: upper.eventReactions
+      }
     }
 
     property var popupAction: Kirigami.Action {
diff --git a/src/contents/ui/EventReactions.qml b/src/contents/ui/EventReactions.qml
new file mode 100644
--- /dev/null
+++ b/src/contents/ui/EventReactions.qml
@@ -0,0 +1,88 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import org.kde.kirigami as Kirigami
+import moe.kazv.mxc.kazv 0.0 as MK
+
+Flow {
+  required property var reactions
+  Repeater {
+    model: reactions
+    delegate: RowLayout {
+      id: reaction
+      objectName: `reaction${index}`
+      spacing: -1
+      Kirigami.Theme.colorSet: Kirigami.Theme.Button
+      required property int index
+      readonly property var item: reactions.at(index)
+      readonly property string key: item.key
+      readonly property double commonHeight: Math.max(content.implicitHeight, count.implicitHeight)
+      readonly property string selfReactEventId: getSelfReactEventId()
+
+      function getSelfReactEventId() {
+        for (let i = 0; i < item.count; ++i) {
+          const e = item.at(i);
+          if (e.sender === matrixSdk.userId) {
+            return e.eventId;
+          }
+        }
+        return '';
+      }
+
+      ItemDelegate {
+        id: content
+        objectName: 'reactionContent'
+        Layout.preferredHeight: reaction.commonHeight
+        rightInset: 0
+        contentItem: Text {
+          objectName: 'reactionContentText'
+          textFormat: Qt.RichText
+          horizontalAlignment: Qt.AlignHCenter
+          verticalAlignment: Qt.AlignVCenter
+          // See the comments in event-types/Reaction.qml
+          text: '<span style="font-family: emoji, sans-serif;">' + MK.KazvUtil.escapeHtml(key) + '</span>'
+          Accessible.description: l10n.get('event-react-button-description', { key })
+          color: content.checked ? Kirigami.Theme.activeTextColor : Kirigami.Theme.highlightedTextColor
+        }
+        checked: !!reaction.selfReactEventId
+        onClicked: reaction.selfReactEventId ? deleteEventRequestedNoConfirm(reaction.selfReactEventId, /* isLocalEcho = */ false) : eventView.reactWith(key)
+        background: Rectangle {
+          topLeftRadius: Kirigami.Units.cornerRadius
+          bottomLeftRadius: Kirigami.Units.cornerRadius
+          border.color: Kirigami.Theme.highlightColor
+          color: content.hovered
+            ? Kirigami.Theme.hoverColor
+            : content.checked
+            ? Kirigami.Theme.highlightColor
+            : 'transparent'
+        }
+      }
+
+      ItemDelegate {
+        id: count
+        objectName: 'reactionCount'
+        Layout.preferredHeight: reaction.commonHeight
+        leftInset: 0
+        contentItem: Text {
+          horizontalAlignment: Qt.AlignHCenter
+          verticalAlignment: Qt.AlignVCenter
+          text: `${reaction.item.count}`
+        }
+        background: Rectangle {
+          topRightRadius: Kirigami.Units.cornerRadius
+          bottomRightRadius: Kirigami.Units.cornerRadius
+          border.color: Kirigami.Theme.highlightColor
+          color: count.hovered
+            ? Kirigami.Theme.hoverColor
+            : 'transparent'
+        }
+      }
+    }
+  }
+}
diff --git a/src/contents/ui/EventView.qml b/src/contents/ui/EventView.qml
--- a/src/contents/ui/EventView.qml
+++ b/src/contents/ui/EventView.qml
@@ -243,7 +243,9 @@
       return 'ignore';
 
     case 'm.reaction':
-      return 'reaction';
+      // Only display a reaction if it is a failed local echo
+      // (otherwise it will be stuck forever and we have no way to delete it)
+      return e.isFailed ? 'reaction' : 'ignore';
 
     default:
       return 'unknown';
diff --git a/src/contents/ui/RoomTimelineView.qml b/src/contents/ui/RoomTimelineView.qml
--- a/src/contents/ui/RoomTimelineView.qml
+++ b/src/contents/ui/RoomTimelineView.qml
@@ -23,6 +23,7 @@
   signal pinEventRequested(string eventId)
   signal unpinEventRequested(string eventId)
   signal deleteEventRequested(eventIdOrTxnId: string, isLocalEcho: bool)
+  signal deleteEventRequestedNoConfirm(eventIdOrTxnId: string, isLocalEcho: bool)
 
   property var pinEvent: Kazv.AsyncHandler {
     property var eventIds
@@ -81,6 +82,20 @@
     confirmDeletionPopupComp.createObject(Overlay.overlay, { eventIdOrTxnId, isLocalEcho }).open()
   }
 
+  onDeleteEventRequestedNoConfirm: (eventIdOrTxnId, isLocalEcho) => {
+    doDeleteEvent(eventIdOrTxnId, isLocalEcho);
+  }
+
+  function doDeleteEvent(eventIdOrTxnId, isLocalEcho) {
+    if (isLocalEcho) {
+      deleteLocalEcho.txnId = eventIdOrTxnId;
+      deleteLocalEcho.call();
+    } else {
+      deleteEvent.eventId = eventIdOrTxnId;
+      deleteEvent.call();
+    }
+  }
+
   property var pinEventPopupComp: Component {
     Kazv.ConfirmationOverlay {
       objectName: 'pinEventPopup'
@@ -135,15 +150,7 @@
       message: l10n.get('confirm-deletion-popup-message')
       confirmActionText: l10n.get('confirm-deletion-popup-confirm-action')
       cancelActionText: l10n.get('confirm-deletion-popup-cancel-action')
-      onAccepted: {
-      if (isLocalEcho) {
-          deleteLocalEcho.txnId = eventIdOrTxnId;
-          deleteLocalEcho.call();
-        } else {
-          deleteEvent.eventId = eventIdOrTxnId;
-          deleteEvent.call();
-        }
-      }
+      onAccepted: doDeleteEvent(eventIdOrTxnId, isLocalEcho)
 
       EventViewWrapper {
         id: eventView
diff --git a/src/l10n/cmn-Hans/100-ui.ftl b/src/l10n/cmn-Hans/100-ui.ftl
--- a/src/l10n/cmn-Hans/100-ui.ftl
+++ b/src/l10n/cmn-Hans/100-ui.ftl
@@ -220,6 +220,7 @@
 event-react-accept-action = 回应
 event-react-cancel-action = 取消
 event-react-with-prompt = 回应以:
+event-react-button-description = 回应以 { $key }
 event-edit-action = 编辑
 event-encrypted = 这条消息已加密
 event-msgtype-fallback-text = 未知的消息类型:{ $msgtype }
diff --git a/src/l10n/en/100-ui.ftl b/src/l10n/en/100-ui.ftl
--- a/src/l10n/en/100-ui.ftl
+++ b/src/l10n/en/100-ui.ftl
@@ -239,6 +239,7 @@
 event-react-accept-action = React
 event-react-cancel-action = Cancel
 event-react-with-prompt = React with:
+event-react-button-description = React with { $key }
 event-edit-action = Edit
 event-encrypted = This message is encrypteed
 event-msgtype-fallback-text = Unknown message type: { $msgtype }
diff --git a/src/matrix-event-reaction-list-model.hpp b/src/matrix-event-reaction-list-model.hpp
new file mode 100644
--- /dev/null
+++ b/src/matrix-event-reaction-list-model.hpp
@@ -0,0 +1,33 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+#include <kazv-defs.hpp>
+#include <lager/extra/qt.hpp>
+#include <room/room.hpp>
+#include "kazv-abstract-list-model.hpp"
+Q_MOC_INCLUDE("matrix-event-reaction.hpp")
+
+class MatrixEventReaction;
+
+class MatrixEventReactionListModel : public KazvAbstractListModel
+{
+    Q_OBJECT
+    QML_ELEMENT
+    QML_UNCREATABLE("")
+
+    lager::reader<QMap<QString /* key */, Kazv::EventList>> m_reactions;
+    lager::reader<QList<QString>> m_reactionKeys;
+public:
+    explicit MatrixEventReactionListModel(
+        Kazv::Room room,
+        lager::reader<std::string> parentEventId,
+        QObject *parent = 0);
+
+    ~MatrixEventReactionListModel() override;
+
+    Q_INVOKABLE MatrixEventReaction *at(int index) const;
+};
diff --git a/src/matrix-event-reaction-list-model.cpp b/src/matrix-event-reaction-list-model.cpp
new file mode 100644
--- /dev/null
+++ b/src/matrix-event-reaction-list-model.cpp
@@ -0,0 +1,59 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include "matrix-event-reaction-list-model.hpp"
+#include <kazv-defs.hpp>
+#include <json-utils.hpp>
+#include "matrix-event-reaction.hpp"
+
+using namespace Kazv;
+
+MatrixEventReactionListModel::MatrixEventReactionListModel(
+    Room room,
+    lager::reader<std::string> parentEventId,
+    QObject *parent)
+    : KazvAbstractListModel(parent)
+    , m_reactions(room.relatedEvents(parentEventId, "m.annotation")
+        .map([](const auto &events) {
+            QMap<QString, Kazv::EventList> reactions;
+            for (const auto &e : events) {
+                auto rel = e.mRelatesTo().get();
+                // This automatically excludes redacted events,
+                // because redacted events' content is empty.
+                if (hasAtThat(rel, "key", &json::is_string)) {
+                    auto key = QString::fromStdString(rel["key"].template get<std::string>());
+                    reactions[key] = reactions[key].push_back(e);
+                }
+            }
+            return reactions;
+        }))
+    , m_reactionKeys(m_reactions.map([](const auto &reactions) {
+        return reactions.keys();
+    }))
+{
+    initCountCursor(m_reactions.map([](const auto &reactions) -> int {
+        return reactions.size();
+    }));
+}
+
+MatrixEventReactionListModel::~MatrixEventReactionListModel() = default;
+
+MatrixEventReaction *MatrixEventReactionListModel::at(int index) const
+{
+    return new MatrixEventReaction(lager::with(m_reactionKeys, m_reactions)
+        .map([index](const auto &keys, const auto &reactions) {
+            if (index >= 0 && index < keys.size()) {
+                return reactions.value(keys[index]);
+            }
+            return EventList{};
+        }),
+        m_reactionKeys.map([index](const auto &keys) {
+            if (index >= 0 && index < keys.size()) {
+                return keys[index];
+            }
+            return QString();
+        }));
+}
diff --git a/src/matrix-event-reaction.hpp b/src/matrix-event-reaction.hpp
new file mode 100644
--- /dev/null
+++ b/src/matrix-event-reaction.hpp
@@ -0,0 +1,25 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+#include <kazv-defs.hpp>
+#include <lager/extra/qt.hpp>
+#include <room/room.hpp>
+#include "matrix-event-list.hpp"
+
+class MatrixEventReaction : public MatrixEventList
+{
+    Q_OBJECT
+    QML_ELEMENT
+    QML_UNCREATABLE("")
+
+public:
+    explicit MatrixEventReaction(lager::reader<Kazv::EventList> events, lager::reader<QString> key, QObject *parent = nullptr);
+
+    ~MatrixEventReaction() override;
+
+    LAGER_QT_READER(QString, key);
+};
diff --git a/src/matrix-event-reaction.cpp b/src/matrix-event-reaction.cpp
new file mode 100644
--- /dev/null
+++ b/src/matrix-event-reaction.cpp
@@ -0,0 +1,15 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include "matrix-event-reaction.hpp"
+
+MatrixEventReaction::MatrixEventReaction(lager::reader<Kazv::EventList> events, lager::reader<QString> key, QObject *parent)
+    : MatrixEventList(events, parent)
+    , LAGER_QT(key)(key)
+{
+}
+
+MatrixEventReaction::~MatrixEventReaction() = default;
diff --git a/src/matrix-event.hpp b/src/matrix-event.hpp
--- a/src/matrix-event.hpp
+++ b/src/matrix-event.hpp
@@ -17,9 +17,11 @@
 #include "qt-json.hpp"
 Q_MOC_INCLUDE("matrix-event-list.hpp")
 Q_MOC_INCLUDE("matrix-event-reader-list-model.hpp")
+Q_MOC_INCLUDE("matrix-event-reaction-list-model.hpp")
 
 class MatrixEventReaderListModel;
 class MatrixEventList;
+class MatrixEventReactionListModel;
 
 class MatrixEvent : public QObject
 {
@@ -69,4 +71,6 @@
     Kazv::Event underlyingEvent() const;
 
     Q_INVOKABLE MatrixEventList *history() const;
+
+    Q_INVOKABLE MatrixEventReactionListModel *reactions() const;
 };
diff --git a/src/matrix-event.cpp b/src/matrix-event.cpp
--- a/src/matrix-event.cpp
+++ b/src/matrix-event.cpp
@@ -8,6 +8,7 @@
 
 #include "matrix-event.hpp"
 #include "matrix-event-reader-list-model.hpp"
+#include "matrix-event-reaction-list-model.hpp"
 #include "matrix-event-list.hpp"
 #include "helper.hpp"
 #include "kazv-log.hpp"
@@ -211,3 +212,14 @@
             );
         }));
 }
+
+MatrixEventReactionListModel *MatrixEvent::reactions() const
+{
+    if (!m_room) {
+        return nullptr;
+    }
+    return new MatrixEventReactionListModel(
+        m_room.value(),
+        m_eventIdStd
+    );
+}
diff --git a/src/tests/matrix-event-test.cpp b/src/tests/matrix-event-test.cpp
--- a/src/tests/matrix-event-test.cpp
+++ b/src/tests/matrix-event-test.cpp
@@ -15,6 +15,8 @@
 #include <matrix-room.hpp>
 #include <matrix-event-list.hpp>
 #include <matrix-event.hpp>
+#include <matrix-event-reaction-list-model.hpp>
+#include <matrix-event-reaction.hpp>
 #include "test-model.hpp"
 #include "test-utils.hpp"
 
@@ -29,6 +31,7 @@
 private Q_SLOTS:
     void testEdits();
     void testEncryptedEdits();
+    void testReactions();
 };
 
 void MatrixEventTest::testEdits()
@@ -107,6 +110,60 @@
     QCOMPARE(v1->relationType(), QStringLiteral());
 }
 
+void MatrixEventTest::testReactions()
+{
+    auto r = makeRoom();
+    auto events = EventList{
+        makeEvent(withEventId("$0") | withEventType("m.room.encrypted")).setDecryptedJson(json{
+            {"room_id", r.roomId},
+            {"type", "m.room.message"},
+            {"content", {{"body", "first"}}},
+        }, Event::Decrypted),
+        makeEvent(withEventId("$1")
+            | withEventType("m.room.encrypted")
+            | withEventRelationship("m.annotation", "$0")
+            | withEventKV("/content/m.relates_to/key"_json_pointer, "mew")
+        ).setDecryptedJson(json{
+            {"room_id", r.roomId},
+            {"type", "m.reaction"},
+        }, Event::Decrypted),
+        makeEvent(withEventId("$2")
+            | withEventType("m.room.encrypted")
+            | withEventRelationship("m.annotation", "$0")
+            | withEventKV("/content/m.relates_to/key"_json_pointer, "mew")
+        ).setDecryptedJson(json{
+            {"room_id", r.roomId},
+            {"type", "m.reaction"},
+        }, Event::Decrypted),
+        makeEvent(withEventId("$3")
+            | withEventType("m.room.encrypted")
+            | withEventRelationship("m.annotation", "$0")
+            | withEventKV("/content/m.relates_to/key"_json_pointer, "meww")
+        ).setDecryptedJson(json{
+            {"room_id", r.roomId},
+            {"type", "m.reaction"},
+        }, Event::Decrypted),
+    };
+    withRoomTimeline(events)(r);
+
+    auto model = SdkModel{makeClient(withRoom(r))};
+    std::unique_ptr<MatrixSdk> sdk{makeTestSdk(model)};
+    auto roomList = toUniquePtr(sdk->roomList());
+    auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
+    auto timeline = toUniquePtr(room->timeline());
+
+    auto event = toUniquePtr(timeline->at(events.size() - 1));
+    QCOMPARE(event->eventId(), u"$0"_s);
+    auto reactions = toUniquePtr(event->reactions());
+    QCOMPARE(reactions->count(), 2);
+    auto r0 = toUniquePtr(reactions->at(0));
+    auto r1 = toUniquePtr(reactions->at(1));
+    QVERIFY(r0->count() == 1 || r1->count() == 1);
+    QVERIFY(r0->count() == 2 || r1->count() == 2);
+    QVERIFY(r0->key() == u"mew"_s || r1->key() == u"mew"_s);
+    QVERIFY(r0->key() == u"meww"_s || r1->key() == u"meww"_s);
+}
+
 QTEST_MAIN(MatrixEventTest)
 
 #include "matrix-event-test.moc"
diff --git a/src/tests/quick-tests/tst_EventReactions.qml b/src/tests/quick-tests/tst_EventReactions.qml
new file mode 100644
--- /dev/null
+++ b/src/tests/quick-tests/tst_EventReactions.qml
@@ -0,0 +1,115 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Layouts
+import QtTest
+import moe.kazv.mxc.kazv 0.0 as MK
+import org.kde.kirigami 2.13 as Kirigami
+import '../../contents/ui' as Kazv
+import 'test-helpers' as QmlHelpers
+
+QmlHelpers.TestItem {
+  id: item
+
+  function getReaction(i) {
+    return {
+      count: 2,
+      key: `r${i}`,
+      at(index) {
+        return index === 0 ? {
+          sender: `@foo${i}:example.com`,
+          eventId: `$xxx${i}`,
+        } : {
+          sender: `@bar${i}:example.com`,
+          eventId: `$yyy${i}`,
+        };
+      },
+    };
+  }
+  readonly property var reactions: ListModel {
+    ListElement {}
+    ListElement {}
+
+    function at(index) {
+      return item.getReaction(index);
+    }
+  }
+
+  readonly property var deleteEventRequestedNoConfirm: mockHelper.noop()
+  readonly property var eventView: QtObject {
+    readonly property var reactWith: item.mockHelper.noop()
+  }
+
+  ColumnLayout {
+    Kazv.EventReactions {
+      id: eventReactions
+      reactions: item.reactions
+    }
+
+    Kazv.EventView {
+      id: eventViewReaction
+      event: ({
+        type: 'm.reaction',
+        content: {},
+        isFailed: false,
+      })
+    }
+
+    Kazv.EventView {
+      id: eventViewReactionFailed
+      event: ({
+        type: 'm.reaction',
+        content: {},
+        isFailed: true,
+      })
+    }
+  }
+
+  TestCase {
+    id: eventViewVideoTest
+    name: 'EventViewVideoTest'
+    when: windowShown
+
+    function init() {
+      item.mockHelper.clearAll();
+      item.matrixSdk.userId = '@foo0:example.com';
+    }
+
+    function test_reactionEvent() {
+      compare(eventViewReaction.messageType, 'ignore');
+      compare(eventViewReactionFailed.messageType, 'reaction');
+    }
+
+    function test_reactionsReacted() {
+      const reaction = findChild(eventReactions, 'reaction0');
+      const text = findChild(reaction, 'reactionContentText');
+      verify(text.text.includes('r0'));
+      const content = findChild(reaction, 'reactionContent');
+      verify(content.checked);
+      compare(reaction.selfReactEventId, '$xxx0');
+
+      mouseClick(content);
+      compare(item.eventView.reactWith.calledTimes(), 0);
+      compare(item.deleteEventRequestedNoConfirm.calledTimes(), 1);
+      compare(item.deleteEventRequestedNoConfirm.lastArgs()[0], '$xxx0');
+    }
+
+    function test_reactionsUnreacted() {
+      const reaction = findChild(eventReactions, 'reaction1');
+      const text = findChild(reaction, 'reactionContentText');
+      verify(text.text.includes('r1'));
+      const content = findChild(reaction, 'reactionContent');
+      verify(!content.checked);
+      verify(!reaction.selfReactEventId);
+
+      mouseClick(content);
+      compare(item.deleteEventRequestedNoConfirm.calledTimes(), 0);
+      compare(item.eventView.reactWith.calledTimes(), 1);
+      compare(item.eventView.reactWith.lastArgs()[0], 'r1');
+    }
+  }
+}