BitVirtualize is a high-performance virtualization (windowing) component that renders only the items currently visible in its scroll viewport (plus a configurable overscan buffer). It supports fixed and dynamically measured item sizes, vertical and horizontal orientation, in-memory or lazy-loaded data, placeholders, sticky group headers, and a bottom-anchored (chat) mode.

Notes

To use this component, you need to install the
Bit.BlazorUI.Extras
nuget package, as described in the Optional steps of the Getting started page.

Usage


Basic

<style>
    .list {
        height: 25rem;
        border: 1px solid gray;
    }

    .basic-item {
        gap: 0.5rem;
        display: flex;
        height: 100%;
        padding: 0 1rem;
        align-items: center;
        box-sizing: border-box;
        border-bottom: 1px solid lightgray;
    }
</style>

<BitNumberField @bind-Value="basicTargetIndex" Min="0" Max="999999" />
<BitButton OnClick="ScrollToBasicIndex">Scroll to index</BitButton>
<BitTag Text="@($"{basicVisibleStart:N0} - {basicVisibleEnd:N0} visible")" />

<BitVirtualize @ref="basicRef" Items="basicItems" ItemSize="56"
               TItem="int" Context="item"
               OnVisibleRangeChanged="OnBasicRangeChanged"
               Class="list">
    <div class="basic-item">
        <b>#@item.ToString("N0")</b>
        <span>Item number @item of one million</span>
    </div>
</BitVirtualize>
@code {
    private BitVirtualize<int> basicRef = default!;
    private readonly int[] basicItems = Enumerable.Range(0, 1_000_000).ToArray();
    private int basicTargetIndex;
    private int basicVisibleStart;
    private int basicVisibleEnd;
    
    private async Task ScrollToBasicIndex()
    {
        await basicRef.ScrollToIndexAsync(basicTargetIndex, BitVirtualizeScrollAlignment.Start, smooth: true);
    }
    
    private void OnBasicRangeChanged((int Start, int End) range)
    {
        (basicVisibleStart, basicVisibleEnd) = range;
        StateHasChanged();
    }
}
                    
One million fixed-size rows. Only the rows in view exist in the DOM, so you can scroll anywhere instantly.

0 - 18 visible

ItemsProvider

<style>
    .list {
        height: 25rem;
        border: 1px solid gray;
    }

    .provider-item {
        display: flex;
        height: 100%;
        padding: 0 1rem;
        flex-direction: column;
        justify-content: center;
        box-sizing: border-box;
        border-bottom: 1px solid lightgray;
    }
</style>

<BitVirtualize TItem="Product" ItemsProvider="LoadProducts" ItemSize="60" Class="list">
    <ItemTemplate Context="product">
        <div class="provider-item">
            <b>@product.Name</b>
            <span>record #@product.Id.ToString("N0") · loaded at @product.LoadedAt</span>
        </div>
    </ItemTemplate>
    <PlaceholderTemplate Context="context">
        <div class="provider-item">
            <BitShimmer Height="@($"{context.Size / 2}px")" Width="@($"{100 - (context.Index % 3) * 15}%")" />
        </div>
    </PlaceholderTemplate>
</BitVirtualize>
@code {
    private const int TotalProducts = 100_000;
    
    private async ValueTask<BitVirtualizeItemsProviderResult<Product>> LoadProducts(BitVirtualizeItemsProviderRequest request)
    {
        await Task.Delay(500, request.CancellationToken); // simulate a network fetch
    
        var items = Enumerable.Range(request.StartIndex, Math.Min(request.Count, TotalProducts - request.StartIndex))
                              .Select(i => new Product(i, $"Product {i:N0}", DateTime.Now.ToString("HH:mm:ss")))
                              .ToList();
    
        return new(items, TotalProducts);
    }
    
    public record Product(int Id, string Name, string LoadedAt);
}
                    
