Virtual scroll
قائمة تعرض نافذة من عناصرها — عشرة آلاف صف بنحو خمس وعشرين عقدة DOM.
- المتصفحات
- Chrome 123+Firefox 121+Safari 17.5+
- الفئة
- 2
- الحجم المضغوط
- 4.16 kB
- ميزانية التخطيط القسري
- 0 ms
- ميزانية axe
- 0
- متوافق مع SSR
- نعم
- العقد المنشور
- بيئة الاختبار
المختبر
Virtual scroll
10,000 team members with checkbox, avatar, and dropdown actions. Only visible rows (+ buffer) mount in the DOM — open DevTools and count [data-uiify-virtual-scroll] [data-part="row"] nodes while scrolling.
Horizontal
The same users as a horizontal strip with orientation="horizontal". rowHeight sets each card's width; the window follows the viewport width.
import { VirtualScroll } from "@gunkin/uiify";
const items = Array.from({ length: 10000 }, (_, index) => ({
id: String(index),
label: "Row " + (index + 1),
}));
export default function Example() {
return (
<>
<VirtualScroll className="" height={400} items={items} renderRow={(item) => <div>{item.label}</div>} />
{/* rowHeight is the item size along the scroll axis — here, each card's width. */}
<VirtualScroll
className=""
height={120}
items={items}
orientation="horizontal"
rowHeight={144}
renderRow={(item) => <div>{item.label}</div>}
/>
</>
);
}/* Generic skin for custom core surfaces used by the demo and consumers. */
@layer components {
[data-uiify-surface] {
display: grid;
gap: var(--space-md);
inline-size: max-content;
max-inline-size: min(28rem, calc(100vw - 2 * var(--space-lg)));
padding: var(--space-lg);
border: var(--border-width) solid var(--border);
border-radius: var(--radius-xl);
corner-shape: squircle;
background-color: var(--card);
color: var(--card-foreground);
box-shadow: var(--shadow-popover);
}
[data-uiify-surface][popover]:not(:popover-open) {
display: none;
}
@media (prefers-reduced-motion: no-preference) {
[data-uiify-surface][popover] {
transition: var(--transition-presence);
}
[data-uiify-surface][popover]:not(:popover-open) {
opacity: 0;
scale: var(--presence-scale);
}
@starting-style {
[data-uiify-surface][popover]:popover-open {
opacity: 0;
scale: var(--presence-scale);
}
}
}
}import { VirtualScroll } from "@gunkin/uiify";
const items = Array.from({ length: 10000 }, (_, index) => ({
id: String(index),
label: "Row " + (index + 1),
}));
export default function Example() {
return (
<>
<VirtualScroll
className="
grid gap-md w-max max-w-[min(28rem,calc(100vw_-_2_*_var(--space-lg)))] p-6 border border-border rounded-xl
[corner-shape:squircle] bg-card text-card-foreground shadow-popover [&[popover]:not(:popover-open)]:hidden
motion-safe:[&[popover]]:[transition:var(--transition-presence)]
motion-safe:[&[popover]:not(:popover-open)]:opacity-0
motion-safe:[&[popover]:not(:popover-open)]:[scale:var(--presence-scale)]
motion-safe:starting:[&[popover]:popover-open]:opacity-0
motion-safe:starting:[&[popover]:popover-open]:[scale:var(--presence-scale)]
"
height={400}
items={items}
renderRow={(item) => <div>{item.label}</div>}
/>
{/* rowHeight is the item size along the scroll axis — here, each card's width. */}
<VirtualScroll
className="
grid gap-md w-max max-w-[min(28rem,calc(100vw_-_2_*_var(--space-lg)))] p-6 border border-border rounded-xl
[corner-shape:squircle] bg-card text-card-foreground shadow-popover [&[popover]:not(:popover-open)]:hidden
motion-safe:[&[popover]]:[transition:var(--transition-presence)]
motion-safe:[&[popover]:not(:popover-open)]:opacity-0
motion-safe:[&[popover]:not(:popover-open)]:[scale:var(--presence-scale)]
motion-safe:starting:[&[popover]:popover-open]:opacity-0
motion-safe:starting:[&[popover]:popover-open]:[scale:var(--presence-scale)]
"
height={120}
items={items}
orientation="horizontal"
rowHeight={144}
renderRow={(item) => <div>{item.label}</div>}
/>
</>
);
}/* app.css */
@layer reset, ui, app, tokens, base, demo, components, utilities, theme;
@import "@gunkin/uiify/styles/reset";
@import "@gunkin/uiify/components/behavior.css";
@import "@gunkin/uiify/styles/tokens";
@import "@gunkin/uiify/styles/tailwind";
@import "tailwindcss/utilities.css";
@theme { --spacing: 0.25rem; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; }
@source inline('grid gap-md w-max max-w-[min(28rem,calc(100vw_-_2_*_var(--space-lg)))] p-6 border border-border rounded-xl [corner-shape:squircle] bg-card text-card-foreground shadow-popover [&[popover]:not(:popover-open)]:hidden motion-safe:[&[popover]]:[transition:var(--transition-presence)] motion-safe:[&[popover]:not(:popover-open)]:opacity-0 motion-safe:[&[popover]:not(:popover-open)]:[scale:var(--presence-scale)] motion-safe:starting:[&[popover]:popover-open]:opacity-0 motion-safe:starting:[&[popover]:popover-open]:[scale:var(--presence-scale)]');
@layer base { :root { --uiify-control-text: var(--text-sm); } @media (pointer: coarse) { :root { --uiify-control-text: var(--text-base); } } }
/* Authored animation and registered properties used by the Tailwind skin.
* Visual rules belong in utility recipes; only at-rule definitions live here. */
@layer components {
@keyframes uiify-presence-in {
from {
opacity: 0;
scale: var(--presence-scale);
}
}
}
@layer components {
@keyframes uiify-presence-out {
to {
opacity: 0;
scale: var(--presence-scale);
}
}
}
@property --uiify-spinner-tail {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@property --uiify-spinner-length {
syntax: "<angle>";
inherits: false;
initial-value: 36deg;
}
@layer components {
@keyframes uiify-spinner-rotate {
to {
transform: rotate(360deg);
}
}
}
@layer components {
@keyframes uiify-spinner-orbit {
0% {
--uiify-spinner-tail: 0deg;
--uiify-spinner-length: 36deg;
}
50% {
--uiify-spinner-tail: 90deg;
--uiify-spinner-length: 324deg;
}
100% {
--uiify-spinner-tail: 360deg;
--uiify-spinner-length: 36deg;
}
}
}
@layer components {
@keyframes uiify-skeleton-pulse {
50% {
opacity: 0.45;
}
}
}
@layer components {
@keyframes uiify-skeleton-shimmer {
to {
background-position: -200% 0;
}
}
}
@layer components {
@keyframes uiify-skeleton-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
@property --candle-body-height {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-body-top {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --candle-slot {
syntax: "<percentage>";
inherits: true;
initial-value: 100%;
}
@property --candle-wick-height {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-wick-top {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --candle-x {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --candle-scale {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@property --line-y {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --line-prev-y {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --line-count {
syntax: "<integer>";
inherits: false;
initial-value: 1;
}
@property --line-scale {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@layer components {
@keyframes uiify-otp-input-caret-blink {
50% {
opacity: 0;
}
}
}API
VirtualScroll
| الخاصية | النوع | افتراضي | الوصف |
|---|---|---|---|
items* | T[] | — | قائمة العناصر كاملة، ولكل عنصر معرّف ثابت؛ ولا تُعرض إلا الصفوف في النطاق الحالي. |
renderRow* | (item: T, index: number) => ReactNode | — | تعرض محتويات صف واحد بناءً على عنصره وفهرسه المطلق. |
height | number | — | ارتفاع صريح بالبكسل. احذفه ومرّر style={{ blockSize: '100%' }} لملء ارتفاع محدّد للأب؛ والافتراضي ستمئة بكسل إن لم يُمرّر أي منهما. |
rowHeight | number | 64 | الحجم الثابت بالبكسل لكل صف على محور التمرير (الارتفاع عموديًا، والعرض أفقيًا)، ويُستخدم لحساب حجم التمرير والصفوف المرئية. |
orientation | "vertical" | "horizontal" | "vertical" | محور التمرير. تُمرّر القوائم الأفقية على المحور السطري، وتضبط نافذتها بحسب عرض إطار العرض المقاس، وتنعكس صحيحًا في تخطيطات اليمين إلى اليسار، وتكتب القيمة أيضًا في data-orientation. |
overscan | number | 5 | عدد الصفوف الإضافية المعروضة أعلى النطاق المرئي وأسفله كي لا يظهر فراغ عند التمرير السريع. |
estimateRowHeight | (index: number) => number | — | تقدير ارتفاع لكل فهرس يحوّل القائمة إلى تخطيط ديناميكي، تُقاس فيه الصفوف المعروضة ويُصحّح التقدير. |
role | "list" | "grid" | "list" | دور ARIA المطبّق على حاوية التمرير. |
anchor | "start" | "end" | "start" | طرف القائمة الذي يظل موضع التمرير مثبتًا عنده عند إضافة عناصر أو حذفها. |
followAppend | boolean | "smooth" | false | يمرّر إلى العناصر المضافة في النهاية حين تكون القائمة عند نهايتها بالفعل، مع تمرير سلس اختياري. |
scrollRef | Ref<VirtualScrollHandle> | — | يتلقى المقبض البرمجي الذي يتيح scrollToIndex وscrollToOffset وscrollToEnd. |
onRangeChange | (range: VirtualScrollRangeChange) => void | — | تُستدعى بنطاقَي فهارس الجزء المرئي والجزء الإضافي كلما تحرّكت النافذة المعروضة. |
onScrollStateChange | (state: VirtualScrollScrollState) => void | — | تُستدعى عند بدء التمرير واستقراره. |
isScrollingResetDelay | number | 150 | مدة السكون بالمللي ثانية قبل اعتبار التمرير مستقرًا في المتصفحات التي لا تدعم حدث scrollend. |
className | string | — | الفئة المطبّقة على حاوية التمرير. |
testId | string | — | قيمة سمة data-testid لحاوية التمرير. |
لوحة المفاتيح
| المفتاح | الإجراء |
|---|---|
| Scroll | تحريك النافذة بعجلة الفأرة أو لوحة اللمس أو شريط التمرير |
| Tab | نقل التركيز بين عناصر تحكم الصف (مربع الاختيار وزر تفعيل القائمة) |
| Arrow keys | التنقل بين العناصر داخل قائمة منسدلة مفتوحة |
| Escape | إغلاق قائمة منسدلة مفتوحة |
إمكانية الوصول
يحافظ فاصل بارتفاع كامل على دقة شريط التمرير الأصلي، بينما لا تُثبّت إلا مجموعة ثابتة من خانات الصفوف، مع صفوف إضافية خارج الجزء المرئي؛ وتعيد مفاتيح الخانات الثابتة استخدام عقد DOM. تكشف الصفوف role="listitem" مع aria-posinset وaria-setsize داخل منفذ تمرير بدور role="list". تستخدم مزامنة التمرير مستمعًا بوضع passive، وتحدّث القفزات الكبيرة، مثل استخدام شريط التمرير أو Home/End أو Cmd+Arrow، النطاق تزامنيًا.
نقاط التنسيق
data-uiify-virtual-scrolldata-part="spacer"data-part="row"