diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiChatScrollbar.cs
new file mode 100644
index 00000000..6274f7b4
--- /dev/null
+++ b/src/AcDream.App/UI/UiChatScrollbar.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.App.UI;
+
+///
+/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the
+/// content/view ratio, and up/down step buttons. Drives a linked
+/// . Ports retail UIElement_Scrollbar::UpdateLayout
+/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from
+/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line).
+///
+///
+/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68),
+/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains
+/// the full scrollbar widget with distinct up/down button children:
+/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
+/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
+/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat).
+/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
+/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the
+/// rendered scrollbar's height; the widget responds to those regions directly via hit
+/// comparison in OnEvent without requiring separate child elements.
+///
+public sealed class UiChatScrollbar : UiElement
+{
+ /// The scroll model this bar reflects + drives (shared with the transcript).
+ public UiScrollable? Model { get; set; }
+
+ /// RenderSurface id → (GL tex, w, h). 0 id = skip.
+ public Func? SpriteResolve { get; set; }
+
+ /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).
+ public uint TrackSprite { get; set; }
+
+ /// Thumb sprite id (3-slice middle tile: 0x06004C63; the widget draws
+ /// a single stretched sprite for simplicity — Task H can upgrade to 3-slice).
+ public uint ThumbSprite { get; set; }
+
+ /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).
+ public uint UpSprite { get; set; }
+
+ /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).
+ public uint DownSprite { get; set; }
+
+ /// Retail attribute 0x89 floor: minimum thumb height in pixels.
+ private const float MinThumb = 8f;
+
+ /// Up/down button height in pixels. Matches element height 16px from
+ /// the up/down button children in base layout 0x2100003E.
+ private const float ButtonH = 16f;
+
+ private bool _draggingThumb;
+ private float _dragOffsetY;
+
+ public UiChatScrollbar() { CapturesPointerDrag = true; }
+
+ ///
+ /// Computes the thumb rectangle (local y origin and height) within the track area
+ /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout
+ /// @0x4710d0: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top
+ /// offset = trackTop + (trackLen - thumbH) * PositionRatio.
+ ///
+ /// The scroll model.
+ /// Y of the top of the usable track area (below up-button).
+ /// Pixel length of the usable track area (between up and down buttons).
+ /// Local Y of the thumb's top edge, and its pixel height.
+ public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen)
+ {
+ float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
+ float travel = trackLen - h;
+ float y = trackTop + travel * m.PositionRatio;
+ return (y, h);
+ }
+
+ protected override void OnDraw(UiRenderContext ctx)
+ {
+ if (Model is not { } m || SpriteResolve is not { } resolve) return;
+
+ // Track background, full element bounds.
+ DrawSprite(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
+
+ // Up button — top ButtonH rows.
+ DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
+
+ // Down button — bottom ButtonH rows.
+ DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
+
+ // Thumb — only when content overflows the view.
+ if (m.HasOverflow)
+ {
+ float trackTop = ButtonH;
+ float trackLen = Height - 2f * ButtonH;
+ var (ty, th) = ThumbRect(m, trackTop, trackLen);
+ DrawSprite(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
+ }
+ }
+
+ private void DrawSprite(UiRenderContext ctx, Func resolve,
+ uint id, float x, float y, float w, float h)
+ {
+ if (id == 0) return;
+ var (tex, _, _) = resolve(id);
+ if (tex == 0) return;
+ ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+
+ public override bool OnEvent(in UiEvent e)
+ {
+ if (Model is not { } m) return false;
+
+ switch (e.Type)
+ {
+ case UiEventType.MouseDown:
+ {
+ // e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
+ float ly = e.Data2;
+
+ // Up-button region: top ButtonH rows.
+ if (ly <= ButtonH) { m.ScrollByLines(-1); return true; }
+
+ // Down-button region: bottom ButtonH rows.
+ if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; }
+
+ // Track interior: start a thumb drag or page-scroll.
+ float trackTop = ButtonH;
+ float trackLen = Height - 2f * ButtonH;
+ var (ty, th) = ThumbRect(m, trackTop, trackLen);
+
+ if (ly >= ty && ly <= ty + th)
+ {
+ // Clicked inside the thumb — begin drag with offset from thumb top.
+ _draggingThumb = true;
+ _dragOffsetY = ly - ty;
+ }
+ else
+ {
+ // Clicked above or below thumb — page scroll (HandleButtonClick page case).
+ m.ScrollByPage(ly < ty ? -1 : 1);
+ }
+ return true;
+ }
+
+ case UiEventType.MouseMove when _draggingThumb:
+ {
+ // Map current local Y (minus drag offset from thumb top) back to a
+ // position ratio across the available travel distance.
+ float trackTop = ButtonH;
+ float trackLen = Height - 2f * ButtonH;
+ float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
+ float travel = MathF.Max(1f, trackLen - thumbH);
+ float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel;
+ m.SetPositionRatio(newRatio);
+ return true;
+ }
+
+ case UiEventType.MouseUp:
+ _draggingThumb = false;
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs
new file mode 100644
index 00000000..3f4ddbba
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs
@@ -0,0 +1,81 @@
+using AcDream.App.UI;
+using Xunit;
+
+namespace AcDream.App.Tests.UI;
+
+///
+/// Pure unit tests for — no GL dependency.
+///
+public class UiChatScrollbarTests
+{
+ // Model: content=400, view=100, trackLen=200.
+ // ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50.
+ // Travel = 200 - 50 = 150.
+
+ [Fact]
+ public void ThumbRect_AtStart_HasCorrectSizeAndZeroOffset()
+ {
+ var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
+ // PositionRatio = 0 (start).
+ var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
+ Assert.Equal(50f, h, 3f);
+ Assert.Equal(0f, y, 3f);
+ }
+
+ [Fact]
+ public void ThumbRect_AtEnd_PinsToBottomOfTrack()
+ {
+ var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
+ m.ScrollToEnd(); // PositionRatio = 1.
+ float trackTop = 16f, trackLen = 200f;
+ var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop, trackLen);
+ Assert.Equal(50f, h, 3f);
+ // y = trackTop + travel * 1 = 16 + 150 = 166.
+ Assert.Equal(166f, y, 3f);
+ }
+
+ [Fact]
+ public void ThumbRect_WithButtonH_CorrectlyOffsetsFromTrackTop()
+ {
+ // Matches task spec: content=400, view=100, trackLen=200, PositionRatio=1.
+ // thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150.
+ var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
+ m.ScrollToEnd();
+ var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f);
+ Assert.Equal(50f, h, 3f);
+ Assert.Equal(166f, y, 3f); // 16 + 150
+ }
+
+ [Fact]
+ public void ThumbRect_MidScroll_InterpolatesPosition()
+ {
+ // content=400 view=100 → MaxScroll=300; ScrollY=150 → PositionRatio=0.5.
+ var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
+ m.SetScrollY(150);
+ Assert.Equal(0.5f, m.PositionRatio, 3);
+
+ var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
+ Assert.Equal(50f, h, 3f);
+ // y = 0 + 150 * 0.5 = 75.
+ Assert.Equal(75f, y, 3f);
+ }
+
+ [Fact]
+ public void ThumbRect_SmallContent_EnforcesMinThumb()
+ {
+ // content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8.
+ var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 };
+ var (_, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
+ Assert.Equal(8f, h, 3f);
+ }
+
+ [Fact]
+ public void ThumbRect_NoOverflow_ThumbFillsTrack()
+ {
+ // content <= view → ThumbRatio = 1 → thumbH = trackLen.
+ var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
+ var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f);
+ Assert.Equal(100f, h, 3f);
+ Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop
+ }
+}