Skip to content

Columns

Columns are declared with DataTableHeader<T>. One instance per column, in display order.

DataTableHeader<Person>(
    key = "salary",                          // unique column identifier
    title = "Salary",                        // header label
    value = { it.salary },                   // value extractor — raw, not formatted
    format = DataTableFormatters.currency(), // how that value is displayed
    width = 120.dp,                          // fixed width (null = fill with weight)
    align = TextAlign.End,                   // text alignment
    sortable = true,                         // enable sorting
    fixed = false,                           // true = frozen/pinned column
    visible = true,                          // toggle visibility
    maxLines = 1,                            // text line limit
    overflow = TextOverflow.Ellipsis,        // overflow strategy
    comparator = compareBy { it.salary },    // custom sort comparator
    cellContent = { item ->                  // custom cell composable, replacing `format`
        Text(item.salary.toString(), fontWeight = FontWeight.Bold)
    },
    headerContent = {                        // custom header composable
        Text("Salary (USD)", fontWeight = FontWeight.Bold)
    },
    filterable = true,                       // give it a field in the filter row
    filterPlaceholder = "Min",               // hint shown while the field is empty
    filterPredicate = { item, query ->       // default is a case-insensitive contains
        query.toDoubleOrNull()?.let { item.salary >= it } ?: true
    },
    editable = true,                         // edit this column in place
    editValue = { it.salary.toString() },    // raw text to seed the editor with
    validateEdit = { _, text ->              // null accepts, a message rejects
        if (text.toDoubleOrNull() == null) "Enter a number" else null
    },
)

key must be unique — it identifies the column for sorting, for filtering, and for resize overrides.

Width

A column is either fixed or weighted:

width Behaviour
120.dp Exactly that wide.
null (default) Takes an equal share of the space the fixed columns leave.

Weighted columns divide up the viewport minus the fixed columns, the selection and expand controls, and any frozen section. When the fixed columns already overflow the viewport there is nothing left to share, so each weighted column falls back to minColumnWidth and the table scrolls.

Mixing both in one table is fine. Mixing them under a single grouped header is not — see Nested headers.

Text overflow

The defaults are maxLines = Int.MAX_VALUE and overflow = TextOverflow.Clip, so text wraps — but a row is a fixed height (see Row height), so the wrapped lines past the first are cut off. Say what you want to happen for any column of long values:

DataTableHeader(
    key = "notes",
    title = "Notes",
    value = { it.notes },
    width = 300.dp,
    maxLines = 1,
    overflow = TextOverflow.Ellipsis,
)

One line ending in an ellipsis is what most tables want — the user can see there is more without the row growing. To let a column genuinely occupy several lines, give the table a taller rowHeight, or rowHeight = null when the number of lines varies from row to row.

Formatting

format turns the extracted value into the text the cell shows. It is the whole of the difference between a column that displays money and a column that holds money:

DataTableHeader(
    key = "salary",
    title = "Salary",
    value = { it.salary },                   // stays a Double
    format = DataTableFormatters.currency(), // reads "$92,000.00"
    align = TextAlign.End,
)

Formatting inside value instead — value = { "$${it.salary}" } — is the tempting shortcut, and it quietly costs you three things. The column sorts as text, so $1,000 lands between $100 and $2, and you need a comparator to undo it. An editable column opens its editor onto $92,000, and you need an editValue to undo that. And anything else that reads the column — validation, your own onCopy exporter — gets a string where a number was.

format keeps the value raw and applies only where text is shown:

Reads the raw value Reads the formatted text
Sorting and comparator The rendered cell
editValue, validateEdit, and the oldText of a reported edit Clipboard copy, including selection.cells

Copy taking the formatted text is deliberate: what you see is what you paste, the way a spreadsheet behaves. A handler that wants the underlying numbers still has them — onCopy hands you columns, each with its own value extractor, and rows to run it against.

Built-in formatters

DataTableFormatters covers the common cases:

DataTableFormatters.currency()                          // $1,299.00
DataTableFormatters.currency(Currency.getInstance("KES"))
DataTableFormatters.number(decimals = 2)                // 1,234.57
DataTableFormatters.number(grouping = false)            // 100234, for numeric ids
DataTableFormatters.percent(decimals = 1)               // 0.1234 -> 12.3%
DataTableFormatters.percent(fraction = false)           // 15.0   -> 15%
DataTableFormatters.date("dd MMM yyyy")                 // 07 Mar 2026
DataTableFormatters.boolean("Active", "Inactive")

Each takes a locale (and date a zone), defaulting to the machine's. Each is total: a null value renders as its nullText — empty unless you set it — and a value of a type it cannot handle falls back to toString() rather than throwing. A formatter runs during layout, once per visible cell, so an exception there would take the table down with it.

They hold their locale and number format, so build one once rather than per recomposition:

private val money = DataTableFormatters.currency(locale = Locale.US)
// or, inside a composable:
val money = remember { DataTableFormatters.currency() }

Anything else is a lambda:

format = { value -> (value as? Int)?.let { "$it kg" } ?: "—" }

format sees the value, not the row

It is (Any?) -> String — enough for the value in front of it, and nothing else. When the text depends on the rest of the row, that is what cellContent is for.

A column with cellContent draws itself and ignores format on screen, but copy still uses it — which is how an icon column copies as Active rather than true.

Filtering

