diff --git a/.changeset/table-row-cell-order.md b/.changeset/table-row-cell-order.md
new file mode 100644
index 00000000..c21c8227
--- /dev/null
+++ b/.changeset/table-row-cell-order.md
@@ -0,0 +1,5 @@
+---
+'@human-kit/ui': patch
+---
+
+Scrolling a wide virtualized table no longer stalls. A row kept the order of its cells in a `SvelteSet` and a `SvelteMap`, and rewrote the index of every cell after one that left, so a row of fourteen columns leaving the viewport made around a hundred reactive writes. The order now lives in a plain array, read with `indexOf`, and the row's existing epoch announces when a removal moves the cells after it.
diff --git a/packages/ui/src/lib/table/root/table-drop-column-test.svelte b/packages/ui/src/lib/table/root/table-drop-column-test.svelte
new file mode 100644
index 00000000..3c8ad04f
--- /dev/null
+++ b/packages/ui/src/lib/table/root/table-drop-column-test.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+
+ {#each columns as column (column)}
+
+ {column}
+
+ {/each}
+
+
+
+
+ {#each rows as row (row.id)}
+
+ {#each columns as column (column)}
+ {row[column]}
+ {/each}
+
+ {/each}
+
+
+
+
diff --git a/packages/ui/src/lib/table/root/table-root.test.ts b/packages/ui/src/lib/table/root/table-root.test.ts
index c31950e0..99fcc0c1 100644
--- a/packages/ui/src/lib/table/root/table-root.test.ts
+++ b/packages/ui/src/lib/table/root/table-root.test.ts
@@ -3,6 +3,7 @@ import { render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import TableTest from './table-test.svelte';
import TableReorderTest from './table-reorder-test.svelte';
+import TableDropColumnTest from './table-drop-column-test.svelte';
import { expectNoFalseFocusAttributes } from '../../test-utils/focus-contract';
function getHeaderCells(container: HTMLElement) {
@@ -1266,4 +1267,21 @@ describe('Table.Root', () => {
getBodySecondColumnCells(document.body).map((cell) => cell.textContent?.trim())
).toContain('Admin');
});
+
+ // A row keeps the order of its cells in plain arrays, announced by an epoch: a cell that
+ // leaves from the middle has to move the ones after it down, in the header and the body.
+ it('moves the cells after a removed one down a column', async () => {
+ render(TableDropColumnTest);
+ const index = (testId: string) =>
+ document.querySelector(`[data-testid="${testId}"]`)?.getAttribute('data-column-index');
+
+ expect(index('role-header')).toBe('2');
+ expect(index('role-cell-danilo')).toBe('2');
+
+ document.querySelector('[data-testid="drop-group"]')?.click();
+
+ await expect.poll(() => index('role-header')).toBe('1');
+ await expect.poll(() => index('role-cell-danilo')).toBe('1');
+ await expect.poll(() => index('role-cell-zahra')).toBe('1');
+ });
});
diff --git a/packages/ui/src/lib/table/row/table-row.svelte b/packages/ui/src/lib/table/row/table-row.svelte
index 3bf88406..661ebfd6 100644
--- a/packages/ui/src/lib/table/row/table-row.svelte
+++ b/packages/ui/src/lib/table/row/table-row.svelte
@@ -1,6 +1,5 @@