diff --git a/src/gui/guiEditBox.cpp b/src/gui/guiEditBox.cpp
index ac715e74a..34689fc13 100644
--- a/src/gui/guiEditBox.cpp
+++ b/src/gui/guiEditBox.cpp
@@ -774,9 +774,9 @@ bool GUIEditBox::processMouse(const SEvent &event)
 		}
 	case EMIE_MOUSE_WHEEL:
 		if (m_vscrollbar && m_vscrollbar->isVisible()) {
-			s32 pos = m_vscrollbar->getPos();
+			s32 pos = m_vscrollbar->getTargetPos();
 			s32 step = m_vscrollbar->getSmallStep();
-			m_vscrollbar->setPos(pos - event.MouseInput.Wheel * step);
+			m_vscrollbar->setPosInterpolated(pos - event.MouseInput.Wheel * step);
 			return true;
 		}
 		break;
diff --git a/src/gui/guiHyperText.cpp b/src/gui/guiHyperText.cpp
index 76bc98a71..3db6f0071 100644
--- a/src/gui/guiHyperText.cpp
+++ b/src/gui/guiHyperText.cpp
@@ -1084,7 +1084,7 @@ bool GUIHyperText::OnEvent(const SEvent &event)
 			checkHover(event.MouseInput.X, event.MouseInput.Y);
 
 		if (event.MouseInput.Event == EMIE_MOUSE_WHEEL && m_vscrollbar->isVisible()) {
-			m_vscrollbar->setPos(m_vscrollbar->getPos() -
+			m_vscrollbar->setPosInterpolated(m_vscrollbar->getTargetPos() -
 					event.MouseInput.Wheel * m_vscrollbar->getSmallStep());
 			m_text_scrollpos.Y = -m_vscrollbar->getPos();
 			m_drawer.draw(m_display_text_rect, m_text_scrollpos);
diff --git a/src/gui/guiScrollBar.cpp b/src/gui/guiScrollBar.cpp
index 60e9b05f0..88f101a2b 100644
--- a/src/gui/guiScrollBar.cpp
+++ b/src/gui/guiScrollBar.cpp
@@ -12,6 +12,7 @@ the arrow buttons where there is insufficient space.
 
 #include "guiScrollBar.h"
 #include "guiButton.h"
+#include "porting.h"
 #include <IGUISkin.h>
 
 GUIScrollBar::GUIScrollBar(IGUIEnvironment *environment, IGUIElement *parent, s32 id,
@@ -38,40 +39,32 @@ bool GUIScrollBar::OnEvent(const SEvent &event)
 		switch (event.EventType) {
 		case EET_KEY_INPUT_EVENT:
 			if (event.KeyInput.PressedDown) {
-				const s32 old_pos = scroll_pos;
+				const s32 old_pos = getTargetPos();
 				bool absorb = true;
 				switch (event.KeyInput.Key) {
 				case KEY_LEFT:
 				case KEY_UP:
-					setPos(scroll_pos - small_step);
+					setPosInterpolated(old_pos - small_step);
 					break;
 				case KEY_RIGHT:
 				case KEY_DOWN:
-					setPos(scroll_pos + small_step);
+					setPosInterpolated(old_pos + small_step);
 					break;
 				case KEY_HOME:
-					setPos(min_pos);
+					setPosInterpolated(min_pos);
 					break;
 				case KEY_PRIOR:
-					setPos(scroll_pos - large_step);
+					setPosInterpolated(old_pos - large_step);
 					break;
 				case KEY_END:
-					setPos(max_pos);
+					setPosInterpolated(max_pos);
 					break;
 				case KEY_NEXT:
-					setPos(scroll_pos + large_step);
+					setPosInterpolated(old_pos + large_step);
 					break;
 				default:
 					absorb = false;
 				}
-				if (scroll_pos != old_pos) {
-					SEvent e;
-					e.EventType = EET_GUI_EVENT;
-					e.GUIEvent.Caller = this;
-					e.GUIEvent.Element = nullptr;
-					e.GUIEvent.EventType = EGET_SCROLL_BAR_CHANGED;
-					Parent->OnEvent(e);
-				}
 				if (absorb)
 					return true;
 			}
@@ -79,16 +72,9 @@ bool GUIScrollBar::OnEvent(const SEvent &event)
 		case EET_GUI_EVENT:
 			if (event.GUIEvent.EventType == EGET_BUTTON_CLICKED) {
 				if (event.GUIEvent.Caller == up_button)
-					setPos(scroll_pos - small_step);
+					setPosInterpolated(getTargetPos() - small_step);
 				else if (event.GUIEvent.Caller == down_button)
-					setPos(scroll_pos + small_step);
-
-				SEvent e;
-				e.EventType = EET_GUI_EVENT;
-				e.GUIEvent.Caller = this;
-				e.GUIEvent.Element = nullptr;
-				e.GUIEvent.EventType = EGET_SCROLL_BAR_CHANGED;
-				Parent->OnEvent(e);
+					setPosInterpolated(getTargetPos() + small_step);
 				return true;
 			} else if (event.GUIEvent.EventType == EGET_ELEMENT_FOCUS_LOST)
 				if (event.GUIEvent.Caller == this)
@@ -102,14 +88,7 @@ bool GUIScrollBar::OnEvent(const SEvent &event)
 				if (Environment->hasFocus(this)) {
 					s8 d = event.MouseInput.Wheel < 0 ? -1 : 1;
 					s8 h = is_horizontal ? 1 : -1;
-					setPos(getPos() + (d * small_step * h));
-
-					SEvent e;
-					e.EventType = EET_GUI_EVENT;
-					e.GUIEvent.Caller = this;
-					e.GUIEvent.Element = nullptr;
-					e.GUIEvent.EventType = EGET_SCROLL_BAR_CHANGED;
-					Parent->OnEvent(e);
+					setPosInterpolated(getTargetPos() + (d * small_step * h));
 					return true;
 				}
 				break;
@@ -228,11 +207,45 @@ void GUIScrollBar::draw()
 	IGUIElement::draw();
 }
 
+static inline s32 interpolate_scroll(s32 from, s32 to, f32 amount)
+{
+	s32 step = core::round32((to - from) * core::clamp(amount, 0.001f, 1.0f));
+	if (step == 0)
+		return to;
+	return from + step;
+}
+
+void GUIScrollBar::interpolatePos()
+{
+	if (target_pos.has_value()) {
+		// Adjust to match 60 FPS. This also means that interpolation is
+		// effectively disabled at <= 30 FPS.
+		f32 amount = 0.5f * (last_delta_ms / 16.667f);
+		setPosRaw(interpolate_scroll(scroll_pos, *target_pos, amount));
+		if (scroll_pos == target_pos)
+			target_pos = std::nullopt;
+
+		SEvent e;
+		e.EventType = EET_GUI_EVENT;
+		e.GUIEvent.Caller = this;
+		e.GUIEvent.Element = nullptr;
+		e.GUIEvent.EventType = EGET_SCROLL_BAR_CHANGED;
+		Parent->OnEvent(e);
+	}
+}
+
+void GUIScrollBar::OnPostRender(u32 time_ms)
+{
+	last_delta_ms = porting::getDeltaMs(last_time_ms, time_ms);
+	last_time_ms = time_ms;
+	interpolatePos();
+}
+
 void GUIScrollBar::updateAbsolutePosition()
 {
 	IGUIElement::updateAbsolutePosition();
 	refreshControls();
-	setPos(scroll_pos);
+	updatePos();
 }
 
 s32 GUIScrollBar::getPosFromMousePos(const core::position2di &pos) const
@@ -250,7 +263,12 @@ s32 GUIScrollBar::getPosFromMousePos(const core::position2di &pos) const
 	return core::isnotzero(range()) ? s32(f32(p) / f32(w) * range() + 0.5f) + min_pos : 0;
 }
 
-void GUIScrollBar::setPos(const s32 &pos)
+void GUIScrollBar::updatePos()
+{
+	setPosRaw(scroll_pos);
+}
+
+void GUIScrollBar::setPosRaw(const s32 &pos)
 {
 	s32 thumb_area = 0;
 	s32 thumb_min = 0;
@@ -276,6 +294,23 @@ void GUIScrollBar::setPos(const s32 &pos)
 		border_size;
 }
 
+void GUIScrollBar::setPos(const s32 &pos)
+{
+	setPosRaw(pos);
+	target_pos = std::nullopt;
+}
+
+void GUIScrollBar::setPosInterpolated(const s32 &pos)
+{
+	s32 clamped = core::s32_clamp(pos, min_pos, max_pos);
+	if (scroll_pos != clamped) {
+		target_pos = clamped;
+		interpolatePos();
+	} else {
+		target_pos = std::nullopt;
+	}
+}
+
 void GUIScrollBar::setSmallStep(const s32 &step)
 {
 	small_step = step > 0 ? step : 10;
@@ -295,7 +330,7 @@ void GUIScrollBar::setMax(const s32 &max)
 	bool enable = core::isnotzero(range());
 	up_button->setEnabled(enable);
 	down_button->setEnabled(enable);
-	setPos(scroll_pos);
+	updatePos();
 }
 
 void GUIScrollBar::setMin(const s32 &min)
@@ -307,13 +342,13 @@ void GUIScrollBar::setMin(const s32 &min)
 	bool enable = core::isnotzero(range());
 	up_button->setEnabled(enable);
 	down_button->setEnabled(enable);
-	setPos(scroll_pos);
+	updatePos();
 }
 
 void GUIScrollBar::setPageSize(const s32 &size)
 {
 	page_size = size;
-	setPos(scroll_pos);
+	updatePos();
 }
 
 void GUIScrollBar::setArrowsVisible(ArrowVisibility visible)
@@ -327,6 +362,15 @@ s32 GUIScrollBar::getPos() const
 	return scroll_pos;
 }
 
