Sep 8, 2023

Smooth React virtual scroll with fixed rows/columns

One of our ongoing projects, Neptyne, introduces an Excel-like grid written in React. We used a library to apply virtual scroll to it, but we stumbled upon a problem with fixed rows and columns inside the grid. Here I would like to describe this problem, how it occurs, and how we handled it.


Why simple virtual scroll is not enough

The main goal of a virtual scroll is to reduce client resource consumption by rendering only visible elements of a large scrollable list/table. Since this is a common technique, there are lots of articles you can read to catch up on how it works (here, here).

However, we cannot use a simple virtual scroll to implement a table with frozen rows and columns. For better understanding, let me show you how Google Sheets implement its frozen rows/cols:


As you can see, frozen rows/cols still need to be virtualized. Essentially, frozen columns A-B should have a virtual height, and rows 1-3 should have a virtual width. They must be scrollable and must not render cells out of the viewport. There is no CSS to make this work: we need to use something more complex than a single virtual scroll element.

Multiple virtual scroll windows

To make a virtualized table with frozen rows/cols, we have to treat each area as a separate table. Each of these tables has a viewport visible to the user and a “canvas“, where the content is drawn.


Each table has its own set of data to be drawn. “Unfrozen“ table does not have access to content rendered in the frozen rows and columns. For example, if we have row 1 frozen, then “unfrozen“ part will work with rows 2-999, and “frozen rows” will be provided with data from row 1.

However, to achieve the effect we have seen with Google Sheets, we need to synchronize scroll states between tables. For instance, if we scroll the “unfrozen” table to the right, the frozen rows should be scrolled to the right as well. If we scroll “unfrozen“ table to the bottom, frozen columns should be scrolled to the bottom too.

We did a little research and found a couple of libraries that implemented this approach. The most prominent of them was react-virtualized - it was relatively simple and rich in features.

It implements a classic virtual scroll pattern mentioned above: we have a tiny DOM element for a viewport, which has a fixed width and height. Inside the viewport, we have a large DOM element that contains the actual grid cells. When the user scrolls, the library determines which cells are visible through a viewport, and draws only them.

That worked pretty well for us feature-wise, but there was one notable issue: scroll lag related to fixed rows and columns.


As you can see in the GIF, there is a slight lag between scrolling the main part and the fixed parts. This effect gets more apparent with weaker computers and more sophisticated grid cell components. So we had to find out the reason for this and re-implement this feature manually.

Where does the lag come from?

Frozen rows and columns came in separate tables. Each of these tables has an independent scroll state synchronized by a common parent.

So when the user scrolls any of the tables, react-virtualized reads its updated scroll position and sets the same position for the rest of the tables.

Lag comes from the fact that the browser draws scrolled content of the first table before the rest have a chance to re-render.


The solution is to somehow have the same scroll position confidence source set for all tables at the same time.

We decided to listen to onWheel event of the parent table. This event gives us the offset user scrolled for. Nothing has been scrolled so far. We then calculate the updated scroll position and set it to all tables.

The scroll state is updated and broadcasted to all the tables simultaneously, allowing them to adjust their position without the lag.

Since this “four synchronized tables“ approach is effectively hardcoded in the react-virtualized library, we moved to a lighter react-window, which is easier to customise for our case.

Below you can see an example of React-component that allows rendering tables with frozen rows and cols. The code should be covered with comments well enough to understand what is going on, but here is a brief recap anyway.

We render four VariableSizeGrid that reflect four tables with data;

Three of them are “frozen” tables - frozen rows, frozen cols, and this frozen rectangle in upper left part if we have both frozen rows and cols;

All “frozen“ parts have a CSS class scrollable-grid that prevents them from scrolling natively;

The main table can be scrolled freely. It simplifies the flow if we only have unfrozen part, and does not affect the scrolling of frozen parts since we don’t synchronize them the usual way;

We also have an outer-grid-container DOM element, that handles scroll synchronization. It owns an event handler described on line 232. This handler listens to any scroll event inside its children, simultaneously applying scroll to every grid that we have.

So, in order to synchronize the scrolling of several tables, you basically need to “mute“ scroll event in the tables and listen to scroll in a parent element. This would allow you to programmatically change the scroll state to all the tables, giving you fairly smooth scrolling.

// css
// .scrollable-grid {
//   overflow: scroll;
//   -ms-overflow-style: none;
//   // prohibit scroll for "frozen" grid containers
//   scrollbar-width: none;
// }