Rows are fetched on demand in windows through the ItemsProvider function. A simulated latency shows the PlaceholderTemplate while each window loads.

Dynamic size

<style>
    .list {
        height: 25rem;
        border: 1px solid gray;
    }

    .post {
        padding: 1rem;
        border-bottom: 1px solid lightgray;
    }

    .post-header {
        color: gray;
        font-size: 0.75rem;
        margin-bottom: 0.25rem;
    }
</style>

<BitVirtualize Items="posts" Dynamic EstimatedItemSize="96"
               TItem="Post" Context="post"
               Class="list">
    <div class="post">
        <div class="post-header">@post.Author · @post.Time</div>
        <div>@post.Body</div>
    </div>
</BitVirtualize>
@code {
    private List<Post> posts = [];
    
    protected override void OnInitialized()
    {
        var random = new Random(42);
        var words = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua".Split(' ');
    
        posts = Enumerable.Range(0, 10_000).Select(i =>
        {
            var body = string.Join(' ', Enumerable.Range(0, random.Next(5, 60)).Select(_ => words[random.Next(words.Length)]));
            return new Post($"Author {i % 20}", $"{random.Next(1, 59)}m ago", body);
        }).ToList();
    }
    
    public record Post(string Author, string Time, string Body);
}
                    
Each post wraps to a different height. In Dynamic mode the component measures every rendered item and anchors the scroll position so content never jumps as sizes settle.

Horizontal

<style>
    .horizontal-list {
        height: 9rem;
        border: 1px solid gray;
    }

    .tile {
        display: flex;
        height: 100%;
        margin: 0.5rem;
        border-radius: 0.5rem;
        align-items: center;
        justify-content: center;
        box-sizing: border-box;
        border: 1px solid lightgray;
        height: calc(100% - 1rem);
    }
</style>

<BitVirtualize Items="horizontalItems" ItemSize="120" Horizontal
               TItem="int" Context="item"
               Class="horizontal-list">
    <div class="tile">
        <b>@item.ToString("N0")</b>
    </div>
</BitVirtualize>
@code {
    private readonly int[] horizontalItems = Enumerable.Range(0, 100_000).ToArray();
}
                    
The same engine oriented along the x-axis, rendering a hundred thousand fixed-width tiles.

Infinite scrolling

<style>
    .list {
        height: 25rem;
        border: 1px solid gray;
    }

    .basic-item {
        gap: 0.5rem;
        display: flex;
        height: 100%;
        padding: 0 1rem;
        align-items: center;
        box-sizing: border-box;
        border-bottom: 1px solid lightgray;
    }
</style>

<BitVirtualize @ref="feedRef" Items="feedItems" ItemSize="56"
               TItem="string" Context="item"
               OnEndReached="LoadMoreFeedItems" ReachedThreshold="6"
               Class="list">
    <div class="basic-item">@item</div>
</BitVirtualize>

<div>@(feedLoading ? "Loading more..." : $"{feedItems.Count:N0} items loaded · scroll down to load more")</div>
@code {
    private BitVirtualize<string> feedRef = default!;
    private readonly List<string> feedItems = [.. Enumerable.Range(0, 25).Select(i => $"Feed item {i:N0}")];
    private bool feedLoading;
    
    private async Task LoadMoreFeedItems()
    {
        if (feedLoading) return;
        feedLoading = true;
        StateHasChanged();
    
        await Task.Delay(700); // simulate a network fetch
    
        feedItems.AddRange(Enumerable.Range(feedItems.Count, 25).Select(i => $"Feed item {i:N0}"));
        feedLoading = false;
    
        await feedRef.RefreshDataAsync();
    }
}
                    
This feed has no known total. The OnEndReached callback fires when the last rows come within ReachedThreshold items of the viewport, appending the next batch.


25 items loaded · scroll down to load more

Sticky headers

