diff --git a/.changeset/tidy-rtl-scroll.md b/.changeset/tidy-rtl-scroll.md
new file mode 100644
index 000000000..573e76c36
--- /dev/null
+++ b/.changeset/tidy-rtl-scroll.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/virtual-core': patch
+---
+
+Fix programmatic scrolling in horizontal RTL elements by converting logical offsets, including measurement adjustments, to negative DOM scroll offsets.
diff --git a/packages/marko-virtual/e2e/app/e2e/option-gates.spec.ts b/packages/marko-virtual/e2e/app/e2e/option-gates.spec.ts
index f1e97e19b..dd1074a82 100644
--- a/packages/marko-virtual/e2e/app/e2e/option-gates.spec.ts
+++ b/packages/marko-virtual/e2e/app/e2e/option-gates.spec.ts
@@ -141,6 +141,18 @@ test('isRtl: a right-to-left horizontal list advances as the user scrolls left',
expect(idx[idx.length - 1]!).toBeGreaterThan(50)
})
+test('isRtl: scrollToIndex reaches a column using the default scroll function', async ({
+ page,
+}) => {
+ await page.goto('/rtl')
+ await page.waitForSelector('[data-index="0"]')
+ await page.getByTestId('scroll-50').click()
+
+ const scroller = page.getByTestId('scroller')
+ await expect.poll(() => scroller.evaluate((el) => el.scrollLeft)).toBe(-5000)
+ await expect(page.locator('[data-index="50"]')).toBeInViewport()
+})
+
test('custom measureElement: item sizes come from the custom measurer (+10)', async ({
page,
}) => {
diff --git a/packages/marko-virtual/e2e/app/src/routes/rtl/+page.marko b/packages/marko-virtual/e2e/app/src/routes/rtl/+page.marko
index 1a796664a..f570a28c5 100644
--- a/packages/marko-virtual/e2e/app/src/routes/rtl/+page.marko
+++ b/packages/marko-virtual/e2e/app/src/routes/rtl/+page.marko
@@ -5,15 +5,18 @@
gate: isRtl
+ 100
+ overscan=5
+ horizontal=true
+ isRtl=true
+ getScrollElement=(): HTMLDivElement | null => scrollEl()
+ />
+
-
100
- overscan=5
- horizontal=true
- isRtl=true
- getScrollElement=() => scrollEl()
- />
(
]
}
-const scrollWithAdjustments = (
+const scrollWithAdjustments = <
+ TScrollElement extends Element | Window,
+ TItemElement extends Element,
+>(
offset: number,
{
adjustments = 0,
behavior,
}: { adjustments?: number; behavior?: ScrollBehavior },
- instance: Virtualizer
,
+ instance: Virtualizer,
) => {
- instance.scrollElement?.scrollTo?.({
- [instance.options.horizontal ? 'left' : 'top']: offset + adjustments,
+ const { scrollElement, options } = instance
+ const adjustedOffset = offset + adjustments
+ const isRtlElement =
+ options.horizontal &&
+ options.isRtl &&
+ scrollElement !== null &&
+ 'ownerDocument' in scrollElement
+
+ scrollElement?.scrollTo?.({
+ [options.horizontal ? 'left' : 'top']: isRtlElement
+ ? -adjustedOffset
+ : adjustedOffset,
behavior,
})
}
diff --git a/packages/virtual-core/tests/dom-observers.test.ts b/packages/virtual-core/tests/dom-observers.test.ts
index 8056d57a9..3b31e83d4 100644
--- a/packages/virtual-core/tests/dom-observers.test.ts
+++ b/packages/virtual-core/tests/dom-observers.test.ts
@@ -49,6 +49,61 @@ test('elementScroll: uses left when horizontal is true', () => {
expect(scrollTo).toHaveBeenCalledWith({ left: 100, behavior: 'auto' })
})
+// In a right-to-left element scrollLeft runs from 0 down to negative values,
+// which is why observeElementOffset negates it under isRtl. The write has to
+// convert back, or the browser clamps the positive value to 0.
+test.each([
+ { adjustments: 0, left: -100 },
+ { adjustments: 25, left: -125 },
+ { adjustments: -25, left: -75 },
+])(
+ 'elementScroll: writes a negative left when horizontal and isRtl (adjustments: $adjustments)',
+ ({ adjustments, left }) => {
+ const scrollEl = document.createElement('div')
+ const scrollTo = vi.fn()
+ scrollEl.scrollTo = scrollTo
+ elementScroll(
+ 100,
+ { adjustments, behavior: 'auto' },
+ makeBaseInstance(scrollEl, { horizontal: true, isRtl: true }) as any,
+ )
+ expect(scrollTo).toHaveBeenCalledWith({ left, behavior: 'auto' })
+ },
+)
+
+test('elementScroll: isRtl does not change a vertical write', () => {
+ const scrollEl = document.createElement('div')
+ const scrollTo = vi.fn()
+ scrollEl.scrollTo = scrollTo
+ elementScroll(
+ 100,
+ { behavior: 'auto' },
+ makeBaseInstance(scrollEl, { horizontal: false, isRtl: true }) as any,
+ )
+ expect(scrollTo).toHaveBeenCalledWith({ top: 100, behavior: 'auto' })
+})
+
+test('elementScroll: reading back its own RTL write gives the written offset', () => {
+ const scrollEl = document.createElement('div')
+ scrollEl.scrollTo = ((options: ScrollToOptions) => {
+ // A dir="rtl" scroller clamps scrollLeft to [-(scrollWidth - clientWidth), 0].
+ scrollEl.scrollLeft = Math.min(0, Math.max(-9500, options.left!))
+ scrollEl.dispatchEvent(new Event('scroll'))
+ }) as typeof scrollEl.scrollTo
+ const instance = makeObserveInstance(scrollEl, {
+ horizontal: true,
+ isRtl: true,
+ })
+ const cb = vi.fn()
+ const cleanup = observeElementOffset(instance as any, cb)
+
+ elementScroll(5000, { behavior: 'auto' }, instance as any)
+
+ expect(scrollEl.scrollLeft).toBe(-5000)
+ expect(cb).toHaveBeenLastCalledWith(5000, true)
+ cleanup?.()
+})
+
test('windowScroll: calls scrollTo with top + behavior on the window', () => {
const scrollTo = vi.fn()
const win = { scrollTo }
@@ -67,6 +122,18 @@ test('windowScroll: applies adjustments + horizontal', () => {
expect(scrollTo).toHaveBeenCalledWith({ left: 240, behavior: 'auto' })
})
+// observeWindowOffset reads scrollX as is, so the window write stays positive.
+test('windowScroll: isRtl does not change a horizontal write', () => {
+ const scrollTo = vi.fn()
+ const win = { scrollTo }
+ windowScroll(
+ 250,
+ { behavior: 'auto' },
+ makeBaseInstance(win, { horizontal: true, isRtl: true }) as any,
+ )
+ expect(scrollTo).toHaveBeenCalledWith({ left: 250, behavior: 'auto' })
+})
+
test('elementScroll / windowScroll: no-op when scrollElement is null', () => {
expect(() =>
elementScroll(100, {}, makeBaseInstance(null) as any),
diff --git a/packages/virtual-core/tests/rtl-scroll.test.ts b/packages/virtual-core/tests/rtl-scroll.test.ts
new file mode 100644
index 000000000..9101b1d05
--- /dev/null
+++ b/packages/virtual-core/tests/rtl-scroll.test.ts
@@ -0,0 +1,44 @@
+import { expect, test } from 'vitest'
+import { Virtualizer, elementScroll, observeElementOffset } from '../src/index'
+
+test.each([
+ { horizontal: true, isRtl: true, adjustments: 0 },
+ { horizontal: true, isRtl: true, adjustments: 25 },
+ { horizontal: true, isRtl: true, adjustments: -25 },
+ { horizontal: true, isRtl: false, adjustments: 25 },
+ { horizontal: false, isRtl: true, adjustments: 25 },
+])(
+ 'element scrolling preserves logical offsets: %o',
+ ({ horizontal, isRtl, adjustments }) => {
+ const element = document.createElement('div')
+ element.scrollTo = (offsets) => {
+ if (typeof offsets === 'object') {
+ element.scrollLeft = offsets.left ?? element.scrollLeft
+ element.scrollTop = offsets.top ?? element.scrollTop
+ }
+ }
+ const virtualizer = new Virtualizer({
+ horizontal,
+ isRtl,
+ count: 100,
+ getScrollElement: () => element,
+ estimateSize: () => 100,
+ scrollToFn: elementScroll,
+ observeElementRect: () => {},
+ observeElementOffset,
+ })
+ virtualizer.scrollElement = element
+ virtualizer.targetWindow = window
+ let observedOffset = 0
+ const cleanup = observeElementOffset(virtualizer, (offset) => {
+ observedOffset = offset
+ })
+ try {
+ elementScroll(100, { adjustments }, virtualizer)
+ element.dispatchEvent(new Event('scroll'))
+ expect(observedOffset).toBe(100 + adjustments)
+ } finally {
+ cleanup?.()
+ }
+ },
+)
diff --git a/packages/virtual-core/tests/scroll-commands.test.ts b/packages/virtual-core/tests/scroll-commands.test.ts
index a4737e756..f7eaacaae 100644
--- a/packages/virtual-core/tests/scroll-commands.test.ts
+++ b/packages/virtual-core/tests/scroll-commands.test.ts
@@ -1,9 +1,11 @@
import { expect, test, vi } from 'vitest'
import {
Virtualizer,
+ elementScroll,
observeElementOffset,
observeElementRect,
} from '../src/index'
+import { makeMockScrollElement } from './helpers'
import type { VirtualizerOptions } from '../src/index'
test('should not throw when component unmounts during scrollToIndex rAF loop', () => {
@@ -1946,3 +1948,81 @@ test('scrollToIndex reaches an index added since the last render once the sizer
expect(el.scrollTop).toBe(5500)
expect(v.scrollState).toBeNull()
})
+
+// A dir="rtl" horizontal scroller as browsers implement it: scrollLeft is 0 at
+// the start (the right edge) and negative towards the end, and a write outside
+// [-(scrollWidth - clientWidth), 0] is clamped. The scroll event arrives in
+// the next frame.
+function setupRtlScroller() {
+ const rafCallbacks: Array = []
+ const mockWindow = {
+ requestAnimationFrame: (cb: FrameRequestCallback) => rafCallbacks.push(cb),
+ cancelAnimationFrame: () => {},
+ setTimeout: () => 0,
+ clearTimeout: () => {},
+ }
+ const el = makeMockScrollElement({
+ scrollTop: 0,
+ scrollLeft: 0,
+ scrollWidth: 10000,
+ clientWidth: 500,
+ scrollHeight: 120,
+ clientHeight: 120,
+ offsetWidth: 500,
+ offsetHeight: 120,
+ ownerDocument: { defaultView: mockWindow },
+ })
+ el.scrollTo = ({ left }: ScrollToOptions) => {
+ el.scrollLeft = Math.min(
+ 0,
+ Math.max(left!, el.clientWidth - el.scrollWidth),
+ )
+ rafCallbacks.push(() => el._dispatch('scroll'))
+ }
+ const v = new Virtualizer({
+ count: 100,
+ horizontal: true,
+ isRtl: true,
+ estimateSize: () => 100,
+ getScrollElement: () => el,
+ scrollToFn: elementScroll,
+ observeElementRect: (_inst, cb) => {
+ cb({ width: 500, height: 120 })
+ return () => {}
+ },
+ observeElementOffset,
+ })
+ v._willUpdate()
+ v.getVirtualItems()
+ const settle = () => {
+ for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) {
+ rafCallbacks.splice(0).forEach((cb) => cb(0))
+ }
+ }
+ return { v, el, settle }
+}
+
+test.each([
+ {
+ command: 'scrollToIndex',
+ run: (v: Virtualizer) => v.scrollToIndex(50, { align: 'start' }),
+ },
+ {
+ command: 'scrollToOffset',
+ run: (v: Virtualizer) => v.scrollToOffset(5000),
+ },
+ { command: 'scrollBy', run: (v: Virtualizer) => v.scrollBy(5000) },
+])(
+ 'isRtl: $command moves a horizontal element with the default elementScroll',
+ ({ run }) => {
+ const { v, el, settle } = setupRtlScroller()
+
+ run(v)
+ settle()
+
+ expect(el.scrollLeft).toBe(-5000)
+ expect(v.scrollOffset).toBe(5000)
+ expect(v.getVirtualItems()[0]!.index).toBeLessThanOrEqual(50)
+ expect(v.getVirtualItems().at(-1)!.index).toBeGreaterThanOrEqual(54)
+ },
+)