Table and layout
Sticky headers, density, column reorder and resize, per-column sizing and truncation, scroll affordances and the pagination bar.
Table & layout defaults
These behaviors changed from opt-in to on-by-default, because each one was otherwise re-implemented in every consuming app. The version column is the release that flipped it:
| Behavior | Default | Opt out | Since |
|---|---|---|---|
| Columns clip overflowing text | on | truncate: false per column (wrap/grow imply it) | 4.0 |
| Pagination layout | 'sticky' | paginationVariant='fixed' | 'inline' | 4.0 |
| Rows-per-page selector | [20, 50, 100, 200] | pageSizeOptions: false | 4.0 |
| Scroll to the list top on page change | on | scrollToTopOnPageChange: false | 4.0 |
| Minimum width per unsized column | 140px | table.minColumnWidth (0 disables the floor) | 4.1 |
The column floor. A 'fixed' layout shares the container equally among the
columns that declare no width, so column count alone decides how much each
one gets: twelve columns in a 1280px shell get 106px each — not enough for a
date, let alone a pair of action buttons. The table therefore carries a
min-width of unsized columns x minColumnWidth (plus any declared widths,
summed in calc since they are CSS lengths), and scrolls horizontally once
the container drops below it rather than squeezing further.
It is a min-width, not a measured breakpoint: the browser re-evaluates it on
every resize with no re-render, no layout read, and the same answer during SSR.
Pair it with sticky: 'right' on the actions column so the buttons stay put
while the rest scrolls — that is the combination that makes a wide table usable.
A pinned cell paints with bg-inherit, so a row background must be opaque.
listkit's own states are; if rowClassName returns something like
bg-amber-50/60, the scrolling content shows through the pinned column.
Pinning starts at md. A checkbox plus an actions column is ~150px, a fifth
of a phone, and the reason it is worth spending on a wide screen is exactly the
reason it is not on a narrow one. The selection checkbox pins on its own
whenever selection is enabled — a selection you cannot see is one you lose
track of halfway across a wide table.
Edge actions. overlay: true renders a column as the trailing mirror of
the selection checkbox: always visible, slim px-2 padding, a crisp border-l
divider instead of a header label, pinned right from md up:
{ key: 'actions', header: 'Acciones', width: '7rem', overlay: true,
render: (item, i) => <RowActions item={item} index={i} variant='inline' actions={…} /> }overlay also implies exportable: false — the column holds buttons, and its
key names no value on the row, so it would otherwise write a checked, empty
column into every CSV. Pass exportable: true if you really do want it.
Size the width to the buttons plus the padding — 28px per icon button, 4px
per gap, 16px of padding, so three buttons ≈ '7rem'. Not revealed on hover on
purpose: actions nobody can see are actions nobody uses, and a touch screen
never hovers.
Table UX: sticky header, density, reorder, resize
These are on by default. Any config with a table gets the column manager, the density toggle, header drag-to-reorder, edge resizing and the options menu — no flags — and every choice persists to localStorage. Write false to take one away:
table: {
columns,
// Everything below is optional; the defaults are already `true`.
columnControl: false, // lock the columns (no hide/show/reorder panel)
reorderable: false, // no header drag-to-reorder
resizable: false, // no edge resizing
density: false, // no comfortable/compact toggle
optionsMenu: false, // drop the options menu entirely
defaultDensity: 'comfortable',
stickyHeader: true, // header stays visible while the table scrolls
maxBodyHeight: '70vh', // scroll-area height for the sticky header (default '70vh')
}stickyHeadergives the table a bounded scroll area (capped bymaxBodyHeight, default'70vh') so the header stays pinned to its top and the pagination bar below — both visible while you scroll. Horizontal scroll is contained in the same box, so a wide table never spills off-page on small screens. Table view only.density+defaultDensityexpose the comfortable ↔ compact toggle (overrides the staticcompact).reorderable/resizableadd header drag-to-reorder and edge-resize; resized widths persist per column.
Column sizing & truncation
By default the table uses layout: 'auto' — columns size to their content, so a long cell widens its column and pushes the others. To keep columns stable and clip overflow instead, opt a column into truncate:
table: {
columns: [
// One-line ellipsis. Auto-switches the table to layout: 'fixed' so the clip
// tracks the real column width — widen/resize the column and more text shows.
{ key: 'name', header: 'Name', truncate: true, width: '14rem' },
// Clamp to N lines.
{ key: 'notes', header: 'Notes', truncate: 2 },
// Opt back into wrapping for one column.
{ key: 'address', header: 'Address', wrap: true },
// Bound the resize range.
{ key: 'sku', header: 'SKU', minWidth: 96, maxWidth: 240 },
],
}truncate: trueclips to one line with an ellipsis;truncate: Nclamps to N lines. It's dynamic — the clip tracks the column's visible width, so widening or resizing the column reveals more text live (no per-column config needed). Atitletooltip with the full text is added automatically for plain-text cells; for a JSXrender, passtooltip: item => '…'to surface the full value on hover.truncatemakes the tablelayout: 'fixed'so the clip is tied to the column's real width — this is the fix when text stays cut off even after you widen or resize a column (anautolayout lets the content win). For a customrenderwith stacked lines (e.g. a name over an id), keeptruncateon your own inner elements and settable.layout: 'fixed'directly — don't wrap fixed widths likemax-w-[180px]inside the cell, as those ignore resizing.grow: truemarks the priority column: it absorbs the leftover width and is never truncated, so the most important value always shows in full while its neighbours clip.- Auto-fit: with
resizable, double-click a column's resize handle to size it to its widest visible cell (clamped tomaxWidth). No need to guess a fixed width. widthis a hint inautolayout and authoritative infixed; it's just the initial size and never blocks resizing.minWidth/maxWidth(px) are optional caps for the cell and the resize handle (default floor 48px, no ceiling) — notemaxWidthalso caps how far the handle drags, so omit it for unbounded resize.
Toolbar stays tidy. Density, columns, and export don't each add a button —
<ListView>folds them into a single options menu (⚙), leaving only the essentials (view toggle, result count) inline. It's responsive (available on mobile too), and in cards view it shows export only. The standaloneDensityToggle,ColumnManager,ExportButton, andTableOptionsMenuare exported if you build your own toolbar.
Scroll affordances
Every bounded scroller in listkit — the filter sidebar, the options menu, the
column manager, a select's options, the export dialog, and the table's
horizontal scroll — fades its clipped edges, so content past the fold
announces itself instead of reading as the end of the list. ScrollArea and
useScrollFade are exported for your own panels.
Every fade dissolves into the surface by default (the 'surface' tone, white
in light mode). Where you want the edge to darken instead, ScrollArea takes
fadeTone='shadow' | 'shadow-strong'.
Where a table pins columns, the fade does not disappear — it shifts inward to
the seam between the pinned stack and the scrolling content, so the affordance
stays visible on every device (a fade left at the container edge would paint
under the opaque pinned cells). The table's fades also start below the header
row: they wash scrolling data, never the column titles. The outermost pinned cell adds a hairline
divider on that same boundary. ScrollArea exposes the pieces for your own
scrollers: fadeLeft / fadeRight suppress a side, fadeInsetLeft /
fadeInsetRight (a CSS length) move a fade off the container edge from md
up, and the wrapper is a group/scroll carrying data-scroll-left /
data-scroll-right so descendants can style off the scroll position.
Dialogs take a fixed height so their content scrolls instead of the dialog
resizing under the cursor while the user filters a list inside it.
Pagination bar: fixed vs sticky
paginationVariant chooses the layout:
-
'fixed'(default) — a full-width bar pinned to the bottom of the viewport. Best for admin/dashboard shells. PasspaginationClassNameto clear app chrome such as a sidebar (merged via tailwind-merge, so aleft-*overrides the defaultleft-0):<ListView config={config} adapter={adapter} paginationClassName='lg:left-64' />For a sidebar whose width changes (collapse), drive it with a CSS variable, e.g.
left-[var(--sidebar-w)]. -
'sticky'— a floating, semi-transparent card that stays in the content flow, so it never overlaps a page footer. Best for landing/storefront pages:<ListView config={config} adapter={adapter} paginationVariant='sticky' />
Paging, filtering and sorting update the URL without scrolling to the top (the Next.js adapter uses { scroll: false }), so the list stays put as you page.