Askr documentation
UI & Components

Virtual List

Virtual List: anatomy, keyboard behavior, state, and theming in Askr.

Example

import { VirtualList } from '@askrjs/themes/components';

<VirtualList
  items={projects}
  rowHeight={48}
  getKey={(project) => project.id}
  rowComponent={({ item }) => <ProjectRow project={item} />}
/>

Published props

Generated from the TypeScript declarations shipped by the installed package. Named types in the Type column define the accepted values.

VirtualListAsChildProps

Import from @askrjs/ui/virtual-list.

PropTypeDefaultDescription
apiRefapiRef?: Ref<VirtualListApi<Item> | null>;
asChildasChild: true;
childrenchildren: JSXElement;
followBottomfollowBottom?: boolean | { threshold?: number; } | undefined;
getKeygetKey: (item: Item, index: number) => string | number;
itemsitems: readonly Item[];
onScrollonScroll?: ((event: Event) => void) | undefined;
overscanoverscan?: VirtualOverscan | undefined;
refref?: Ref<HTMLElement>;
rowComponentrowComponent: VirtualListRowComponent<Item>;
rowHeightrowHeight: number;
viewportviewport?: "lg" | undefined;

VirtualListProps

Import from @askrjs/ui/virtual-list.

PropTypeDefaultDescription
apiRefapiRef?: Ref<VirtualListApi<Item> | null>;
asChildasChild?: false | undefined;
followBottomfollowBottom?: boolean | { threshold?: number; } | undefined;
getKeygetKey: (item: Item, index: number) => string | number;
itemsitems: readonly Item[];
onScrollonScroll?: ((event: Event) => void) | undefined;
overscanoverscan?: VirtualOverscan | undefined;
refref?: Ref<HTMLElement>;
rowComponentrowComponent: VirtualListRowComponent<Item>;
rowHeightrowHeight: number;
viewportviewport?: "lg" | undefined;

VirtualListRowComponentProps

Import from @askrjs/ui/virtual-list.

PropTypeDefaultDescription
indexindex: number;
isVisibleisVisible: boolean;
itemitem: Item;
rowKeyrowKey: string;

Purpose

Virtual List renders only the rows currently in view, for collections long enough that putting every node in the DOM costs you scroll performance. You give it `items`, a fixed `rowHeight`, a `getKey` for stable identity, and a `rowComponent` that renders one item; `overscan` controls how many extra rows are kept outside the viewport. `followBottom` pins the view to the newest row, which is what you want for a log or a chat transcript, and `apiRef` exposes imperative scrolling for jump-to-row.

Install and import

VirtualList comes from @askrjs/ui/virtual-list; @askrjs/themes doesn't have a dedicated `/virtual-list` subpath or a styled wrapper component layered on top, but the same VirtualList is re-exported as-is through the `@askrjs/themes/components` barrel, so `import { VirtualList } from '@askrjs/themes/components'` is a real, working import — it just isn't wrapped or restyled beyond what the CSS below already covers. Styling arrives the same way regardless of which package you imported it from: @askrjs/themes/default's CSS carries rules for [data-slot="virtual-list"] and [data-slot="virtual-list-row"], both of which VirtualList renders on its own elements.

Live examples

VirtualList takes items, a fixed rowHeight, a getKey function, and a rowComponent that renders one row from {item, index, rowKey, isVisible}; it mounts only the rows currently in (or near) the viewport instead of the full array. A chat log or activity feed is the canonical use case: pass followBottom (a boolean or {threshold}) to keep the view pinned to the newest item as more arrive, unless the user has scrolled up to read history.

Anatomy

VirtualList renders a single scrollable root containing an inner spacer sized to totalHeight, with only the visible (plus overscanned) rows mounted as real DOM nodes — everything above and below is represented by blank space rather than elements. You don't construct this structure yourself; you hand VirtualList your data and a row renderer and it manages the tree internally. asChild lets you merge the root's scroll listeners and props onto your own element instead of the default div.

State model

Internally VirtualList tracks scrollTop, the computed visibleRange, isAtTop/isAtBottom, whether it's currently following the bottom, and pendingUnseenCount — items that arrived while the user had scrolled away from the bottom. Use apiRef to reach VirtualListApi and imperatively call scrollToIndex, scrollToTop, scrollToBottom, getState, or setFollowBottom(false), for example to stop auto-scrolling once the user scrolls up. All of this state is internal; there's no controlled scrollTop prop you pass in yourself.

Keyboard and accessibility

Rows carry a data-visible attribute, but VirtualList doesn't manage focus or a roving tabindex — keyboard interaction is whatever your rowComponent implements for its own content, such as a focusable button inside a row getting tabbed to normally. Because off-screen rows are unmounted rather than hidden, a screen reader's virtual cursor or the browser's find-in-page won't encounter items scrolled out of view, which is inherent to any virtualized list and worth knowing before you rely on either for browsing the full dataset.

Styling and tokens

With @askrjs/themes/default loaded, the rendered [data-slot="virtual-list"] root gets a bordered, scrollable surface, and rows get hover and [data-visible="false"] styling out of the box, purely from CSS matching attributes VirtualList already produces. If you skip loading a theme, VirtualList still functions but renders with no visual styling at all.

API

VirtualListProps<Item> requires items, rowHeight, getKey, and rowComponent; optional props are overscan (a number or {before, after}), followBottom, onScroll, viewport ('lg' is currently the only value), apiRef, and asChild. VirtualListApi exposes scrollToIndex(index, alignment?), scrollToTop(), scrollToBottom(), getState(), getVisibleRange(), getItemCount(), getScrollTop(), isAtTop(), isAtBottom(), isFollowingBottom(), getPendingUnseenCount(), and setFollowBottom(followBottom).

Edge cases

rowHeight is fixed — VirtualList doesn't measure variable-height rows, so content that wraps to different heights per item will clip or leave gaps; keep row content a predictable height or pad it to fit. getKey must return stable, unique keys across renders, or scroll position and followBottom tracking can jump when items are added or removed. When followBottom is on and the user scrolls away from the bottom, new items increment pendingUnseenCount instead of forcing a scroll — read that value if you want a 'N new' indicator rather than silently losing the count.

See Virtual Table for the same windowing approach applied to multi-column tabular data instead of a single-column list. See Scroll Area if you need a styled custom scrollbar around non-virtualized scrollable content. See Table for the plain, non-virtualized tabular primitives.