Kebechet.Blazor.TanStack.VirtualCore 3.19.11

Prefix Reserved
dotnet add package Kebechet.Blazor.TanStack.VirtualCore --version 3.19.11
                    
NuGet\Install-Package Kebechet.Blazor.TanStack.VirtualCore -Version 3.19.11
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Kebechet.Blazor.TanStack.VirtualCore" Version="3.19.11" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Kebechet.Blazor.TanStack.VirtualCore" Version="3.19.11" />
                    
Directory.Packages.props
<PackageReference Include="Kebechet.Blazor.TanStack.VirtualCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Kebechet.Blazor.TanStack.VirtualCore --version 3.19.11
                    
#r "nuget: Kebechet.Blazor.TanStack.VirtualCore, 3.19.11"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Kebechet.Blazor.TanStack.VirtualCore@3.19.11
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Kebechet.Blazor.TanStack.VirtualCore&version=3.19.11
                    
Install as a Cake Addin
#tool nuget:?package=Kebechet.Blazor.TanStack.VirtualCore&version=3.19.11
                    
Install as a Cake Tool

"Buy Me A Coffee"

Blazor.TanStack.VirtualCore

NuGet Version NuGet Downloads Build codecov Storybook Last updated Twitter

Virtualization for Blazor lists whose items are genuinely different sizes, powered by @tanstack/virtual-core.

Every item is measured individually. Blazor keeps ownership of rendering - the JS side only answers "which indices are visible, and at what offsets", and the component renders that slice. Vertical, horizontal and multi-lane (masonry) layouts are all supported.

The virtual-core build is vendored and pinned inside the package, so there is no npm step, no CDN, and no <script> tag.

Live storybook - interactive stories for every feature.

Why not the built-in Virtualize?

The framework's Virtualize assumes one fixed ItemSize. .NET 11 adds variable-height support (dotnet/aspnetcore#65158), and for most lists that will be the right choice - it is in the box and needs no JS.

Reach for this package when the approximation is the problem:

Built-in Virtualize (.NET 11) This package
Row sizing weighted running average, no per-item storage exact measurement, cached per item key
Cache across item-set changes re-measured preserved per key, so a filter change keeps known heights
Content resize at a stable key manual RefreshDataAsync() targeted InvalidateItemSize(key)

If your rows differ by a little, take the built-in one. If a 60px row sits next to a 400px expanded one, an average places both wrong and the list visibly snaps as measurements arrive.

Installation

dotnet add package Kebechet.Blazor.TanStack.VirtualCore

No service registration is needed - the component imports its own interop module.

Usage

The component fills its nearest scrollable ancestor, which it finds by walking up for a computed overflow-y of auto or scroll, falling back to the document scroller.

@using Kebechet.Blazor.TanStack.VirtualCore

<div style="height: 100vh; overflow-y: auto;">
    <AdaptiveVirtualize TItem="Exercise"
                        Items="_exercises"
                        ItemKeyExtractor="@(x => x.Id.ToString())"
                        ItemSizeEstimator="@(x => x.IsExpanded ? 240 : 72)"
                        Options="_options">
        <ItemContent>
            <ExerciseRow Exercise="context" />
        </ItemContent>
    </AdaptiveVirtualize>
</div>

@code {
    private readonly VirtualizeOptions _options = new() { Overscan = 5 };
}

ItemKeyExtractor is load-bearing. It is both Blazor's @key and the measurement-cache key, so it must be stable for an item across renders and unique between items - otherwise measurements attach to the wrong item.

Options carries the whole upstream surface and is reactive: assigning a new instance pushes the changed values to the live virtualizer. Mutating the existing instance in place does nothing, because the component compares references.

Layouts

// A horizontal card rail
new VirtualizeOptions { IsHorizontal = true, Gap = 8 }

// A three-column masonry grid, balanced by measured size
new VirtualizeOptions { Lanes = 3, LaneAssignmentMode = LaneAssignmentMode.Measured, Gap = 10 }

