Skip to content

feat(table): sticky header footer in virtualized mode#6217

Open
ChronicStone wants to merge 7 commits intonuxt:v4from
ChronicStone:features/sticky-virtual-table
Open

feat(table): sticky header footer in virtualized mode#6217
ChronicStone wants to merge 7 commits intonuxt:v4from
ChronicStone:features/sticky-virtual-table

Conversation

@ChronicStone
Copy link
Copy Markdown

🔗 Linked issue

Resolves #5464

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

The sticky prop was explicitly disabled when virtualize was on, and the docs listed it as unsupported.

The approach I found that works cleanest is switching from transform-based row positioning to the spacer-row strategy. Instead of applying translateY to each virtual row, two empty <tr> elements act as spacers at the top and bottom of <tbody>:

paddingTop  = virtualItems[0].start
paddingBottom = totalSize - virtualItems[last].end

Rows render at their natural position in the flow with no transforms, which means position: sticky on <thead> and <tfoot> just works. The outer <div> height wrapper and the renderedSize computed (used only for the old tfoot transform) are also gone.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@github-actions github-actions bot added the v4 #4488 label Mar 19, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5dde9404-f355-480d-acec-68b756c82902

📥 Commits

Reviewing files that changed from the base of the PR and between 4400dee and d83f507.

⛔ Files ignored due to path filters (2)
  • test/components/__snapshots__/Table-vue.spec.ts.snap is excluded by !**/*.snap
  • test/components/__snapshots__/Table.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (2)
  • src/runtime/components/Table.vue
  • test/components/Table.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • test/components/Table.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/runtime/components/Table.vue

📝 Walkthrough

Walkthrough

Updated docs to state sticky can be used with virtualize and clarified that virtualization incompatibility refers to row pinning (divider) rather than sticky. In src/runtime/components/Table.vue: JSDoc adjusted for virtualize and sticky; ui.sticky now reflects props.sticky regardless of virtualization; virtualized body spacing now computes virtualPaddingTop/virtualPaddingBottom instead of summing rendered sizes; per-row transform: translateY(...) and index offsets were removed in favor of top/bottom spacer <tr> elements and per-row heights; tfoot virtualization transform removed; the extra virtualization wrapper <div> was removed. Added a parameterized test covering virtualize: true with sticky: true.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: enabling sticky header/footer functionality in virtualized table mode, which is the core objective of the PR.
Description check ✅ Passed The description clearly explains the problem (sticky prop disabled with virtualize), the solution approach (spacer-row strategy), and technical details about the implementation.
Linked Issues check ✅ Passed The PR addresses the sticky headers requirement from issue #5464 by implementing spacer-row positioning and removing forced UI styling that prevented sticky from working with virtualization.
Out of Scope Changes check ✅ Passed All changes are directly related to enabling sticky headers/footers in virtualized mode: documentation updates, JSDoc revisions, UI styling changes, virtualization logic refactoring, and test coverage additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/components/Table.vue`:
- Around line 625-632: The key/prop mismatch arises because virtualRow.index may
be out of bounds for centerRows: align behavior by skipping rendering when the
row is undefined—inside the v-for over virtualizer.getVirtualItems(), compute
const row = centerRows[virtualRow.index] and only render ReuseRowTemplate when
row exists (i.e., guard on row) so both the :key and :row prop use the same
non-null value; reference virtualizer.getVirtualItems(), centerRows,
virtualRow.index, ReuseRowTemplate, virtualPaddingTop and virtualPaddingBottom
to locate the block and add the guard that prevents rendering when row is
undefined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b1c6c703-c842-4f02-9d59-a5e8d3e2dce1

📥 Commits

Reviewing files that changed from the base of the PR and between 6b43c98 and f808997.

📒 Files selected for processing (2)
  • docs/content/docs/2.components/table.md
  • src/runtime/components/Table.vue

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
PR.md (1)

18-18: Optional wording polish for readability.

“works cleanest” reads slightly awkwardly; “works the cleanest” or “is the cleanest approach” is smoother.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PR.md` at line 18, Edit the sentence in PR.md that currently reads "The
approach I found that works cleanest is switching from transform-based row
positioning to the spacer-row strategy." and replace "works cleanest" with a
smoother alternative such as "works the cleanest" or "is the cleanest approach"
to improve readability; ensure the rest of the sentence remains unchanged
(keeping references to `translateY`, `<tr>`, and `<tbody>` intact).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@PR.md`:
- Around line 20-23: The fenced code block containing the expressions for
paddingTop and paddingBottom lacks a language identifier; update the markdown by
adding a language tag (e.g., ```text) to the opening fence so the block is
annotated (the code showing paddingTop = virtualItems[0].start and paddingBottom
= totalSize - virtualItems[last].end should be wrapped with ```text ... ```).
This will satisfy the MD040 lint rule without changing the content of the
expressions or variable names (paddingTop, paddingBottom, virtualItems,
totalSize, last).

---

Nitpick comments:
In `@PR.md`:
- Line 18: Edit the sentence in PR.md that currently reads "The approach I found
that works cleanest is switching from transform-based row positioning to the
spacer-row strategy." and replace "works cleanest" with a smoother alternative
such as "works the cleanest" or "is the cleanest approach" to improve
readability; ensure the rest of the sentence remains unchanged (keeping
references to `translateY`, `<tr>`, and `<tbody>` intact).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eb996ead-9350-40fb-aeb4-cfd4552814ca

📥 Commits

Reviewing files that changed from the base of the PR and between f808997 and e80700f.

⛔ Files ignored due to path filters (2)
  • test/components/__snapshots__/Table-vue.spec.ts.snap is excluded by !**/*.snap
  • test/components/__snapshots__/Table.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (1)
  • PR.md

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 19, 2026

