本文へ移動
uiify

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
User 2
Viewer
User 3
Editor
User 4
Admin
User 5
Editor
User 6
Viewer
User 7
Admin
User 8
Viewer
User 9
Editor
User 10
Admin
User 11
Editor
User 12
Viewer
User 13
Admin
User 14
Viewer
User 15
Editor
User 16
Admin
User 17
Editor
User 18
Viewer

Horizontal

The same users as a horizontal strip with orientation="horizontal". rowHeight sets each card's width; the window follows the viewport width.

User 1
Admin
User 2
Viewer
User 3
Editor
User 4
Admin
User 5
Editor
User 6
Viewer
User 7
Admin
User 8
Viewer
User 9
Editor
User 10
Admin
User 11
Editor
User 12
Viewer
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行の内容を描画します。
heightnumber—ピクセル単位の明示的な高さ。省略してstyle={{ blockSize: '100%' }}を渡すと、高さが確定した親を満たします。どちらも指定しなければ既定は600pxです。
rowHeightnumber64スクロール軸上の各行の固定ピクセルサイズ(縦方向では高さ、横方向では幅)。スクロール領域のサイズと表示行の計算に使います。
orientation"vertical" | "horizontal""vertical"スクロール軸。横方向のリストはインライン軸でスクロールし、実測したビューポート幅で表示範囲を決め、右から左のレイアウトでも正しく反転し、値をdata-orientationにも反映します。
overscannumber5高速スクロールで空白が見えないよう、表示範囲の前後に追加で描画する行数。
estimateRowHeight(index: number) => number—インデックスごとの推定高さ。リストを動的レイアウトに切り替え、描画した行を実測して推定を補正します。
role"list" | "grid""list"スクロールコンテナーに適用するARIAロール。
anchor"start" | "end""start"項目の追加・削除時に、スクロール位置をリストのどちらの端に固定するか。
followAppendboolean | "smooth"falseリストがすでに末尾にある場合、新しく追加した項目へスクロールします。任意でスムーズスクロールを使えます。
scrollRefRef<VirtualScrollHandle>—scrollToIndex、scrollToOffset、scrollToEndを公開する命令的ハンドルを受け取ります。
onRangeChange(range: VirtualScrollRangeChange) => void—描画範囲が移動するたび、表示範囲とオーバースキャン範囲のインデックスで呼び出します。
onScrollStateChange(state: VirtualScrollScrollState) => void—スクロール開始時と停止確定時に呼び出します。
isScrollingResetDelaynumber150scrollendイベントがないブラウザーで、スクロールの停止と判断するまでの無操作時間(ミリ秒)。
classNamestring—スクロールコンテナーに適用するクラス。
testIdstring—スクロールコンテナーの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"
このページは機械翻訳によるもので、レビューは未実施です。