<style>
    .list {
        height: 25rem;
        border: 1px solid gray;
    }

    .group-header {
        display: flex;
        height: 100%;
        font-weight: bold;
        padding: 0.5rem 1rem;
        align-items: center;
        box-sizing: border-box;
        background-color: #f4f4f4;
    }

    .contact {
        display: flex;
        height: 100%;
        padding: 0.5rem 1rem;
        flex-direction: column;
        justify-content: center;
        box-sizing: border-box;
        border-bottom: 1px solid lightgray;
    }
</style>

<BitVirtualize Items="contacts" Dynamic EstimatedItemSize="56"
               TItem="Contact"
               IsStickyItem="c => c.IsHeader"
               Class="list">
    <ItemTemplate Context="contact">
        @if (contact.IsHeader)
        {
            <div class="group-header">@contact.Name</div>
        }
        else
        {
            <div class="contact">
                <b>@contact.Name</b>
                <span>@contact.Email</span>
            </div>
        }
    </ItemTemplate>
    <StickyTemplate Context="contact">
        <div class="group-header pinned">@contact.Name</div>
    </StickyTemplate>
</BitVirtualize>
@code {
    private List<Contact> contacts = [];
    
    protected override void OnInitialized()
    {
        var random = new Random(11);
        contacts = [];
        for (var c = 'A'; c <= 'Z'; c++)
        {
            contacts.Add(new Contact(true, c.ToString(), string.Empty));
            for (var i = 0; i < random.Next(5, 20); i++)
            {
                var name = $"{c}ontact {i + 1}";
                contacts.Add(new Contact(false, name, $"{name.Replace(" ", ".").ToLower()}@example.com"));
            }
        }
    }
    
    public record Contact(bool IsHeader, string Name, string Email);
}
                    
Contacts are grouped A-Z. The header of the current group stays pinned to the top of the viewport (IsStickyItem) and is gently pushed out by the next group as it arrives.

Reversed (chat)

<style>
    .chat-list {
        height: 25rem;
        border: 1px solid gray;
    }

    .message {
        display: flex;
        padding: 0.25rem 1rem;
        box-sizing: border-box;
    }

    .message.mine {
        justify-content: flex-end;
    }

    .bubble {
        max-width: 70%;
        padding: 0.5rem 1rem;
        border-radius: 1rem;
        background-color: #f4f4f4;
    }

    .message.mine .bubble {
        color: white;
        background-color: dodgerblue;
    }

    .composer {
        gap: 0.5rem;
        display: flex;
        margin-top: 0.5rem;
    }
</style>

<BitVirtualize @ref="chatRef" Items="messages" Dynamic EstimatedItemSize="48"
               TItem="Message" Context="message"
               Reversed ItemKey="m => m.Id"
               OnStartReached="LoadChatHistory" ReachedThreshold="3"
               Class="chat-list">
    <div class="message @(message.Mine ? "mine" : null)">
        <div class="bubble">@message.Text</div>
    </div>
</BitVirtualize>
<div class="composer">
    <BitTextField @bind-Value="draftMessage" Immediate Placeholder="Write a message..." Style="flex-grow:1" />
    <BitButton OnClick="SendChatMessage" IsEnabled="@(string.IsNullOrWhiteSpace(draftMessage) is false)">Send</BitButton>
