Skip to content

Interactions

Row callbacks

DataTable(
    // ...
    onRowClick = { person -> println("Clicked: ${person.name}") },
    onRowDoubleClick = { person -> println("Double-clicked: ${person.name}") },
    onRowContextMenu = { person, offset -> println("Right-click: ${person.name} at $offset") },
)

onRowContextMenu hands you the row and the pointer position, so you can position your own popup:

var menuTarget by remember { mutableStateOf<Pair<Person, Offset>?>(null) }

DataTable(
    // ...
    onRowContextMenu = { person, offset -> menuTarget = person to offset },
)

menuTarget?.let { (person, offset) ->
    // position a Popup at `offset` and act on `person`
}

Only pass onRowDoubleClick if you use it

Gesture detection has to wait for a possible second tap before it can report a single one. The table skips that wait entirely when no double-click handler is supplied, so leaving it null keeps single clicks instant.

Keyboard navigation

Click anywhere in the table to give it focus, or Tab to it, then:

Key Action
Up / Down Move the focused row
Enter Fire onRowClick on the focused row
Space Toggle selection on the focused row
Home Focus the first row
End Focus the last row

Tab order

Per-row checkboxes and expand buttons are deliberately outside the Tab order. One stop per control per row would mean two presses per visible row just to get past the table — unusable on real data. Both remain clickable, and Space on the focused row toggles its selection without needing to reach the checkbox.

What stays reachable by Tab: the table itself as a single stop, the header's select-all checkbox, and the footer's page buttons and rows-per-page selector. The cost of tabbing past a table is therefore fixed — a forty-row table takes the same presses as a three-row one.

Focus is tracked by key, exposed as state.focusedKey. Because it is a key rather than a position, focus stays on the same row when the table is re-sorted instead of sticking to an offset.

If the focused row leaves the view — filtered out, or on another page — the next arrow key starts again from the first row, and Enter/Space do nothing rather than acting on whatever row has drifted into that position.

The table takes focus on click rather than on appearing, so dropping one into a form does not steal focus from whatever the user was typing in. To start it focused, call focusRow yourself.

DataTableState

rememberDataTableState() gives you programmatic control over scrolling, focus, and column widths.

val tableState = rememberDataTableState()

DataTable(
    // ...
    state = tableState,
)

Scrolling

val scope = rememberCoroutineScope()

scope.launch { tableState.animateScrollToItem(index = 42) }
scope.launch { tableState.scrollToItem(index = 0) }

val firstVisible = tableState.firstVisibleItemIndex

Focus

val focused = tableState.focusedKey        // Any?, null when nothing is focused

tableState.focusRow(person.id)             // move focus
tableState.focusRow(null)                  // clear it

focusRow only moves focus; it does not scroll. DataTableState never sees items, so it cannot resolve a key to a position on its own. Pair the two when you need both:

val position = people.indexOfFirst { it.id == personId }
tableState.focusRow(personId)
if (position >= 0) {
    scope.launch { tableState.animateScrollToItem(position) }
}

Focusing a key that is not currently displayed is harmless — nothing draws the indicator.

Column widths

Button(onClick = { tableState.resetColumnWidths() }) {
    Text("Reset Columns")
}

Clears every user resize, reverting to the widths declared on the headers.

State is not persisted

Column widths, sort, focus, and scroll position live only as long as the composition. Saving and restoring a user's grid layout across restarts is not built in — hoist the pieces you care about and persist them yourself.

Scrolling behaviour

Horizontal scrolling responds to a horizontal wheel or trackpad swipe, and to Shift + vertical wheel. Press-and-drag deliberately does not pan the table — that would fight the column resize handles and text selection.

Scrollbars can be hidden with showScrollbars = false.