Virtual Scrolling with Tailwind: Styling Huge Lists Without the Jank
AI generated
tw
Tailwind CSS · Performance · TanStack Virtual
Virtual Scrolling: Styling Huge Lists with Tailwind
Render only what's visible, without breaking the styling

A list with several thousand rows brings any browser to its knees once every row is its own DOM node carrying Tailwind classes for padding, borders, and hover states. Virtualization fixes this by rendering only the currently visible rows, but it introduces its own styling challenges, from variable item heights to keeping sticky section headers working inside the virtualized list.

16 min read TanStack Virtual · overscan · measureElement Skeleton loaders · sticky headers · a11y

1. Why long lists in the DOM become a performance problem

A product list with five thousand rows, where each row nests several divs for an image, a title, a badge, and action buttons, quickly produces tens of thousands of DOM nodes. The browser has to run style calculation, layout, and paint for every one of those nodes, even though only a fraction is actually within the visible viewport at any moment. The result is noticeably longer load times, sluggish scrolling, and memory usage that grows linearly with list length.

On weaker hardware or mobile devices this adds up quickly to a perceptible lag between an interaction and the page reacting to it. Even when every individual Tailwind class is trivial on its own, the calculation cost across tens of thousands of nodes adds up to a real bottleneck. This is exactly the point where virtualization steps in, tackling the root cause instead of merely papering over it.

2. The principle of virtualization: render only what's visible

A virtualized list renders, at any given moment, only the rows that actually sit inside the visible window, plus a small buffer just outside it. The remaining, unrendered height gets simulated through a placeholder container with a calculated total height, so the native scrollbar keeps showing an accurate, proportional position within the list.

Libraries like TanStack Virtual take care of measuring item heights, positioning rows via transform: translateY, and recycling DOM nodes as the user scrolls. Tailwind in this model still only handles the visual styling, while the actual positioning logic runs entirely in JavaScript through inline styles that the library computes on every scroll frame.

3. Setting up TanStack Virtual and styling it with Tailwind classes

The useVirtualizer hook from TanStack Virtual needs three core inputs: the total item count, a reference to the scrolling element, and an estimate function for item height. The outer container gets a fixed height plus overflow-y-auto as a Tailwind class, while the inner container receives the virtualizer's computed total height as an inline style so the scrollbar sizes correctly.

Every individual virtual item is absolutely positioned and shifted to its computed spot via a transform, while the visual styling such as borders, spacing, and hover states comes entirely from ordinary Tailwind utility classes. This clean split between positioning owned by the library and looks owned by Tailwind keeps the component maintainable, since design changes never touch the virtualization logic.


import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';

