niko-table
componentdata-table-column-auto-fitsource: ok · hit
DataTable Column Auto-fit
Opt-in even-split fill for resizable columns. Drop <DataTableColumnAutoFit/> next to <DataTableColumnResize/> and the resizable columns grow by an equal share of the leftover space on load (the alternative to the default single-flex-column fill). Self-contained — the body ships no auto-fit code unless you install this.
npx shadcn add "https://niko-table.com/r/data-table-column-auto-fit.json"free
Where it goes
data-table
Visual style
terminal-mono
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
https://niko-table.com/r/data-table-core.jsonhttps://niko-table.com/r/data-table-column-resize.json
Files2 · source cached 10/9/2026, 1:52:16 AM
src/components/niko-table/components/data-table-column-auto-fit.tsx
"use client"
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
import { useDataTable } from "../core/data-table-context"
import { useColumnAutoFit } from "../lib/use-column-auto-fit"
/**
* Opt-in column auto-fit — mix-and-match, like every other niko-table feature.
* Pairs with column resizing (`<DataTableColumnResize />`). DROP THIS anywhere
* inside `<DataTableRoot>` and, on load, the resizable columns scale up by an
* EQUAL SHARE of the leftover space. Leave it out and this whole feature (and
* its code) never ships — the default single-flex-column fill applies instead
* (the first non-pinned data column absorbs the surplus; see `meta.flex`).
*
* Self-contained: it runs the auto-fit logic itself off the table + scroll
* container from context, so the body structures carry no auto-fit code. It
* renders nothing. Auto-fit re-fits when the container grows and backs off once
* the user resizes a column or a saved `columnSizing` is restored, so manual
* and persisted widths win. No-ops unless resizing is enabled.
*
* @example
* <DataTableRoot data={data} columns={columns}>
* <DataTableColumnResize />
* <DataTableColumnAutoFit />
* <DataTable>
* <DataTableHeader />
* <DataTableBody />
* </DataTable>
* </DataTableRoot>
*/
export function DataTableColumnAutoFit() {
const { table, scrollContainer } = useDataTable()
const enabled = table.options.enableColumnResizing ?? false
useColumnAutoFit(table, scrollContainer, enabled)
return null
}
DataTableColumnAutoFit.displayName = "DataTableColumnAutoFit"
src/components/niko-table/lib/use-column-auto-fit.ts
"use client"
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
/**
* Fill the container with resizable columns.
*
* When column resizing is enabled, cells render at `column.getSize()` (a fixed
* pixel width) instead of the flex-fill layout the non-resizable table uses.
* If the columns' natural sizes don't add up to the scroll container's width,
* that leaves dead space on the right. This hook removes it: on load (and when
* the container grows or columns are toggled) it grows the RESIZABLE columns by
* an equal share of the leftover space so they fill the available width (wide
* and narrow columns end up closer), seeding `columnSizing` so `getSize()` —
* the source of truth for both rendering and drag math — stays consistent.
*
* Rules:
* - Only resizable columns are scaled; fixed utility columns (selection,
* actions, gutters — `enableResizing: false`) keep their size, and their
* width is subtracted from the space the resizable columns fill.
* - Once the user manually resizes any column — drag, keyboard nudge, or
* double-click autosize — auto-fit stops for the life of the mount, so their
* widths are never overwritten.
* - When the columns already meet or exceed the container width, it does
* nothing and horizontal scrolling takes over.
* - With width PERSISTENCE wired in, the fitted widths are saved like any
* other `columnSizing` change, and on later visits they count as restored
* sizing — so auto-fit runs once per user, not once per session. That's the
* deliberate trade-off of "restored widths always win": the user keeps a
* stable layout, at the cost of not re-fitting when their viewport grows.
*
* Idempotent: after a fit the columns sum to the container width, so the next
* run finds no surplus and makes no change.
*/
import type { RowData } from "@tanstack/react-table"
import type { DataTableInstance } from "../types"
import * as React from "react"
import { DEFAULT_MAX_COLUMN_SIZE, DEFAULT_MIN_COLUMN_SIZE } from "./constants"
export function useColumnAutoFit<TData extends RowData>(
table: DataTableInstance<TData>,
scrollElement: HTMLElement | null,
enabled: boolean,
): void {
// A manual resize permanently exits auto-fit for this mount — otherwise a
// container resize would clobber the width the user just set. Drags latch
// here via `isResizingColumn`; keyboard nudges, double-click autosize, and
// consumer writes don't go through the drag handler, so they latch via the
// `lastFitSizingRef` comparison in the fit effect below.
const userResizedRef = React.useRef(false)
const isResizingColumn = table.state.columnResizing.isResizingColumn
React.useEffect(() => {
if (isResizingColumn) userResizedRef.current = true
}, [isResizingColumn])
// JSON of the sizing this hook last applied. If `columnSizing` is ever
// non-empty and NOT what the hook wrote, something else resized a column
// (keyboard nudge, double-click autosize, a consumer `setColumnSizing`) —
// treat it like a manual drag, or the next fit would redistribute the very
// space the user just removed on purpose.
const lastFitSizingRef = React.useRef<string | null>(null)
// Sizes already present (restored from persistence, or provided by the
// consumer) are respected as-is — auto-fit only fills the unsized first-load
// case, so it never overwrites a saved column layout. Captured lazily on the
// first *measured* pass (see below), NOT at mount: on a full page load the
// first render is SSR/hydration where `columnSizing` is momentarily empty
// (localStorage-backed widths arrive a tick later), and latching `false` there
// would let auto-fit clobber the persisted widths. `null` = not yet captured.
const hadInitialSizingRef = React.useRef<boolean | null>(null)
// Reactively track the container's inner width so a fit re-runs when the
// available space changes (window resize, sidebar collapse, panel dock).
const [containerWidth, setContainerWidth] = React.useState(0)
React.useLayoutEffect(() => {
if (!scrollElement) return
const measure = () => setContainerWidth(scrollElement.clientWidth)
measure()
const observer = new ResizeObserver(measure)
observer.observe(scrollElement)
return () => observer.disconnect()
}, [scrollElement])
// Re-derive on any layout-affecting state change. `columnSizing` is included
// so the effect converges (after a fit it re-runs, finds no surplus, stops).
const { columnSizing, columnVisibility, columnOrder } = table.state
React.useLayoutEffect(() => {
// The width guard is load-bearing for the persistence race, not just a
// no-op skip: persisted widths land (via the consumer's storage
// subscription) at least one commit BEFORE the ResizeObserver's first
// measurement can set `containerWidth`, so gating the capture below behind
// a real measurement guarantees restored sizing is visible when it runs.
// Capturing earlier (or measuring synchronously in render) would reopen
// the reload clobber this ordering prevents.
if (!enabled || containerWidth <= 0) return
// Capture whether the consumer restored widths on the first measured pass —
// by now hydration has settled and localStorage-backed `columnSizing` is in.
if (hadInitialSizingRef.current === null) {
hadInitialSizingRef.current =
Object.keys(table.state.columnSizing).length > 0
}
if (userResizedRef.current || hadInitialSizingRef.current) return
// Sizing that this hook didn't write means a manual resize happened
// through a path that bypasses `isResizingColumn` (keyboard, autosize,
// consumer write) — latch and stop fitting for this mount.
if (
Object.keys(columnSizing).length > 0 &&
JSON.stringify(columnSizing) !== lastFitSizingRef.current
) {
userResizedRef.current = true
return
}
const leafColumns = table.getVisibleLeafColumns()
const resizable = leafColumns.filter(c => c.getCanResize())
if (resizable.length === 0) return
const fixedTotal = leafColumns
.filter(c => !c.getCanResize())
.reduce((sum, c) => sum + c.getSize(), 0)
const resizableTotal = resizable.reduce((sum, c) => sum + c.getSize(), 0)
const available = containerWidth - fixedTotal
// Already fills or overflows (allow 1px for subpixel rounding) — let the
// horizontal scrollbar handle it rather than shrinking columns to fit.
if (resizableTotal <= 0 || resizableTotal >= available - 1) return
// Split the leftover space evenly across the resizable columns (rather than
// scaling proportionally), so wide and narrow columns end up closer.
const perColumn = (available - resizableTotal) / resizable.length
const next: Record<string, number> = {}
let changed = false
for (const column of resizable) {
const min = column.columnDef.minSize ?? DEFAULT_MIN_COLUMN_SIZE
const max = column.columnDef.maxSize ?? DEFAULT_MAX_COLUMN_SIZE
const size = Math.round(
Math.min(Math.max(column.getSize() + perColumn, min), max),
)
next[column.id] = size
if (size !== column.getSize()) changed = true
}
if (!changed) return
// Record what we're about to write so the external-change check above can
// tell hook-applied sizing apart from a user's keyboard/autosize resize.
const merged = { ...columnSizing, ...next }
lastFitSizingRef.current = JSON.stringify(merged)
table.setColumnSizing(() => merged)
// `table` identity is stable; state slices below drive re-fitting.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, containerWidth, columnSizing, columnVisibility, columnOrder])
}