npm i https://pkg.pr.new/@nuxt/ui@6217

commit: d83f507

@Ombrenoirz
Copy link
Copy Markdown

Ombrenoirz commented Mar 20, 2026

Thanks ! It's been 2 days I'm trying to find a workaround that works good. Every try I get problems.

By the way for the dividers, this workaround worked for me:

adding this to UTable
:ui="{ td: 'border-b border-default', }

Copy link
Copy Markdown
Contributor

@howwohmm howwohmm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean approach — the spacer-row strategy is the right way to enable sticky headers with virtualization. TanStack Virtual's own examples use this pattern.

one thing worth checking: the spacer `` elements are self-closing with no `` children. while this is technically valid HTML ("zero or more" in the content model), some browsers may not reliably apply `height` to a childless `` since there's no cell to drive the row's block formatting. adding a single `` inside each spacer would be safer for cross-browser consistency.

also noticed there's no test case for the sticky + virtualize combination — the existing "renders with virtualize correctly" snapshot doesn't pass `sticky: true`. since that's the feature being unlocked, a dedicated test would be good.

@ChronicStone ChronicStone force-pushed the features/sticky-virtual-table branch from 331ffaa to a988c50 Compare March 26, 2026 13:45
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/runtime/components/Table.vue (1)

626-629: ⚠️ Potential issue | 🟡 Minor

Guard centerRows[virtualRow.index] before rendering.

:key already treats this lookup as nullable, but :row="centerRows[virtualRow.index]!" still dereferences it. A transient out-of-bounds virtual item during row-model updates will blow up inside ReuseRowTemplate.

🛡️ Suggested guard
-            <template v-for="virtualRow in virtualizer.getVirtualItems()" :key="centerRows[virtualRow.index]?.id">
+            <template v-for="virtualRow in virtualizer.getVirtualItems()" :key="centerRows[virtualRow.index]?.id ?? virtualRow.index">
               <ReuseRowTemplate
