virtual icon indicating copy to clipboard operation
virtual copied to clipboard

Sticky header in a useReactTable table with useVirtualizer disappears when scrolling

Open wjthieme opened this issue 1 year ago • 32 comments

Describe the bug

When using a react-table together with vritualizer and a sticky header the sticky header disappears when scrolling. This is due to the fact that when combining the two the table should be wrapped in two divs:

  1. The container that takes the height that should be taken up. This is the div that is scrollable
  2. The div that directly wraps the container and takes the height of all the virtual items combined

Since the table element at any given time only contains the visible rows (plus overscan) the table itself has a height smaller than the wrapper div (nr. 2). This causes the sticky header to disappear when you scroll down since the sticky header cannot escape the table element.

Your minimal, reproducible example

https://codesandbox.io/p/devbox/tanstack-react-virtual-example-dynamic-mr8t3x

Steps to reproduce

  1. Add position: sticky to the thead element
  2. Scroll down the table and watch how the header dissapears

Expected behavior

The header should stay on top since it is sticky.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

Chrome

tanstack-virtual version

3.0.0-beta.68

TypeScript version

5.3.2

Additional context

I've tried getting rid of the wrapper div (nr. 2) and setting the height: ${getTotalSize()}px` directly on the table element but this causes the rows' height the grow because there are only ever enough rows to fit on the screen (plus overscan) but having the table the full height causes the rows to evenly divide the space between each other (making them a lot larger).

Terms & Code of Conduct

  • [X] I agree to follow this project's Code of Conduct
  • [X] I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

wjthieme avatar Dec 20 '23 20:12 wjthieme

I found a way to fix this, and am linking a working example. The trick is to calculate the height difference between the table element and the scrollable div, and then append an invisible pseudo-element to the table with that calculated height. This results in the table having the same height as the scrollable div, and stops the header from vanishing since you'll no longer scroll past the "end" of table element.

Doing this is a little bit tricky since pseudo elements can't be controlled directly through React/JSX inline styling, and directly adding new styles to the DOM is super expensive and forces the browser to recalculate the layout (which will make scrolling janky). The best way I found to do this was with with CSS variables, which the browser is very efficient at handling and it doesn't force a full layout recalc. I added a virtual-table::after CSS class with a variable height, and made sure to class my <table> element with virtual-table. Then just set that height from your handlers in your React table component.

