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:

BehaviorDefaultOpt outSince
Columns clip overflowing textontruncate: 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: false4.0
Scroll to the list top on page changeonscrollToTopOnPageChange: false4.0
Minimum width per unsized column140pxtable.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')
}
  • stickyHeader gives the table a bounded scroll area (capped by maxBodyHeight, 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 + defaultDensity expose the comfortable ↔ compact toggle (overrides the static compact).
  • reorderable / resizable add 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: true clips to one line with an ellipsis; truncate: N clamps 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). A title tooltip with the full text is added automatically for plain-text cells; for a JSX render, pass tooltip: item => '…' to surface the full value on hover.
  • truncate makes the table layout: '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 (an auto layout lets the content win). For a custom render with stacked lines (e.g. a name over an id), keep truncate on your own inner elements and set table.layout: 'fixed' directly — don't wrap fixed widths like max-w-[180px] inside the cell, as those ignore resizing.
  • grow: true marks 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 to maxWidth). No need to guess a fixed width.
  • width is a hint in auto layout and authoritative in fixed; 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) — note maxWidth also 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 standalone DensityToggle, ColumnManager, ExportButton, and TableOptionsMenu are 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. Pass paginationClassName to clear app chrome such as a sidebar (merged via tailwind-merge, so a left-* overrides the default left-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.

On this page