+                v-if="centerRows[virtualRow.index]"
                 :row="centerRows[virtualRow.index]!"
                 :style="{ height: `${virtualRow.size}px` }"
               />
             </template>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Table.vue` around lines 626 - 629, The
ReuseRowTemplate currently dereferences centerRows[virtualRow.index] without
guarding, which can throw if virtualRow.index is temporarily out-of-bounds;
update the v-for block that iterates virtualizer.getVirtualItems() to first
resolve the row (e.g., const row = centerRows[virtualRow.index]) and render
ReuseRowTemplate only when row is defined (use a v-if or skip that virtualRow),
keep the :key using the guarded value and pass the non-null row to the :row prop
(remove the non-null assertion) so ReuseRowTemplate never receives an undefined
row.
🧹 Nitpick comments (1)
src/runtime/components/Table.vue (1)

279-285: Please add a sticky + virtualize regression case.

This flips the feature gate, but test/components/Table.spec.ts still covers sticky and virtualize separately. Adding a combined case with columns would also exercise the newly affected tfoot path.

🧪 Suggested coverage
 renderEach(Table, [
   // Props
   ['with data', { props }],
   ['without data', {}],
   ['with empty', { props: { empty: 'There is no data' } }],
   ['with caption', { props: { ...props, caption: 'Table caption' } }],
   ['with columns', { props: { ...props, columns } }],
   ['with sticky', { props: { ...props, sticky: true } }],
+  ['with sticky and virtualize', { props: { ...props, columns, sticky: true, virtualize: true } }],
   ['with loading', { props: { ...props, loading: true } }],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Table.vue` around lines 279 - 285, Add a regression
test to test/components/Table.spec.ts that exercises the combined sticky +
virtualize scenario (e.g., a new "sticky + virtualize" it/test) by mounting the
Table component with props sticky: true and virtualize: true, providing a
non-empty columns array and a tfoot slot (to hit the tfoot path), and asserting
that the tfoot is rendered and that sticky-related classes/attributes and
virtualization behavior are present; reference the Table component mount helpers
used elsewhere in the file to keep the pattern consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/runtime/components/Table.vue`:
- Around line 626-629: The ReuseRowTemplate currently dereferences
centerRows[virtualRow.index] without guarding, which can throw if
virtualRow.index is temporarily out-of-bounds; update the v-for block that
iterates virtualizer.getVirtualItems() to first resolve the row (e.g., const row
= centerRows[virtualRow.index]) and render ReuseRowTemplate only when row is
defined (use a v-if or skip that virtualRow), keep the :key using the guarded
value and pass the non-null row to the :row prop (remove the non-null assertion)
so ReuseRowTemplate never receives an undefined row.

---

Nitpick comments:
In `@src/runtime/components/Table.vue`:
- Around line 279-285: Add a regression test to test/components/Table.spec.ts
that exercises the combined sticky + virtualize scenario (e.g., a new "sticky +
virtualize" it/test) by mounting the Table component with props sticky: true and
virtualize: true, providing a non-empty columns array and a tfoot slot (to hit
the tfoot path), and asserting that the tfoot is rendered and that
sticky-related classes/attributes and virtualization behavior are present;
reference the Table component mount helpers used elsewhere in the file to keep
the pattern consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f237faac-0996-4b36-874d-269adbeab8dc

📥 Commits

Reviewing files that changed from the base of the PR and between e80700f and a988c50.

⛔ Files ignored due to path filters (2)
  • test/components/__snapshots__/Table-vue.spec.ts.snap is excluded by !**/*.snap
  • test/components/__snapshots__/Table.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (2)
  • docs/content/docs/2.components/table.md
  • src/runtime/components/Table.vue
✅ Files skipped from review due to trivial changes (1)
  • docs/content/docs/2.components/table.md

@ChronicStone
Copy link
Copy Markdown
Author

Hi @howwohmm thanks for the feedback, I took that solution straight out of tanstack table examples & github issues, I explored other solutions but this is what worked best.

I adressed the two things you mentionned, childless and missing test case, everything should be good now 😄
Let me know if there's anything else missing, I'll take care of it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UTable: row dividers and sticky headers are achievable when virtualized

3 participants