Also, since the number of table rows (<tr>) being rendered to the DOM by the virtualizer at any given time varies (and therefore the height of the table + your originally set pseudo element varies), you need to pay attention to this as you near the bottom of your scrollable area as this could result in a large divergence (meaning you'll over-scroll past the table, or the pseudo element will be too short and the header will disappear again). The way I did this was set up an event listener for the scroll that flags when we're near the bottom 5% of the list, and triggers the pseudo element height recalculation.

Sticky Table Header Example

I'm sure this can be improved and there are further efficiencies, but it works buttery smooth for me.

As for the "issue" itself, I would argue that this isn't really a react-virtual issue. I don't think there's anything the library could really do for you. This is just a quirk of virtualizing that you need to account for. Virtualizing tables is kind of a bastardization of things. That said, it would be good to include this as an example in the existing docs, as none of this is immediately obvious.

riptusk331 avatar Dec 28 '23 19:12 riptusk331

@wjthieme i think the example you linked is just the regular virtual table example...there's nothing with sticky headers in it.

ryanjoycemacq avatar Dec 30 '23 17:12 ryanjoycemacq

@ryanjoycemacq if you add position: sticky to the <thead> element in the example you can reproduce this.

@riptusk331 I'll check if this works!

wjthieme avatar Jan 01 '24 09:01 wjthieme

TEMP Workaround - issues see post below Cleaner "hack/solution", but not super smooth can use parentRef (ref of scrolling component) to generate your own code version of sticky less performant, but more maintainable until bug solved

<TableRow
    key={headerGroup.id}
    className="relative z-20"
    style={{
      transform: `translateY(${
        parentRef.current?.scrollTop || 0
      }px)`,
    }} >
           

aronedwards avatar Jan 10 '24 14:01 aronedwards

There are few issues here, overfall tables don't quite like when rows are positions absolute, one option is to change the positioning strategy by adding before/after row that will move items when scrolling

https://codesandbox.io/p/devbox/zealous-firefly-8vm5y2?file=%2Fsrc%2Fmain.tsx%3A91%2C19

btw there is no clear answer what positioning strategy is better, it's really hard to compare performance in a significant way.

piecyk avatar Jan 10 '24 15:01 piecyk

Try applying the transform only if you are not scrolling and add a smooth transition when the header appears

const headerPosition = virtualizer.scrollOffset

<table style={{ position: "relative" }>
<thead>
<th
    key={headerGroup.id}
    style={{
      position: "relative",
      zIndex: 2,
      transition: "transform 0.5s",
      transform: !virtualizer.isScrolling
        ? `translateY(${headerPosition}px)`
        : "translateY(0px)",
    }}
  >

kelvinfloresta avatar May 29 '24 05:05 kelvinfloresta

@kelvinfloresta if you scroll up, header is detached and shown in the middle

Lipus86 avatar May 30 '24 22:05 Lipus86

Here's a clean and working solution:

Separate the TableHead and the TableBody and add toggle (or other) buttons as required.

Sample implementation with Search Bar and Column Visibility toggles:

"use client";

import React, { useState } from "react";
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  useReactTable,
  VisibilityState,
  SortingState,
  getSortedRowModel,
} from "@tanstack/react-table";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/src/components/ui/table"; // Adjust the import path
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { Input } from "./input"; // Adjust the import path
import { Button } from "./button"; // Adjust the import path
import { ScrollArea, ScrollBar } from "./scroll-area"; // Adjust the import path

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  searchKey: string;
  searchKeyLabel: string;
}

