On this pageOverview
VirtualList
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.
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.
- SSarah Chen merged PR #11m ago
- MMarcus Davies opened issue #142h ago
- PPriya Patel commented on PR #275h ago
- AAlex Kim approved PR #407h ago
- JJordan Lee closed issue #539h ago
- SSam Rivera reopened issue #6612h ago
- BBen Carter requested review on PR #7914h ago
- MMira Patel pushed to fix/dialog-focus16h ago
- LLucy Hong merged PR #10518h ago
- CCasey Park opened issue #11821h ago
- RRobin Adams commented on PR #13123h ago
- TTomás Reyes approved PR #1441d 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,
)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.
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.
| Attribute | Condition |
|---|---|
data-virtual-list-id | Present on the scrollable container. Carries the id from InitConfig so subscriptions and tests can find the right element. |
data-virtual-list-item-index | Present 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. |
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.
Configuration object passed to VirtualList.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the virtual list instance. Applied to the scrollable container and used by the subscription to attach scroll and resize listeners. |
rowHeightPx | number | - | 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. |
initialScrollTop | number | 0 | Initial 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. |
Configuration object passed to VirtualList.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | VirtualList.Model | - | The virtual list state from your parent Model. |
items | ReadonlyArray<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. |
overscan | number | 5 | Number 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. |
rowElement | TagName | '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. |
className | string | - | CSS class applied to the scrollable container. The container needs a constrained height (e.g. h-96) for virtualization to work. |
attributes | ReadonlyArray<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>. |