// A log tail that follows new entries
new VirtualizeOptions { FollowOnAppend = FollowOnAppend.Instant, AnchorTo = ScrollAnchor.End }

// Virtualize against the document rather than a scrollable ancestor
new VirtualizeOptions { UseWindowScroller = true }

In a multi-lane layout each placement reports the Lane it landed in, and the component turns that into the item's cross-axis position and size - so your ItemContent only owns its own inner spacing. An outer margin would fight the computed lane width.

Keeping items rendered off-screen

new VirtualizeOptions { AlwaysRenderedIndexes = [0, 1] }

Those indices stay in the DOM however far away you scroll - a sticky header, a pinned selection, a row being dragged. This is upstream's rangeExtractor, reshaped: a JS predicate would cost an interop round-trip on every range computation, so the wrapper takes the indices and compiles the extractor itself.

Invalidating one row

When a row's content changes shape but its key does not - a group toggling collapsed to expanded - the preserved measurement is stale:

private AdaptiveVirtualize<Exercise>? _virtualize;

private async Task ToggleGroup(Exercise exercise)
{
    exercise.IsExpanded = !exercise.IsExpanded;
    await _virtualize!.InvalidateItemSize(exercise.Id.ToString());
}

Scrolling and inspecting

await _virtualize.ScrollToIndex(42, ScrollAlignment.Center, ScrollBehavior.Smooth);
await _virtualize.ScrollToOffset(2000);
await _virtualize.ScrollToEnd(ScrollBehavior.Instant);
await _virtualize.ScrollBy(300);
await _virtualize.ScrollToTop();

// The user started scrolling themselves: stop the scroll above from re-aiming at its target
await _virtualize.CancelScroll();

// Override a size directly, without waiting for the DOM to be measured
await _virtualize.ResizeItem(index: 0, size: 200);

// Live state: offset, total size, isScrolling, isAtEnd, direction, rendered range
var snapshot = await _virtualize.GetSnapshot();

// Save measurements, then restore them on a later mount to skip re-measuring
var measurements = await _virtualize.TakeSnapshot();

These are safe to fire off without awaiting while the component may be going away. Called after disposal they do nothing, and one still in flight when the component is disposed completes without throwing: a query returns its default, and the failure is logged - at Debug when the JS side simply went away.

Two-phase rendering

Directional overscan

Overscan buffers both sides equally. OverscanLeading / OverscanTrailing spend more of that budget on the side the user is scrolling towards, which is where the next items are actually needed:

private readonly VirtualizeOptions _options = new()
{
    Overscan = 5,          // the fallback for either side
    OverscanLeading = 10,  // ahead of travel
    OverscanTrailing = 5,  // behind travel - the floor a reversal starts from
};

The leading side follows the virtualizer's scroll direction and flips when the user reverses; a list that has never scrolled is treated as scrolling forward. Biasing toward travel is the default in react-window's _getRangeToRender, react-virtualized's defaultOverscanIndicesGetter and Angular CDK's FixedSizeVirtualScrollStrategy.

⚠️ OverscanTrailing is held at all times, not only while scrolling - which is where this deliberately parts company with react-window. React re-renders when its isScrolling state flips, so its range genuinely recomputes at rest. virtual-core memoizes getVirtualIndexes on [rangeExtractor, overscan, count, startIndex, endIndex, lanes], and isScrolling is not among them, so an extractor that read it would keep returning the scrolling-time answer until the visible range moved again - the bias would outlive the scroll rather than lapse with it.

Holding the trailing side instead means a reversal is never worse than a symmetric window of the same size. Leave OverscanTrailing at whatever Overscan you would otherwise have used and treat OverscanLeading as the addition. Raise it further when unmounting an item is expensive - a row owning a <video>, say - since those items are already mounted and holding them costs far less than rebuilding them.