export function DataTable<TData, TValue>({
  columns,
  data,
  searchKey,
  searchKeyLabel,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});

  const table = useReactTable({
    data,
    columns,
    initialState: { pagination: { pageSize: 13 } },
    getPaginationRowModel: getPaginationRowModel(),
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getSortedRowModel: getSortedRowModel(),
    onColumnVisibilityChange: setColumnVisibility,
    onSortingChange: setSorting,
    state: { sorting, columnVisibility },
  });

  return (
    <>
      <div className="flex items-center justify-between mb-4">
        <Input
          placeholder={`Search ${searchKeyLabel}...`}
          value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
          onChange={(event) =>
            table.getColumn(searchKey)?.setFilterValue(event.target.value)
          }
          className="w-full md:max-w-sm"
        />
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="outline" className="hover:scale-105 transition-transform duration-200">
              Select Columns
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            {table.getAllColumns().filter(column => column.getCanHide()).map(column => (
              <DropdownMenuCheckboxItem
                key={column.id}
                className="capitalize"
                checked={column.getIsVisible()}
                onCheckedChange={value => column.toggleVisibility(!!value)}
              >
                {column.id}
              </DropdownMenuCheckboxItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      <ScrollArea className="rounded-md border h-[calc(80vh-220px)]">
        <Table className="table-fixed">
          <TableHeader className="sticky top-0 z-10 bg-white">
            {table.getHeaderGroups().map(headerGroup => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map(header => (
                  <TableHead key={header.id} className="p-1 text-left" style={{ width: header.column.columnDef.meta?.width }}>
                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.length ? (
              table.getRowModel().rows.map(row => (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map(cell => (
                    <TableCell key={cell.id} className="p-1" style={{ width: cell.column.columnDef.meta?.width }}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="h-24 text-center">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
        <ScrollBar orientation="horizontal" />
      </ScrollArea>

      <div className="flex items-center justify-end space-x-2 py-4">
        <div className="flex-1 text-sm text-muted-foreground">
          {table.getFilteredSelectedRowModel().rows.length} of{" "}
          {table.getFilteredRowModel().rows.length} row(s) selected.
        </div>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </>
  );
}

bgrgv avatar Jun 12 '24 20:06 bgrgv

Here's a clean and working solution:

Separate the TableHead and the TableBody and add toggle (or other) buttons as required.

Sample implementation with Search Bar and Column Visibility toggles:

"use client";

import React, { useState } from "react";
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  useReactTable,
  VisibilityState,
  SortingState,
  getSortedRowModel,
} from "@tanstack/react-table";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/src/components/ui/table"; // Adjust the import path
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { Input } from "./input"; // Adjust the import path
import { Button } from "./button"; // Adjust the import path
import { ScrollArea, ScrollBar } from "./scroll-area"; // Adjust the import path

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  searchKey: string;
  searchKeyLabel: string;
}

export function DataTable<TData, TValue>({
  columns,
  data,
  searchKey,
  searchKeyLabel,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});

  const table = useReactTable({
    data,
    columns,
    initialState: { pagination: { pageSize: 13 } },
    getPaginationRowModel: getPaginationRowModel(),
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getSortedRowModel: getSortedRowModel(),
    onColumnVisibilityChange: setColumnVisibility,
    onSortingChange: setSorting,
    state: { sorting, columnVisibility },
  });

  return (
    <>
      <div className="flex items-center justify-between mb-4">
        <Input
          placeholder={`Search ${searchKeyLabel}...`}
          value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
          onChange={(event) =>
            table.getColumn(searchKey)?.setFilterValue(event.target.value)
          }
          className="w-full md:max-w-sm"
        />
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="outline" className="hover:scale-105 transition-transform duration-200">
              Select Columns
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            {table.getAllColumns().filter(column => column.getCanHide()).map(column => (
              <DropdownMenuCheckboxItem
                key={column.id}
                className="capitalize"
                checked={column.getIsVisible()}
                onCheckedChange={value => column.toggleVisibility(!!value)}
              >
                {column.id}
              </DropdownMenuCheckboxItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      <ScrollArea className="rounded-md border h-[calc(80vh-220px)]">
        <Table className="table-fixed">
          <TableHeader className="sticky top-0 z-10 bg-white">
            {table.getHeaderGroups().map(headerGroup => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map(header => (
                  <TableHead key={header.id} className="p-1 text-left" style={{ width: header.column.columnDef.meta?.width }}>
                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.length ? (
              table.getRowModel().rows.map(row => (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map(cell => (
                    <TableCell key={cell.id} className="p-1" style={{ width: cell.column.columnDef.meta?.width }}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="h-24 text-center">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
        <ScrollBar orientation="horizontal" />
      </ScrollArea>

      <div className="flex items-center justify-end space-x-2 py-4">
        <div className="flex-1 text-sm text-muted-foreground">
          {table.getFilteredSelectedRowModel().rows.length} of{" "}
          {table.getFilteredRowModel().rows.length} row(s) selected.
        </div>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </>
  );
}

This doesn't use virtualization.

Ghostblad3 avatar Jun 28 '24 10:06 Ghostblad3

https://codesandbox.io/p/devbox/zealous-firefly-8vm5y2?file=%2Fsrc%2Fmain.tsx%3A91%2C19

Could you possibly add the relevant code to this thread? Your link doesn't work for me

Ryanjso avatar Jul 21 '24 22:07 Ryanjso

@wjthieme Did you solve this problem? I have same one with you. I want to make table header sticky, but it does not work.

jihea-park avatar Jul 23 '24 08:07 jihea-park

https://codesandbox.io/p/devbox/zealous-firefly-8vm5y2?file=%2Fsrc%2Fmain.tsx%3A91%2C19

Could you possibly add the relevant code to this thread? Your link doesn't work for me

import * as React from 'react'
import { createRoot } from 'react-dom/client'

import { useVirtualizer, notUndefined } from '@tanstack/react-virtual'
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  Row,
  SortingState,
  useReactTable,
} from '@tanstack/react-table'
import { makeData, Person } from './makeData'
import './index.css'

function ReactTableVirtualized() {
  const [sorting, setSorting] = React.useState<SortingState>([])

  const columns = React.useMemo<ColumnDef<Person>[]>(
    () => [
      {
        accessorKey: 'id',
        header: 'ID',
        size: 60,
      },
      {
        accessorKey: 'firstName',
        cell: (info) => info.getValue(),
      },
      {
        accessorFn: (row) => row.lastName,
        id: 'lastName',
        cell: (info) => info.getValue(),
        header: () => <span>Last Name</span>,
      },
      {
        accessorKey: 'age',
        header: () => 'Age',
        size: 50,
      },
      {
        accessorKey: 'visits',
        header: () => <span>Visits</span>,
        size: 50,
      },
      {
        accessorKey: 'status',
        header: 'Status',
      },
      {
        accessorKey: 'progress',
        header: 'Profile Progress',
        size: 80,
      },
      {
        accessorKey: 'createdAt',
        header: 'Created At',
        cell: (info) => info.getValue<Date>().toLocaleString(),
      },
    ],
    [],
  )

  const [data, setData] = React.useState(() => makeData(50_000))

  const table = useReactTable({
    data,
    columns,
    state: {
      sorting,
    },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    debugTable: true,
  })

  const { rows } = table.getRowModel()

  const parentRef = React.useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 34,
    overscan: 0,
  })
  const items = virtualizer.getVirtualItems()
  const [before, after] =
  items.length > 0
    ? [
        notUndefined(items[0]).start - virtualizer.options.scrollMargin,
        virtualizer.getTotalSize() - notUndefined(items[items.length - 1]).end,
      ]
    : [0, 0]
  const colSpan = 8

  return (
    <div ref={parentRef} className="container">
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <th
                    key={header.id}
                    colSpan={header.colSpan}
                    style={{ 
                      width: header.getSize(),
                      position: 'sticky',
                      top: 0,
                      backgroundColor: 'red'
                    }}
                  >
                    {header.isPlaceholder ? null : (
                      <div
                        {...{
                          className: header.column.getCanSort()
                            ? 'cursor-pointer select-none'
                            : '',
                          onClick: header.column.getToggleSortingHandler(),
                        }}
                      >
                        {flexRender(
                          header.column.columnDef.header,
                          header.getContext(),
                        )}
                        {{
                          asc: ' 🔼',
                          desc: ' 🔽',
                        }[header.column.getIsSorted() as string] ?? null}
                      </div>
                    )}
                  </th>
                )
              })}
            </tr>
          ))}
        </thead>
        <tbody>
          {before > 0 && (
            <tr>
              <td colSpan={colSpan} style={{ height: before }} />
            </tr>
          )}
          {items.map((virtualRow, index) => {
            const row = rows[virtualRow.index] as Row<Person>
            return (
              <tr
                key={row.id}
                style={{
                  height: `${virtualRow.size}px`,
                }}
              >
                {row.getVisibleCells().map((cell) => {
                  return (
                    <td key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext(),
                      )}
                    </td>
                  )
                })}
              </tr>
            )
          })}
          {after > 0 && (
            <tr>
              <td colSpan={colSpan} style={{ height: after }} />
            </tr>
          )}
        </tbody>
      </table>
    </div>
  )
}

function App() {
  return (
    <div>
      <p>
        For tables, the basis for the offset of the translate css function is
        from the row's initial position itself. Because of this, we need to
        calculate the translateY pixel count different and base it off the the
        index.
      </p>
      <ReactTableVirtualized />
      <br />
      <br />
      {process.env.NODE_ENV === 'development' ? (
        <p>
          <strong>Notice:</strong> You are currently running React in
          development mode. Rendering performance will be slightly degraded
          until this application is build for production.
        </p>
      ) : null}
    </div>
  )
}

const container = document.getElementById('root')
const root = createRoot(container!)
const { StrictMode } = React

root.render(
  <StrictMode>
    <App />
  </StrictMode>,
)

Ghostblad3 avatar Jul 24 '24 15:07 Ghostblad3