Scroll Navigation
@domphy/virtual exposes four imperative scroll methods. The createVirtualizer handle forwards scrollToIndex and scrollToOffset directly; the remaining methods live on handle.virtualizer.
scrollToIndex
Scroll to bring item index into view.
list.scrollToIndex(index: number, options?: {
align?: "start" | "center" | "end" | "auto"
behavior?: "auto" | "smooth" | "instant"
})align | Result |
|---|---|
"start" | Item's leading edge aligns with the viewport start |
"center" | Item is centered in the viewport |
"end" | Item's trailing edge aligns with the viewport end |
"auto" (default) | Minimum movement — no-op if item is already visible |
behavior maps directly to the browser's ScrollBehavior:
"auto"— browser default (usually instant)"smooth"— animated scroll"instant"— always instant regardless of browser setting
// Jump to the first item
list.scrollToIndex(0)
// Center item 500 with smooth animation
list.scrollToIndex(500, { align: "center", behavior: "smooth" })
// Bring item 9999 just into view with minimum movement
list.scrollToIndex(9999, { align: "auto" })Scroll on mount
Restore a saved index when the container mounts:
import { createVirtualizer } from "@domphy/virtual/domphy"
import { themeColor, themeSpacing } from "@domphy/theme"
const SAVED_INDEX = 250
const rows = Array.from({ length: 10_000 }, (_, i) => `Row #${i + 1}`)
const list = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: rows.length,
estimateSize: () => 40,
overscan: 5,
})
const App = {
div: [
{
div: (l) =>
list.getVirtualItems(l).map((item) => ({
div: rows[item.index],
_key: item.key,
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${item.size}px`,
transform: `translateY(${item.start}px)`,
paddingInline: themeSpacing(3),
display: "flex",
alignItems: "center",
borderBottom: (cl) => `1px solid ${themeColor(cl, "shift-3")}`,
},
})),
style: {
position: "relative",
height: (l) => `${list.getTotalSize(l)}px`,
},
},
],
_onMount: (node) => {
list.setScrollElement(node.domElement as HTMLDivElement)
// scrollToIndex after wiring — virtualizer needs the scroll element first
list.scrollToIndex(SAVED_INDEX, { align: "start" })
},
_onRemove: () => list.destroy(),
style: { height: "400px", overflowY: "auto" },
}scrollToOffset
Scroll to an absolute pixel offset from the start of the list.
list.scrollToOffset(px: number, options?: {
align?: "start" | "center" | "end" | "auto"
behavior?: "auto" | "smooth" | "instant"
})// Jump to 3000px
list.scrollToOffset(3000)
// Smooth scroll to the midpoint
const mid = list.virtualizer.getTotalSize() / 2
list.scrollToOffset(mid, { behavior: "smooth" })
// Scroll to top
list.scrollToOffset(0)scrollBy
Scroll a relative amount from the current position. Lives on handle.virtualizer.
list.virtualizer.scrollBy(delta: number, options?: {
behavior?: "auto" | "smooth" | "instant"
})Positive delta scrolls forward (down/right), negative scrolls backward.
// Page down by 600px
list.virtualizer.scrollBy(600)
// Page up, smooth
list.virtualizer.scrollBy(-600, { behavior: "smooth" })scrollToEnd
Scroll to the last item. Lives on handle.virtualizer.
list.virtualizer.scrollToEnd(options?: {
behavior?: "auto" | "smooth" | "instant"
})// Jump to bottom
list.virtualizer.scrollToEnd()
// Smooth scroll to bottom
list.virtualizer.scrollToEnd({ behavior: "smooth" })When count > 0, this is equivalent to scrollToIndex(count - 1, { align: "end" }).
Checking scroll position
isAtEnd
list.virtualizer.isAtEnd(threshold?: number): booleanReturns true when the scroll offset is within threshold pixels of the maximum (default 0 — exactly at the end). Use it to trigger pagination or show/hide a "scroll to bottom" button:
import { effect } from "@domphy/core"
import { toState } from "@domphy/core"
const showScrollButton = toState(false)
effect(() => {
// version() subscribes to virtualizer changes
list.version(l)
showScrollButton.set(!list.virtualizer.isAtEnd(80))
})getDistanceFromEnd
list.virtualizer.getDistanceFromEnd(): numberPixels remaining between the current scroll offset and the maximum scroll position.
const dist = list.virtualizer.getDistanceFromEnd()
if (dist < 200) {
loadMoreItems()
}getOffsetForIndex
Compute the target offset without scrolling — useful to determine whether a scroll is needed before firing:
const result = list.virtualizer.getOffsetForIndex(index, "start")
// result is [offsetPx, alignUsed] | undefined
if (result) {
const [offset] = result
console.log(`Item ${index} would scroll to ${offset}px`)
}Returns undefined for out-of-bounds indexes.
Keyboard navigation
Combine scrollToIndex with a keydown listener for accessible list navigation:
import { createVirtualizer } from "@domphy/virtual/domphy"
import { toState } from "@domphy/core"
const rows = Array.from({ length: 1000 }, (_, i) => `Item ${i}`)
const activeIndex = toState(0)
const list = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: rows.length,
estimateSize: () => 40,
overscan: 3,
})
const App = {
div: [
{
div: (l) =>
list.getVirtualItems(l).map((item) => ({
div: rows[item.index],
_key: item.key,
style: {
position: "absolute",
top: 0,
height: `${item.size}px`,
transform: `translateY(${item.start}px)`,
background: (cl) =>
item.index === activeIndex.get(cl)
? themeColor(cl, "primary-2")
: "transparent",
paddingInline: themeSpacing(3),
display: "flex",
alignItems: "center",
},
})),
style: {
position: "relative",
height: (l) => `${list.getTotalSize(l)}px`,
},
},
],
_onMount: (node) => {
list.setScrollElement(node.domElement as HTMLDivElement)
node.domElement.addEventListener("keydown", (e: KeyboardEvent) => {
const cur = activeIndex.get()
if (e.key === "ArrowDown") {
const next = Math.min(cur + 1, rows.length - 1)
activeIndex.set(next)
list.scrollToIndex(next, { align: "auto" })
e.preventDefault()
} else if (e.key === "ArrowUp") {
const prev = Math.max(cur - 1, 0)
activeIndex.set(prev)
list.scrollToIndex(prev, { align: "auto" })
e.preventDefault()
}
})
},
_onRemove: () => list.destroy(),
tabIndex: 0,
style: {
height: "400px",
overflowY: "auto",
outline: "none",
position: "relative",
},
}"Back to top" button
import { toState } from "@domphy/core"
const scrollOffset = toState(0)
// Subscribe to scroll changes via version()
effect(() => {
list.version(l)
scrollOffset.set(list.virtualizer.getScrollOffset())
})
const BackToTop = {
button: "Back to top",
onClick: () => list.scrollToIndex(0, { align: "start", behavior: "smooth" }),
style: {
display: (l) => (scrollOffset.get(l) > 300 ? "block" : "none"),
position: "fixed",
bottom: themeSpacing(4),
right: themeSpacing(4),
},
}