// .scrollable-grid::-webkit-scrollbar {
//   display: none;
// }

// .main-grid {
//   display: flex;
// }

import {
} from "react-window";
import {
} from "react";
import { ReactWindowCellRenderer } from "./react-window-cell/ReactWindowCellRenderer";

interface Properties {
  className?: string;
  columnWidth: (index: number) => number;
  rowHeight: (index: number) => number;
  columnCount: number;
  rowCount: number;
  width: number;
  height: number;

  frozenRowCount: number;
  frozenColumnCount: number;

  hideScrollbars?: boolean;

  gridData: unknown;

interface OffsetCellRendererProps extends GridChildComponentProps {
  rowOffset: number;
  colOffset: number;

  display: "flex",
  flexDirection: "row",

// special cell renderer to reflect cells with particular position. Offset allows to "skip"
// rendering first "frozen" cells.
const OffsetCellRenderer: FunctionComponent = memo(
  ({ rowOffset, colOffset, ...props }) => (

export const VirtualizedGrid = (props: Properties) => {
  // number of rows and columns that should be rendered in separate "frozen" tables
  const { frozenRowCount, frozenColumnCount, className } = props;

  // calculator functions to get size of particular row and column.
  // Used to calculate sizes of frozen/unfrozen tables for virtual scroll.
  const { rowHeight, columnWidth } = props;

  // the object that contains business data of an app. It can contain some parameters to be applied
  // to the cells, and the actial cell data in a form of two-dimensional array.
  const { gridData } = props;

  // refs to actual containers that display data
  const cornerRef = useRef(null);
  const topHeaderRef = useRef(null);
  const leftHeaderRef = useRef(null);
  const mainGridRef = useRef(null);

  // ref to a "canvas" div with size of entire grid. The actual DOM element that we scroll
  const mainGridContainerRef = useRef(null);

  // a special element that listens to scroll events and dispatches them to actual grid containers
  const outerContainerRef = useRef(null);

    () => {
      const { current: grid } = mainGridRef;
      const { current: top } = topHeaderRef;
      const { current: left } = leftHeaderRef;
      if (grid && top && left) {
        grid.scrollTo({ scrollLeft: 0, scrollTop: 0 });
        top.scrollTo({ scrollLeft: 0 });
        left.scrollTo({ scrollTop: 0 });
    [] /* only on initial render, make sure we're at grid origin */

  // calculate summarized sizes of frozen rows/cols
  const frozenRowHeight = useMemo(() => {
    let height = 0;
    for (let i = 0; i < frozenRowCount; i++) {
      height += rowHeight(i);
    return height;
  }, [frozenRowCount, rowHeight]);
  const frozenColWidth = useMemo(() => {
    let width = 0;
    for (let i = 0; i < frozenColumnCount; i++) {
      width += columnWidth(i);
    return width;
  }, [columnWidth, frozenColumnCount]);

  // calculate summarized sizes of unfrozen rows/cols
  const unfrozenHeight = props.height - frozenRowHeight;
  const unfrozenWidth = props.width - frozenColWidth;

  const cornerGridStyle = useMemo(
    (): CSSProperties => ({
      display: "flex",
      ...(frozenColumnCount > 1 ? { borderRight: "2px solid gray" } : {}),
      ...(frozenRowCount > 1 ? { borderBottom: "2px solid gray" } : {}),
    [frozenColumnCount, frozenRowCount]

  const topGridStyle = useMemo(
    (): CSSProperties => ({
      display: "flex",
      ...(frozenRowCount > 1 ? { borderBottom: "2px solid gray" } : {}),

  const leftGridStyle = useMemo(
    (): CSSProperties => ({
      display: "flex",
      ...(frozenColumnCount > 1 ? { borderRight: "2px solid gray" } : {}),

  const offsetColumnWidth = useCallback(
    (index: number) => columnWidth(index + frozenColumnCount),
    [columnWidth, frozenColumnCount]
  const offsetRowHeight = useCallback(
    (index: number) => rowHeight(index + frozenRowCount),
    [frozenRowCount, rowHeight]

  // cell renderers
  const FrozenCornerRenderer = useCallback(
    (props: GridChildComponentProps) => (
  const FrozenRowRenderer = useCallback(
    (props: GridChildComponentProps) => (
  const FrozenColumnRenderer = useCallback(
    (props: GridChildComponentProps) => (
  const FrozenCellRenderer = useCallback(
    (props: GridChildComponentProps) => (
    [frozenColumnCount, frozenRowCount]

  const handleScroll = useCallback(({ scrollLeft, scrollTop }: GridOnScrollProps) => {
    topHeaderRef.current?.scrollTo({ scrollLeft });
    leftHeaderRef.current?.scrollTo({ scrollTop });
  }, []);

  const handleVerticalScroll = useCallback(({ scrollTop }: GridOnScrollProps) => {
    mainGridRef.current?.scrollTo({ scrollTop });
  }, []);

  const handleHorizontalScroll = useCallback(({ scrollLeft }: GridOnScrollProps) => {
    mainGridRef.current?.scrollTo({ scrollLeft });
  }, []);

  const outerGridStyle = { width: "100%", height: "100%" };

  // calculate total grid size
  const [gridHeight, gridWidth] = useMemo(() => {
    let height = 0;
    let width = 0;
    for (let i = 0; i < props.rowCount; i++) {
      height += rowHeight(i);
    for (let i = 0; i < props.columnCount; i++) {
      width += columnWidth(i);
    return [height, width];
  }, [columnWidth, rowHeight, props.columnCount, props.rowCount]);

  // maximum value in pixels to which we can scroll
  const maxVScroll = gridHeight - frozenRowHeight - unfrozenHeight;
  const maxHScroll = gridWidth - frozenColWidth - unfrozenWidth;

  // The magic happens here. We listen to scroll event in an external div, and apply scroll data to
  // grids. This allows us to synchronize scroll events between grids, applying changes in one
  // render "tick".
  useEffect(() => {
    const { current: outerGrid } = outerContainerRef;
    if (outerGrid) {
      const handler = (e: WheelEvent) => {
        const { deltaX, deltaY } = e;
        const { current: grid } = mainGridRef;
        const { current: top } = topHeaderRef;
        const { current: left } = leftHeaderRef;
        const { current: gridDiv } = mainGridContainerRef;
        if (gridDiv && grid && top && left) {
          let { scrollLeft, scrollTop } = gridDiv;
          scrollLeft += deltaX;
          scrollTop += deltaY;
          if (scrollLeft > maxHScroll) {
            scrollLeft = maxHScroll;
          if (scrollTop > maxVScroll) {
            scrollTop = maxVScroll;
          grid.scrollTo({ scrollLeft, scrollTop });
          top.scrollTo({ scrollLeft });
          left.scrollTo({ scrollTop });
      outerGrid.addEventListener("wheel", handler);
      return () => outerGrid.removeEventListener("wheel", handler);

  const unfrozenHorizontalDivStyles = useMemo(
    (): CSSProperties => ({ position: "absolute", left: frozenColWidth, top: 0 }),
  const mainGridInnerContainerStyles = useMemo(
    (): CSSProperties => ({
      position: "absolute",
      left: frozenColWidth,
      top: frozenRowHeight,
    [frozenColWidth, frozenRowHeight]

  return (
); };

This approach allowed us to provide smooth scrolling of frozen rows and columns while simultaneously reusing many of the features of a third-party virtual scroll library. Since this ideological approach relies on pure event handlers and HTML/CSS, it should be applicable to virtual scrolls with other libraries and frameworks.

Hope this article helps you, happy coding!

Subscribe for the news and updates

More thoughts
Sep 21, 2020Technology
How to Optimize Django ORM Queries

Django ORM is a very abstract and flexible API. But if you do not know exactly how it works, you will likely end up with slow and heavy views, if you have not already. So, this article provides practical solutions to N+1 and high loading time issues. For clarity, I will create a simple view that demonstrates common ORM query problems and shows frequently used practices.

Jun 14, 2017Technology
How to Deploy a Django Application on Heroku?

In this article I'll show you how to deploy Django with Celery and Postgres to Heroku.

May 18, 2017Technology
Angular2: Development Tips and Trick

In this article we'll discuss some tricks you can use with Angular to make routing cleaner and improve SEO of your application.

Aug 31, 2016Technology
Angular vs React Comparison

In this article, we will compare two most popular JS Libraries (Angular vs React). Both of them were created by professionals and have been used in famous big projects.

Jun 25, 2011Technology
Ajax blocks in Django

Quite often we have to write paginated or filtered blocks of information on page. I created a decorator that would automate this process.

Apr 3, 2011Technology
Sprite cache invalidation

When we use css-sprites it's important to make browser cache them for longest period possible. On other hand, we need to refresh them when they are updated. This is especially visible when all icons are stored in single sprite. When it's outdated - entire site becomes ugly.