fix(ui): use column-reverse for jitter-free bottom-anchored scrolling

Switch the scroll viewport to flex-direction: column-reverse so the
browser natively anchors to the bottom (scrollTop=0 = bottom). This
eliminates the 1-frame jitter between content height changes and scroll
position updates. Update all scrollTop math across auto-scroll, scroll
spy, gesture detection, hash scroll, and custom thumb to account for
the inverted coordinate system.
This commit is contained in:
Kit Langton
2026-03-02 21:00:30 -05:00
parent 1b9ca3da27
commit 28538bc65d
8 changed files with 44 additions and 24 deletions

View File

@@ -125,7 +125,10 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
// With column-reverse, adding content at the top doesn't shift the
// viewport because scroll origin is at the bottom. Subtract delta
// to maintain position (beforeTop is negative or zero).
el.scrollTop = beforeTop - delta
})
}
@@ -209,7 +212,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -1028,7 +1032,8 @@ export default function Page() {
const overflow = max > 1
// If auto-scroll is tracking the bottom, always report bottom: true
// to prevent the scroll-down arrow from flashing during height animations
const bottom = !overflow || el.scrollTop >= max - 2 || !autoScroll.userScrolled()
// With column-reverse, scrollTop=0 is at the bottom
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -1119,9 +1124,8 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
const stick = el
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
: false
// With column-reverse, near bottom = scrollTop near 0
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
dockHeight = next

View File

@@ -28,10 +28,11 @@ describe("shouldMarkBoundaryGesture", () => {
})
test("marks when scrolling beyond top boundary", () => {
// column-reverse: scrollTop=-590 means 590px from bottom (10px from top, max=600)
expect(
shouldMarkBoundaryGesture({
delta: -40,
scrollTop: 10,
scrollTop: -590,
scrollHeight: 1000,
clientHeight: 400,
}),
@@ -39,10 +40,11 @@ describe("shouldMarkBoundaryGesture", () => {
})
test("marks when scrolling beyond bottom boundary", () => {
// column-reverse: scrollTop=-20 means 20px from bottom
expect(
shouldMarkBoundaryGesture({
delta: 50,
scrollTop: 580,
scrollTop: -20,
scrollHeight: 1000,
clientHeight: 400,
}),
@@ -50,10 +52,11 @@ describe("shouldMarkBoundaryGesture", () => {
})
test("does not mark when nested scroller can consume movement", () => {
// column-reverse: scrollTop=-400 means 400px from bottom (middle of scroll)
expect(
shouldMarkBoundaryGesture({
delta: 20,
scrollTop: 200,
scrollTop: -400,
scrollHeight: 1000,
clientHeight: 400,
}),

View File

@@ -14,8 +14,9 @@ export const shouldMarkBoundaryGesture = (input: {
if (max <= 1) return true
if (!input.delta) return false
if (input.delta < 0) return input.scrollTop + input.delta <= 0
// With column-reverse: scrollTop=0 at bottom, -max at top
if (input.delta < 0) return input.scrollTop + input.delta <= -max
const remaining = max - input.scrollTop
const remaining = -input.scrollTop // distance from bottom
return input.delta > remaining
}

View File

@@ -574,6 +574,7 @@ export function MessageTimeline(props: {
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<div>
<Show when={showHeader()}>
<div
data-session-title
@@ -789,6 +790,7 @@ export function MessageTimeline(props: {
}}
</For>
</div>
</div>
</ScrollView>
</div>
</Show>

View File

@@ -47,7 +47,8 @@ export const useSessionHashScroll = (input: {
const b = root.getBoundingClientRect()
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
// With column-reverse, scrollTop is negative — don't clamp to 0
const top = a.top - b.top + root.scrollTop - inset
root.scrollTo({ top, behavior })
return true
}

View File

@@ -9,6 +9,8 @@
overflow-y: auto;
scrollbar-width: none;
outline: none;
display: flex;
flex-direction: column-reverse;
}
.scroll-view__viewport::-webkit-scrollbar {

View File

@@ -57,9 +57,12 @@ export function ScrollView(props: ScrollViewProps) {
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
// With column-reverse: scrollTop=0 is at bottom, negative = scrolled up
// Normalize so 0 = at top, maxScrollTop = at bottom
const normalizedScrollTop = maxScrollTop + scrollTop
const top = maxScrollTop > 0 ? (normalizedScrollTop / maxScrollTop) * maxThumbTop : 0
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
// Ensure thumb stays within bounds
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setThumbHeight(height)
@@ -147,11 +150,13 @@ export function ScrollView(props: ScrollViewProps) {
break
case "Home":
e.preventDefault()
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
// With column-reverse, top of content = -(scrollHeight - clientHeight)
viewportRef.scrollTo({ top: -(viewportRef.scrollHeight - viewportRef.clientHeight), behavior: "smooth" })
break
case "End":
e.preventDefault()
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
// With column-reverse, bottom of content = 0
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
break
case "ArrowUp":
e.preventDefault()

View File

@@ -32,7 +32,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight - el.scrollTop
// With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
return Math.abs(el.scrollTop)
}
const canScroll = (el: HTMLElement) => {
@@ -54,13 +55,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (scrollAnim) cancelSmooth()
if (!force && store.userScrolled) return
const next = Math.max(0, el.scrollHeight - el.clientHeight)
if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) {
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
el.scrollTop = next
el.scrollTop = 0
markProgrammatic()
}
@@ -78,13 +79,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
cancelSmooth()
if (store.userScrolled) setStore("userScrolled", false)
const next = Math.max(0, el.scrollHeight - el.clientHeight)
if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) {
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
scrollAnim = animate(el.scrollTop, next, {
scrollAnim = animate(el.scrollTop, 0, {
type: "spring",
visualDuration: 0.35,
bounce: 0,
@@ -248,7 +249,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
const el = scroll
if (!el) return
if (store.userScrolled) setStore("userScrolled", false)
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight)
// With column-reverse, scrollTop=0 is at the bottom
el.scrollTop = 0
markProgrammatic()
},
userScrolled: () => store.userScrolled,