virtual
virtual copied to clipboard
Sticky header in a useReactTable table with useVirtualizer disappears when scrolling
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:
- The container that takes the height that should be taken up. This is the div that is scrollable
- 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
- Add
position: stickyto thetheadelement - 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.
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.
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.
@wjthieme i think the example you linked is just the regular virtual table example...there's nothing with sticky headers in it.
@ryanjoycemacq if you add position: sticky to the <thead> element in the example you can reproduce this.
@riptusk331 I'll check if this works!
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)`,
}} >
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.
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 if you scroll up, header is detached and shown in the middle
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>
</>
);
}
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.
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
@wjthieme Did you solve this problem? I have same one with you. I want to make table header sticky, but it does not work.
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>,
)