Virtual scroll
表示範囲を限定したリスト。1万行を約25個の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.
User 1
Admin
View profile
Edit role
Remove
User 2
Viewer
View profile
Edit role
Remove
User 3
Editor
View profile
Edit role
Remove
User 4
Admin
View profile
Edit role
Remove
User 5
Editor
View profile
Edit role
Remove
User 6
Viewer
View profile
Edit role
Remove
User 7
Admin
View profile
Edit role
Remove
User 8
Viewer
View profile
Edit role
Remove
User 9
Editor
View profile
Edit role
Remove
User 10
Admin
View profile
Edit role
Remove
User 11
Editor
View profile
Edit role
Remove
User 12
Viewer
View profile
Edit role
Remove
User 13
Admin
View profile
Edit role
Remove
User 14
Viewer
View profile
Edit role
Remove
User 15
Editor
View profile
Edit role
Remove
User 16
Admin
View profile
Edit role
Remove
User 17
Editor
View profile
Edit role
Remove
User 18
Viewer
View profile
Edit role
Remove
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
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
items* | T[] | — | 各項目が安定したIDを持つ、全項目のリスト。現在の範囲内の行だけを描画します。 |
renderRow* | (item: T, index: number) => ReactNode | — | 項目と絶対インデックスから1行の内容を描画します。 |
height | number | — | ピクセル単位の明示的な高さ。省略してstyle={{ blockSize: '100%' }}を渡すと、高さが確定した親を満たします。どちらも指定しなければ既定は600pxです。 |
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="list"のスクロール領域内で、role="listitem"、aria-posinset、aria-setsizeを公開します。スクロール同期はパッシブで、スクロールバー、Home/End、Cmd+Arrowなどによる大きな移動では、範囲を同期的に更新します。
スタイリングのフック
data-uiify-virtual-scrolldata-part="spacer"data-part="row"