+s32 GUIScrollBar::getTargetPos() const
+{
+	if (target_pos.has_value()) {
+		s32 clamped = core::s32_clamp(*target_pos, min_pos, max_pos);
+		return clamped;
+	}
+	return scroll_pos;
+}
+
 void GUIScrollBar::refreshControls()
 {
 	IGUISkin *skin = Environment->getSkin();
diff --git a/src/gui/guiScrollBar.h b/src/gui/guiScrollBar.h
index 3ff3bba35..a976d1a59 100644
--- a/src/gui/guiScrollBar.h
+++ b/src/gui/guiScrollBar.h
@@ -13,6 +13,7 @@ the arrow buttons where there is insufficient space.
 #pragma once
 
 #include "irrlichttypes_extrabloated.h"
+#include <optional>
 
 class ISimpleTextureSource;
 
@@ -33,21 +34,30 @@ public:
 		DEFAULT
 	};
 
-	virtual void draw();
-	virtual void updateAbsolutePosition();
-	virtual bool OnEvent(const SEvent &event);
+	virtual void draw() override;
+	virtual void updateAbsolutePosition() override;
+	virtual bool OnEvent(const SEvent &event) override;
+	virtual void OnPostRender(u32 time_ms) override;
 
 	s32 getMax() const { return max_pos; }
 	s32 getMin() const { return min_pos; }
 	s32 getLargeStep() const { return large_step; }
 	s32 getSmallStep() const { return small_step; }
 	s32 getPos() const;