filterable puts a field under the column's header, and the table filters on what is typed. It has a guide of its own — see Filtering for predicates, custom controls, hoisted filter state, and manualFiltering.

Custom content

cellContent and headerContent replace the default text rendering entirely. This is where the table stops caring that it has no Material dependency — put whatever you like in a cell.

DataTableHeader(
    key = "status",
    title = "Status",
    width = 150.dp,
    cellContent = { item ->
        AssistChip(
            onClick = {},
            label = { Text(item.status) },
        )
    },
)

Hoist your header list

DataTableHeader is @Immutable, but a list rebuilt inline on every recomposition is still a new list. Wrap it in remember — keyed on whatever it depends on — so the table can skip re-sorting:

val headers = remember(showNotesColumn) { listOf(/* ... */) }

Visibility

visible = false removes a column from rendering without disturbing the rest of the list. For letting the user show and hide columns, turn on the table's own menu with showColumnMenuButton — see Column Layout.

var showNotes by remember { mutableStateOf(true) }

val headers = remember(showNotes) {
    listOf(
        // ...
        DataTableHeader(key = "notes", title = "Notes", value = { it.notes }, visible = showNotes),
    )
}

Editing

editable = true lets a column be edited in place. editValue seeds the editor with the raw value behind a formatted display, validateEdit rejects values before they are reported, and editorContent replaces the built-in text field with a picker of your own.

Declaring any editable column also turns on cell-level focus and grid keyboard navigation, since an editor you cannot reach is no use. See Cell Editing for the whole feature.

Frozen (pinned) columns

fixed = true pins a column to the left edge so it does not scroll horizontally.

DataTableHeader<Person>(
    key = "id",
    title = "ID",
    value = { it.id },
    width = 60.dp,
    fixed = true,
)

Frozen columns need an explicit width

The frozen section sits outside the horizontally scrolling area, so a weighted column there has nothing to take a share of. fixed = true without a width throws, naming the column.

Resizable columns

DataTable(
    // ...
    resizableColumns = true,
    minColumnWidth = 50.dp,
)

Drag the right edge of any header to resize. Overrides live on DataTableState; call state.resetColumnWidths() to clear them.

Grouped headers resize with their children — a group's width is recomputed from its leaves, so the band stays aligned.

Row height and horizontal virtualization

These are one setting, not two. rowHeight defaults to a single line of body text at the table's density — 52dp at DEFAULT, 44dp at COMFORTABLE, 36dp at COMPACT — and every data row is exactly that tall:

DataTable(
    items = orderLines,
    headers = headers,
    itemKey = { it.id },
    rowHeight = 56.dp,      // taller: these cells hold a status chip
)

Because a row's height is known without measuring its cells, the table composes only the columns near the viewport and replaces each run that scrolled out of sight with a single spacer of the same width. Rows stay aligned with the header, and a forty-column grid stops composing forty cells for every visible row. A couple of columns either side are composed ahead of the eye.

Rows that size to content

rowHeight = null brings back rows that grow to fit their tallest cell:

DataTable(
    items = notes,
    headers = headers,
    itemKey = { it.id },
    rowHeight = null,       // a column of prose that wraps to however many lines it needs
)

Columns are then not culled, and cannot be. A wrapping column makes every row tall even while it is off screen; cull it and the rows snap short, scroll it back and they grow again, shoving everything below down the page. In the library's own test a row moves 333 points doing nothing but scrolling sideways. So rowHeight = null composes every column at every offset — correct, and the reason it is not the default.

Prefer a taller rowHeight over null whenever the cell content has a fixed size — a chip, an avatar, two lines of text. You keep the culling and you keep uniform rows. Reach for null only when the number of lines genuinely varies per row.

What culling costs

A cellContent that runs an effect stops running it once its column leaves. LazyColumn already does this to a row scrolled off the bottom, so an effect in a cell has to tolerate disposal regardless — but a paginated table never scrolls off the bottom at all, and there the horizontal axis is the first thing that will dispose one.

The header and filter rows are never culled. They cost one row apiece rather than one per data row, and culling the filter row would pull the focus out of a field mid-scroll.

Nested (grouped) headers

Give a header children and it becomes a group: a band drawn above its children, spanning them. Only the leaves render as real columns — sorting, resizing, and cell content all belong to them.

val headers = listOf(
    DataTableHeader<Person>(key = "id", title = "ID", value = { it.id }, width = 60.dp),
    DataTableHeader(
        key = "contact",
        title = "Contact",
        children = listOf(
            DataTableHeader(key = "email", title = "Email", value = { it.email }, width = 200.dp),
            DataTableHeader(key = "phone", title = "Phone", value = { it.phone }, width = 140.dp),
        ),
    ),
)
ID Contact
Email Phone
1 ada@example.com +1 555 0100
2 grace@example.com +1 555 0101

Groups nest to any depth. A leaf sitting beside a group — ID above — stretches over the full header height and centres its title, rather than leaving blank rows above it.

A group's own value, width, and sorting settings are ignored. It takes its width from the leaves beneath it.

Two rules, both enforced

  • Leaves under one group must be either all fixed-width or all weighted. A group sums its leaves, and the two sizing models cannot be added together.
  • A group cannot straddle the freeze boundary — mark every column under it fixed = true, or none of them. Half a group would scroll out from under the other half.

Both throw with a message naming the group, rather than misrendering.