function ProductList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72,
    overscan: 6,
  });

  return (
    <div ref={parentRef} class="h-[600px] overflow-y-auto rounded-lg border border-gray-200">
      <div
        class="relative w-full"
        style={{ height: `${virtualizer.getTotalSize()}px` }}
      >
        {virtualizer.getVirtualItems().map((row) => (
          <div
            key={row.key}
            class="absolute left-0 top-0 flex w-full items-center gap-3 border-b border-gray-100 px-4 py-3 hover:bg-gray-50"
            style={{ height: `${row.size}px`, transform: `translateY(${row.start}px)` }}
          >
            <span class="font-medium text-gray-900">{items[row.index].name}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

4. Styling challenges with variable item height

Once items are not all the same height, say comments with wildly differing text lengths, a fixed estimate is no longer enough. The virtualizer then measures the actual rendered height of each node through measureElement after the fact and corrects its internal position calculations accordingly, which triggers a brief additional render pass.

For measurement to stay reliable, rendered items must not carry external margin values through Tailwind classes like mb-4, because margins fall outside the measured bounding box and skew the position math over time. Spacing between items instead belongs on an inner wrapper as padding or as a border-bottom, so the measured height matches exactly what the virtualizer expects for its calculation.

5. Designing skeleton placeholders while scrolling

With server-paginated data, fast scrolling can land a user in a region whose data hasn't loaded yet. Instead of a blank, empty area, a skeleton placeholder with animate-pulse and gray bars should appear, clearly signaling that content is on its way rather than making it look like the list simply ends there.

The critical detail is that the skeleton must match the exact height of the real item that will replace it, otherwise the rest of the list jumps up or down abruptly once the real content loads. Proven Tailwind classes for this are something like h-16 rounded bg-gray-200 dark:bg-gray-700 animate-pulse, combined with the same spacing classes used on the real item.

6. Combining sticky headers with the same virtualized list

Grouped lists, like a contact directory split by letter, need a section header that stays pinned while the user scrolls. Since the virtualizer only knows about the currently visible range of items, this header has to be computed separately and rendered as its own element positioned with sticky top-0 z-10, sitting outside the normal virtual item flow.

The common pattern here determines, on every scroll frame, the index of the most recently passed group header and renders it additionally, pinned above the actual list, while the ordinary items keep scrolling normally underneath. That way orientation is preserved even though, technically, only a small slice of the full list ever exists in the DOM.

7. Overscan, scroll performance, and avoiding layout thrashing

The overscan parameter controls how many extra items get pre-rendered just outside the visible area. Too low a value causes brief blank windows (blanking) during fast scrolling, while too high a value inflates the DOM node count again and eats into the benefit virtualization was supposed to bring. In practice, values between four and eight items usually strike a good balance.

Layout thrashing happens when reading (say, getBoundingClientRect) and writing layout properties alternate within the same frame, forcing the browser to re-layout repeatedly. Height measurements through measureElement should therefore be batched rather than run synchronously on every single scroll event, to avoid these expensive forced-reflow cycles.

8. Accessibility considerations in virtualized lists

A screen reader only perceives the elements currently sitting in the DOM, not the full logical list. The attributes aria-setsize and aria-posinset on every rendered item still communicate the true overall size and position to the screen reader, even though physically only a small slice exists, complemented by role="list" on the container.

With arrow-key keyboard navigation, the scroll container needs to auto-scroll as soon as the focused element leaves the visible area, otherwise focus silently drifts out of view and the app feels broken to keyboard users. This synchronization between focus and scroll position has to be handled explicitly in the virtualization logic, it does not happen automatically.

9. Practical tips: when virtualization pays off and when it doesn't

As a rule of thumb, virtualization pays off noticeably once a list would otherwise permanently produce more than roughly two to three hundred DOM nodes. Below that threshold, the extra implementation and maintenance cost for measurement logic, sticky-header edge cases, and accessibility adjustments often outweighs the performance gain, which barely registers in daily use.

For simpler cases, the CSS property content-visibility: auto can already deliver part of the effect without any JavaScript library, though without the fine-grained control over sticky headers or variable heights. For search-engine-relevant content, virtualization should generally be avoided, since crawlers only see the initially rendered slice; pagination or full server-side rendering is the better choice there.

Approach DOM nodes at 5,000 items Library size Best fit
No virtualization over 5,000 nodes 0 KB Small lists, simplest implementation
TanStack Virtual roughly 20 to 40 nodes about 4 KB Large dynamic lists, variable height
react-window roughly 20 to 40 nodes about 6 KB Fixed item height, lean API
CSS content-visibility: auto all nodes present, but not rendered 0 KB Medium-sized lists without a JS dependency

Mironsoft

Tailwind CSS architecture, design systems, and performance

Tailwind frontends that stay maintainable despite thousands of utility classes?

We review existing Tailwind projects for bloated class lists, inconsistent design tokens, and unused CSS remnants, then build a design system that scales cleanly instead of getting messier with every component.

Design System Review

Checking tokens, spacing scale, and component consistency for maintainability.

Performance Optimization

Systematically reducing CSS bundle size, purge configuration, and load times.

Component Architecture

Building reusable, well-structured components instead of sprawling class lists.

10. Summary

Virtual Scrolling with Tailwind: Key Takeaways

Overscan value

Four to eight extra buffer items prevent blanking during fast scrolling.

Item height

Fixed height is fastest; variable height requires measureElement to re-measure.

Accessibility

aria-setsize and aria-posinset communicate the logical list position to screen readers.

SEO note

Virtualized content is invisible to crawlers; prefer pagination when SEO matters.

11. FAQ: Virtual Scrolling with Tailwind: Key Takeaways

1At what point does virtual scrolling actually pay off?
As a rule of thumb, once a list would otherwise permanently render more than about two to three hundred DOM nodes. Below that, the extra implementation effort usually outweighs the performance gain you'd feel day to day.
2Does virtualization work without TanStack Virtual?
Yes, the principle can be implemented manually too, for example using the Intersection Observer and manual position math. Libraries mostly save you from the error-prone parts around measurement and DOM node recycling.
3What happens to browser find (Ctrl+F) in a virtualized list?
Browser find only matches text currently present in the DOM, not the entire logical list. For searchable content you should offer a dedicated search or filter feature instead of relying on native browser search.
4How does virtualization affect SEO?
Search engine crawlers generally only see the initially rendered slice of the list, not the full dataset. For indexable content, pagination or full server-side rendering is the better choice.
5Can virtualization be combined with infinite scroll?
Yes, that's a common pattern. The virtualizer exposes a callback with the index of the last visible item, and reaching it triggers loading the next data page and bumping the total item count.
6How do you fix jank during fast scrolling?
Usually a higher overscan value helps, along with avoiding synchronous layout reads on every scroll event. Expensive CSS effects like shadows or blur on every single item should also be used sparingly in virtualized lists.
7Do horizontal lists need a different library?
No, TanStack Virtual supports both vertical and horizontal scrolling through the same API via a horizontal parameter. Positioning internally switches from translateY to translateX.
8How do you test virtualized lists automatically?
End-to-end tests should verify that expected items appear in the DOM after programmatic scrolling, rather than expecting the whole list at once. Unit tests for the height-estimate function additionally catch regressions with variable item sizes.
9What's the difference between virtualization and pagination?
Pagination loads and displays only a limited page of data at a time, while virtualization keeps all the data in memory but renders only the visible slice as DOM. Both approaches can also be combined.
10Does content-visibility work as an alternative to JS virtualization?
For simpler cases, yes, since the browser itself skips rendering outside the viewport. Fine control like sticky headers or exact overscan behavior isn't available with the pure CSS approach though.