Skip to content
uiify

Virtual scroll

Windowed list — 10k rows, ~25 DOM nodes.

Browsers
Chrome 123+Firefox 121+Safari 17.5+
Tier
2
Bundle compressed
4.16 kB
Forced layout budget
0 ms
Axe budget
0
SSR compatible
Yes
Published contract
Test harness

Playground

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

PropTypeDefaultDescription
items*T[]—The full list of items, each with a stable id; only the rows in the current range are rendered.
renderRow*(item: T, index: number) => ReactNode—Renders the contents of one row from its item and absolute index.
heightnumber—Explicit height in pixels. Omit it and pass style={{ blockSize: '100%' }} to fill a definite parent height; 600px is the default when neither is supplied.
rowHeightnumber64Fixed pixel size of each row on the scroll axis (height when vertical, width when horizontal), used to compute scroll size and visible rows.
orientation"vertical" | "horizontal""vertical"Scroll axis. Horizontal lists scroll on the inline axis, size the window from the measured viewport width, mirror correctly in right-to-left layouts, and mirror the value to data-orientation.
overscannumber5Number of extra rows rendered above and below the visible range so fast scrolling does not reveal blank space.
estimateRowHeight(index: number) => number—Per-index height estimate that switches the list to dynamic layout, where rendered rows are measured and the estimate is corrected.
role"list" | "grid""list"ARIA role applied to the scroll container.
anchor"start" | "end""start"Which end of the list the scroll position stays pinned to when items are added or removed.
followAppendboolean | "smooth"falseScrolls to newly appended items when the list is already at the end, optionally with smooth scrolling.
scrollRefRef<VirtualScrollHandle>—Receives the imperative handle exposing scrollToIndex, scrollToOffset and scrollToEnd.
onRangeChange(range: VirtualScrollRangeChange) => void—Called with the visible and overscanned index ranges whenever the rendered window moves.
onScrollStateChange(state: VirtualScrollScrollState) => void—Called when scrolling starts and when it settles.
isScrollingResetDelaynumber150Milliseconds of quiet before scrolling is treated as settled in browsers without the scrollend event.
classNamestring—Class applied to the scroll container.
testIdstring—Value for the scroll container's data-testid attribute.

Keyboard

KeyAction
ScrollWheel, trackpad, or scrollbar moves the window
TabMove focus between row controls (checkbox, menu trigger)
Arrow keysRove items inside an open dropdown menu
EscapeClose an open dropdown menu

Accessibility

A full-height spacer keeps the native scrollbar accurate while only a fixed pool of row slots, plus overscan, is mounted; stable slot keys recycle DOM nodes. Rows expose role="listitem" with aria-posinset and aria-setsize inside a role="list" scrollport. Scroll sync is passive, and large jumps such as the scrollbar, Home/End or Cmd+Arrow update the range synchronously.

Styling hooks

data-uiify-virtual-scrolldata-part="spacer"data-part="row"