+	s32 getTargetPos() const;
 
 	void setMax(const s32 &max);
 	void setMin(const s32 &min);
 	void setSmallStep(const s32 &step);
 	void setLargeStep(const s32 &step);
+	//! Sets a position immediately, aborting any ongoing interpolation.
+	// setPos does not send EGET_SCROLL_BAR_CHANGED events for you.
 	void setPos(const s32 &pos);
+	//! Sets a target position for interpolation.
+	// If you want to do an interpolated addition, use
+	// setPosInterpolated(getTargetPos() + x).
+	// setPosInterpolated takes care of sending EGET_SCROLL_BAR_CHANGED events.
+	void setPosInterpolated(const s32 &pos);
 	void setPageSize(const s32 &size);
 	void setArrowsVisible(ArrowVisibility visible);
 
@@ -79,4 +89,11 @@ private:
 	video::SColor current_icon_color;
 
 	ISimpleTextureSource *m_tsrc;
+
+	void setPosRaw(const s32 &pos);
+	void updatePos();
+	std::optional<s32> target_pos;
+	u32 last_time_ms = 0;
+	u32 last_delta_ms = 17; // assume 60 FPS
+	void interpolatePos();
 };
diff --git a/src/gui/guiTable.cpp b/src/gui/guiTable.cpp
index 81c38ffd8..530580124 100644
--- a/src/gui/guiTable.cpp
+++ b/src/gui/guiTable.cpp
@@ -869,7 +869,7 @@ bool GUITable::OnEvent(const SEvent &event)
 		core::position2d<s32> p(event.MouseInput.X, event.MouseInput.Y);
 
 		if (event.MouseInput.Event == EMIE_MOUSE_WHEEL) {
-			m_scrollbar->setPos(m_scrollbar->getPos() +
+			m_scrollbar->setPosInterpolated(m_scrollbar->getTargetPos() +
 					(event.MouseInput.Wheel < 0 ? -3 : 3) *
 					- (s32) m_rowheight / 2);
 			return true;