Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sticky header in a useReactTable table with useVirtualizer disappears when scrolling #640

Open
2 tasks done
wjthieme opened this issue Dec 20, 2023 · 24 comments
Open
2 tasks done

Comments

@wjthieme
Copy link

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

  • I agree to follow this project's Code of Conduct
  • 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.
@riptusk331
Copy link

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.

@ryanjoycemacq
Copy link

ryanjoycemacq commented Dec 30, 2023

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

@wjthieme
Copy link
Author

wjthieme commented Jan 1, 2024

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

@riptusk331 I'll check if this works!

@aronedwards
Copy link

aronedwards commented Jan 10, 2024

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)`,
    }} >
           

@piecyk
Copy link
Collaborator

piecyk commented Jan 10, 2024

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.

@kelvinfloresta
Copy link

kelvinfloresta commented May 29, 2024

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)",
    }}
  >

@Lipus86
Copy link

Lipus86 commented May 30, 2024

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

@bgrgv
Copy link

bgrgv commented Jun 12, 2024

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>
    </>
  );
}

@Ghostblad3
Copy link

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.

@Ryanjso
Copy link

Ryanjso commented Jul 21, 2024

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

@jihea-park
Copy link

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

@Ghostblad3
Copy link

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>,
)

@aman-jha-gsc
Copy link

aman-jha-gsc commented Aug 29, 2024

Here is the working code for your reference

import { Card } from 'components/ui';
import { flexRender } from '@tanstack/react-table';
import { DataTablePagination } from 'components/dataTable/data-table-pagination';
import { DataTableViewOptions } from 'components/dataTable/data-table-view-options';
import { ScrollArea, ScrollBar } from 'components/ui/scroll-area';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/ui/table';

const DataTableCard = ({ title, description, table, columns, toolbar, actionButtons, height = '300px', children }) => {
  return (
    <Card className="h-full flex flex-col overflow-hidden">
      <div className="bg-white">
        <div className="space-y-1.5 p-3 flex flex-row items-start bg-muted/50">
          <div className="grid gap-0.5">
            <h3 className="font-semibold tracking-tight group flex items-center gap-2 text-lg">{title}</h3>
            {description && <p className="text-sm text-muted-foreground">{description}</p>}
          </div>
        </div>
        <div className="space-x-4 p-3 flex items-center justify-between">
          {toolbar && <div className="flex items-center">{toolbar}</div>}
          <div className="ml-auto flex items-center gap-2">
            <DataTableViewOptions table={table} />
            {actionButtons}
          </div>
        </div>
      </div>
      <div className="flex-grow overflow-hidden flex flex-col">
        <div className="flex-grow">
          <Table>
            <ScrollArea className={` h-[calc(95vh-${height})]`}>
              <TableHeader className="sticky top-0 z-10 bg-[#c4eaf5]">
                {table.getHeaderGroups().map((headerGroup) => (
                  <TableRow key={headerGroup.id}>
                    {headerGroup.headers.map((header) => {
                      // Check if this header is a group
                      const isGroup = header.column.columnDef.columns !== undefined;
                      return (
                        <TableHead
                          className={` text-blue-800 font-bold p-2 text-left ${isGroup > 0 ? 'border-l-2 border-blue-300' : ''}`}
                          key={header.id}
                          colSpan={isGroup ? header.column.columnDef.columns.length : 1}
                        >
                          {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                        </TableHead>
                      );
                    })}
                  </TableRow>
                ))}
              </TableHeader>
              <TableBody>
                {table.getRowModel().rows?.length ? (
                  table.getRowModel().rows.map((row) => (
                    <TableRow className="even:bg-slate-100" key={row.id} data-state={row.getIsSelected() && 'selected'}>
                      {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>
              <ScrollBar orientation="horizontal" />
            </ScrollArea>
          </Table>
        </div>
      </div>
      <div className="mt-auto p-3">
        <DataTablePagination table={table} />
      </div>
    </Card>
  );
};

export default DataTableCard;

@damiaomk
Copy link

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

  • 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.

Did you solve this problem ?

@newbeelearn
Copy link

newbeelearn commented Oct 9, 2024

here is the snippet that works, i have just copy, pasted did not cleaned it up so please ignore the irrelevant bits.
Key bits are

style={{
		height: `${virtualizer.getTotalSize()}px`
              }}> 

on tbody this will ensure that your scrollbar indicates true size of table and if you use sticky in header it will not go away.

<tr
                    key={virtualRow.index}
                    className="border-b transition-colors hover:bg-muted/50 h-12 absolute w-full"
                    style={{
                      transform: `translateY(${virtualRow.start}px)`,
                    }}
                  >

This will ensure that your virtualized rows are correctly positioned.

Last but not the least after this if you have sufficiently large table(million+ rows) you will hit browsers css pixel limit, you can refer to this issue #460 . If its not the case than the current solution should work.

with the way current lib is setup, i am not sure if there is a solution with pixel based approach

(
        <div ref={parentRef} className="h-[600px] overflow-auto relative">
          <table className="w-full caption-bottom text-sm border">
            <thead className="[&_tr]:border-b">
              <tr className="border-b transition-colors hover:bg-muted/50">
                {visibleColumns
                  .map((column) => (
                    <th
                      key={column.id}
		      ref={(el) => (headerRefs.current[column.id] = el)}
                      className="h-12 px-4 text-left font-medium text-muted-foreground sticky z-10 top-0 bg-background"
                    >
                      <HeaderCell
                        column={column}
                        onSort={() => handleSort(column.id)}
                        sortDirection={ sorting.find(s => s.column === column.id)?.operator || 'none' }
                      />
                    </th>
                  ))}
              </tr>
            </thead>
            <tbody className={tableMetadata.styles?.tableBody ?? `p-4 relative w-full`}
	    style={{
		height: `${virtualizer.getTotalSize()}px`
              }}>
              {virtualizer.getVirtualItems().map((virtualRow) => {
                const row = tableData[virtualRow.index];
                return (
                  <tr
                    key={virtualRow.index}
                    className="border-b transition-colors hover:bg-muted/50 h-12 absolute w-full"
                    style={{
                      transform: `translateY(${virtualRow.start}px)`,
                    }}
                  >
                    {visibleColumns.map((column) => (
                      <td key={column.id} className="px-4 py-1 text-left"
		      style={{ width: `${columnWidths[column.id]}px` }}>
                        <p className="max-w-full h-10 overflow-auto overflow-y-auto whitespace-normal"
                          title={String(getValue(dataTypes, column.id, row))}>
                          {String(getValue(dataTypes, column.id, row))}
                        </p>
                      </td>
                    ))}
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
	) 

@piecyk
Copy link
Collaborator

piecyk commented Oct 11, 2024

@newbeelearn Thanks for sharing the snippet! You're right, the current implementation doesn't handle the maximum pixel limit, and this is something that can happen with very large tables. Handling this is not trivial, as it introduces additional complexity with managing large offsets.

That said, we’re open to ideas and would be happy to accept any PR proposals addressing this. If you have a solution in mind, feel free to contribute!

@seedydocloop
Copy link

@newbeelearn Thanks for sharing your solution !

Sadly, for tables with fixed-layout, setting position: 'absolute' breaks column layout.

Is there any solution with relatively positioned table rows ?

@MKSisti
Copy link

MKSisti commented Oct 31, 2024

@newbeelearn Thanks for sharing your solution !

Sadly, for tables with fixed-layout, setting position: 'absolute' breaks column layout.

Is there any solution with relatively positioned table rows ?

Did you find a solution for this ? @seedydocloop, been stuck on this for a while now

@piecyk
Copy link
Collaborator

piecyk commented Nov 3, 2024

@seedydocloop @MKSisti relatively positioned rows directly usually won’t work as intended because tables do not behave like regular block elements.

@seedydocloop
Copy link

@piecyk @MKSisti I ended up using a before and after row which height simulate the missing space.
This way, rows are 'position: static'.
Found this in another discussion.

@MKSisti
Copy link

MKSisti commented Nov 4, 2024

Thank you for replying 🙏
@piecyk, makes sense but what would be a good approach to having virtualized tables that keep the column layout if there's any .
@seedydocloop as in injecting a before row in the data ? or adding a row before the virtual rows?, can you share a link to the discussion might be helpful, thanks a lot in advance.

@seedydocloop
Copy link

seedydocloop commented Nov 4, 2024

@MKSisti I implemented my solution from this discussion : #585 (comment)

Check the codesandbox from @piecyk

Sorry, it took me time to find it 🙏

I really think this example should be one mainstream solution shown in the tanstack virtual docs

@soliyapintu
Copy link

@piecyk Do you have any suggestions or solutions for implementing sticky columns when using horizontal virtualization in @tanstack/react-table?

@houfeng0923
Copy link

houfeng0923 commented Jan 8, 2025

@piecyk @wjthieme
we can add prepend and append empty <tr> in <tbody> with computed height.
or add extra <tbody empty></tbody><tbody data><tbody><tbody empty></tbody>

pls check this demo:

https://stackblitz.com/edit/tanstack-virtual-wrmyygfq?file=src%2FApp.vue

and

https://stackblitz.com/edit/tanstack-virtual-nbspetuk?file=src%2FApp.vue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests