{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sticky-table-header",
  "title": "Sticky Table Header",
  "description": "Keeps a long table's header row visible while the rows scroll, and freezes up to three leading columns while it scrolls sideways — Excel's freeze panes, for an HTML table — by wrapping the <table> you already have, with no change to a single <th>. Reach for it wherever a table is taller than the screen and the column names stop being visible a third of the way down: an admin users, accounts or customers list, an orders, invoices or billing-history table, a transactions or ledger view, logs and audit trails, an inventory or stock table, a reporting or analytics breakdown, a data grid on a dashboard, a pricing or comparison matrix, a spreadsheet-like editor, search results, a tickets or issues queue, and any wide table people scroll sideways through. Common asks it answers: \"sticky table header react\", \"shadcn sticky table header\", \"shadcn table header not sticking\", \"freeze header row table css\", \"frozen header table react\", \"table header stays on scroll\", \"position sticky th not working\", \"sticky thead not working\", \"sticky top-0 does nothing on table header\", \"why is my sticky header not sticking\", \"sticky table header border disappears\", \"border bottom missing on sticky header\", \"sticky header rows show through\", \"table rows visible through header\", \"transparent sticky header table\", \"freeze first column table react\", \"sticky first column and header\", \"sticky column overlaps header\", \"corner cell z-index table\", \"pin multiple columns table\", \"sticky header under fixed navbar\", \"table header hidden behind app header\", \"sticky header offset fixed navbar\", \"scrollable table with fixed header\", \"table with max height and sticky header\", \"tanstack table sticky header\", \"data table sticky header shadcn\", \"react-table sticky header css\", \"freeze panes html table\", \"excel freeze panes react\", \"freeze top row and first column\", \"tailwind sticky table header not working\", \"sticky header border-collapse\", \"sticky thead inside overflow-x-auto\", \"sticky header inside a scrollable div\", \"shadcn table wrapper breaks sticky\", \"テーブル ヘッダー 固定 react\", \"見出し行 固定 スクロール\", \"表 ヘッダ 固定 shadcn\", \"列固定 テーブル react\", \"先頭列 固定 横スクロール\". Official shadcn/ui has nothing here, and the measurement is not close: fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping all 223,289 bytes of source, sticky, position: sticky, position:sticky, sticky top-0, stickyHeader and scrollLeft are every one of them a zero hit, as are getBoundingClientRect, ResizeObserver, IntersectionObserver and offsetHeight, so nothing in that registry measures an element at all. Its table is 2,468 bytes of six styling wrappers in which the word sticky never appears. So this gets hand-rolled, and four things go wrong every time — three of them invisible until you actually scroll. The first is that the border under the header disappears. Tailwind's preflight sets border-collapse: collapse on every <table>, in v3 and v4 alike, and in the collapsed model a border belongs to the table's grid rather than to the cell that declared it. The cell is what sticks; the grid is not — so border-b on a pinned <th> scrolls away with the rows and the header is left with no bottom edge, floating over the data. The line here is an ::after box, which belongs to the cell and travels with it. And the repair the search results hand you for that — flip the table to border-separate so the borders belong to the cells — is the one thing this refuses to do, because the separated model ignores borders on a <tr> and shadcn's own TableRow puts its border-b exactly there. Flipping the table trades one missing line in the header for every missing divider in the body, which is a much larger regression arriving as a fix. The second is that the header is transparent. A <th> has no background of its own, so a pinned one does not stay in front of the rows, it stays in place while they pass through it, and the heading and whatever row is underneath are painted over each other. It reads as a rendering fault rather than as a missing colour, which is how it survives review. The third is the one that produces \"sticky just does not work\": a sticky cell is pinned to its nearest scrolling ancestor, never to the window unless the window is that ancestor, so the question is never whether you wrote sticky top-0 but which element is the scrollport. Official shadcn/ui's own <Table> is the usual culprit — it renders <div class=\"relative w-full overflow-x-auto\"> around the table, and a box with overflow-x: auto computes overflow-y: auto as well, so that div is a scrollport on both axes and a header pinned to it never moves, because it is the page that is scrolling. Pass maxHeight and this wrapper becomes the scrollport itself, which always works and is the only arrangement in which a table can scroll sideways and keep a pinned header at the same time; leave it off and the page scrolls, in which case nothing between this wrapper and the <table> may have an overflow. The fourth is the corner. Pinned columns need a left that starts where the previous column ended, which cannot be a static class because it is the measured width of the column before it — pinning them all at left: 0 stacks three columns in the space of one, each painting its text through the others — and a sticky cell makes its own stacking context, so a header cell and a body cell that tie on z-index are resolved by document order, which puts the body on top and slides the first column over the heading as soon as the table moves sideways. stickyColumns pins up to three leading columns with offsets measured through a ResizeObserver, and lifts the corner above both. offset takes the height of a fixed app header, without which the table header pins correctly and parks underneath the toolbar for the whole of the scroll. maxHeight takes anything CSS does — 420, \"60vh\", \"calc(100dvh - 12rem)\". Recolour the pinned cells with an important utility, className=\"[&_thead_th]:!bg-card\", since these are descendant rules. One header row is supported: a second row of grouping headers would need its own measured offset, and pinning both at the same top would stack them. Within pulld it is what a long table wants next to sort-header, which orders one column; scroll-shadow, which fades the edges so you can tell there is more sideways; virtual-list and infinite-scroll, which deal with how many rows there are rather than where the header is; and sortable-list, which drags rows into an order by hand. One file, your <table> stays yours, and it adds no dependencies at all.",
  "files": [
    {
      "path": "registry/ui/sticky-table-header.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Which element scrolls, and therefore what `top` is measured from.\n *\n * `position: sticky` is always relative to the nearest *scrollport* — the closest ancestor that\n * scrolls — and never to the window unless the window is that ancestor. Nearly every \"the header\n * will not stick\" report is this and nothing else, so the mode is a decision this component makes\n * out loud rather than a side effect of a class somebody happened to add.\n */\nexport type StickyTableMode = \"page\" | \"container\"\n\n/** How many leading columns can be pinned. Past this the class names stop being literal — see below. */\nexport const MAX_STICKY_COLUMNS = 3\n\n/**\n * The stacking order of the three kinds of pinned cell.\n *\n * A sticky cell creates its own stacking context, so these compete directly and a tie is broken by\n * document order — which puts the *body* on top, because it comes later. That is the corner bug in\n * one sentence: scroll sideways and the first column slides over the header instead of under it.\n * Exported because a table with its own layered cell (a selection checkbox, a row menu) has to pick\n * a number that lands between them rather than guessing.\n */\nexport const STICKY_Z = {\n  /** A pinned column in `<tbody>`. Above ordinary cells, below the header. */\n  column: 10,\n  /** A header cell that is not in a pinned column. */\n  header: 20,\n  /** The corner: a header cell that is *also* in a pinned column, pinned on both axes. */\n  corner: 30,\n} as const\n\n/** A CSS length from a number of pixels or a string written out. `0` is a length, not a missing value. */\nexport function cssLength(value: number | string | undefined | null, fallback = \"0px\"): string {\n  if (value === undefined || value === null || value === \"\") return fallback\n  return typeof value === \"number\" ? `${value}px` : value\n}\n\n/**\n * Whether this wrapper scrolls or the page does.\n *\n * Giving a height is what makes an element scroll, so a height is exactly what the choice turns on.\n */\nexport function resolveStickyMode(maxHeight?: number | string | null): StickyTableMode {\n  return maxHeight === undefined || maxHeight === null || maxHeight === \"\" ? \"page\" : \"container\"\n}\n\n/**\n * Where each pinned column comes to rest, given the widths of the columns before it.\n *\n * The first is always at 0 and every one after it starts where the previous ended, which is the part\n * that cannot be written as a static class: a second pinned column needs the *measured* width of the\n * first. Pinning them all at `left: 0` is what gets written instead, and it stacks the columns on\n * top of each other the moment the table scrolls sideways — three columns in the space of one, the\n * two underneath still painting their text through.\n *\n * Widths that are not usable numbers count as zero rather than poisoning every offset after them\n * with `NaN`, which would resolve to `left: NaNpx` and drop the pinning silently.\n */\nexport function cumulativeOffsets(widths: readonly number[]): number[] {\n  const out: number[] = []\n  let sum = 0\n  for (const width of widths) {\n    out.push(sum)\n    sum += Number.isFinite(width) && width > 0 ? width : 0\n  }\n  return out\n}\n\n/** The CSS custom properties the class names below read. Written here so the tree is the contract. */\nexport function stickyTableVars(offset: number | string | undefined, columns: number): React.CSSProperties {\n  const vars: Record<string, string> = { \"--sticky-table-top\": cssLength(offset) }\n  // Seeded at 0 so the first paint pins the columns at the left edge rather than at `left: auto`,\n  // which is not pinned at all. The measured values replace these after layout.\n  for (let i = 0; i < columns; i++) vars[`--sticky-table-left-${i}`] = \"0px\"\n  return vars as React.CSSProperties\n}\n\n/**\n * What makes the header cells stick.\n *\n * Written as descendant rules on the wrapper rather than as props on a cell, so this works on the\n * `<table>` you already have — official shadcn/ui's `<Table>`, a hand-written one, one produced by a\n * data-table library — without touching a single `<th>`.\n *\n * Three of these five lines are load-bearing and each of them is a bug that ships:\n *\n * **`bg-background`.** A `<th>` has no background of its own, and a transparent sticky header does\n * not stay in front of the rows — it stays in *place* while they pass through it, so the heading and\n * whatever row is underneath are painted over each other. It looks like a rendering fault rather\n * than a missing colour, which is why it survives review.\n *\n * **The line is a pseudo-element, not a border.** Tailwind's preflight sets `border-collapse:\n * collapse` on every `<table>` — v3 and v4 both — and in the collapsed model a border belongs to the\n * table's grid rather than to the cell that declared it. The cell is what sticks; the grid is not,\n * so `border-b` on a sticky `<th>` scrolls away with the rows and leaves the header with no bottom\n * edge at all. An `::after` box belongs to the cell and travels with it.\n *\n * The obvious repair for that — switch the table to `border-separate` so the borders belong to the\n * cells — is the one thing this must not do, and it is worth being explicit about because it is what\n * the search results tell you to do. In the separated model borders on rows are *ignored*, and\n * shadcn's `TableRow` puts its `border-b` on the `<tr>`: flipping the table would silently erase\n * every divider in the body to fix one line in the header.\n *\n * **`top` is a variable.** A fixed app header covers the table header otherwise — the pinning works,\n * it just parks underneath the toolbar — and the height of that toolbar is not knowable from here.\n */\nconst HEADER_CELLS = [\n  \"[&_thead_th]:sticky [&_thead_th]:top-[var(--sticky-table-top)] [&_thead_th]:z-20\",\n  \"[&_thead_th]:bg-background\",\n  \"[&_thead_th]:after:absolute [&_thead_th]:after:inset-x-0 [&_thead_th]:after:bottom-0\",\n  \"[&_thead_th]:after:h-px [&_thead_th]:after:bg-border [&_thead_th]:after:content-['']\",\n  \"[&_thead_th]:after:pointer-events-none\",\n].join(\" \")\n\n/**\n * What pins the *n*th column, one literal string per index.\n *\n * A loop building `` `[&_tr>*:nth-child(${i})]:sticky` `` would be the shorter way to write this and\n * it produces nothing: Tailwind reads source files as text, so a class name that only exists once\n * the component runs is never compiled and the column is never pinned. Every one of them has to be\n * spelled out, which is also what caps this at three.\n *\n * Specificity does the rest of the work. The corner rule below is written through `thead` so it\n * scores above both of these, and the ladder in {@link STICKY_Z} lands the right way up without any\n * of the three having to know about the others.\n */\nconst COLUMN_CELLS: readonly string[] = [\n  \"[&_tr>*:nth-child(1)]:sticky [&_tr>*:nth-child(1)]:left-[var(--sticky-table-left-0)] [&_tr>*:nth-child(1)]:z-10 [&_tr>*:nth-child(1)]:bg-background\",\n  \"[&_tr>*:nth-child(2)]:sticky [&_tr>*:nth-child(2)]:left-[var(--sticky-table-left-1)] [&_tr>*:nth-child(2)]:z-10 [&_tr>*:nth-child(2)]:bg-background\",\n  \"[&_tr>*:nth-child(3)]:sticky [&_tr>*:nth-child(3)]:left-[var(--sticky-table-left-2)] [&_tr>*:nth-child(3)]:z-10 [&_tr>*:nth-child(3)]:bg-background\",\n]\n\n/** The corner cells: pinned on both axes, and above everything else so nothing slides over them. */\nconst CORNER_CELLS: readonly string[] = [\n  \"[&_thead_tr>*:nth-child(1)]:z-30\",\n  \"[&_thead_tr>*:nth-child(2)]:z-30\",\n  \"[&_thead_tr>*:nth-child(3)]:z-30\",\n]\n\n/** Clamps a requested column count to what there are literal class names for. */\nexport function resolveStickyColumns(count: number | undefined): number {\n  if (!Number.isFinite(count)) return 0\n  return Math.max(0, Math.min(Math.floor(count as number), MAX_STICKY_COLUMNS))\n}\n\n/**\n * Whether the pinned columns need measuring at all.\n *\n * Only from the second one: the first rests at 0, which is a constant and already seeded, so a table\n * with one pinned column — most of them — never observes anything. From the second the offset is the\n * measured width of the column before it and there is no static answer.\n *\n * Named and exported rather than left as `columns < 2` inside the effect because it is the whole\n * contract of that effect, and a threshold buried in a condition is invisible to anything that\n * cannot run layout: off by one, a two-column table silently stops measuring and its second column\n * goes back to resting on top of the first.\n */\nexport function needsMeasurement(columns: number): boolean {\n  return columns >= 2\n}\n\n/**\n * Keeps `--sticky-table-left-*` in step with the real widths of the pinned columns.\n *\n * Widths are read from the first `<tr>` in the table, which is the header row whenever there is a\n * `<thead>`. Any row gives the same answer: a table column is a single width for every cell in it,\n * so a `<th>` already reports whatever the widest cell in its column made it. What there is no\n * static answer for is the width itself, which is the whole reason this effect exists.\n */\nfunction useStickyColumnOffsets(\n  ref: React.RefObject<HTMLDivElement | null>,\n  columns: number\n): void {\n  React.useEffect(() => {\n    const root = ref.current\n    if (!root || !needsMeasurement(columns)) return\n\n    const measure = () => {\n      const row = root.querySelector(\"tr\")\n      if (!row) return\n      const cells = Array.from(row.children).slice(0, columns) as HTMLElement[]\n      const offsets = cumulativeOffsets(cells.map((cell) => cell.getBoundingClientRect().width))\n      for (let i = 0; i < offsets.length; i++) {\n        root.style.setProperty(`--sticky-table-left-${i}`, `${offsets[i]}px`)\n      }\n    }\n\n    measure()\n\n    // A column's width changes without anything being added or removed: a window resize, a sidebar\n    // opening, a long cell arriving from the next page of data, a font finally loading. Observing\n    // the cells themselves rather than the table catches the last two, which are the ones that\n    // happen after the component has stopped being looked at.\n    if (typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(measure)\n    observer.observe(root)\n    const row = root.querySelector(\"tr\")\n    if (row) for (const cell of Array.from(row.children).slice(0, columns)) observer.observe(cell)\n    return () => observer.disconnect()\n  }, [ref, columns])\n}\n\nexport interface StickyTableHeaderProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * How far below the top of the scrollport the header rests. Defaults to `0`.\n   *\n   * This is where a fixed app header's height goes. Without it the table header pins correctly and\n   * pins itself underneath the toolbar, so it is invisible for the whole of the scroll.\n   */\n  offset?: number | string\n  /**\n   * Makes this wrapper the scrolling element, capped at this height, instead of letting the page\n   * scroll. Anything CSS accepts — `420`, `\"60vh\"`, `\"calc(100dvh-12rem)\"`.\n   *\n   * Prefer it. A wrapper that scrolls is a scrollport this component owns, so the header sticks\n   * regardless of what the page around it does, and it is the only arrangement in which a table can\n   * scroll sideways and keep a pinned header at the same time.\n   */\n  maxHeight?: number | string\n  /** How many leading columns stay put when scrolling sideways. 0 (default) to 3. */\n  stickyColumns?: number\n  /** Pin the header row. Defaults to `true`; turn it off to pin only columns. */\n  stickyHeader?: boolean\n}\n\n/**\n * Keeps a table's header row — and optionally its first columns — in place while the rows scroll.\n *\n * ```tsx\n * <StickyTableHeader maxHeight=\"60vh\">\n *   <table className=\"w-full text-sm\">\n *     <thead>\n *       <tr>\n *         <th className=\"px-4 py-3 text-left font-medium\">Name</th>\n *         <th className=\"px-4 py-3 text-left font-medium\">Status</th>\n *       </tr>\n *     </thead>\n *     <tbody>{rows.map(…)}</tbody>\n *   </table>\n * </StickyTableHeader>\n * ```\n *\n * Your `<table>` stays yours: this wraps it and styles its cells from the outside, so it drops onto\n * a table that already exists — with `<SortHeader>` in the cells, a checkbox column, whatever — and\n * changes no markup.\n *\n * **Which element scrolls is the whole component.** A sticky cell is pinned to its nearest scrolling\n * ancestor, so the question is never \"did I write `sticky top-0`\" but \"what is the scrollport\". Give\n * `maxHeight` and this wrapper becomes it, which is the arrangement that always works.\n *\n * Leave `maxHeight` off and the page is the scrollport — in which case nothing between this wrapper\n * and the `<table>` may scroll, and the usual reason one does is official shadcn/ui's own `<Table>`:\n * it renders `<div class=\"relative w-full overflow-x-auto\">` around the table, and a box with\n * `overflow-x: auto` computes to `overflow-y: auto` as well, so that div is a scrollport on both\n * axes. The header then pins to a box that never scrolls vertically, which looks exactly like\n * sticky not working. Either render the bare `<table>` in page mode, or pass `maxHeight` and let\n * this wrapper do the scrolling.\n *\n * With `stickyColumns`, the corner cells are pinned on both axes and lifted above both — see\n * {@link STICKY_Z} for why that has to be said out loud.\n *\n * One header row is supported. A second row of grouping headers would need its own offset, measured\n * from the height of the first, and pinning both at the same `top` would stack them.\n *\n * To recolour the pinned cells — a header on a card rather than on the page background — pass the\n * class with `!`, as in `className=\"[&_thead_th]:!bg-card\"`: the rules here are descendant rules and\n * outrank a plain utility on the cell itself.\n */\nexport function StickyTableHeader({\n  offset = 0,\n  maxHeight,\n  stickyColumns = 0,\n  stickyHeader = true,\n  className,\n  style,\n  children,\n  ...props\n}: StickyTableHeaderProps) {\n  const mode = resolveStickyMode(maxHeight)\n  const columns = resolveStickyColumns(stickyColumns)\n  const ref = React.useRef<HTMLDivElement | null>(null)\n  useStickyColumnOffsets(ref, columns)\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"relative\",\n        // Only in container mode. In page mode this must not create a scrollport of its own — that\n        // would make it the very ancestor the docs above warn about, and the header would pin to a\n        // box that never moves.\n        mode === \"container\" && \"overflow-auto\",\n        stickyHeader && HEADER_CELLS,\n        COLUMN_CELLS.slice(0, columns).join(\" \"),\n        stickyHeader && CORNER_CELLS.slice(0, columns).join(\" \"),\n        className\n      )}\n      style={{\n        ...stickyTableVars(offset, columns),\n        ...(mode === \"container\" ? { maxHeight: cssLength(maxHeight) } : null),\n        ...style,\n      }}\n      {...props}\n    >\n      {children}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
