‮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

‮Prop‬‮Type‬‮Default‬‮Description‬
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.‬
rowHeightnumber64‮Fixed 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.‬
overscannumber5‮Number 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"false‮Scrolls 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.‬
isScrollingResetDelaynumber150‮Milliseconds 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‬

‮Key‬‮Action‬
Scroll‮Wheel, trackpad, or scrollbar moves the window‬
Tab‮Move focus between row controls (checkbox, menu trigger)‬
Arrow keys‮Rove items inside an open dropdown menu‬
Escape‮Close 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"