A data table that gets out of the way
Six decisions behind the Koala UI DataTable: no box, rules or chips, quiet fills, drag that lifts the real row, a selection bar that never moves the page, and a view that remembers.
Short answer
A good data table adds as little structure as it can. Let it run full bleed instead of boxing it, mark rows with straight rules or with rounded chips but never both, keep hover and selection fills a small step off the surface, reorder rows by lifting the real row with pointer events, and show bulk actions in a bar that floats over the table instead of pushing it down.
A data table is where people spend their working day in a product: scanning, sorting, picking rows, acting on them. This is how we designed the Koala UI DataTable to stay out of the way.
Six decisions and the reasons behind each, with the real component running on this page.
1. No box around the table
The first instinct is to frame a table: a card with padding, then the table inside with its own border. That draws two frames around one thing, and every row rule stops short of the edge it should reach.
Koala tables take variant="full-bleed" for this. In a card or straight on an app page, the rules run to the edges while the first cell stays on the same line as the title and the search field above it. The table brings its own structure; it does not need a box to have one.
| Order | Customer | Status | Amount |
|---|---|---|---|
| #1043 | Northwind Labs | $2,980.00 | |
| #1042 | Juniper & Co | $86.00 | |
| #1041 | Fieldnote | $412.50 |
| Order | Customer | Status | Amount |
|---|---|---|---|
| #1043 | Northwind Labs | $2,980.00 | |
| #1042 | Juniper & Co | $86.00 | |
| #1041 | Fieldnote | $412.50 |
2. Rules or chips, never both
A rounded hover or selected fill looks friendly on its own. Put it between two straight row rules and it reads as a mistake: a soft shape sliced by hard lines above and below.
So a Koala table picks one language. The default draws straight rules on the cells and fills a row flat, edge to edge. The rounded, inset and separated variants drop the rules altogether and turn the fill into a padded chip.
| Northwind Labs |
| Juniper & Co |
| Fieldnote |
| Northwind Labs |
| Juniper & Co |
| Fieldnote |
3. Quiet hover and selection
Hover and selection fills are where tables most often feel heavy. A fill that reads darker than the content around it, or one that presses against the first cell's text, makes the whole screen look unfinished.
Koala builds both from the surface itself. The default table hovers with half a step of the muted tone mixed into the surface and selects with one full step. The chip variants mix 4% of the ink for hover and 5% for selected. Either way it is enough to follow the pointer and keep your place, and quiet enough to read straight through.
4. Reorder by lifting, not by ghosting
When people drag a row, the row should come with them. The grabbed row follows the pointer and the rows it passes step aside, so the gap itself shows where it will land. There is no insertion line to aim at.
That rules out the browser's HTML5 drag and drop. It is unreliable on touch, it drags a washed-out snapshot instead of the real row, it takes over the cursor, and it cannot ease the drop into place.
The table tracks the gesture with pointer events instead, from row geometry measured at grab time. Escape cancels, the view scrolls when the row nears its edges, and a press that moves less than 4px is still a click.
Two details took the longest. The lifted row has no drop shadow: on a table row a shadow paints under the next row, and moved onto the cells it leaves a seam at every cell boundary. It rises with a slightly raised fill and a hairline on its top edge instead.
And the drag barely touches React: the offset lives in a CSS variable, so the table re-renders once for each row the drag passes, not sixty times a second.
| Reorder | # | Member | Role | Status | Balance | |
|---|---|---|---|---|---|---|
| 1 | A Aria Montgomeryaria@koala.dev | Owner | $4,820 | |||
| 2 | B Bruno Vasquezbruno@koala.dev | Admin | $1,290 | |||
| 3 | C Cleo Nakamuracleo@koala.dev | Member | $0 | |||
| 4 | D Dario Fielddario@koala.dev | Member | $760 | |||
| 5 | E Esme Hollowayesme@koala.dev | Viewer | -$120 | |||
| 6 | F Finn Albrechtfinn@koala.dev | Member | $3,410 |
// pointermove: no React render, just one CSS variable
scroller.style.setProperty("--row-drag", `${dy}px`)
// the lifted row reads it on the compositor
liftedRow.style.transform = "translate3d(0, var(--row-drag), 0)"
// React only hears about it when the target slot changes
if (slot !== toIndex) setToIndex(slot)5. Selecting rows never moves the page
Bulk actions usually arrive as a bar that pushes the table down the moment you tick a box, so the row you were about to tick next is no longer under your pointer. Koala's selection bar floats over the last rows instead, out of the layout, centered in the visible width even when the table scrolls sideways, and announced to screen readers as a status.
| Member | Role | Status | Balance | ||
|---|---|---|---|---|---|
A Aria Montgomeryaria@koala.dev | Owner | $4,820 | |||
B Bruno Vasquezbruno@koala.dev | Admin | $1,290 | |||
C Cleo Nakamuracleo@koala.dev | Member | $0 | |||
D Dario Fielddario@koala.dev | Member | $760 | |||
E Esme Hollowayesme@koala.dev | Viewer | -$120 | |||
F Finn Albrechtfinn@koala.dev | Member | $3,410 |
6. Remember the view, and let density be a choice
People shape a table to their work: they hide columns, sort, reorder and pick a page size. With a persistKey the table keeps all of it between visits.
Density is one prop, compact or comfortable, shared with the rest of the system, so a table matches the screen around it.
| Member | Role | Status | Balance | |
|---|---|---|---|---|
A Aria Montgomeryaria@koala.dev | Owner | $4,820 | ||
B Bruno Vasquezbruno@koala.dev | Admin | $1,290 | ||
C Cleo Nakamuracleo@koala.dev | Member | $0 | ||
D Dario Fielddario@koala.dev | Member | $760 | ||
E Esme Hollowayesme@koala.dev | Viewer | -$120 | ||
F Finn Albrechtfinn@koala.dev | Member | $3,410 |
<DataTable
variant="full-bleed"
columns={columns}
data={orders}
onRowReorder={({ fromIndex, toIndex }) =>
setOrders((rows) => moveItem(rows, fromIndex, toIndex))
}
enableRowSelection
renderSelectionActions={() => <Button variant="ghost" size="sm">Archive</Button>}
density="compact"
persistKey="orders"
/>Use the DataTable
The DataTable docs cover filtering, grouping, sticky columns, server-side data, loading and empty states, with every example live. And the status pills in every table above are the badges we rebuilt for light mode.
About Koala UI
Koala UI is a React and Figma design system: accessible components, four themes, and source code you own.


Frequently asked questions
What color should a table row hover be?
Keep it a small step off the table surface rather than a grey wash on top: half a step of your muted tone for hover and a full step for selected, or about 4% and 5% of the text color mixed into the surface. Anything that reads darker than the content makes the table feel heavy.
Should a data table have borders?
Separate rows with thin horizontal rules and skip the outer box when the table already sits in a card or a page section. If rows get a rounded hover or selected fill, drop the rules: a rounded fill between straight lines reads as a mistake.
How do you build drag and drop row reordering in React?
Track the gesture with pointer events on the window from row geometry measured when the drag starts, move the lifted row with a CSS transform driven by a CSS variable, shift the rows it passes by one row height, and update React state only when the target index changes. Commit the new order after the row has eased into its slot.
Why not use HTML5 drag and drop for tables?
The native API is unreliable on touch devices, drags a translucent snapshot instead of the real row, takes control of the cursor and cannot animate the drop. Pointer events handle mouse, pen and touch with one code path and let you animate every frame.
How do I show bulk actions without shifting the layout?
Render the bulk action bar as an absolutely positioned overlay over the bottom of the table instead of a block in the page flow, and announce it with role status so screen reader users hear the selection count.
Sources