VirtualList
Render lists of 10,000+ items smoothly by drawing only the rows on screen.
'use client';
import { Badge, Button, Input, VirtualList, type VirtualListHandle } from '@akhatmirzo/ui';
import { useMemo, useRef, useState } from 'react';
const LEVELS = [
{ label: 'Info', color: 'info', text: 'Foydalanuvchi tizimga kirdi' },
{ label: 'Tayyor', color: 'success', text: "To'lov qabul qilindi" },
{ label: 'Ogohlantirish', color: 'warning', text: 'Omborda mahsulot kam qoldi' },
{ label: 'Xato', color: 'danger', text: "SMS yuborib bo'lmadi" },
] as const;
export default function VirtualListBasic() {
const logs = useMemo(
() => Array.from({ length: 10_000 }, (_, i) => ({ id: i + 1, ...LEVELS[i % LEVELS.length]! })),
[],
);
const ref = useRef<VirtualListHandle>(null);
const [target, setTarget] = useState('5000');
const go = () => ref.current?.scrollToIndex(Number(target) - 1, { align: 'start' });
return (
<div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Input
aria-label="Qator raqami"
inputMode="numeric"
value={target}
onChange={(e) => setTarget(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => e.key === 'Enter' && go()}
className="w-28"
/>
<Button variant="soft" onClick={go}>
Qatorga o'tish
</Button>
</div>
<VirtualList
ref={ref}
aria-label="Tizim jurnali"
items={logs}
estimateSize={48}
height={400}
overscan={8}
getItemKey={(log) => log.id}
className="rounded-xl border border-border bg-surface"
renderItem={(log) => (
<div className="flex h-12 items-center gap-3 border-b border-border px-4 text-sm">
<span className="w-14 shrink-0 text-text-faint tabular-nums">#{log.id}</span>
<Badge color={log.color}>{log.label}</Badge>
<span className="min-w-0 flex-1 truncate">{log.text}</span>
</div>
)}
/>
</div>
);
}
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
onEndReachedandloadingMore, no pagination buttons. - Need a table with columns, sorting and filters? Use the
virtualmode of DataTable. - Give it a fixed height:
heightis 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 elementVariable 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.
Assalomu alaykum!
Yetkazish manzilini o'zgartirsam bo'ladimi? Hozir Samarqanddaman, kuryer oldindan qo'ng'iroq qilsin.
Rahmat, mahsulot juda yoqdi. Qadoqlash ham a'lo darajada edi, do'stlarimga ham tavsiya qilaman.
Buyurtmam qachon yetib keladi? Kecha to'lov qilgan edim.
Rahmat, mahsulot juda yoqdi. Qadoqlash ham a'lo darajada edi, do'stlarimga ham tavsiya qilaman.
Buyurtmam qachon yetib keladi? Kecha to'lov qilgan edim.
Ha, albatta.
Assalomu alaykum!
Ha, albatta.
Assalomu alaykum!
Yetkazish manzilini o'zgartirsam bo'ladimi? Hozir Samarqanddaman, kuryer oldindan qo'ng'iroq qilsin.
'use client';
import { Avatar, VirtualList } from '@akhatmirzo/ui';
const NAMES = ['Aziza Karimova', 'Bobur Toshmatov', "G'ayrat Usmonov", 'Malika Yusupova'];
const TEXTS = [
'Assalomu alaykum!',
"Buyurtmam qachon yetib keladi? Kecha to'lov qilgan edim.",
"Yetkazish manzilini o'zgartirsam bo'ladimi? Hozir Samarqanddaman, kuryer oldindan qo'ng'iroq qilsin.",
'Ha, albatta.',
"Rahmat, mahsulot juda yoqdi. Qadoqlash ham a'lo darajada edi, do'stlarimga ham tavsiya qilaman.",
];
const messages = Array.from({ length: 2000 }, (_, i) => ({
id: i,
author: NAMES[(i * 3) % NAMES.length]!,
text: TEXTS[(i * 2 + (i >> 2)) % TEXTS.length]!,
}));
export default function VirtualListDynamic() {
return (
<VirtualList
dynamic
aria-label="Mijozlar bilan yozishmalar"
items={messages}
estimateSize={72}
height={400}
getItemKey={(m) => m.id}
className="w-full rounded-xl border border-border bg-surface"
renderItem={(m) => (
<div className="flex gap-3 border-b border-border px-4 py-3">
<Avatar name={m.author} size="sm" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">{m.author}</span>
<p className="m-0 text-sm text-text-muted">{m.text}</p>
</div>
</div>
)}
/>
);
}
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
'use client';
import { formatMoney } from '@akhatmirzo/core';
import { VirtualList } from '@akhatmirzo/ui';
import { useCallback, useState } from 'react';
const PAGE = 40;
const TOTAL = 400;
const NAMES = ['Jasur Karimov', 'Malika Rahimova', 'Nodira Saidova', 'Otabek Qodirov'];
const makeOrders = (from: number) =>
Array.from({ length: PAGE }, (_, i) => {
const id = from + i + 1;
return {
id,
customer: NAMES[id % NAMES.length]!,
total: ((id * 7919) % 90) * 15_000 + 150_000,
};
});
export default function VirtualListInfinite() {
const [orders, setOrders] = useState(() => makeOrders(0));
const [loading, setLoading] = useState(false);
const loadMore = useCallback(() => {
if (orders.length >= TOTAL) return;
setLoading(true);
// Haqiqiy loyihada — fetch(`/api/orders?offset=${orders.length}`)
setTimeout(() => {
setOrders((prev) => [...prev, ...makeOrders(prev.length)]);
setLoading(false);
}, 900);
}, [orders.length]);
return (
<div className="flex w-full flex-col gap-2">
<p className="m-0 text-sm text-text-muted">
Yuklangan: {orders.length} / {TOTAL}
</p>
<VirtualList
aria-label="Buyurtmalar"
items={orders}
estimateSize={52}
height={360}
loadingMore={loading}
onEndReached={loadMore}
getItemKey={(o) => o.id}
labels={{ loadingMore: 'Buyurtmalar yuklanmoqda...' }}
className="rounded-xl border border-border bg-surface"
renderItem={(o) => (
<div className="flex h-13 items-center gap-3 border-b border-border px-4 text-sm">
<span className="w-14 shrink-0 text-text-faint tabular-nums">№{o.id}</span>
<span className="min-w-0 flex-1 truncate">{o.customer}</span>
<span className="shrink-0 font-medium tabular-nums">{formatMoney(o.total)}</span>
</div>
)}
/>
</div>
);
}
Empty state
When items is empty, emptyState is shown (default: "Ro'yxat bo'sh", "the list is empty").
'use client';
import { VirtualList } from '@akhatmirzo/ui';
export default function VirtualListEmpty() {
return (
<VirtualList
aria-label="Bildirishnomalar"
items={[]}
estimateSize={48}
height={160}
renderItem={() => null}
emptyState="Hali bildirishnomalar yo'q"
className="w-full max-w-md rounded-xl border border-border bg-surface"
/>
);
}
Props
Accepts the remaining <div> attributes (applied to the scroll container). aria-label /
aria-labelledby go to the inner list element (role="list").
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | readonly T[] | — | The items |
renderItemrequired | (item: T, index: number) => ReactNode | — | Renders one row |
estimateSizerequired | number | ((index: number) => number) | — | Estimated row height in px |
heightrequired | number | string | — | Container height: px or a CSS value ("60vh") |
overscan | number | 5 | Extra rows rendered outside the viewport |
dynamic | boolean | false | Rows have different heights — measure them |
getItemKey | (item: T, index: number) => string | number | — | Row key (defaults to the index) |
gap | number | 0 | Space between rows in px |
onEndReached | () => void | — | Near the end of the list (once per length) |
endReachedThreshold | number | 5 | How many rows before the end |
loadingMore | boolean | false | Spinner row at the bottom; onEndReached won't fire meanwhile |
emptyState | ReactNode | — | Shown when the list is empty |
onScroll | ComponentProps<'div'>['onScroll'] | — | |
labels | { loadingMore?: string; empty?: ReactNode } | { loadingMore: 'Yuklanmoqda...', empty: "Ro'yxat bo'sh" } | |
listClassName | string | — | Class for the role="list" element |
ref | Ref<VirtualListHandle> | — | Control handle; the DOM node is handle.element |
VirtualListHandle
| Prop | Type | Default | Description |
|---|---|---|---|
element | HTMLDivElement | 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 rowrole="listitem", witharia-setsize(total) andaria-posinset(position) — screen readers announce "item 5,000 of 10,000" even though only 20 rows are in the DOM. - Name it: pass
aria-labeloraria-labelledby; it goes to the list. - Loading: while
loadingMoreis on, the list hasaria-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.