Skip to main content
On this pageOverview

VirtualList

Overview

A virtualization primitive for large lists. Only items inside the viewport plus an overscan buffer are mounted. Spacer divs above and below the visible slice keep the scrollbar physically correct. The demo below manages ten thousand items; only the rows currently visible exist in the DOM.

See it in an app

Check out how VirtualList is wired up in a real Foldkit app.

Example

Items live in your Model, not the component, and pass through ViewConfig.items on each render. The parent owns the data and can swap, filter, sort, or paginate freely without sending Messages to the list. Each item must be keyed via itemToKey so the VDOM matches rows by data identity, not by position, when the visible slice shifts.

10,000 activity events
  • S
    Sarah Chen merged PR #1
    1m ago
  • M
    Marcus Davies opened issue #14
    2h ago
  • P
    Priya Patel commented on PR #27
    5h ago
  • A
    Alex Kim approved PR #40
    7h ago
  • J
    Jordan Lee closed issue #53
    9h ago
  • S
    Sam Rivera reopened issue #66
    12h ago
  • B
    Ben Carter requested review on PR #79
    14h ago
  • M
    Mira Patel pushed to fix/dialog-focus
    16h ago
  • L
    Lucy Hong merged PR #105
    18h ago
  • C
    Casey Park opened issue #118
    21h ago
  • R
    Robin Adams commented on PR #131
    23h ago
  • T
    Tomás Reyes approved PR #144
    1d ago
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt. Fit them into your own Model, init, Message,
// update, view, and subscription definitions.
import { Effect, Schema as S, Stream } from 'effect'
import { Command, Subscription, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Class, div, span } from './html'

// Add a field to your Model for the VirtualList Submodel. The list items
// stay in your domain Model (your own `activities`, `messages`, `rows`,
// whatever you call them); only scroll and measurement state live here:
const Model = S.Struct({
  activityList: Ui.VirtualList.Model,
  // ...your other fields, including the items array you want to render
})

// In your init function, give the list a unique id and a row height in
// pixels. All rows share this height:
const init = () => [
  {
    activityList: Ui.VirtualList.init({
      id: 'activity-list',
      rowHeightPx: 56,
    }),
    // ...your other fields
  },
  [],
]

// Embed the VirtualList Message in your parent Message:
const GotActivityListMessage = m('GotActivityListMessage', {
  message: Ui.VirtualList.Message,
})

// Inside your update function's M.tagsExhaustive({...}), delegate to
// VirtualList.update:
GotActivityListMessage: ({ message }) => {
  const [nextList, commands] = Ui.VirtualList.update(
    model.activityList,
    message,
  )

  return [
    evo(model, { activityList: () => nextList }),
    commands.map(
      Command.mapEffect(
        Effect.map(message => GotActivityListMessage({ message })),
      ),
    ),
  ]
}

// Wire the VirtualList container subscription into your app's
// SubscriptionDeps and subscriptions. This powers scroll tracking and
// container resize observation:
const virtualListFields = Ui.VirtualList.SubscriptionDeps.fields

const SubscriptionDeps = S.Struct({
  activityListEvents: virtualListFields['containerEvents'],
  // ...your other subscription deps
})

const virtualListSubscriptions = Ui.VirtualList.subscriptions

const subscriptions = Subscription.makeSubscriptions(SubscriptionDeps)<
  Model,
  Message
>({
  activityListEvents: {
    modelToDependencies: model =>
      virtualListSubscriptions.containerEvents.modelToDependencies(
        model.activityList,
      ),
    dependenciesToStream: (dependencies, readDependencies) =>
      virtualListSubscriptions.containerEvents
        .dependenciesToStream(dependencies, readDependencies)
        .pipe(Stream.map(message => GotActivityListMessage({ message }))),
  },
})

