TableLayout.isStickyColumn() never sets allowOverflow, so a sticky column can't actually stick during virtualized horizontal scroll
Versions
react-aria-components: 1.18.0
react-stately: 3.47.0
react-aria: 3.49.0
react: 18.3.1
Summary
TableLayout (react-stately/private/layout/TableLayout) exposes isStickyColumn() as a protected, subclass-only extension point — overriding it does get LayoutInfo.isSticky/zIndex set correctly, and keeps that column's cells mounted across virtualization's scroll-driven mount/unmount cycle (stickyColumnIndices, used by persistedIndices). But it never also sets LayoutInfo.allowOverflow, which VirtualizerItem's layoutInfoToStyle needs to actually render the sticky cell so it isn't clipped by its own absolutely-positioned virtualizer wrapper. The result: position: sticky is applied to the DOM node and looks correct in a computed-style check, but the column still visibly scrolls out of view with the rest of the row on a real horizontal scroll.
Every other spatial Layout class in the same package (ListLayout, GridLayout, WaterfallLayout) sets layoutInfo.allowOverflow = true unconditionally on every item it builds — so any of their own items that also happen to be sticky (e.g. ListLayout's sticky section headers) are never clipped by this. TableLayout sets allowOverflow on no node, anywhere.
Root cause, with source references
VirtualizerItem.mjs's layoutInfoToStyle (react-aria):
top: layoutInfo.rect.y - (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.y : 0),
[xProperty]: layoutInfo.rect.x - (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.x : 0),
...
position: layoutInfo.isSticky ? 'sticky' : 'absolute',
...
overflow: layoutInfo.allowOverflow ? 'visible' : 'hidden',
(There's even an existing // TODO: ... quite ambiguous ... comment right above this block acknowledging the sticky/overflow interaction here isn't fully settled.)
So a sticky node's own x/top is only computed relative to the real (unscrolled) coordinate space when both layoutInfo.isSticky and its parent's allowOverflow are true. Otherwise it's offset by the parent's own (already-scrolled) position — which defeats position: sticky entirely, since the parent itself is overflow: hidden and moves with the scroll.
TableLayout.isStickyColumn() (react-stately/private/layout/TableLayout.mjs:232) only feeds isSticky/zIndex (:220-221, :338-339) and stickyColumnIndices (:78, used for persistedIndices at :417/:463-465). Grepping the entire file for allowOverflow returns zero matches — no method in TableLayout ever sets it, for any node.
Contrast the siblings:
ListLayout.mjs:221 — layoutNode.layoutInfo.allowOverflow = true; in buildChild, unconditionally, for every item.
GridLayout.mjs:106 — layoutInfo.allowOverflow = true;, unconditionally, for every item.
WaterfallLayout.mjs:90 — layoutInfo.allowOverflow = true;, unconditionally, for every item.
TableLayout is the outlier: it has the machinery to mark a node sticky (isStickyColumn) but not the matching machinery to let that stickiness actually render.
Reproduction
import { Virtualizer, TableLayout, Table, TableHeader, Column, TableBody, Row, Cell } from 'react-aria-components';
class StickyFirstColumnLayout extends TableLayout {
isStickyColumn(node) {
return node.column?.key === 'name';
}
}
function App() {
return (
<div style={{ height: 400, width: 400, overflow: 'auto' }}>
<Virtualizer layout={StickyFirstColumnLayout} layoutOptions={{ estimatedRowHeight: 40 }}>
<Table aria-label="Accounts">
<TableHeader>
<Column key="name" isRowHeader>Name</Column>
<Column key="type">Type</Column>
<Column key="opened">Opened</Column>
<Column key="balance">Balance</Column>
</TableHeader>
<TableBody items={rows /* wide enough that the columns overflow horizontally */}>
{(row) => (
<Row>
<Cell>{row.name}</Cell>
<Cell>{row.type}</Cell>
<Cell>{row.opened}</Cell>
<Cell>{row.balance}</Cell>
</Row>
)}
</TableBody>
</Table>
</Virtualizer>
</div>
);
}
getComputedStyle(nameColumnHeader).position → 'sticky' (looks correct).
- Scroll the table horizontally (
scrollLeft = 150 on the scrolling ancestor).
nameColumnHeader.getBoundingClientRect().left moves by (roughly) the scrolled distance — the column visibly scrolls away instead of staying pinned. A computed-style assertion alone will not catch this; only checking the real bounding rect after a real scroll does.
Expected behavior
A column marked sticky via isStickyColumn() stays visually pinned during horizontal scroll, the same way ListLayout's sticky section headers do (which stay pinned because their allowOverflow chain is intact by virtue of every item getting allowOverflow = true).
Suggested fix
Either:
TableLayout sets allowOverflow = true on the ancestor chain (header/header-row/row/row-group layout nodes) whenever any of its descendant columns is sticky — mirroring what the sibling layouts already do unconditionally, just scoped to when it's actually needed; or
- At minimum, document that
isStickyColumn() alone is insufficient, and that a real fix requires a consumer to also override buildTableHeader/buildHeaderRow/buildRow/buildRowGroup to set allowOverflow on every ancestor between the sticky node and the real scrollport.
Current workaround
We ship a subclass doing exactly that — overriding isStickyColumn() plus buildTableHeader/buildHeaderRow/buildRow/buildRowGroup to set allowOverflow = true on each. Happy to share it if useful as a starting point for a fix.
TableLayout.isStickyColumn()never setsallowOverflow, so a sticky column can't actually stick during virtualized horizontal scrollVersions
react-aria-components: 1.18.0react-stately: 3.47.0react-aria: 3.49.0react: 18.3.1Summary
TableLayout(react-stately/private/layout/TableLayout) exposesisStickyColumn()as aprotected, subclass-only extension point — overriding it does getLayoutInfo.isSticky/zIndexset correctly, and keeps that column's cells mounted across virtualization's scroll-driven mount/unmount cycle (stickyColumnIndices, used bypersistedIndices). But it never also setsLayoutInfo.allowOverflow, whichVirtualizerItem'slayoutInfoToStyleneeds to actually render the sticky cell so it isn't clipped by its own absolutely-positioned virtualizer wrapper. The result:position: stickyis applied to the DOM node and looks correct in a computed-style check, but the column still visibly scrolls out of view with the rest of the row on a real horizontal scroll.Every other spatial
Layoutclass in the same package (ListLayout,GridLayout,WaterfallLayout) setslayoutInfo.allowOverflow = trueunconditionally on every item it builds — so any of their own items that also happen to be sticky (e.g.ListLayout's sticky section headers) are never clipped by this.TableLayoutsetsallowOverflowon no node, anywhere.Root cause, with source references
VirtualizerItem.mjs'slayoutInfoToStyle(react-aria):(There's even an existing
// TODO: ... quite ambiguous ...comment right above this block acknowledging the sticky/overflow interaction here isn't fully settled.)So a sticky node's own
x/topis only computed relative to the real (unscrolled) coordinate space when bothlayoutInfo.isStickyand its parent'sallowOverfloware true. Otherwise it's offset by the parent's own (already-scrolled) position — which defeatsposition: stickyentirely, since the parent itself isoverflow: hiddenand moves with the scroll.TableLayout.isStickyColumn()(react-stately/private/layout/TableLayout.mjs:232) only feedsisSticky/zIndex(:220-221,:338-339) andstickyColumnIndices(:78, used forpersistedIndicesat:417/:463-465). Grepping the entire file forallowOverflowreturns zero matches — no method inTableLayoutever sets it, for any node.Contrast the siblings:
ListLayout.mjs:221—layoutNode.layoutInfo.allowOverflow = true;inbuildChild, unconditionally, for every item.GridLayout.mjs:106—layoutInfo.allowOverflow = true;, unconditionally, for every item.WaterfallLayout.mjs:90—layoutInfo.allowOverflow = true;, unconditionally, for every item.TableLayoutis the outlier: it has the machinery to mark a node sticky (isStickyColumn) but not the matching machinery to let that stickiness actually render.Reproduction
getComputedStyle(nameColumnHeader).position→'sticky'(looks correct).scrollLeft = 150on the scrolling ancestor).nameColumnHeader.getBoundingClientRect().leftmoves by (roughly) the scrolled distance — the column visibly scrolls away instead of staying pinned. A computed-style assertion alone will not catch this; only checking the real bounding rect after a real scroll does.Expected behavior
A column marked sticky via
isStickyColumn()stays visually pinned during horizontal scroll, the same wayListLayout's sticky section headers do (which stay pinned because theirallowOverflowchain is intact by virtue of every item gettingallowOverflow = true).Suggested fix
Either:
TableLayoutsetsallowOverflow = trueon the ancestor chain (header/header-row/row/row-group layout nodes) whenever any of its descendant columns is sticky — mirroring what the sibling layouts already do unconditionally, just scoped to when it's actually needed; orisStickyColumn()alone is insufficient, and that a real fix requires a consumer to also overridebuildTableHeader/buildHeaderRow/buildRow/buildRowGroupto setallowOverflowon every ancestor between the sticky node and the real scrollport.Current workaround
We ship a subclass doing exactly that — overriding
isStickyColumn()plusbuildTableHeader/buildHeaderRow/buildRow/buildRowGroupto setallowOverflow = trueon each. Happy to share it if useful as a starting point for a fix.