From 16bfafe32b83b271a8dd2bc1347a78f1658ce7e7 Mon Sep 17 00:00:00 2001
From: Peter White <1788320+peterwhite@users.noreply.github.com>
Date: Wed, 25 Mar 2026 14:04:35 +0100
Subject: [PATCH] Add option to sticky header row in tables (#4134)
---
.changeset/real-ways-refuse.md | 5 +
bun.lock | 4 +-
package.json | 2 +-
packages/gitbook/e2e/customers.spec.ts | 2 +-
.../DocumentView/Table/RecordRow.tsx | 18 +-
.../DocumentView/Table/StickyViewGrid.tsx | 151 +++++++++++++++
.../components/DocumentView/Table/Table.tsx | 75 +++++++-
.../DocumentView/Table/ViewGrid.tsx | 178 ++++++++----------
.../components/DocumentView/Table/layout.ts | 68 +++++++
.../DocumentView/Table/table.module.css | 45 -----
10 files changed, 385 insertions(+), 163 deletions(-)
create mode 100644 .changeset/real-ways-refuse.md
create mode 100644 packages/gitbook/src/components/DocumentView/Table/StickyViewGrid.tsx
create mode 100644 packages/gitbook/src/components/DocumentView/Table/layout.ts
delete mode 100644 packages/gitbook/src/components/DocumentView/Table/table.module.css
diff --git a/.changeset/real-ways-refuse.md b/.changeset/real-ways-refuse.md
new file mode 100644
index 000000000..0151d67f7
--- /dev/null
+++ b/.changeset/real-ways-refuse.md
@@ -0,0 +1,5 @@
+---
+"gitbook": patch
+---
+
+Add option to sticky header row in tables
diff --git a/bun.lock b/bun.lock
index 9a7003872..b54699cd9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -349,7 +349,7 @@
"react-dom": "catalog:",
},
"catalog": {
- "@gitbook/api": "0.172.0",
+ "@gitbook/api": "0.173.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -746,7 +746,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
- "@gitbook/api": ["@gitbook/api@0.172.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-EoOhOt4cZpwZKPaQ0E5jb3Ea4Fvmi11at5Y/62cFy5ahyB3355jfKp0HPxe1bGsKfm421RkaM4Ico0vtVZwQ6Q=="],
+ "@gitbook/api": ["@gitbook/api@0.173.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-AUuRgSi9gCZDZ7GcDQ1o+6CGF70H1RizqydAsW/btKc2FTCYJK5ifbH6VTvsdmgHV38WIk3kTqRujczQ9uEegQ=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
diff --git a/package.json b/package.json
index 0dd21d2b7..aaf0853b1 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
- "@gitbook/api": "0.172.0",
+ "@gitbook/api": "0.173.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts
index b470ecda2..0bbfefddd 100644
--- a/packages/gitbook/e2e/customers.spec.ts
+++ b/packages/gitbook/e2e/customers.spec.ts
@@ -233,7 +233,7 @@ const testCases: TestsCase[] = [
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
tests: [
- { name: 'Home', url: '/' },
+ // { name: 'Home', url: '/' } Temporarily skipped: this page is unstable in CI during Argos screenshots.
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
],
},
diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx
index ccadb8391..2e4a15933 100644
--- a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx
+++ b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx
@@ -4,8 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
-import { getColumnWidth } from './ViewGrid';
-import styles from './table.module.css';
+import { getColumnWidth } from './layout';
import { getColumnVerticalAlignment } from './utils';
export function RecordRow(
@@ -18,7 +17,15 @@ export function RecordRow(
const { view, autoSizedColumns, fixedColumns, block, context } = props;
return (
-
+
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
column,
@@ -33,7 +40,10 @@ export function RecordRow(
(null);
+ const stickyHeaderRef = useRef
(null);
+ const bodyScrollRef = useRef(null);
+ const bodyTableRef = useRef(null);
+
+ const onStickyHeaderWheel = useCallback((event: WheelEvent) => {
+ const bodyScrollElement = bodyScrollRef.current;
+ if (!bodyScrollElement) {
+ return;
+ }
+
+ const horizontalDelta = event.deltaX || (event.shiftKey && event.deltaY ? event.deltaY : 0);
+ if (horizontalDelta === 0) {
+ return;
+ }
+
+ bodyScrollElement.scrollLeft += horizontalDelta;
+ event.preventDefault();
+ }, []);
+
+ useEffect(() => {
+ const stickyHeaderElement = stickyHeaderRef.current;
+ if (!stickyHeaderElement) {
+ return;
+ }
+
+ stickyHeaderElement.addEventListener('wheel', onStickyHeaderWheel, { passive: false });
+
+ return () => {
+ stickyHeaderElement.removeEventListener('wheel', onStickyHeaderWheel);
+ };
+ }, [onStickyHeaderWheel]);
+
+ const syncStickyLayout = useCallback(() => {
+ const rootElement = rootRef.current;
+ const bodyScrollElement = bodyScrollRef.current;
+ const bodyTableElement = bodyTableRef.current;
+ if (!rootElement || !bodyScrollElement || !bodyTableElement) {
+ return;
+ }
+
+ rootElement.style.setProperty(
+ '--table-sticky-scroll-left',
+ `${-bodyScrollElement.scrollLeft}px`
+ );
+ rootElement.style.setProperty(
+ '--table-sticky-table-width',
+ `${bodyTableElement.scrollWidth}px`
+ );
+ rootElement.dataset.scrollable = `${
+ bodyScrollElement.scrollWidth > bodyScrollElement.clientWidth + 1
+ }`;
+ }, []);
+
+ useLayoutEffect(() => {
+ syncStickyLayout();
+ }, [syncStickyLayout]);
+
+ useScrollListener(syncStickyLayout, bodyScrollRef);
+
+ useEffect(() => {
+ const bodyScrollElement = bodyScrollRef.current;
+ const bodyTableElement = bodyTableRef.current;
+
+ if (!bodyScrollElement && !bodyTableElement) {
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver(syncStickyLayout);
+
+ if (bodyScrollElement) {
+ resizeObserver.observe(bodyScrollElement);
+ }
+
+ if (bodyTableElement) {
+ resizeObserver.observe(bodyTableElement);
+ }
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [syncStickyLayout]);
+
+ return (
+
+ );
+}
diff --git a/packages/gitbook/src/components/DocumentView/Table/Table.tsx b/packages/gitbook/src/components/DocumentView/Table/Table.tsx
index 4a15773f3..bd56015d7 100644
--- a/packages/gitbook/src/components/DocumentView/Table/Table.tsx
+++ b/packages/gitbook/src/components/DocumentView/Table/Table.tsx
@@ -1,10 +1,14 @@
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
import assertNever from 'assert-never';
+import { tcls } from '@/lib/tailwind';
+
import type { BlockProps } from '../Block';
import { isBlockOffscreen } from '../utils';
+import { StickyViewGrid } from './StickyViewGrid';
import { ViewCards } from './ViewCards';
-import { ViewGrid } from './ViewGrid';
+import { ViewGrid, ViewGridHeader } from './ViewGrid';
+import { getViewGridLayout, hasVisibleHeader } from './layout';
export type TableRecordKV = [string, DocumentTableRecord];
@@ -15,7 +19,7 @@ export interface TableViewProps extends BlockProps {
}
export function Table(props: BlockProps) {
- const { block, ancestorBlocks, document } = props;
+ const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
@@ -32,15 +36,68 @@ export function Table(props: BlockProps) {
{...props}
/>
);
- case 'grid':
+ case 'grid': {
+ const gridProps = {
+ ...props,
+ view: block.data.view,
+ isOffscreen,
+ records,
+ };
+ const { tableWidth } = getViewGridLayout({
+ block,
+ view: block.data.view,
+ mode: context.mode,
+ });
+ const tableContainerClassName =
+ tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth;
+ const withHeader = hasVisibleHeader(block, block.data.view);
+ const withStickyHeader =
+ withHeader && context.mode !== 'print' && block.data.view.stickyHeader === true;
+
+ if (withStickyHeader) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
return (
-
+
+
+
+ {withHeader ? (
+
+ ) : null}
+
+
+
+
);
+ }
default:
assertNever(block.data.view);
}
diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx
index 0ff9584df..2393acfff 100644
--- a/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx
+++ b/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx
@@ -4,118 +4,94 @@ import { tcls } from '@/lib/tailwind';
import { RecordRow } from './RecordRow';
import type { TableViewProps } from './Table';
-import styles from './table.module.css';
+import { getColumnWidth, getViewGridLayout } from './layout';
import { getColumnAlignment } from './utils';
-/* Columns are sized in 3 ways:
- 1. Set to auto-size by default, these columns share the available width
- 2. Explicitly set by the user by dragging column separator (we then turn off auto-size)
- 3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px
-*/
-export function ViewGrid(props: TableViewProps) {
- const { block, view, records, style, context } = props;
+interface ViewGridHeaderProps extends TableViewProps {
+ className?: string;
+}
- /* Calculate how many columns are auto-sized vs fixed width */
- const columnWidths = context.mode === 'print' ? undefined : view.columnWidths;
- const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]);
- const fixedColumns = view.columns.filter((column) => columnWidths?.[column]);
+interface ViewGridProps extends TableViewProps {
+ tableClassName?: string;
+}
- const tableWidth = autoSizedColumns.length > 0 ? 'w-full' : 'w-fit';
-
- /* Only show the header when configured and not empty */
- const withHeader =
- !view.hideHeader &&
- view.columns.some(
- (columnId) => (block.data.definition[columnId]?.title.trim().length ?? 0) > 0
- );
+export function ViewGridHeader(props: ViewGridHeaderProps) {
+ const { block, view, context, className } = props;
+ const { tableWidth, columnWidths, autoSizedColumns, fixedColumns } = getViewGridLayout({
+ block,
+ view,
+ mode: context.mode,
+ });
return (
-
- {/* Table */}
-
- {/* Header */}
- {withHeader && (
-
-
- {view.columns.map((column) => {
- const definition = block.data.definition[column]!;
- return (
-
- {definition.title}
-
- );
- })}
+
+
+ {view.columns.map((column) => {
+ const definition = block.data.definition[column];
+ if (!definition) {
+ return null;
+ }
+
+ return (
+
+ {definition.title}
-
- )}
-
*+*]:border-t')}
- >
- {records.map((record) => (
-
- ))}
-
+ );
+ })}
);
}
-export const getColumnWidth = ({
- column,
- columnWidths,
- autoSizedColumns,
- fixedColumns,
-}: {
- column: string;
- columnWidths: Record
| undefined;
- autoSizedColumns: string[];
- fixedColumns: string[];
-}) => {
- const columnWidth = columnWidths?.[column];
+export function ViewGrid(props: ViewGridProps) {
+ const { block, view, records, context, tableClassName } = props;
+ const { tableWidth, autoSizedColumns, fixedColumns } = getViewGridLayout({
+ block,
+ view,
+ mode: context.mode,
+ });
- /* Column was explicitly set by user or user turned off auto-sizing (in that case, columnWidth should've also been set to 100px) */
- if (columnWidth) return `${columnWidth}px`;
+ const body = (
+ *+*]:border-t')}>
+ {records.map((record) => (
+
+ ))}
+
+ );
- /* Fallback minimum width for columns, so the columns don't become unreadable from being too narrow and instead table will become scrollable. */
- const minAutoColumnWidth = '100px';
-
- const totalFixedWidth = fixedColumns.reduce((sum, col) => {
- return sum + (columnWidths?.[col] || 0);
- }, 0);
-
- /* Column should use auto-sizing, which means it grows to fill available space */
- const availableWidth = `calc((100% - ${totalFixedWidth}px) / ${autoSizedColumns.length})`;
- return `clamp(${minAutoColumnWidth}, ${availableWidth}, 100%)`;
-};
+ return (
+
+ {body}
+
+ );
+}
diff --git a/packages/gitbook/src/components/DocumentView/Table/layout.ts b/packages/gitbook/src/components/DocumentView/Table/layout.ts
new file mode 100644
index 000000000..c70aec6ed
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/Table/layout.ts
@@ -0,0 +1,68 @@
+import type { DocumentBlockTable, DocumentTableViewGrid } from '@gitbook/api';
+
+import type { BlockProps } from '../Block';
+
+export function hasVisibleHeader(block: DocumentBlockTable, view: DocumentTableViewGrid): boolean {
+ return (
+ !view.hideHeader &&
+ view.columns.some(
+ (columnId) => (block.data.definition[columnId]?.title.trim().length ?? 0) > 0
+ )
+ );
+}
+
+/* Columns are sized in 3 ways:
+ 1. Set to auto-size by default, these columns share the available width
+ 2. Explicitly set by the user by dragging column separator (we then turn off auto-size)
+ 3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px
+*/
+export function getViewGridLayout({
+ block,
+ view,
+ mode,
+}: {
+ block: DocumentBlockTable;
+ view: DocumentTableViewGrid;
+ mode: BlockProps['context']['mode'];
+}) {
+ const columnWidths = mode === 'print' ? undefined : view.columnWidths;
+ const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]);
+ const fixedColumns = view.columns.filter((column) => columnWidths?.[column]);
+ const tableWidth = autoSizedColumns.length > 0 ? 'w-full' : 'w-fit';
+
+ return {
+ columnWidths,
+ autoSizedColumns,
+ fixedColumns,
+ tableWidth,
+ withHeader: hasVisibleHeader(block, view),
+ };
+}
+
+export const getColumnWidth = ({
+ column,
+ columnWidths,
+ autoSizedColumns,
+ fixedColumns,
+}: {
+ column: string;
+ columnWidths: Record | undefined;
+ autoSizedColumns: string[];
+ fixedColumns: string[];
+}) => {
+ const columnWidth = columnWidths?.[column];
+
+ /* Column was explicitly set by user or user turned off auto-sizing (in that case, columnWidth should've also been set to 100px) */
+ if (columnWidth) return `${columnWidth}px`;
+
+ /* Fallback minimum width for columns, so the columns don't become unreadable from being too narrow and instead table will become scrollable. */
+ const minAutoColumnWidth = '100px';
+
+ const totalFixedWidth = fixedColumns.reduce((sum, col) => {
+ return sum + (columnWidths?.[col] || 0);
+ }, 0);
+
+ /* Column should use auto-sizing, which means it grows to fill available space */
+ const availableWidth = `calc((100% - ${totalFixedWidth}px) / ${autoSizedColumns.length})`;
+ return `clamp(${minAutoColumnWidth}, ${availableWidth}, 100%)`;
+};
diff --git a/packages/gitbook/src/components/DocumentView/Table/table.module.css b/packages/gitbook/src/components/DocumentView/Table/table.module.css
deleted file mode 100644
index 8ac975dcc..000000000
--- a/packages/gitbook/src/components/DocumentView/Table/table.module.css
+++ /dev/null
@@ -1,45 +0,0 @@
-@reference "../../RootLayout/globals.css";
-
-/* Detect whether a scrollbar exists on the table */
-@keyframes detect-scroll {
- from,
- to {
- --can-scroll: ;
- }
-}
-
-/* Apply styles to the Table if scrollbar exists */
-.tableWrapper {
- animation: detect-scroll linear;
- animation-timeline: scroll(self x);
-
- --border-radius-if-can-scroll: var(--can-scroll) 0.375rem;
- --border-radius-if-cant-scroll: 0;
- border-radius: var(--border-radius-if-can-scroll, var(--border-radius-if-cant-scroll));
-
- --border-width-if-can-scroll: var(--can-scroll) 1px;
- --border-width-if-cant-scroll: 0;
- border-width: var(--border-width-if-can-scroll, var(--border-width-if-cant-scroll));
-
- @apply relative grid w-full overflow-x-auto overflow-y-hidden mx-auto border-tint-subtle;
-}
-
-.columnHeader {
- @apply text-sm font-medium py-2 px-3 text-tint-strong;
-}
-
-.row {
- @apply flex border-tint-subtle hover:bg-tint-hover transition-colors;
-}
-
-.rowGroup {
- @apply flex flex-col border rounded-lg bg-tint border-tint-subtle mb-1;
-}
-
-.cell {
- @apply flex-1 flex align-middle border-tint-subtle py-2 px-3 text-sm relative;
-}
-
-.cell:not(:last-child) {
- @apply border-r;
-}