</div>
@code {
    private BitVirtualize<Message> chatRef = default!;
    private List<Message> messages = [];
    private string? draftMessage;
    private bool loadingChatHistory;
    private int chatHistoryRemaining = 381; // count of the older messages (0..380) not loaded yet
    
    protected override void OnInitialized()
    {
        // The newest messages (381..400); scrolling up loads the older ones down to 0.
        messages = Enumerable.Range(381, 20).Select(i => new Message(i, i % 3 == 0, $"Message number {i}")).ToList();
    }
    
    private async Task SendChatMessage()
    {
        if (string.IsNullOrWhiteSpace(draftMessage)) return;
    
        var id = messages.Count == 0 ? 1000 : Math.Max(1000, messages.Max(m => m.Id) + 1);
        messages.Add(new Message(id, true, draftMessage.Trim()));
        draftMessage = string.Empty;
    
        await chatRef.RefreshDataAsync(); // Reversed mode keeps the list pinned to the bottom
    }
    
    private async Task LoadChatHistory()
    {
        if (loadingChatHistory || chatHistoryRemaining <= 0) return;
        loadingChatHistory = true;
    
        await Task.Delay(600); // simulate fetching older messages
    
        var batch = Math.Min(15, chatHistoryRemaining);
        messages.InsertRange(0, Enumerable.Range(chatHistoryRemaining - batch, batch).Select(i => new Message(i, i % 3 == 0, $"Message number {i}")));
        chatHistoryRemaining -= batch;
        loadingChatHistory = false;
    
        await chatRef.RefreshDataAsync(); // Reversed mode preserves the scroll position
    }
    
    public record Message(int Id, bool Mine, string Text);
}
                    
With Reversed the list starts at the newest message and stays pinned to the bottom as you send more. Scroll up and OnStartReached lazily prepends older history while the viewport stays exactly where it was.

API

BitVirtualize parameters

Name Type Default value Description
ChildContent RenderFragment<TItem>? null The custom template to render each item.
Dynamic bool false Enables dynamic item sizing in which each rendered item gets measured in the browser and its real size gets cached, using the EstimatedItemSize for the items that have not been measured yet.
EmptyTemplate RenderFragment? null The custom template to render when there is no item available.
EstimatedItemSize float 50 The assumed size in pixels of the items that have not been measured yet in dynamic mode.
Horizontal bool false Renders the items horizontally so the viewport scrolls along the x-axis.
InitialIndex int? null The index of the item to scroll to on the first render. Ignored when Reversed is set.
IsStickyItem Func<TItem, bool>? null A predicate that marks certain items (for example, group headers) as sticky. The active sticky item gets pinned to the leading edge of the viewport while its group scrolls. Fully supported with in-memory Items; in provider mode it is applied on a best-effort basis to the currently loaded window.
Items ICollection<TItem>? null The in-memory collection of items to virtualize. Mutually exclusive with ItemsProvider.
ItemKey Func<TItem, object>? null A function that returns a stable identity key for an item. When provided, rendered rows are keyed by identity (instead of by index) so per-item DOM/component state survives insertions, removals and reordering, and dynamic measurements follow their item across those mutations.
ItemSize float 50 The size in pixels of each item along the scroll axis when the Dynamic mode is off.
ItemsProvider BitVirtualizeItemsProvider<TItem>? null The item provider function that lazily supplies windows of items on demand. Mutually exclusive with Items.
ItemTemplate RenderFragment<TItem>? null Alias for ChildContent.
LoadingTemplate RenderFragment? null The custom template to render before the component performs its first load.
OnEndReached EventCallback The callback to be called when the last item comes within ReachedThreshold items of the visible window, useful for appending more data in infinite scrolling scenarios. Fires once per item-count value.
OnStartReached EventCallback The callback to be called when the first item comes within ReachedThreshold items of the visible window, useful for prepending older data (for example, loading chat history when scrolling up).
OnVisibleRangeChanged EventCallback<(int Start, int End)> The callback to be called whenever the visible index range changes.
OverscanCount int 3 The number of extra items to render on each side of the visible window for smoother scrolling.
PlaceholderTemplate RenderFragment<BitVirtualizePlaceholderContext>? null The custom template to render an item whose data has not been loaded yet in provider mode.
ReachedThreshold int 0 The number of items away from an edge the visible window must be before OnEndReached/OnStartReached fire.
Reversed bool false Enables the bottom-anchored mode in which the list starts scrolled to the end and automatically keeps the newest items in view when data gets appended while the user is at the bottom. Ideal for chat and log views.
StickyTemplate RenderFragment<TItem>? null The custom template to render the pinned sticky item. Falls back to the item template when not provided.

BitVirtualize public members

Name Type Default value Description
RefreshDataAsync Func<Task> Re-requests the data from the ItemsProvider (or re-reads the Items) and refreshes the view.
ScrollToIndexAsync Func<int, BitVirtualizeScrollAlignment, bool, Task> Scrolls the viewport so that the item at the provided index becomes visible.
ScrollToOffsetAsync Func<double, bool, Task> Scrolls to an absolute pixel offset along the scroll axis.
ScrollToStartAsync Func<bool, Task> Scrolls to the start (top/left) of the list.
ScrollToEndAsync Func<bool, Task> Scrolls to the end (bottom/right) of the list. Useful for chat and log views.

BitComponentBase parameters

Name Type Default value Description
AriaLabel string? null Gets or sets the accessible label for the component, used by assistive technologies.
Class string? null Gets or sets the CSS class name(s) to apply to the rendered element.
Dir BitDir? null Gets or sets the text directionality for the component's content.
ForceAnimation bool false Gets or sets a value indicating whether the component's animations play at their full duration even when reduced motion is requested.
HtmlAttributes Dictionary<string, object> new Dictionary<string, object>() Captures additional HTML attributes to be applied to the rendered element, in addition to the component's parameters.
Id string? null Gets or sets the unique identifier for the component's root element.
IsEnabled bool true Gets or sets a value indicating whether the component is enabled and can respond to user interaction.
Style string? null Gets or sets the CSS style string to apply to the rendered element.
TabIndex string? null Gets or sets the tab order index for the component when navigating with the keyboard.
Visibility BitVisibility BitVisibility.Visible Gets or sets the visibility state (visible, hidden, or collapsed) of the component.

BitComponentBase public members

Name Type Default value Description
UniqueId Guid Guid.NewGuid() Gets the readonly unique identifier for the component's root element, assigned when the component instance is constructed.
RootElement ElementReference Gets the reference to the root HTML element associated with this component.

BitVirtualizeItemsProviderRequest properties

The request passed to the ItemsProvider function for a window of items.

Name Type Default value Description
StartIndex int 0 The zero-based index of the first item requested.
Count int 0 The maximum number of items requested.
CancellationToken CancellationToken A token that is cancelled when this request is no longer needed.

BitVirtualizeItemsProviderResult<TItem> properties

The result returned from the ItemsProvider function.

Name Type Default value Description
Items IReadOnlyList<TItem> The items that were loaded for the requested window.
TotalItemCount int 0 The total number of items in the underlying data source.

BitVirtualizePlaceholderContext properties

The context passed to the PlaceholderTemplate while real items are being loaded.

Name Type Default value Description
Index int 0 The zero-based index of the item this placeholder represents.
Size double 0 The estimated size (px) reserved for the placeholder along the scroll axis.

BitVirtualizeScrollAlignment enum

Name Value Description
Auto 0 Scroll the minimum amount required to bring the item fully into view.
Start 1 Align the item to the start (top/left) of the viewport.
Center 2 Center the item within the viewport.
End 3 Align the item to the end (bottom/right) of the viewport.

BitVisibility enum

Name Value Description
Visible 0 The content of the component is visible.
Hidden 1 The content of the component is hidden, but the space it takes on the page remains (visibility:hidden).
Collapsed 2 The component is hidden (display:none).

BitDir enum

Name Value Description
Ltr 0 Ltr (left to right) is to be used for languages that are written from the left to the right (like English).
Rtl 1 Rtl (right to left) is to be used for languages that are written from the right to the left (like Arabic).
Auto 2 Auto lets the user agent decide. It uses a basic algorithm as it parses the characters inside the element until it finds a character with a strong directionality, then applies that directionality to the whole element.

Feedback

You can give us your feedback through our GitHub repo by filing a new Issue or starting a new Discussion.


Or you can review / edit this page on GitHub.


Or you can review / edit this component on GitHub.