diff --git a/src/citra/emu_window/emu_window_sdl2.cpp b/src/citra/emu_window/emu_window_sdl2.cpp
index 183dac64c..175480fda 100644
--- a/src/citra/emu_window/emu_window_sdl2.cpp
+++ b/src/citra/emu_window/emu_window_sdl2.cpp
@@ -41,6 +41,35 @@ void EmuWindow_SDL2::OnMouseButton(u32 button, u8 state, s32 x, s32 y) {
     }
 }
 
+std::pair<unsigned, unsigned> EmuWindow_SDL2::TouchToPixelPos(float touch_x, float touch_y) const {
+    int w, h;
+    SDL_GetWindowSize(render_window, &w, &h);
+
+    touch_x *= w;
+    touch_y *= h;
+
+    return {static_cast<unsigned>(std::max(std::round(touch_x), 0.0f)),
+            static_cast<unsigned>(std::max(std::round(touch_y), 0.0f))};
+}
+
+void EmuWindow_SDL2::OnFingerDown(float x, float y) {
+    // TODO(NeatNit): keep track of multitouch using the fingerID and a dictionary of some kind
+    // This isn't critical because the best we can do when we have that is to average them, like the
+    // 3DS does
+
+    const auto [px, py] = TouchToPixelPos(x, y);
+    TouchPressed(px, py);
+}
+
+void EmuWindow_SDL2::OnFingerMotion(float x, float y) {
+    const auto [px, py] = TouchToPixelPos(x, y);
+    TouchMoved(px, py);
+}
+
+void EmuWindow_SDL2::OnFingerUp() {
+    TouchReleased();
+}
+
 void EmuWindow_SDL2::OnKeyEvent(int key, u8 state) {
     if (state == SDL_PRESSED) {
         InputCommon::GetKeyboard()->PressKey(key);
@@ -178,11 +207,26 @@ void EmuWindow_SDL2::PollEvents() {
             OnKeyEvent(static_cast<int>(event.key.keysym.scancode), event.key.state);
             break;
         case SDL_MOUSEMOTION:
-            OnMouseMotion(event.motion.x, event.motion.y);
+            // ignore if it came from touch
+            if (event.button.which != SDL_TOUCH_MOUSEID)
+                OnMouseMotion(event.motion.x, event.motion.y);
             break;
         case SDL_MOUSEBUTTONDOWN:
         case SDL_MOUSEBUTTONUP:
-            OnMouseButton(event.button.button, event.button.state, event.button.x, event.button.y);
+            // ignore if it came from touch
+            if (event.button.which != SDL_TOUCH_MOUSEID) {
+                OnMouseButton(event.button.button, event.button.state, event.button.x,
+                              event.button.y);
+            }
+            break;
+        case SDL_FINGERDOWN:
+            OnFingerDown(event.tfinger.x, event.tfinger.y);
+            break;
+        case SDL_FINGERMOTION:
+            OnFingerMotion(event.tfinger.x, event.tfinger.y);
+            break;
+        case SDL_FINGERUP:
+            OnFingerUp();
             break;
         case SDL_QUIT:
             is_open = false;
diff --git a/src/citra/emu_window/emu_window_sdl2.h b/src/citra/emu_window/emu_window_sdl2.h
index 7d5cfffb6..d486306ce 100644
--- a/src/citra/emu_window/emu_window_sdl2.h
+++ b/src/citra/emu_window/emu_window_sdl2.h
@@ -40,6 +40,18 @@ private:
     /// Called by PollEvents when a mouse button is pressed or released
     void OnMouseButton(u32 button, u8 state, s32 x, s32 y);
 
+    /// Translates pixel position (0..1) to pixel positions
+    std::pair<unsigned, unsigned> TouchToPixelPos(float touch_x, float touch_y) const;
+
+    /// Called by PollEvents when a finger starts touching the touchscreen
+    void OnFingerDown(float x, float y);
+
+    /// Called by PollEvents when a finger moves while touching the touchscreen
+    void OnFingerMotion(float x, float y);
+
+    /// Called by PollEvents when a finger stops touching the touchscreen
+    void OnFingerUp();
+
     /// Called by PollEvents when any event that may cause the window to be resized occurs
     void OnResize();
 
diff --git a/src/citra_qt/bootmanager.cpp b/src/citra_qt/bootmanager.cpp
index aa828f7fd..9dd2b338e 100644
--- a/src/citra_qt/bootmanager.cpp
+++ b/src/citra_qt/bootmanager.cpp
@@ -112,6 +112,7 @@ GRenderWindow::GRenderWindow(QWidget* parent, EmuThread* emu_thread)
     std::string window_title = fmt::format("Citra {} | {}-{}", Common::g_build_name,
                                            Common::g_scm_branch, Common::g_scm_desc);
     setWindowTitle(QString::fromStdString(window_title));
+    setAttribute(Qt::WA_AcceptTouchEvents);
 
     InputCommon::Init();
 }
@@ -191,11 +192,17 @@ QByteArray GRenderWindow::saveGeometry() {
         return geometry;
 }
 
-qreal GRenderWindow::windowPixelRatio() {
+qreal GRenderWindow::windowPixelRatio() const {
     // windowHandle() might not be accessible until the window is displayed to screen.
     return windowHandle() ? windowHandle()->screen()->devicePixelRatio() : 1.0f;
 }
 
+std::pair<unsigned, unsigned> GRenderWindow::ScaleTouch(const QPointF pos) const {
+    const qreal pixel_ratio = windowPixelRatio();
+    return {static_cast<unsigned>(std::max(std::round(pos.x() * pixel_ratio), qreal{0.0})),
+            static_cast<unsigned>(std::max(std::round(pos.y() * pixel_ratio), qreal{0.0}))};
+}
+
 void GRenderWindow::closeEvent(QCloseEvent* event) {
     emit Closed();
     QWidget::closeEvent(event);
@@ -210,31 +217,81 @@ void GRenderWindow::keyReleaseEvent(QKeyEvent* event) {
 }
 
 void GRenderWindow::mousePressEvent(QMouseEvent* event) {
+    if (event->source() == Qt::MouseEventSynthesizedBySystem)
+        return; // touch input is handled in TouchBeginEvent
+
     auto pos = event->pos();
     if (event->button() == Qt::LeftButton) {
-        qreal pixelRatio = windowPixelRatio();
-        this->TouchPressed(static_cast<unsigned>(pos.x() * pixelRatio),
-                           static_cast<unsigned>(pos.y() * pixelRatio));
+        const auto [x, y] = ScaleTouch(pos);
+        this->TouchPressed(x, y);
     } else if (event->button() == Qt::RightButton) {
         InputCommon::GetMotionEmu()->BeginTilt(pos.x(), pos.y());
     }
 }
 
 void GRenderWindow::mouseMoveEvent(QMouseEvent* event) {
+    if (event->source() == Qt::MouseEventSynthesizedBySystem)
+        return; // touch input is handled in TouchUpdateEvent
+
     auto pos = event->pos();
-    qreal pixelRatio = windowPixelRatio();
-    this->TouchMoved(std::max(static_cast<unsigned>(pos.x() * pixelRatio), 0u),
-                     std::max(static_cast<unsigned>(pos.y() * pixelRatio), 0u));
+    const auto [x, y] = ScaleTouch(pos);
+    this->TouchMoved(x, y);
     InputCommon::GetMotionEmu()->Tilt(pos.x(), pos.y());
 }
 
 void GRenderWindow::mouseReleaseEvent(QMouseEvent* event) {
+    if (event->source() == Qt::MouseEventSynthesizedBySystem)
+        return; // touch input is handled in TouchEndEvent
+
     if (event->button() == Qt::LeftButton)
         this->TouchReleased();
     else if (event->button() == Qt::RightButton)
         InputCommon::GetMotionEmu()->EndTilt();
 }
 
+void GRenderWindow::TouchBeginEvent(const QTouchEvent* event) {
+    // TouchBegin always has exactly one touch point, so take the .first()
+    const auto [x, y] = ScaleTouch(event->touchPoints().first().pos());
+    this->TouchPressed(x, y);
+}
+
+void GRenderWindow::TouchUpdateEvent(const QTouchEvent* event) {
+    QPointF pos;
+    int active_points = 0;
+
+    // average all active touch points
+    for (const auto tp : event->touchPoints()) {
+        if (tp.state() & (Qt::TouchPointPressed | Qt::TouchPointMoved | Qt::TouchPointStationary)) {
+            active_points++;
+            pos += tp.pos();
+        }
+    }
+
+    pos /= active_points;
+
+    const auto [x, y] = ScaleTouch(pos);
+    this->TouchMoved(x, y);
+}
+
+void GRenderWindow::TouchEndEvent() {
+    this->TouchReleased();
+}
+
+bool GRenderWindow::event(QEvent* event) {
+    if (event->type() == QEvent::TouchBegin) {
+        TouchBeginEvent(static_cast<QTouchEvent*>(event));
+        return true;
+    } else if (event->type() == QEvent::TouchUpdate) {
+        TouchUpdateEvent(static_cast<QTouchEvent*>(event));
+        return true;
+    } else if (event->type() == QEvent::TouchEnd || event->type() == QEvent::TouchCancel) {
+        TouchEndEvent();
+        return true;
+    }
+
+    return QWidget::event(event);
+}
+
 void GRenderWindow::focusOutEvent(QFocusEvent* event) {
     QWidget::focusOutEvent(event);
     InputCommon::GetKeyboard()->ReleaseAllKeys();
diff --git a/src/citra_qt/bootmanager.h b/src/citra_qt/bootmanager.h
index 349bb17cb..56b00ea70 100644
--- a/src/citra_qt/bootmanager.h
+++ b/src/citra_qt/bootmanager.h
@@ -15,6 +15,7 @@
 
 class QKeyEvent;
 class QScreen;
+class QTouchEvent;
 
 class GGLWidgetInternal;
 class GMainWindow;
@@ -119,7 +120,7 @@ public:
     void restoreGeometry(const QByteArray& geometry); // overridden
     QByteArray saveGeometry();                        // overridden
 
-    qreal windowPixelRatio();
+    qreal windowPixelRatio() const;
 
     void closeEvent(QCloseEvent* event) override;
 
@@ -130,6 +131,8 @@ public:
     void mouseMoveEvent(QMouseEvent* event) override;
     void mouseReleaseEvent(QMouseEvent* event) override;
 
+    bool event(QEvent* event) override;
+
     void focusOutEvent(QFocusEvent* event) override;
 
     void OnClientAreaResized(unsigned width, unsigned height);
@@ -148,6 +151,11 @@ signals:
     void Closed();
 
 private:
+    std::pair<unsigned, unsigned> ScaleTouch(const QPointF pos) const;
+    void TouchBeginEvent(const QTouchEvent* event);
+    void TouchUpdateEvent(const QTouchEvent* event);
+    void TouchEndEvent();
+
     void OnMinimalClientAreaChangeRequest(
         const std::pair<unsigned, unsigned>& minimal_size) override;