@akhatmirzo/ui

VirtualList

Render lists of 10,000+ items smoothly by drawing only the rows on screen.

#1InfoFoydalanuvchi tizimga kirdi
#2TayyorTo'lov qabul qilindi
#3OgohlantirishOmborda mahsulot kam qoldi
#4XatoSMS yuborib bo'lmadi
#5InfoFoydalanuvchi tizimga kirdi
#6TayyorTo'lov qabul qilindi
#7OgohlantirishOmborda mahsulot kam qoldi
#8XatoSMS yuborib bo'lmadi
#9InfoFoydalanuvchi tizimga kirdi
#10TayyorTo'lov qabul qilindi
#11OgohlantirishOmborda mahsulot kam qoldi
#12XatoSMS yuborib bo'lmadi
#13InfoFoydalanuvchi tizimga kirdi
#14TayyorTo'lov qabul qilindi
#15OgohlantirishOmborda mahsulot kam qoldi
#16XatoSMS yuborib bo'lmadi
#17InfoFoydalanuvchi tizimga kirdi

Import

import { VirtualList, type VirtualListHandle } from '@akhatmirzo/ui';

The list above holds 10,000 entries, yet only about 20 rows exist in the DOM at any moment: the visible ones plus a small buffer on each side (overscan). Rows are recycled as you scroll (built on TanStack Virtual).

When to use

  • Lists with hundreds or thousands of items — logs, messages, notifications, catalogs. Under about 100 items, a plain list is simpler and just as fast.
  • Infinite scrolling — with onEndReached and loadingMore, no pagination buttons.
  • Need a table with columns, sorting and filters? Use the virtual mode of DataTable.
  • Give it a fixed height: height is required — a virtual list needs its own scroll container.
  • Give every row a stable key (getItemKey) so rows don't get mixed up when items are added or removed.

Jumping to a row

ref isn't a DOM element but a handle (VirtualListHandle): scrollToIndex, scrollToOffset, measure, plus the scroll container as element. In the demo above, type a number and press Enter.

const ref = useRef<VirtualListHandle>(null);

ref.current?.scrollToIndex(4999, { align: 'start' }); // row 5,000 at the top
ref.current?.scrollToOffset(0); // back to the start
ref.current?.element?.focus(); // the DOM element

Variable heights

When rows have different heights (messages, comments), pass dynamic: each row is measured after it renders. estimateSize then becomes just the initial guess — the closer it is to the real average, the smoother the scrollbar.

Aziza Karimova

Assalomu alaykum!

Malika Yusupova

Yetkazish manzilini o'zgartirsam bo'ladimi? Hozir Samarqanddaman, kuryer oldindan qo'ng'iroq qilsin.

G'ayrat Usmonov

Rahmat, mahsulot juda yoqdi. Qadoqlash ham a'lo darajada edi, do'stlarimga ham tavsiya qilaman.

Bobur Toshmatov

Buyurtmam qachon yetib keladi? Kecha to'lov qilgan edim.

Aziza Karimova

Rahmat, mahsulot juda yoqdi. Qadoqlash ham a'lo darajada edi, do'stlarimga ham tavsiya qilaman.

Malika Yusupova

Buyurtmam qachon yetib keladi? Kecha to'lov qilgan edim.

G'ayrat Usmonov

Ha, albatta.

Bobur Toshmatov

Assalomu alaykum!

Aziza Karimova

Ha, albatta.

Malika Yusupova

Assalomu alaykum!

G'ayrat Usmonov

Yetkazish manzilini o'zgartirsam bo'ladimi? Hozir Samarqanddaman, kuryer oldindan qo'ng'iroq qilsin.

Size the row itself

Without dynamic, each row gets the estimateSize height — make the element inside match it (h-12 = 48px). Use gap for spacing between rows; don't use margin, it isn't measured.

Infinite loading

onEndReached fires when endReachedThreshold rows (5 by default) are left, once per list length. Set loadingMore while fetching: a spinner row appears at the bottom and onEndReached won't fire again. If the request fails (loadingMore goes back to false but the length didn't change), it fires again on the next scroll, so retrying comes for free.

Yuklangan: 40 / 400

№1Malika Rahimova1 485 000 so'm
№2Nodira Saidova1 470 000 so'm
№3Otabek Qodirov1 455 000 so'm
№4Jasur Karimov1 440 000 so'm
№5Malika Rahimova1 425 000 so'm
№6Nodira Saidova1 410 000 so'm
№7Otabek Qodirov1 395 000 so'm
№8Jasur Karimov1 380 000 so'm
№9Malika Rahimova1 365 000 so'm
№10Nodira Saidova1 350 000 so'm
№11Otabek Qodirov1 335 000 so'm
№12Jasur Karimov1 320 000 so'm

Empty state

When items is empty, emptyState is shown (default: "Ro'yxat bo'sh", "the list is empty").

Hali bildirishnomalar yo'q

Props

Accepts the remaining <div> attributes (applied to the scroll container). aria-label / aria-labelledby go to the inner list element (role="list").

PropTypeDefaultDescription
itemsrequiredreadonly T[]—The items
renderItemrequired(item: T, index: number) => ReactNode—Renders one row
estimateSizerequirednumber | ((index: number) => number)—Estimated row height in px
heightrequirednumber | string—Container height: px or a CSS value ("60vh")
overscannumber5Extra rows rendered outside the viewport
dynamicbooleanfalseRows have different heights — measure them
getItemKey(item: T, index: number) => string | number—Row key (defaults to the index)
gapnumber0Space between rows in px
onEndReached() => void—Near the end of the list (once per length)
endReachedThresholdnumber5How many rows before the end
loadingMorebooleanfalseSpinner row at the bottom; onEndReached won't fire meanwhile
emptyStateReactNode—Shown when the list is empty
onScrollComponentProps<'div'>['onScroll']—
labels{ loadingMore?: string; empty?: ReactNode }{ loadingMore: 'Yuklanmoqda...', empty: "Ro'yxat bo'sh" }
listClassNamestring—Class for the role="list" element
refRef<VirtualListHandle>—Control handle; the DOM node is handle.element

VirtualListHandle

PropTypeDefaultDescription
elementHTMLDivElement | null—The scroll container
scrollToIndex(index: number, options?: ScrollToOptions) => void—options: { align?: 'start' | 'center' | 'end' | 'auto', behavior? }
scrollToOffset(offset: number, options?: ScrollToOptions) => void—
measure() => void—Clears the size cache and measures rows again

Keyboard

The scroll container is reachable with Tab (with a visible focus ring). Once focused, the browser's usual scroll keys work: ↑ ↓, PageUp / PageDown, Home / End, Space.

Accessibility

  • List semantics: the inner element has role="list" and each row role="listitem", with aria-setsize (total) and aria-posinset (position) — screen readers announce "item 5,000 of 10,000" even though only 20 rows are in the DOM.
  • Name it: pass aria-label or aria-labelledby; it goes to the list.
  • Loading: while loadingMore is on, the list has aria-busy, and the spinner's hidden text (labels.loadingMore) is read out.
  • Interactive content inside rows (buttons, links) works as usual, but off-screen rows aren't in the DOM — the browser's find-in-page (Ctrl+F) won't see them. Add your own search field if users need it.

On this page