Skip to content

Commit 27e38f3

Browse files
committed
feat: add full-width column resizing example
Adds a new React example demonstrating how to make a table fill its container width with the last column stretching to occupy remaining space. Columns remain individually resizable while maintaining the full-width behavior. Uses ResizeObserver for responsive container tracking and CSS variables for performant resize rendering. This addresses a common use case discussed in #4825, #4880, #5120, and #5870 where users need tables that fill their container width with proper resize behavior.
1 parent 961258c commit 27e38f3

9 files changed

Lines changed: 425 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
.DS_Store
3+
dist
4+
dist-ssr
5+
*.local
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Full-Width Column Resizing Example
2+
3+
This example demonstrates how to make a TanStack Table fill its container width, with the last column automatically stretching to fill remaining space. Columns can still be individually resized while maintaining the full-width behavior.
4+
5+
This pattern is useful when you want the table to always fill its container (like a spreadsheet) rather than having a fixed width based on column sizes.
6+
7+
## Key Features
8+
9+
- Table always fills container width
10+
- Last column stretches to fill remaining space
11+
- Individual columns remain resizable
12+
- Responsive to container size changes via ResizeObserver
13+
- Double-click a column border to reset that column's size
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Vite App</title>
7+
<script type="module" src="https://cdn.skypack.dev/twind/shim"></script>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
<script type="module" src="/src/main.tsx"></script>
12+
</body>
13+
</html>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "tanstack-table-example-column-resizing-full-width",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "vite",
7+
"build": "vite build",
8+
"serve": "vite preview",
9+
"start": "vite",
10+
"lint": "eslint ./src",
11+
"test:types": "tsc"
12+
},
13+
"dependencies": {
14+
"@faker-js/faker": "^10.4.0",
15+
"@tanstack/react-table": "^9.0.0-alpha.33",
16+
"react": "^19.2.5",
17+
"react-dom": "^19.2.5"
18+
},
19+
"devDependencies": {
20+
"@rollup/plugin-replace": "^6.0.3",
21+
"@types/react": "^19.2.14",
22+
"@types/react-dom": "^19.2.3",
23+
"@vitejs/plugin-react": "^6.0.1",
24+
"typescript": "6.0.3",
25+
"vite": "^8.0.8"
26+
}
27+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
* {
2+
box-sizing: border-box;
3+
}
4+
5+
html {
6+
font-family: sans-serif;
7+
font-size: 14px;
8+
}
9+
10+
.table-container {
11+
overflow-x: auto;
12+
border: 1px solid lightgray;
13+
}
14+
15+
.divTable {
16+
min-width: 100%;
17+
}
18+
19+
.tr {
20+
display: flex;
21+
}
22+
23+
tr,
24+
.tr {
25+
min-width: 100%;
26+
}
27+
28+
th,
29+
.th,
30+
td,
31+
.td {
32+
box-shadow: inset 0 0 0 1px lightgray;
33+
padding: 0.25rem;
34+
}
35+
36+
th,
37+
.th {
38+
padding: 2px 4px;
39+
position: relative;
40+
font-weight: bold;
41+
text-align: center;
42+
height: 30px;
43+
}
44+
45+
td,
46+
.td {
47+
height: 30px;
48+
overflow: hidden;
49+
text-overflow: ellipsis;
50+
white-space: nowrap;
51+
}
52+
53+
.resizer {
54+
position: absolute;
55+
top: 0;
56+
height: 100%;
57+
right: 0;
58+
width: 5px;
59+
background: rgba(0, 0, 0, 0.5);
60+
cursor: col-resize;
61+
user-select: none;
62+
touch-action: none;
63+
}
64+
65+
.resizer.isResizing {
66+
background: blue;
67+
opacity: 1;
68+
}
69+
70+
@media (hover: hover) {
71+
.resizer {
72+
opacity: 0;
73+
}
74+
75+
*:hover > .resizer {
76+
opacity: 1;
77+
}
78+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import React from 'react'
2+
import ReactDOM from 'react-dom/client'
3+
import {
4+
columnResizingFeature,
5+
columnSizingFeature,
6+
createColumnHelper,
7+
tableFeatures,
8+
useTable,
9+
} from '@tanstack/react-table'
10+
import { makeData } from './makeData'
11+
import type { Person } from './makeData'
12+
import './index.css'
13+
14+
const _features = tableFeatures({ columnSizingFeature, columnResizingFeature })
15+
16+
const columnHelper = createColumnHelper<typeof _features, Person>()
17+
18+
const columns = columnHelper.columns([
19+
columnHelper.accessor('firstName', {
20+
cell: (info) => info.getValue(),
21+
header: 'First Name',
22+
size: 150,
23+
}),
24+
columnHelper.accessor('lastName', {
25+
cell: (info) => info.getValue(),
26+
header: 'Last Name',
27+
size: 150,
28+
}),
29+
columnHelper.accessor('age', {
30+
header: 'Age',
31+
size: 80,
32+
}),
33+
columnHelper.accessor('visits', {
34+
header: 'Visits',
35+
size: 100,
36+
}),
37+
columnHelper.accessor('status', {
38+
header: 'Status',
39+
size: 150,
40+
}),
41+
columnHelper.accessor('progress', {
42+
header: 'Profile Progress',
43+
size: 180,
44+
}),
45+
])
46+
47+
function App() {
48+
const [data] = React.useState(() => makeData(20))
49+
const tableContainerRef = React.useRef<HTMLDivElement>(null)
50+
const [containerWidth, setContainerWidth] = React.useState(0)
51+
52+
// Track container width with ResizeObserver
53+
React.useEffect(() => {
54+
const container = tableContainerRef.current
55+
if (!container) return
56+
57+
const observer = new ResizeObserver((entries) => {
58+
for (const entry of entries) {
59+
setContainerWidth(entry.contentRect.width)
60+
}
61+
})
62+
observer.observe(container)
63+
return () => observer.disconnect()
64+
}, [])
65+
66+
const table = useTable(
67+
{
68+
_features,
69+
_rowModels: {},
70+
columns,
71+
data,
72+
defaultColumn: {
73+
minSize: 50,
74+
maxSize: 800,
75+
},
76+
columnResizeMode: 'onChange',
77+
},
78+
(state) => ({
79+
columnSizing: state.columnSizing,
80+
columnResizing: state.columnResizing,
81+
}),
82+
)
83+
84+
const visibleColumns = table.getVisibleLeafColumns()
85+
const totalColumnsWidth = table.getTotalSize()
86+
87+
// Determine if the last column should stretch to fill remaining space
88+
const shouldExtendLastColumn = totalColumnsWidth < containerWidth
89+
90+
// Compute the width for each column, stretching the last one if needed
91+
const getColumnWidth = React.useCallback(
92+
(columnId: string, index: number, baseWidth: number) => {
93+
if (shouldExtendLastColumn && index === visibleColumns.length - 1) {
94+
const otherColumnsWidth = visibleColumns
95+
.slice(0, -1)
96+
.reduce((sum, col) => sum + col.getSize(), 0)
97+
return Math.max(baseWidth, containerWidth - otherColumnsWidth)
98+
}
99+
return baseWidth
100+
},
101+
[shouldExtendLastColumn, visibleColumns, containerWidth],
102+
)
103+
104+
// Pre-compute column sizes as CSS variables for performant resizing
105+
const columnSizeVars = React.useMemo(() => {
106+
const headers = table.getFlatHeaders()
107+
const colSizes: Record<string, number> = {}
108+
for (let i = 0; i < headers.length; i++) {
109+
const header = headers[i]
110+
const width = getColumnWidth(
111+
header.column.id,
112+
i,
113+
header.column.getSize(),
114+
)
115+
colSizes[`--header-${header.id}-size`] = width
116+
colSizes[`--col-${header.column.id}-size`] = width
117+
}
118+
return colSizes
119+
}, [table.state.columnResizing, table.state.columnSizing, getColumnWidth])
120+
121+
// Table width: always at least the container width
122+
const tableWidth = Math.max(totalColumnsWidth, containerWidth)
123+
124+
return (
125+
<div className="p-2">
126+
<h3>Full-Width Column Resizing</h3>
127+
<p>
128+
The table fills its container. The last column stretches to fill any
129+
remaining space. Try resizing individual columns — the last column
130+
adjusts automatically. Resize the browser window to see the table adapt.
131+
</p>
132+
<div className="h-4" />
133+
<div ref={tableContainerRef} className="table-container">
134+
<div
135+
className="divTable"
136+
style={{
137+
...columnSizeVars,
138+
width: tableWidth,
139+
}}
140+
>
141+
<div className="thead" style={{ position: 'sticky', top: 0 }}>
142+
{table.getHeaderGroups().map((headerGroup) => (
143+
<div key={headerGroup.id} className="tr">
144+
{headerGroup.headers.map((header) => (
145+
<div
146+
key={header.id}
147+
className="th"
148+
style={{
149+
width: `calc(var(--header-${header.id}-size) * 1px)`,
150+
}}
151+
>
152+
{header.isPlaceholder ? null : (
153+
<table.FlexRender header={header} />
154+
)}
155+
<div
156+
onDoubleClick={() => header.column.resetSize()}
157+
onMouseDown={header.getResizeHandler()}
158+
onTouchStart={header.getResizeHandler()}
159+
className={`resizer ${
160+
header.column.getIsResizing() ? 'isResizing' : ''
161+
}`}
162+
/>
163+
</div>
164+
))}
165+
</div>
166+
))}
167+
</div>
168+
<div className="tbody">
169+
{table.getRowModel().rows.map((row) => (
170+
<div key={row.id} className="tr">
171+
{row.getAllCells().map((cell) => (
172+
<div
173+
key={cell.id}
174+
className="td"
175+
style={{
176+
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
177+
}}
178+
>
179+
{cell.renderValue<string>()}
180+
</div>
181+
))}
182+
</div>
183+
))}
184+
</div>
185+
</div>
186+
</div>
187+
<div className="h-4" />
188+
<pre style={{ fontSize: '12px' }}>
189+
{JSON.stringify(
190+
{
191+
containerWidth,
192+
totalColumnsWidth,
193+
shouldExtendLastColumn,
194+
columnSizing: table.state.columnSizing,
195+
},
196+
null,
197+
2,
198+
)}
199+
</pre>
200+
</div>
201+
)
202+
}
203+
204+
const rootElement = document.getElementById('root')
205+
if (!rootElement) throw new Error('Failed to find the root element')
206+
207+
ReactDOM.createRoot(rootElement).render(
208+
<React.StrictMode>
209+
<App />
210+
</React.StrictMode>,
211+
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { faker } from '@faker-js/faker'
2+
3+
export type Person = {
4+
firstName: string
5+
lastName: string
6+
age: number
7+
visits: number
8+
progress: number
9+
status: 'relationship' | 'complicated' | 'single'
10+
}
11+
12+
const range = (len: number) => {
13+
const arr: Array<number> = []
14+
for (let i = 0; i < len; i++) {
15+
arr.push(i)
16+
}
17+
return arr
18+
}
19+
20+
const newPerson = (): Person => {
21+
return {
22+
firstName: faker.person.firstName(),
23+
lastName: faker.person.lastName(),
24+
age: faker.number.int(40),
25+
visits: faker.number.int(1000),
26+
progress: faker.number.int(100),
27+
status: faker.helpers.shuffle<Person['status']>([
28+
'relationship',
29+
'complicated',
30+
'single',
31+
])[0],
32+
}
33+
}
34+
35+
export function makeData(len: number): Array<Person> {
36+
return range(len).map(() => newPerson())
37+
}

0 commit comments

Comments
 (0)