OnLayoutMeasured fires after each measurement pass. Use it when extra content must be appended only once the preceding row's real height is known, so the new rows land correctly on first paint instead of estimating and snapping.

It is a plain Func<Task> rather than an EventCallback on purpose: EventCallback calls StateHasChanged on the receiver after the handler returns, which on a page with a debounced search input re-renders the bound input with a stale value and clobbers in-flight keystrokes.

Coverage vs. @tanstack/virtual-core 3.19.11

Complete. Every VirtualizerOptions member is reachable, and every Virtualizer member that means something to a .NET caller is surfaced.

Axis virtual-core This package
VirtualizerOptions members 36 36

Set through Options (25): overscan, horizontal, lanes, laneAssignmentMode, gap, paddingStart, paddingEnd, scrollPaddingStart, scrollPaddingEnd, scrollMargin, initialOffset, initialRect, initialMeasurementsCache, anchorTo, followOnAppend, scrollEndThreshold, isScrollingResetDelay, useScrollendEvent, isRtl, enabled, useAnimationFrameWithResizeObserver, useCachedMeasurements, indexAttribute, debug, plus rangeExtractor reshaped as AlwaysRenderedIndexes and OverscanLeading / OverscanTrailing.

Supplied by the component (11), because virtual-core requires them as functions called on every scroll frame - marshalling them to .NET would mean an interop round-trip per frame: count (from Items), estimateSize (ItemSizeEstimator / EstimatedItemSize), getItemKey (ItemKeyExtractor), measureElement, onChange, and the scroll-source trio getScrollElement / observeElementOffset / observeElementRect / scrollToFn - which UseWindowScroller switches between the element and window variants (observeWindowOffset, observeWindowRect, windowScroll).

Methods: ScrollToIndex, ScrollToOffset, ScrollToEnd, ScrollBy, ScrollToTop, CancelScroll, ResizeItem, Measure, GetTotalSize, GetVirtualIndexes, GetOffsetForIndex, GetVirtualItemForOffset, GetDistanceFromEnd, GetSnapshot (scroll offset, total size, isScrolling, isAtEnd, direction and the rendered range), TakeSnapshot, plus InvalidateItemSize - which has no upstream equivalent; it measures a rendered item again by reading it, and evicts any other item's key from the measurement cache.

Deliberately not surfaced: virtual-core's internal plumbing - elementsCache, targetWindow, updateDeps, calculateRange, indexFromElement, shouldAdjustScrollPositionOnItemSizeChange, scrollAdjustments, setOptions (driven by the Options parameter) and the raw options / measurementsCache / itemSizeCache fields, which GetSnapshot and TakeSnapshot expose in a typed form instead.

⚠️ isAtEnd is a method upstream, not a property ((threshold?: number) => boolean). Reading it as a property yields the function object, which is never === true - so a snapshot would report "not at end" forever. Pinned by ScrollToEnd_ReachesTheEndOfTheList.

Coverage is measured against virtual-core's published dist/esm/index.d.ts - the VirtualizerOptions interface and the Virtualizer class - excluding underscore-prefixed internals.

A note on upstream internals

The interop calls the underscore-prefixed _willUpdate() and _didMount() (the contract every upstream framework adapter uses), and reads and writes itemSizeCache directly (declared public in the .d.ts, but paired with a private itemSizeCacheVersion and a private pendingMin that it has to bump and reset with it). They are what make measurement-cache preservation across an item-set change possible, and virtual-core exposes no public equivalent: a public way to forget or set one item's size is proposed in https://github.com/TanStack/virtual/discussions/1362.

This is a real coupling: a virtual-core release could change them without it being a documented breaking change. It is why the package pins an exact version rather than floating, and why every version bump re-verifies those three members before shipping.

Stopgaps for upstream fixes