// Inside your view, render the list. Pass `items` from your Model, key
// each row by a stable identifier (the data id, not its array position),
// and give the container a fixed height. Note `h-96` below: without a
// fixed height the container grows to fit its children and never scrolls.
// The component sets only `overflow: auto` inline; everything else is
// yours:
Ui.VirtualList.view({
  model: model.activityList,
  items: model.activities,
  itemToKey: activity => String(activity.id),
  itemToView: activity =>
    div(
      [Class('grid grid-cols-[2rem_1fr_5rem] items-center gap-3 px-4')],
      [
        div(
          [Class('flex h-7 w-7 items-center justify-center rounded-full')],
          [activity.initial],
        ),
        span([Class('truncate text-sm')], [activity.label]),
        span(
          [Class('text-right text-xs text-gray-500 tabular-nums')],
          [activity.timeAgo],
        ),
      ],
    ),
  className: 'h-96 w-full rounded-lg bg-white ring-1 ring-gray-200',
})

// Programmatic scrolling. Returns [Model, Commands] in the same shape as
// update. Stale completions are version-cancelled, so rapid successive
// calls do not fight each other:
const [nextList, commands] = Ui.VirtualList.scrollToIndex(
  model.activityList,
  500,
)

Subscriptions

VirtualList exposes a single subscription, containerEvents, that listens for scroll events on the container and observes its size with ResizeObserver. Wire it into your app's subscriptions alongside the rest of the framework subscriptions.

Styling

The container needs a constrained height for virtualization to work. Without it, the container grows to fit children and never scrolls. Pass className or attributes on ViewConfig to apply the height through your styling system. The component sets only overflow: auto inline; the rest is yours.

VirtualList exposes two data attributes for styling and test selectors: data-virtual-list-id on the scrollable container and data-virtual-list-item-index on each rendered row.

AttributeCondition
data-virtual-list-idPresent on the scrollable container. Carries the id from InitConfig so subscriptions and tests can find the right element.
data-virtual-list-item-indexPresent on each rendered row wrapper. Carries the data index of the item being rendered (0-based) so tests and consumer styling can address a specific row.

Accessibility

The container is rendered as <ul> and each row as <li>. The top and bottom spacer <li> elements carry role="presentation" so they do not contribute to the list. Each rendered row carries aria-setsize (total item count) and aria-posinset (1-based logical position), so screen readers announce "row 5,234 of 10,000" rather than the much smaller count of mounted rows. No consumer wiring required.

API Reference

InitConfig

Configuration object passed to VirtualList.init().

NameTypeDefaultDescription
idstring-Unique ID for the virtual list instance. Applied to the scrollable container and used by the subscription to attach scroll and resize listeners.
rowHeightPxnumber-Height in pixels of every row. All rows share this height; the value drives spacer math, slice math, and the inline height on row wrappers.
initialScrollTopnumber0Initial scroll position in pixels. When non-zero, the first MeasuredContainer message issues an apply-scroll Command so the DOM and model agree from the first frame.

ViewConfig

Configuration object passed to VirtualList.view().

NameTypeDefaultDescription
modelVirtualList.Model-The virtual list state from your parent Model.
itemsReadonlyArray<Item>-The full item array. Items live in your Model, not the component's; pass them fresh on each render. Swap, filter, sort, or paginate freely without sending Messages to the list.
itemToKey(item: Item, index: number) => string-Returns a stable identifier for an item. Used to key rendered rows so the VDOM matches by data identity rather than by position when the visible slice shifts.
itemToView(item: Item, index: number) => Html-Renders one row's contents. The framework wraps your output in a row-height grid container; use flex or grid with align-items: center inside to vertically center your content.
overscannumber5Number of rows mounted above and below the visible viewport. Higher values smooth out fast scroll at the cost of mounting more DOM. react-window uses 1 and react-virtualized uses 3; pick a value that suits the row mount cost.
rowElementTagName'li'HTML tag for each row wrapper. Defaults to li (since the container is rendered as ul). Override only when you also wrap the list in something whose children aren't expected to be li.
classNamestring-CSS class applied to the scrollable container. The container needs a constrained height (e.g. h-96) for virtualization to work.
attributesReadonlyArray<Attribute<Message>>-Additional attributes spread onto the scrollable container. Pass extra Style({...}) entries for CSS like overscroll-behavior or scroll-margin, data attributes, or any other Attribute<Message>.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson