Components

Virtual List

A long list that renders only the rows near the viewport, in islands: a <ul> whose rows are <li>s placed by createVirtualizer, each keyed by itemKey and carrying aria-posinset and aria-setsize. The children function renders a row's content once; a row kept under its key keeps its DOM and state while its item updates in place. The server renders the first initial rows in flow. 2.2 kB brotli with the virtualizer.

  • 1. Person 1
  • 2. Person 2
  • 3. Person 3
  • 4. Person 4
  • 5. Person 5
  • 6. Person 6
  • 7. Person 7
  • 8. Person 8
  • 9. Person 9
  • 10. Person 10
  • 11. Person 11
  • 12. Person 12
  • 13. Person 13
  • 14. Person 14
  • 15. Person 15
  • 16. Person 16
  • 17. Person 17
  • 18. Person 18
  • 19. Person 19
  • 20. Person 20
A live demo, with the default theme. Its code
ts
import { VirtualList } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"

#Anatomy

tsx
<VirtualList items={people()} itemKey={(person) => person.id} size={40}>
  {(row) => <a href={`/people/${row.item.id}`}>{row.item.name}</a>}
</VirtualList>
Part Renders
VirtualList <ul>, total() tall and position: relative once mounted; an <li data-row aria-posinset aria-setsize> per row

#API

#VirtualList

Prop Type Default
items readonly T[] The items.
itemKey (item: T) => string | number The item's identity, such as its record's id: a row stays while its key does.
children (row: VirtualItem<T>) => JSX.Element A row's content, rendered once per row. row.item and row.index are getters: read them where they are used.
size number | (row: number) => number A row's height in px: fixed (the <li> gets it), or with measure the estimate.
measure boolean false Measures the rendered rows (one ResizeObserver): their heights replace size.
gap number 0 The px between rows.
overscan number 4 Rows rendered past each edge of the viewport.
scroller () => Element | null | undefined window The element that scrolls.
initial number 0 The rows rendered before mount: the server's HTML.
keepFocused boolean true Keeps the row that holds focus rendered while the list scrolls away from it.
itemProps (item: T, index: number) => li props Attributes of each <li>: data attributes, a title. Not style, which places the row.
virtualizer (virtualizer: Virtualizer) => void Receives the virtualizer, for scrollToIndex() and range().

Every other <ul> prop passes through: class, aria-label, aria-labelledby, id. A style object applies too, with the list's own properties (its padding, position and height) over it.

#Example

A thousand people in a scroll box, the first 20 in the server's HTML:

Live at the top of the page: an island
tsx
import { VirtualList, type Virtualizer } from "@lumiose/ui";

const PEOPLE = Array.from({ length: 1000 }, (_, i) => ({
  id: `p${i + 1}`,
  name: `Person ${i + 1}`,
}));

export function PeopleList() {
  let box: HTMLDivElement | undefined;
  let list: Virtualizer | undefined;
  return (
    <>
      <button type="button" onClick={() => list?.scrollToIndex(499, { align: "start" })}>
        Go to person 500
      </button>
      <div
        ref={(el) => (box = el)}
        role="region"
        aria-label="People"
        tabindex={0}
        style={{ "inline-size": "100%", "max-block-size": "16rem", overflow: "auto" }}
      >
        <VirtualList
          aria-label="People"
          style={{ margin: "0", padding: "0", "list-style": "none" }}
          items={PEOPLE}
          itemKey={(person) => person.id}
          size={40}
          initial={20}
          scroller={() => box}
          virtualizer={(v) => (list = v)}
        >
          {(row) => (
            <span>
              {row.index + 1}. {row.item.name}
            </span>
          )}
        </VirtualList>
      </div>
    </>
  );
}

#Styling

Unstyled. Before mount the rows are in flow (each size tall, the rest of the list's height a padding-block-end), so the server's list already has its full height. Once mounted the <ul> gets data-mounted, position: relative and its total height, and each <li> position: absolute, inset: 0 0 auto and a transform. Style with class on the list, [data-mounted], and the rows through the list's class: they are the component's elements, so a scoped selector reaches them with :deep(), as in .people :deep(li). The content you render is yours to style.

#Keyboard and focus

The rows are in DOM order, so Tab moves through them, and the browser scrolls the next one into view. A focused row stays rendered while the list scrolls away from it (keepFocused, on by default for placed rows, decision-84), so focus never falls back to the page. Rows are keyed by the record: a row's focus, typed text or toggled state stays with its record when items are added, removed, reordered or fetched again.

#Without JavaScript

The server renders the first initial rows, in flow, with the list's full height reserved. See Virtualizer for the takeover and for lists that must be complete in the HTML.

#Browser support

ResizeObserver (for measure): every supported browser.