Behaviour that belongs in virtual-core is carried here only until the upstream fix ships, and listed here with the upstream PR and the test that pins it. There are none in this release: the three that 3.19.8 carried - the Android touch-fling stop (https://github.com/TanStack/virtual/pull/1347), the negated IsRtl scroll write (https://github.com/TanStack/virtual/pull/1291) and the measurement refresh before getOffsetForIndex (https://github.com/TanStack/virtual/pull/1363) - shipped in virtual-core 3.19.9 and 3.19.11. Their tests stay, and now pin virtual-core's own behaviour.

⚠️ Known virtual-core issue, carried without a stopgap: on a touchscreen laptop, a scroll command issued mid-fling can be lost. With a classic scrollbar, one that takes layout space, a ScrollToIndex, ScrollToOffset or ScrollToEnd issued while a touch fling is still moving the list is dropped when the fling moves on after it and then stops - at the end of the list, or fading out. virtual-core cannot stop that fling, because hiding the scrollbar would reflow the list, so it holds the command until the fling ends. It then reads the fling's last speed as a finger having caught the fling and drops the command - a check written for iOS, where a finger that catches a fling fires no touch event; elsewhere it does. Phones are not affected: their overlay scrollbars let virtual-core stop the fling and write the command.

⚠️ Remove a stopgap together with the bump to the virtual-core release that contains the fix. Kept past it, a stopgap applies the fix a second time - the IsRtl negation would have sent every write back to the start.

Vendored library provenance

Version @tanstack/virtual-core 3.19.11
File wwwroot/tanstack-virtual-core.js
Source jsDelivr rollup bundle of npm @tanstack/virtual-core@3.19.11 (+esm)
SHA-256 a293372e09c2a90fb1d5d275e5ccd4ded1c65e1e78f3157ee1cce4b2a2e6b995

⚠️ Deliberately the bundled build, not the npm tarball's dist/esm/index.js.

That file is not self-contained - it imports ./lazy-measurements.js and ./utils.js as siblings. Vendoring it alone gives a module that fetches fine and then fails to evaluate, and Blazor's fingerprinted import map turns the missing sibling into a confusing Failed to fetch dynamically imported module naming the outer module. The jsDelivr bundle is a single self-contained ESM file built from that same npm release, with the source stated in its own header. Its private-API surface (_willUpdate, _didMount, itemSizeCache, itemSizeCacheVersion) is re-verified on every bump.

License

MIT. @tanstack/virtual-core is itself MIT licensed.

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 is compatible.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.19.11 102 11/3/2026
3.19.8.4 92 11/3/2026
3.19.8.3 98 11/3/2026
3.19.8.2 98 11/2/2026
3.19.8.1 140 10/28/2026
3.19.8 100 10/28/2026
3.17.7.2 414 8/9/2026
3.17.7 145 8/4/2026

3.19.11: @tanstack/virtual-core 3.19.8 -> 3.19.11, and the three stopgaps it makes redundant are gone.

virtual-core now does itself what this package did in its place: it stops a touch fling before a ScrollToIndex, ScrollToOffset or ScrollToEnd issued during it on Android too, negates the scroll write of a horizontal IsRtl list, and computes GetOffsetForIndex - and the first write of ScrollToIndex - from the current measurements. Behaviour that changes with it: with a classic scrollbar that takes layout space (a touchscreen laptop), a command issued mid-fling now waits for the fling to end, about 150 ms after its last frame, instead of being written at once - and is dropped when the fling moves on after the command, a virtual-core issue described in the README; a ScrollBy issued mid-fling is written at once without stopping the fling, because its target is relative to where the fling is; and under IsRtl in window-scroller mode the write is no longer negated, which matches how virtual-core reads the window's offset. Also from virtual-core: rows that a render inside a ResizeObserver callback mounts are observed from the next frame, and a scroll offset left past the end of a list that just shrank keeps a full rendered range instead of only the last row.

3.19.8.4: the rows an Items change moves are no longer painted hidden, and InvalidateItemSize keeps the size a row is rendered at.

setOptions and setItems now go out from OnParametersSet, in that order, ahead of the render that shows the change, instead of from OnAfterRenderAsync after it. That render draws the rows an Items change moves hidden until the JS side holds the set, and on a WebView bridge the set used to arrive a round trip later: on a Samsung A53 every row below an expanding group was painted hidden for up to 220 ms. It now arrives first, and the rows are placed and shown before the frame is painted. InvalidateItemSize reads a rendered row's size off the row instead of dropping it, so a set arriving before the row's content changes no longer moves the rows below to the estimate; the ResizeObserver takes the new size when the content changes, and a row that is not rendered, or any row under UseCachedMeasurements, still falls back to the estimate. A row of the replaced item set that resizes before the render lands keeps its new size under its own key instead of losing it.

3.19.8.3: rows keep their components across an Items change, and JS places them without waiting for .NET.

A row whose placement went stale on an Items change used to be skipped, so every row component was disposed and built again one round trip later, even rows in both sets; it is now rendered under its own key and hidden until a matching placement arrives. Rows carry a data-key attribute, and the JS side moves every row whose key matches its own item set as soon as it knows the new layout - inside the ResizeObserver callback, before the frame is painted, and when setItems arrives, which also shows the rows .NET hid - instead of one interop round trip later through a .NET render. On a slow WebView bridge that removes the overlap below a row that grew and most of the blank after an insert. A size the ResizeObserver measured now survives the setItems that follows InvalidateItemSize, and a row rendered from an item set JS has not received yet no longer has its size recorded under another item's key.

3.19.8.2: an Items change sends only what the JS side does not hold yet, and snapshots report current sizes.

A change of Items used to send every key and size estimate across the interop bridge again; in a Blazor Hybrid app that is JSON copied through the WebView bridge per keystroke of a search. Both sides now keep a table of the pairs already sent, so a change travels as slot ids (runs packed into two numbers) plus only the new pairs, and an unchanged set sends nothing but a null order: for a 4,010-row search the setItems payload went from 293,225 to 21,170 characters. Per-key measurements survive a change as before. TakeSnapshot, GetOffsetForIndex, ScrollToIndex and GetSnapshot's StartIndex/EndIndex no longer read virtual-core state that lags after a ResizeObserver delivery or an InvalidateItemSize, so they report the sizes and range the virtualizer actually uses, and ScrollToIndex lands its first write on the target.

3.19.8.1: interop calls cut off by disposal no longer throw.

A ScrollToIndex, ScrollToOffset, ScrollToEnd, ScrollBy, ScrollToTop, CancelScroll, ResizeItem, Measure or InvalidateItemSize, or a GetTotalSize, GetVirtualIndexes, GetOffsetForIndex, GetVirtualItemForOffset, GetDistanceFromEnd, GetSnapshot or TakeSnapshot, that was still in flight when the component was disposed no longer throws once the JS side goes away - in a reloaded BlazorWebView it threw a JSException, and a caller that had not awaited it saw an unobserved task exception. A command now completes, a query returns its default, and the failure is logged through ILogger<AdaptiveVirtualize<TItem>>: at Debug for the known ways the JS side goes away, at Warning for anything else. The same holds for a render whose setOptions, setItems or measureRenderedRows was in flight, which surfaced as an unhandled render exception. A failure while the component is alive still throws. Callers no longer need a catch of their own around these calls for teardown.

3.19.8: teardown fixes, IsRtl that works, and @tanstack/virtual-core 3.17.7 -> 3.19.8.

Teardown. DisposeAsync no longer throws a JSException ("JS object instance with ID n does not exist") when the component's JS object is already gone from the page: when a BlazorWebView's page reloads, the reloaded page's attach request disposes the previous page's renderer, so the dispose call reaches a page where the object never existed. Disposing while interop is still in flight is safe as well. A render stops calling into JS once disposal has started, instead of going on to fail on the render path. A module or instance whose import or create was still in flight is released when it arrives, instead of being left behind with its scroll and resize observers attached, and a failure or timeout of those calls after disposal neither escapes nor pins the component. On Blazor Server, an interop call that times out during disposal no longer skips the rest of the cleanup. Items or Options changed while the JS instance was still being created now reach it. Interop failures that teardown deliberately swallows are logged through ILogger<AdaptiveVirtualize<TItem>> instead of disappearing: at Debug for the known ways the JS side goes away, so they stay out of error trackers, and at Warning for anything else.

virtual-core 3.19.8 brings upstream's own teardown fixes (no change notifications after dispose), many iOS WebKit scroll-correction fixes, and cancelScroll, surfaced as CancelScroll(). Behaviour changes: with Lanes above 1, Overscan counts whole rows, and OverscanLeading / OverscanTrailing now count the same way; ScrollToIndex(last, End) no longer overshoots by PaddingEnd, while ScrollToEnd still reaches the very bottom. A scroll write the browser clamped - an end-anchored list whose last item grows before the container does - is re-issued once the container has grown, so a chat-style list stays pinned to the bottom. In Blazor WebAssembly, change notifications raised inside virtual-core's ResizeObserver callbacks now go out one task later, which stops the browser reporting "ResizeObserver loop completed with undelivered notifications" as a window error.

Scrolling. A ScrollToIndex, ScrollToOffset, ScrollToEnd or ScrollBy issued by code while a touch fling is still moving the list now lands on its target on Android, where Chrome used to carry the fling on past the write: the momentum is stopped first by hiding the scroll axis's overflow for two frames, as virtual-core does itself on iOS 27. This is a stopgap until virtual-core does the same on Android (https://github.com/TanStack/virtual/pull/1347), and goes away with that release. IsRtl now works for horizontal lists: items are placed from the right edge, and scroll writes are negated to match how virtual-core reads the offset, so ScrollToIndex no longer clamps to the start. The scroll container itself has to be direction: rtl.

Render-path allocation work. Row keys, placements and the stale-row filtering are now resolved once per change of the window, item set or options instead of on every render, which removed several string allocations per rendered row per render batch. The item-set snapshot crosses interop as two parallel arrays (keys, sizes) rather than an array of objects, cutting both the allocation count and the JSON payload for large sets; ItemDescriptor is gone as a result. Single-lane lists no longer format their cross-axis constants per row.

Adds OverscanLeading / OverscanTrailing: overscan biased toward the side the user is scrolling towards, as react-window, react-virtualized and Angular CDK all do by default. The leading side follows the scroll direction and flips on reversal; the trailing side is a floor held at all times rather than one that lapses when scrolling stops, because virtual-core memoizes getVirtualIndexes on [rangeExtractor, overscan, count, startIndex, endIndex] - isScrolling is not among them, so a value that lapsed would simply never be recomputed. Compiled into the same rangeExtractor as AlwaysRenderedIndexes, so the two now compose instead of one replacing the other.

Wraps @tanstack/virtual-core 3.17.7 with complete option coverage - all 36 VirtualizerOptions are reachable. AdaptiveVirtualize<TItem> measures every item exactly, caches by item key, preserves measurements across item-set changes, and exposes targeted InvalidateItemSize(key). Layouts: vertical, horizontal, multi-lane masonry (per-item Lane), gap and padding as virtualizer geometry, RTL, and a window-scroller mode. Behaviour: FollowOnAppend and AnchorTo for chat-style lists, AlwaysRenderedIndexes (upstream's rangeExtractor reshaped), and IsEnabled suspend/reset. Imperative API: ScrollToIndex, ScrollToOffset, ScrollToEnd, ScrollBy, ScrollToTop, ResizeItem, Measure, GetTotalSize, GetVirtualIndexes, GetOffsetForIndex, GetVirtualItemForOffset, GetDistanceFromEnd, GetSnapshot and TakeSnapshot. Verified by 24 real-browser Playwright tests plus unit tests.