# Mantine DataTable > A fully-featured React data table/data grid component for Mantine UI, trusted by thousands of developers and companies around the world, including Microsoft. `mantine-datatable` is a lightweight, dependency-free, dark-theme aware table component for React applications built with Mantine (https://mantine.dev), supporting asynchronous data loading, pagination, sorting, Gmail-style batch row selection, custom cell rendering, column grouping, dragging, toggling, resizing and pinning, row expansion, nested tables, context menus, RTL layouts and more. Documentation website: https://icflorescu.github.io/mantine-datatable/ Repository: https://github.com/icflorescu/mantine-datatable Changelog: https://github.com/icflorescu/mantine-datatable/blob/main/CHANGELOG.md ## Key facts - Mantine DataTable V9.x is compatible with Mantine V9.x (the major version follows Mantine’s; use `mantine-datatable@8` with Mantine 8, `mantine-datatable@7` with Mantine 7). Peer dependencies are `@mantine/core`, `@mantine/hooks`, `clsx`, `react` and `react-dom` (React 19+). It has no other dependencies. - Import `mantine-datatable/styles.layer.css` (or `mantine-datatable/styles.css`) **after** the Mantine core styles. When using the `styles.layer.css` files, declare `@layer mantine, mantine-datatable;` in your own CSS so Mantine core styles are applied first; otherwise the table may look broken (missing borders, wrong spacing, misplaced pinned columns). - The package entry point is marked `'use client'`. Since `DataTable` props usually include functions (`render`, `onRowClick` etc.), render it from a client component (a file with the `'use client'` directive) in React Server Components environments such as the Next.js app router. - `DataTable` is generic: the record type is inferred from `records`. When declaring columns outside the component, type them as `DataTableColumn[]`. - `records` is the array of rows to display; `columns` is an array of objects, each with an `accessor` (a record key, or a dot-notation path like `department.company.name`). A column without `render` displays the accessor value; when `title` is omitted, it is generated by "humanizing" the accessor (`firstName` → "First name"). An accessor doesn’t have to match a record key if the column has a `render` function. Use `groups` instead of `columns` to group columns under shared headers. - Each record needs a unique key, read from its `id` property by default. Use `idAccessor` (a property name, a dot-notation path or a function `(record) => key`) when your records have a different unique identifier; the same key is used to compare records for selection and row expansion. - The component does **not** paginate, sort or filter data for you. Pagination (`page`, `onPageChange`, `totalRecords`, `recordsPerPage`, with a 1-based `page`) expects `records` to contain only the current page. Sorting (`sortStatus: { columnAccessor, direction }`, `onSortStatusChange`, `sortable: true` on columns) only renders the indicators and reports changes: sort the records yourself. Filtering is done by passing a `filter` node to a column and filtering the records yourself. - Selection is controlled: pass `selectedRecords` and `onSelectedRecordsChange` (optionally `isRecordSelectable`, `selectionTrigger: 'checkbox' | 'cell'`). Shift-click selects ranges, Gmail-style. - Set `fetching` to show a loader overlay while loading data asynchronously; an empty `records` array shows a customizable empty state (`noRecordsText`, `emptyState`, `noRecordsIcon`). - The table is vertically scrollable with a sticky header only when it has a restricted height (`height`, `maxHeight`, or a height-restricted parent); otherwise it grows with its content. It is always horizontally scrollable if its content is wider than its container. - Column dragging, toggling and interactive pinning (`draggable`, `toggleable`, `pinnable` on columns) require a `storeColumnsKey` on the table and the `useDataTableColumns({ key, columns })` hook called with the same `key`: pass the returned `effectiveColumns` to `columns`. Their state is persisted in `localStorage`. Column resizing (`resizable: true`) also works without them, but then widths are not persisted. - Row expansion is configured with the `rowExpansion` property (`content`, `trigger`, `allowMultiple`, controlled `expanded` state); nested tables are built by rendering another `DataTable` (with `noHeader`) inside the expansion content. - Styling: use Mantine table props (`withTableBorder`, `withColumnBorders`, `striped`, `highlightOnHover`, `borderRadius`, `shadow`, `verticalSpacing`, `fz` etc.), color props (`c`, `backgroundColor`, `borderColor`, `rowBorderColor`, `stripedColor`, `highlightOnHoverColor`, `selectionColor`), `className`/`style`/`classNames`/`styles`, per-row `rowClassName`, `rowStyle`, `rowColor`, `rowBackgroundColor`, and per-column `cellsClassName`, `cellsStyle`. `createStyles` (Mantine V6) is not available. - RTL layouts are supported automatically through Mantine’s `DirectionProvider`. Note: the examples use a few documentation-website helpers which are not part of the package: the sample data and types imported from `~/data` (`companies`, `departments`, `employees`, `Company`, `Employee` etc.) and from `~/data/async`, `~/data/nested` and `~/data/nestedAsync` (simulated API calls and hooks such as `getEmployeesAsync`), and small utilities imported from `~/lib/examples` (e.g. `delay`). Replace them with your own data and API calls. Code hidden for brevity is marked with `// ...` comments. ## Getting started Source: https://icflorescu.github.io/mantine-datatable/getting-started/ Mantine DataTable is built on top of Mantine (https://mantine.dev) and has no dependencies of its own. Its peer dependencies are `@mantine/core` and `@mantine/hooks` (version 9 or newer), `react` and `react-dom` (version 19 or newer), and `clsx` (version 2 or newer). The major version of Mantine DataTable follows the major version of Mantine it supports. The current version is built for Mantine 9; if your application uses Mantine 8 or 7, install the matching major version (e.g. `mantine-datatable@8`). For Mantine 6, see the Mantine V6 support section below. Create an application with Mantine (see https://mantine.dev/getting-started/), then install `mantine-datatable` along with `clsx`: ```shell npm i mantine-datatable clsx # or: pnpm i mantine-datatable clsx # or: yarn add mantine-datatable clsx ``` Next, import the necessary CSS files and make sure the styles are applied **in the correct order**: the `@mantine/core` styles must be applied before the Mantine DataTable styles (see the Styling section below). For example, in a Next.js application with the app router, your root `layout.tsx` could look like this: `RootLayout.tsx`: ```tsx import { ColorSchemeScript, MantineProvider } from '@mantine/core'; import '@mantine/core/styles.layer.css'; import 'mantine-datatable/styles.layer.css'; import './layout.css'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` `layout.css`: ```css /* 👇 Make sure the styles are applied in the correct order */ @layer mantine, mantine-datatable; ``` Then import the component and use it in your application like so: `GettingStartedExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { Box } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; export function GettingStartedExample() { return ( ( {party.slice(0, 3).toUpperCase()} ), }, { accessor: 'bornIn' }, ]} // execute this callback when a row is clicked onRowClick={({ record: { name, party, bornIn } }) => showNotification({ title: `Clicked on ${name}`, message: `You clicked on ${name}, a ${party.toLowerCase()} president born in ${bornIn}`, withBorder: true, }) } /> ); } ``` This example uses Mantine Notifications (https://mantine.dev/x/notifications/) to react to row clicks; `@mantine/notifications` is **not** required by Mantine DataTable. `DataTable` is a generic component: the type of the records is inferred from the `records` property, so callbacks such as `render` or `onRowClick` are fully typed and column `accessor` values are autocompleted. ## Styling Source: https://icflorescu.github.io/mantine-datatable/styling/ Mantine doesn’t use CSS-in-JS: all `@mantine/*` packages ship native CSS files, which can be imported from `@mantine/{package}/styles.css` or `@mantine/{package}/styles.layer.css`. Similarly, Mantine DataTable ships native CSS files, which can be imported from `mantine-datatable/styles.css` or `mantine-datatable/styles.layer.css`. **Order matters**: Mantine DataTable styles build on top of the `@mantine/core` styles, so they **must** be applied **after** them. If they’re not, the table may look broken (for instance, missing borders, wrong spacing or misplaced pinned columns). ### Importing styles without CSS layers If your bundler preserves the order of CSS imports (as Vite does, for instance), the simplest approach is to import the `styles.css` files in the right order, at the root of your application: `main.tsx`: ```tsx // 👇 Import the Mantine core styles first... import '@mantine/core/styles.css'; // 👇 ...then the mantine-datatable styles import 'mantine-datatable/styles.css'; import { MantineProvider } from '@mantine/core'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; createRoot(document.getElementById('root')!).render( ); ``` ### Controlling the order of styles with CSS layers Some bundlers and frameworks (including Next.js) don’t let you control the order in which imported stylesheets are applied. You can work around this by using CSS layers: - `@mantine/{package}/styles.layer.css` files place their styles in a layer named `mantine`; - `mantine-datatable/styles.layer.css` places its styles in a layer named `mantine-datatable`. For example, in a Next.js application you can ensure the correct order of styles by importing the `styles.layer.css` files and declaring the layer order with the `@layer` directive: `RootLayout.tsx`: ```tsx import { ColorSchemeScript, MantineProvider } from '@mantine/core'; // 👇 Import the mantine-core layer CSS file; // this will automatically place it in a `mantine` layer import '@mantine/core/styles.layer.css'; // 👇 Import the mantine-datatable layer CSS file; // this will automatically place it in a `mantine-datatable` layer import 'mantine-datatable/styles.layer.css'; // 👇 Import your own CSS file; // make sure to specify the layers order with the `@layer` directive // inside that file import './layout.css'; export function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` `layout.css`: ```css /* 👇 Make sure the styles are applied in the correct order */ @layer mantine, mantine-datatable; ``` For even more control, use the `postcss-import` plugin to choose your own layer names when importing the `styles.css` files, then declare their order with the `@layer` directive: `RootLayout.tsx`: ```tsx import { ColorSchemeScript, MantineProvider } from '@mantine/core'; import './layout.css'; export function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` `postcss.config.js`: ```js module.exports = { plugins: { // 👇 the postcss-import plugin must come first; // make sure to install it (`npm i -D postcss-import`) 'postcss-import': {}, 'postcss-preset-mantine': {}, 'postcss-simple-vars': { // your Mantine variables here... }, }, }; ``` `layout.css`: ```css /* 👇 Expressly import the mantine core styles into mantine-core layer */ @import '@mantine/core/styles.css' layer(mantine-core); /* 👇 Expressly import the mantine-datatable styles into mantine-datatable layer */ @import 'mantine-datatable/dist/styles.css' layer(mantine-datatable); /* 👇 Make sure the styles are applied in the correct order */ @layer mantine-core, mantine-datatable; ``` Styles that are **not** placed in any layer always take precedence over layered styles, so your own un-layered CSS overrides both Mantine and Mantine DataTable styles, regardless of specificity. ### Customizing the look of your tables Mantine DataTable follows the current Mantine theme, so the best way to achieve a consistent look-and-feel is to customize the theme you pass to `MantineProvider`. To go further, use the basic table properties (borders, spacing, font size, shadow, striping etc.), set custom colors, class names or styles (see "Overriding the default styles"), and style individual columns and rows. ### Migrating from Mantine V6 Mantine DataTable V7 was a major release with breaking changes, made to support Mantine V7, which migrated to native CSS. Styling is no longer done with CSS-in-JS (Emotion), so the `createStyles` function is no longer available. When migrating an application from Mantine V6, read the Mantine V7.0 (https://mantine.dev/changelog/7-0-0/) and V7.1 (https://mantine.dev/changelog/7-1-0/) changelogs. ## Example: Basic usage Source: https://icflorescu.github.io/mantine-datatable/examples/basic-usage/ In its most basic usage scenario, the `DataTable` component only requires the `records` and `columns` properties to be set: `BasicUsageExample.tsx`: ```tsx import { DataTable } from 'mantine-datatable'; import companies from '~/data/companies.json'; export function BasicUsageExample() { return ( ); } ``` `companies.json`: ```json [ { "id": "1323addd-a4ac-4dd2-8de2-6f934969a0f1", "name": "Feest, Bogan and Herzog", "streetAddress": "21716 Ratke Drive", "city": "Stromanport", "state": "WY", "missionStatement": "Innovate bricks-and-clicks metrics." }, { "id": "0cf96f1c-62c9-4e3f-97b0-4a2e8fa2bf6b", "name": "Cummerata - Kuhlman", "streetAddress": "6389 Dicki Stream", "city": "South Gate", "state": "NH", "missionStatement": "Harness real-time channels." }, { "id": "bba53ee9-385f-4b3d-a9a4-613ced38ff2c", "name": "Goyette Inc", "streetAddress": "8873 Mertz Rapid", "city": "Dorthyside", "state": "ID", "missionStatement": "Productize front-end web services." }, { "id": "3d80d34a-4aff-468a-b4e5-e17658f7635e", "name": "Runte Inc", "streetAddress": "2996 Ronny Mount", "city": "McAllen", "state": "MA", "missionStatement": "Engage synergistic infrastructures." }, { "id": "3ae22e52-335e-4e49-9e26-f5e0089edb76", "name": "Goldner, Rohan and Lehner", "streetAddress": "632 Broadway Avenue", "city": "North Louie", "state": "WY", "missionStatement": "Incubate cross-platform metrics." }, { "id": "6e9372ad-6b30-40c1-bd05-30211cd00ed2", "name": "Doyle, Hodkiewicz and O'Connell", "streetAddress": "576 Joyce Ways", "city": "Tyraburgh", "state": "KS", "missionStatement": "Scale web-enabled e-business." }, { "id": "2d0ddea1-ee92-477e-8d63-6d0508749ae6", "name": "Rau - O'Hara", "streetAddress": "7508 Lansdowne Road", "city": "Shieldsborough", "state": "MI", "missionStatement": "Innovate real-time applications." }, { "id": "1c509c99-109e-4f2c-bc76-03c23b839222", "name": "Tillman - Jacobi", "streetAddress": "57918 Gwendolyn Circles", "city": "Sheridanport", "state": "MI", "missionStatement": "Matrix viral synergies." }, { "id": "74207e6f-91a7-49a3-8eb4-b0c95cda3105", "name": "Connelly, Feest and Hodkiewicz", "streetAddress": "7057 Stanley Road", "city": "Kearaburgh", "state": "CA", "missionStatement": "Maximize dynamic e-commerce." }, { "id": "3fdba2fc-2347-464b-b29a-3ef8f03ccf56", "name": "Shanahan, Robel and Beier", "streetAddress": "378 Berta Crescent", "city": "West Gerry", "state": "KS", "missionStatement": "Synthesize customized portals." }, { "id": "a9c0b7f0-3a1b-4b0c-8f3d-6a6a7b7b7b7q2", "name": "Kling - McLaughlin", "streetAddress": "8346 Kertzmann Square", "city": "South Joesph", "state": "ID", "missionStatement": "Reinvent cross-platform channels." }, { "id": "a9c0b7f0-3a1b-4b0c-8f3d-6a6a7b7b7b72b", "name": "Jogi - McLaughlin", "streetAddress": "83462 Shazam Street", "city": "North Joesph", "state": "ID", "missionStatement": "Eliminate best-of-breed e-markets." }, { "id": "a9c0b7f0-3a1b-4b0c-8f3d-6a6af7b7b7b7b", "name": "Jogi - McLaughlin", "streetAddress": "83462 Shazam Street", "city": "North Joesph", "state": "ID", "missionStatement": "Eliminate best-of-breed e-markets." } ] ``` A few things worth noting: - each column’s `accessor` tells the component which record property to display in that column; - when a column doesn’t have a `title`, one is generated by “humanizing” its accessor (e.g. `streetAddress` becomes “Street address”); - each record must have a unique `id` property, which is used as the row key; if your records are identified by a different property, see the non-standard record IDs example (https://icflorescu.github.io/mantine-datatable/examples/non-standard-record-ids/). ### Displaying nested properties Accessors support dot-notation, so you can display nested object properties without writing a custom `render` function: `BasicUsageNestedPropertiesExample.tsx`: ```tsx return ( ); ``` ## Example: Basic table properties Source: https://icflorescu.github.io/mantine-datatable/examples/basic-table-properties/ The `DataTable` component passes most properties of the underlying Mantine Table component (https://mantine.dev/core/table/) through, and implements a number of additional ones: - `withRowBorders: boolean` → show borders between rows; defaults to `true`; - `withColumnBorders: boolean` → show borders between columns; - `striped: boolean | 'odd' | 'even'` → stripe rows (odd rows by default); - `highlightOnHover: boolean` → highlight the row under the mouse cursor; - `withTableBorder: boolean` → show an outer table border; - `noHeader: boolean` → hide the table header; - `shadow` → box shadow of the table; - `horizontalSpacing` and `verticalSpacing` → cell padding; - `fz` → font size; - `borderRadius` → radius of the outer table border; - `verticalAlign: 'top' | 'center' | 'bottom'` → vertical alignment of row cells. Here is an example using most of them: ```tsx return ( ); ``` ### Things to keep in mind - row borders are shown by default; set `withRowBorders={false}` to hide them; - `borderRadius` is meant to round the corners of the outer table border, so it can only be set together with `withTableBorder` (TypeScript will complain otherwise); - `horizontalSpacing`, `verticalSpacing`, `fz`, `borderRadius` and `shadow` accept a key of the corresponding Mantine theme property (`xs`, `sm`, `md`, `lg` or `xl`) as well as any valid CSS value; - `verticalAlign` controls the vertical alignment of row cells and defaults to `center`; its effect is only visible when some cells span multiple lines. ### Other table properties Since Mantine DataTable passes unknown properties down to the Mantine Table component, you can also use other Table properties (https://mantine.dev/core/table/?t=props), such as `tabularNums` (which applies `font-variant-numeric: tabular-nums`, so that numbers in different rows line up nicely). Other examples cover related properties in more detail: - overriding the default styles (https://icflorescu.github.io/mantine-datatable/examples/overriding-the-default-styles/) → text, background, border, striped and hover colors, class names and styles; - scrollable vs. auto-height (https://icflorescu.github.io/mantine-datatable/examples/scrollable-vs-auto-height/) → `height`, `minHeight` and `maxHeight`; - disabling text selection (https://icflorescu.github.io/mantine-datatable/examples/disabling-text-selection/) → `textSelectionDisabled`. ## Example: Overriding the default styles Source: https://icflorescu.github.io/mantine-datatable/examples/overriding-the-default-styles/ Mantine DataTable closely follows the current Mantine theme, so it should look great out of the box in most cases. **Keep in mind:** if you want to customize your application’s look-and-feel and keep a consistent design across all areas of the screen, do so by providing a custom theme to the top-level `MantineProvider` instead of overriding individual component styles. You will achieve a better result with less effort and your code will be easier to maintain. However, if you’re not happy with the default Mantine DataTable styling, there are several ways to override it. ### With color properties The quickest way to customize the default colors is to use the `c`, `backgroundColor`, `borderColor`, `rowBorderColor`, `stripedColor`, `highlightOnHoverColor` and `selectionColor` properties. Each of them accepts either a `MantineColor` (a key of `theme.colors` or any valid CSS color string), or an object with `light` and `dark` keys and `MantineColor` values, used for the light and dark color schemes respectively. - `c` sets the text color of the table body, header and footer, and of the pagination text and page buttons (if present); - `backgroundColor` applies to the table body, header and footer, and to the pagination area (if present); - `borderColor` applies to the outer table border (if enabled by `withTableBorder`), the header bottom border, the footer top border, and the pagination top border and page button borders (if present); - `rowBorderColor` defaults to the `borderColor` value and applies to the bottom border of each row and to the column borders (if enabled by `withColumnBorders`); - `stripedColor` sets the background color of striped rows when `striped` is enabled (odd rows by default, or even rows with `striped="even"`); - `highlightOnHoverColor` sets the background color of the row under the mouse cursor when `highlightOnHover` is enabled; - `selectionColor` sets the background color of selected rows, and defaults to the light variant of the theme’s primary color. With pagination, you can also customize the colors of the active page button with `paginationActiveTextColor` and `paginationActiveBackgroundColor`. `ColorsExample.tsx`: ```tsx return ( ); ``` To set the text or background color of individual rows based on their data, use the `rowColor` and `rowBackgroundColor` properties (see https://icflorescu.github.io/mantine-datatable/examples/row-styling/). ### With CSS variables Under the hood, the color properties above are implemented with CSS variables defined on the component root (the element with the `mantine-datatable` class). You can override them in your own CSS: - `--mantine-datatable-color` - `--mantine-datatable-background-color` - `--mantine-datatable-border-color` - `--mantine-datatable-row-border-color` - `--mantine-datatable-striped-color` - `--mantine-datatable-highlight-on-hover-color` - `--mantine-datatable-selection-color` (background color of selected rows, also settable with `selectionColor`; defaults to `--mantine-primary-color-light`) In the example below, a `className` is used to change the colors of the selected and hovered rows: `CssVariablesExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { useState } from 'react'; import { type Company, companies } from '~/data'; import classes from './CssVariablesExample.module.css'; const records = companies.slice(0, 5); export function CssVariablesExample() { const [selectedRecords, setSelectedRecords] = useState([records[1], records[2]]); return ( ); } ``` `CssVariablesExample.module.css`: ```css .root { --mantine-datatable-selection-color: light-dark(var(--mantine-color-teal-1), var(--mantine-color-teal-9)); --mantine-datatable-highlight-on-hover-color: light-dark(var(--mantine-color-teal-0), var(--mantine-color-dark-5)); } ``` **Mind the CSS order:** your overrides have the same specificity as the Mantine DataTable rules defining these variables, so they only take effect if your styles are loaded after the Mantine DataTable ones (or if the Mantine DataTable styles are placed in a CSS layer). See the styling guide (https://icflorescu.github.io/mantine-datatable/styling/) for details. ### With className You can pass a `className` that will be applied to the `DataTable` component root: `StylingWithClassNameExample.tsx`: ```tsx import { DataTable } from 'mantine-datatable'; // ... export function StylingWithClassNameExample() { return ( ); } ``` `StylingWithClassNameExample.module.css`: ```css .root { background: light-dark( color-mix(in srgb, var(--mantine-color-red-6) 5%, var(--mantine-color-body)), color-mix(in srgb, var(--mantine-color-red-6) 10%, var(--mantine-color-body)) ); border: 1px dashed var(--mantine-color-red-6); border-radius: var(--mantine-radius-md); } ``` ### With a style object or function You can style the component root with a `style` object: `StylingWithStyleObjectExample.tsx`: ```tsx return ( ); ``` The `style` property also accepts a function that receives the current theme and returns a style object: `StylingWithStyleFunctionExample.tsx`: ```tsx return ( ({ border: `1px solid ${theme.colors.indigo[6]}`, borderRadius: theme.radius.md, color: theme.colors.violet[6], })} // ... /> ); ``` ### With multiple class names You can target the component root, table, header, footer and pagination individually by passing an object with `root`, `table`, `header`, `footer` and `pagination` keys to the `classNames` property: `StylingWithClassNamesExample.tsx`: ```tsx return ( ); ``` `StylingWithClassNamesExample.module.css`: ```css .root { border: 1px solid var(--mantine-color-red-6); border-radius: var(--mantine-radius-md); background: light-dark( color-mix(in srgb, var(--mantine-color-red-6) 5%, var(--mantine-color-body)), color-mix(in srgb, var(--mantine-color-red-6) 10%, var(--mantine-color-body)) ); } .table { th, td { border-color: var(--mantine-color-orange-6); } } .header { font-style: italic; color: var(--mantine-color-red-6); th { border-bottom: 1px solid var(--mantine-color-orange-6); } } .footer { font-style: italic; color: var(--mantine-color-red-6); th { border-top: 1px solid var(--mantine-color-orange-6); } } .pagination { background: transparent; color: var(--mantine-color-red-6); border-top: 1px dashed var(--mantine-color-red-6); button { background: transparent; color: var(--mantine-color-red-6); border-color: var(--mantine-color-red-6); &[data-active='true'] { color: white; border: transparent; background: var(--mantine-color-red-6); } } } ``` ### With multiple style objects or functions Similarly, the `styles` property accepts an object with the same keys and style objects (or functions receiving the current theme and returning style objects) as values: `StylingWithStyleObjectsAndFunctionsExample.tsx`: ```tsx return ( ({ border: `1px solid ${theme.colors.orange[6]}`, }), table: { color: '#666', }, header: { color: '#A30', fontSize: '125%', }, }} // ... /> ); ``` ### With global class names Mantine DataTable elements have static class names you can target in global stylesheets, such as `mantine-datatable` (the root), `mantine-datatable-table`, `mantine-datatable-header`, `mantine-datatable-row`, `mantine-datatable-footer` and `mantine-datatable-pagination`. Prefer the approaches above when possible, though: they are scoped to a single table and less likely to be affected by changes in the component’s internal structure. ## Example: Column properties and styling Source: https://icflorescu.github.io/mantine-datatable/examples/column-properties-and-styling/ ### The accessor The only property you **have** to specify for a column is its `accessor`: the name of the record property whose value is displayed in the column cells. It supports dot-notation for nested objects (e.g. `'department.company.name'`). The column header title is derived by “humanizing” the accessor (e.g. `'firstName'` → “First name”, `'department.company.name'` → “Department company name”). Override it by setting the column `title`, which can be any `ReactNode`. ### Basic column properties - `width: number | string` → desired column width; - `ellipsis: boolean` → if `true`, cell content will not wrap and will be truncated with an ellipsis if needed (set either this or `noWrap`, not both); - `noWrap: boolean` → if `true`, cell content will not wrap (`white-space: nowrap`) (set either this or `ellipsis`, not both); - `textAlign: 'left' | 'center' | 'right'` → alignment of the column title, cells and footer; defaults to `'left'`; - `hidden: boolean` → if `true`, the column is not visible; - `visibleMediaQuery` → a media query string, or a function receiving the current `MantineTheme` and returning one; if set, the column is only visible when the media query matches; - `render` → a function receiving the current record and its index in `records` and returning a `ReactNode` (strings and numbers are valid React nodes); - `filter` → a `ReactNode`, or a function returning one; if set, a filter button is added to the column header, opening a popover with the provided node; a function receives an object with a `close()` method that closes the popover; - `filtering: boolean` → if `true`, the filter button is highlighted to indicate an active filter; defaults to `false`; - `filterPopoverProps` → props passed to the filter Mantine `Popover` (position, width, shadow, etc.); - `filterPopoverDisableClickOutside: boolean` → if `true`, disables the click-outside handler the DataTable attaches to the filter popover dropdown; combine it with `filterPopoverProps={{ closeOnClickOutside: false }}` to keep the popover open until closed explicitly (useful when the filter contains components rendering their own dropdowns in a portal, such as date pickers or selects). See the searching and filtering example (https://icflorescu.github.io/mantine-datatable/examples/searching-and-filtering/) to learn how to use `filter` and `filtering`. You can create a “virtual column” by providing an accessor that doesn’t refer to an existing (nested) record property; in this case you **must** provide a `render` function. Each accessor must be unique within the columns collection. `ColumnPropertiesExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import dayjs from 'dayjs'; import { employees } from '~/data'; const records = employees.slice(0, 10); export function ColumnPropertiesExample() { return ( index + 1, }, { accessor: 'name', title: 'Full name', render: ({ firstName, lastName }) => `${firstName} ${lastName}`, width: 160, }, { accessor: 'email' }, { accessor: 'department.name', width: 150 }, { // 👇 using dot-notation to access nested object property accessor: 'department.company.name', title: 'Company', width: 150, // 👇 truncate with ellipsis if text overflows the available width ellipsis: true, }, { accessor: 'birthDate', title: 'Birthday', width: 100, render: ({ birthDate }) => dayjs(birthDate).format('MMM D'), // 👇 column is only visible when screen width is over `theme.breakpoints.xs` visibleMediaQuery: (theme) => `(min-width: ${theme.breakpoints.xs})`, }, { // 👇 "virtual column" accessor: 'age', width: 60, textAlign: 'right', // 👇 column is only visible when screen width is over `theme.breakpoints.xs` visibleMediaQuery: (theme) => `(min-width: ${theme.breakpoints.xs})`, render: ({ birthDate }) => dayjs().diff(birthDate, 'years'), }, ]} /> ); } ``` `data/index.ts`: ```ts import companyData from './companies.json'; import departmentData from './departments.json'; import employeeData from './employees.json'; export type Company = { id: string; name: string; streetAddress: string; city: string; state: string; missionStatement: string; }; export type Department = { id: string; name: string; company: Company; }; export type Employee = { id: string; sex: 'male' | 'female'; firstName: string; lastName: string; email: string; birthDate: string; department: Department; }; export const companies: Company[] = companyData; export const departments: Department[] = departmentData.map(({ companyId, ...rest }) => ({ ...rest, company: companies.find(({ id }) => id === companyId)!, })); export const employees = employeeData.map(({ departmentId, ...rest }) => ({ ...rest, department: departments.find(({ id }) => id === departmentId)!, })) as Employee[]; ``` ### Column footers The DataTable displays a footer row at the bottom of the table if you specify a `footer` property for at least one column (this example uses the same `data/index.ts` as above): `ColumnFooterExample.tsx`: ```tsx `${firstName} ${lastName}`, width: 160, // 👇 this column has a footer footer: (
{records.length} employees
), }, // 👇 this column has NO footer { accessor: 'department.name', width: 150 }, { accessor: 'department.company.name', title: 'Company', width: 150, ellipsis: true, // 👇 this column has a footer footer: (
{uniqBy(records, (record) => record.department.company.name).length} companies
), }, { accessor: 'age', width: 60, textAlign: 'right', visibleMediaQuery: (theme) => `(min-width: ${theme.breakpoints.xs})`, render: ({ birthDate }) => dayjs().diff(birthDate, 'years'), // 👇 this column has a footer footer: `Avg: ${Math.round( records.map((record) => dayjs().diff(record.birthDate, 'years')).reduce((a, b) => a + b, 0) / records.length )}`, }, ]} /> ``` The footer is always visible and sticks to the bottom of the table, whether the table is scrollable or taller than its content. ### Styling column titles, cells and footers - `titleClassName: string` → class name for the column title; - `titleStyle` → style object for the column title, or a function receiving the current theme and returning one; - `cellsClassName` → class name for the column cells, or a function receiving the current record and its index and returning one; - `cellsStyle` → a function receiving the current record and its index and returning either a style object for the cells, or a function receiving the current theme and returning a style object; - `footerClassName: string` → class name for the column footer; - `footerStyle` → style object for the column footer, or a function receiving the current theme and returning one. Example (using the same `data/index.ts` as above): `ColumnStylingExample.tsx`: ```tsx 'use client'; import { DataTable, uniqBy } from 'mantine-datatable'; import { rgba } from '@mantine/core'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { employees } from '~/data'; import classes from './ColumnStylingExample.module.css'; const records = employees.slice(0, 10); export function ColumnStylingExample() { return ( index + 1, }, { accessor: 'name', title: 'Full name', width: 160, // 👇 style cells with a function returning a style object // this function receives the current record as its argument, but we're not using it here cellsStyle: () => ({ fontStyle: 'italic' }), // 👇 style cells with a class name depending on current record cellsClassName: ({ sex }) => clsx({ [classes.male]: sex === 'male', [classes.female]: sex === 'female' }), footer: `${records.length} employees`, // style footer with a style object footerStyle: { fontStyle: 'italic' }, render: ({ firstName, lastName }) => `${firstName} ${lastName}`, }, { accessor: 'email' }, { accessor: 'department.name', width: 150, // 👇 style title with a function returning a style object titleStyle: (theme) => ({ color: theme.colors.green[6] }), // 👇 style cells with a function returning a style function cellsStyle: () => (theme) => ({ color: theme.colors.green[8], background: rgba(theme.colors.orange[6], 0.25), }), }, { accessor: 'department.company.name', title: 'Company', width: 150, ellipsis: true, footer: `${uniqBy(records, (record) => record.department.company.name).length} companies`, // 👇 style footer with a function returning a style object footerStyle: (theme) => ({ color: theme.colors.blue[6] }), }, { accessor: 'birthDate', title: 'Birthday', width: 100, // 👇 style title with a custom class name titleClassName: classes.birthdayColumnTitle, // 👇 style cells with a function accepting the current record and returning another // function that accepts the current theme and returns a style object // (i.e. people born in winter will have their birthday in blue) cellsStyle: ({ birthDate }) => (theme) => ({ color: ['Dec', 'Jan', 'Feb'].includes(dayjs(birthDate).format('MMM')) ? theme.colors.blue[6] : undefined, }), render: ({ birthDate }) => dayjs(birthDate).format('MMM D'), }, { accessor: 'age', width: 80, textAlign: 'right', // 👇 style title with a style object titleStyle: { fontStyle: 'italic' }, // 👇 style cells depending on current record, with a function returning a style object cellsStyle: ({ birthDate }) => dayjs().diff(birthDate, 'years') <= 40 ? { fontWeight: 'bold', color: 'green', background: '#FF332222', } : undefined, render: ({ birthDate }) => dayjs().diff(birthDate, 'years'), footer: `Avg: ${Math.round( records.map((record) => dayjs().diff(record.birthDate, 'years')).reduce((a, b) => a + b, 0) / records.length )}`, footerClassName: classes.ageFooter, }, ]} /> ); } ``` `ColumnStylingExample.module.css`: ```css .idColumnCells { font-weight: 700; } .birthdayColumnTitle { color: var(--mantine-color-blue-6); } .male { font-weight: 700; color: var(--mantine-color-blue-6); background: color-mix(in srgb, var(--mantine-color-blue-6), #0000 90%); } .female { font-weight: 700; color: var(--mantine-color-red-6); background: color-mix(in srgb, var(--mantine-color-red-6), #0000 90%); } .ageFooter { color: var(--mantine-color-red-6); background: color-mix(in srgb, var(--mantine-color-yellow-6), #0000 80%); } ``` ### Other column properties - `sortable` and `sortKey` → see sorting (https://icflorescu.github.io/mantine-datatable/examples/sorting/); - `draggable`, `toggleable` and `defaultToggle` → see column dragging and toggling (https://icflorescu.github.io/mantine-datatable/examples/column-dragging-and-toggling/); - `resizable` → see column resizing (https://icflorescu.github.io/mantine-datatable/examples/column-resizing/); - `pinned` and `pinnable` → see pinning arbitrary columns (https://icflorescu.github.io/mantine-datatable/examples/pinning-arbitrary-columns/); - `customCellAttributes` → see custom row or cell attributes (https://icflorescu.github.io/mantine-datatable/examples/custom-row-or-cell-attributes/); - `hiddenContent` → if `true`, the column header is rendered but its body cells are not; only useful in advanced scenarios where you render the table rows yourself, as in the row dragging example (https://icflorescu.github.io/mantine-datatable/examples/row-dragging/). If you find yourself repeating the same properties for many columns, see default column properties (https://icflorescu.github.io/mantine-datatable/examples/default-column-properties/). ## Example: Column grouping Source: https://icflorescu.github.io/mantine-datatable/examples/column-grouping/ To group the headers of closely related columns under a shared title, pass the `groups` prop to the DataTable instead of `columns`. Each group requires: - `id` → used as a React key; any string unique among the groups; a humanized version of it is used as the group header if no `title` is provided; - `columns` → an array of column definitions belonging to the group; alternatively, provide nested `groups` instead of `columns` (see multilevel column grouping below). Optional group properties: - `title` → a `ReactNode` rendered inside the group header (defaults to the humanized `id`); - `textAlign: 'left' | 'center' | 'right'` → alignment of the group header; it doesn’t affect the alignment of the columns in the group, which you set individually; - `className: string` → class name applied to the group header; - `style` → style applied to the group header; a style object or a function receiving the current theme and returning one. Groups are hidden if they don’t have any visible columns (all their columns are hidden by `hidden` or `visibleMediaQuery`, or the group has an empty `columns` array). `ColumnGroupingExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { companies } from '~/data'; export function ColumnGroupingExample() { return ( `(min-width: ${theme.breakpoints.md})` }, ], }, { id: 'contact-info', title: 'Contact information', textAlign: 'center', style: (theme) => ({ color: theme.colors.blue[6] }), columns: [{ accessor: 'streetAddress' }, { accessor: 'city' }, { accessor: 'state', textAlign: 'right' }], }, // 👇 all columns in this group are hidden, so it will not be rendered { id: 'other', columns: [{ accessor: 'id', hidden: true }], }, // 👇 this group has no columns, so it will not be rendered { id: 'empty-group', title: 'Empty group', columns: [], }, ]} /> ); } ``` (`companies.json` is the same as in the "Example: Basic usage" section above.) ### Multilevel column grouping Groups can be nested to create multiple header rows: provide a `groups` array instead of `columns` for a group; each nested group can, in turn, contain either `columns` or further `groups`, but not both. Branches don’t need to have the same depth: if a group containing `columns` sits at a shallower level than others, its header spans the extra header rows. The order of the leaf columns follows their order in the group tree. `MultilevelColumnGroupingExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { employees } from '~/data'; export function MultilevelColumnGroupingExample() { return ( new Date(birthDate).toLocaleDateString(), }, ], }, ], }, { // 👇 a group with columns can sit next to groups with nested groups; // its header will span the extra header rows id: 'contact-info', title: 'Contact Information', columns: [ { accessor: 'email', title: 'Email Address', width: 250, ellipsis: true, }, ], }, { id: 'work-info', title: 'Work Information', groups: [ { id: 'identifiers', title: 'IDs', columns: [ { accessor: 'id', title: 'Employee ID', width: 100, ellipsis: true, }, { accessor: 'department.id', title: 'Department ID', width: 100, ellipsis: true, }, ], }, ], }, ]} /> ); } ``` (`data/index.ts` is the same as in the "Example: Column properties and styling" section above.) Column grouping works together with other column features such as sorting or pinning; see pinning the first column (https://icflorescu.github.io/mantine-datatable/examples/pinning-the-first-column/), pinning the last column (https://icflorescu.github.io/mantine-datatable/examples/pinning-the-last-column/) and pinning arbitrary columns (https://icflorescu.github.io/mantine-datatable/examples/pinning-arbitrary-columns/) to learn how pinning interacts with column groups. ## Example: Default column properties Source: https://icflorescu.github.io/mantine-datatable/examples/default-column-properties/ To avoid repeating the same properties for multiple columns, use the `defaultColumnProps` DataTable prop to set them once as a fallback for all columns. `defaultColumnProps` accepts the same properties as a column definition, except for `accessor`, `hidden`, `visibleMediaQuery` and `render` (for the latter, see default column render: https://icflorescu.github.io/mantine-datatable/examples/default-column-render/). The default properties are shallow-merged with each column’s own properties, so a property set on a column always takes precedence over its default value. In the example below, all columns are right-aligned, truncated with an ellipsis and styled the same way by default, but the first column overrides the default alignment: `DefaultColumnPropertiesExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; dayjs.extend(relativeTime); const records = [ { id: 1, bookTitle: 'The Lord of the Rings', published: new Date(1954, 6, 29), pages: 1178, chapters: 37 }, // more records... ]; const average = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length; const minPublishingDateYearsAgo = dayjs().diff(Math.min(...records.map((r) => r.published.valueOf())), 'years'); const maxPublishingDateYearsAgo = dayjs().diff(Math.max(...records.map((r) => r.published.valueOf())), 'years'); export function DefaultColumnPropertiesExample() { return ( dayjs(published).fromNow(), footer: `${maxPublishingDateYearsAgo} to ${minPublishingDateYearsAgo} years ago`, }, { accessor: 'pages', footer: `μ ${Math.round(average(records.map((r) => r.pages)))}`, }, { accessor: 'chapters', footer: `μ ${Math.round(average(records.map((r) => r.chapters)))}`, }, ]} defaultColumnProps={{ textAlign: 'right', ellipsis: true, // 👇 customize cell style based on record data and Mantine theme cellsStyle: ({ bookTitle }) => bookTitle === 'The Hobbit' ? (theme) => ({ fontStyle: 'italic', color: theme.colors.red[6] }) : undefined, // ...or you could simply return this, if you don't use the record data and Mantine theme: // `cellsStyle: () => ({ fontStyle: 'italic', color: '#F00' })` // 👇 customize column title style based on Mantine theme titleStyle: (theme) => ({ color: theme.colors.green[6] }), // ...or you could simply return an object, if you don't use Mantine theme: // `titleStyle: { color: '#0F0' }` footerStyle: (theme) => ({ color: theme.colors.blue[6] }), }} /> ); } ``` Properties enabling stateful column features, such as `toggleable`, `defaultToggle`, `resizable`, `pinned` or `pinnable`, can be set in `defaultColumnProps` too, and individual columns can still override them. ## Example: Default column render Source: https://icflorescu.github.io/mantine-datatable/examples/default-column-render/ A `defaultColumnRender` function provided to the DataTable is used to render the cells of all columns that don’t have their own `render` function. It receives the current record, its index in `records` and the column `accessor` as arguments, and should return a `ReactNode`. In the example below, a single function renders strings as they are and dates as relative time intervals, so the columns don’t need individual `render` functions: `DefaultColumnRenderExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; dayjs.extend(relativeTime); export function DefaultColumnRenderExample() { return ( { const data = row[accessor as keyof typeof row]; return typeof data === 'string' ? data : dayjs(data).fromNow(); }} /> ); } ``` A column’s own `render` function always takes precedence over `defaultColumnRender`. Also keep in mind that `defaultColumnRender` receives the accessor as-is; for dot-notation accessors such as `'department.name'`, resolve the value with the `getValueAtPath(record, accessor)` helper exported by the package. ## Example: Row styling Source: https://icflorescu.github.io/mantine-datatable/examples/row-styling/ Style individual rows based on their data with the `rowColor`, `rowBackgroundColor`, `rowClassName` and `rowStyle` properties. They are the row-level counterparts of the DataTable `c`, `backgroundColor`, `className` and `style` properties (see https://icflorescu.github.io/mantine-datatable/examples/overriding-the-default-styles/), but accept functions receiving the current record and its index, so each row can be styled differently (`rowClassName` also accepts a plain string). ### Using rowColor and rowBackgroundColor `rowColor` and `rowBackgroundColor` accept a function receiving the current record and its index and returning either a `MantineColor` (a key of `theme.colors` or any valid CSS color string), or an object with `light` and `dark` keys and `MantineColor` values for the light and dark color schemes. Return `undefined` to leave a row with its default colors. `RowStylingWithColorPropertiesExample.tsx`: ```tsx return ( { if (state === 'MA') return 'violet'; }} rowBackgroundColor={({ state }) => { if (state === 'MA') return { dark: '#232b25', light: '#f0f7f1' }; }} // other table props... /> ); ``` ### Using rowClassName `rowClassName` accepts either a string, applied to every row, or a function receiving the current record and its index and returning the class name for the row (or `undefined` for no extra class). `RowStylingWithClassNameExample.tsx`: ```tsx return ( (state === 'WY' ? classes.redRow : undefined)} // other table props... /> ); ``` `RowStylingWithClassNameExample.module.css`: ```css .redRow { color: var(--mantine-color-red-6); background-color: light-dark( color-mix(in srgb, var(--mantine-color-red-6) 10%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-red-6) 30%, var(--mantine-color-black)) ); } ``` ### Using rowStyle `rowStyle` accepts a function receiving the current record and its index and returning either a style object for the row, or a function that receives the current theme and returns such an object: `RowStylingWithStyleObjectExample.tsx`: ```tsx return ( (state === 'NH' ? { fontStyle: 'italic', fontWeight: 'bold' } : undefined)} // other table props... /> ); ``` Building the style based on the current theme, by returning a function: `RowStylingWithStyleFunctionExample.tsx`: ```tsx return ( (theme) => ({ color: state === 'MA' ? theme.colors.violet[6] : theme.colors.gray[6] })} // other table props... /> ); ``` To style individual cells rather than whole rows, use the column `cellsClassName` and `cellsStyle` properties (see https://icflorescu.github.io/mantine-datatable/examples/column-properties-and-styling/). ## Example: Non-standard record IDs Source: https://icflorescu.github.io/mantine-datatable/examples/non-standard-record-ids/ By default, the DataTable assumes each record has an `id` property holding a unique value of a primitive type (such as a string or a number). Record IDs are used as React keys when rendering rows, and to identify records in features such as records selection and row expansion (e.g. the `rowExpansion.expanded.recordIds` array holds the IDs of the expanded records). If your records use a different unique property, set the `idAccessor` property of the DataTable to its name: `NonStandardRecordIdsStringExample.tsx`: ```tsx import { DataTable } from 'mantine-datatable'; export function NonStandardRecordIdsStringExample() { return ( ); } ``` If the unique value is stored in a nested object, use dot notation, e.g. `idAccessor="meta.uuid"`. ### Using functions to generate composite record IDs `idAccessor` also accepts a function that receives a record and returns its ID. This is useful when no single property is unique and you need to build a composite ID from several record properties: `NonStandardRecordIdsFunctionExample.tsx`: ```tsx import { DataTable } from 'mantine-datatable'; export function NonStandardRecordIdsFunctionExample() { return ( `${bookTitle}:${character}`} /> ); } ``` **Make sure IDs are unique:** duplicate record IDs lead to React key collisions and to unpredictable behavior when selecting or expanding rows. ## Example: Scrollable vs. auto-height Source: https://icflorescu.github.io/mantine-datatable/examples/scrollable-vs-auto-height/ The DataTable component embeds a Mantine `ScrollArea` and has a default `height` of `'100%'`. Setting the component `height` or placing it inside a height-restricted container makes it vertically scrollable, with a sticky header: ```tsx return ( ); ``` Otherwise, the table simply grows to fit its content (auto-height): ```tsx return ( ); ``` The DataTable is always horizontally scrollable if its content is wider than its container. ### Using minHeight and maxHeight Besides `height`, you can set `minHeight` and `maxHeight` (numbers in pixels or CSS size strings). They’re particularly useful with a relative `height`; for instance, the table below takes up half of the browser window height, but never less than 200 and never more than 400 pixels: ```tsx ``` `minHeight` is also useful to leave enough room for the empty state indicator or the loader when an auto-height table has few or no records. You can also set a `maxHeight` without a `height`: the table grows with its content and becomes vertically scrollable once the content exceeds the maximum height. ### Customizing the underlying ScrollArea Pass `scrollAreaProps` to customize the underlying scroll area. It accepts Mantine ScrollArea props (https://mantine.dev/core/scroll-area/?t=props) such as `type`, `scrollbarSize`, `scrollHideDelay` or `offsetScrollbars` (except `classNames`, `styles` and `onScrollPositionChange`, which are used internally; to get a reference to the scroll viewport, use the `scrollViewportRef` property instead of `viewportRef`). For instance, to hide the scrollbars: ```tsx ``` To react to scrolling, use the `onScroll`, `onScrollToTop`, `onScrollToBottom`, `onScrollToLeft` and `onScrollToRight` callbacks (see infinite scrolling: https://icflorescu.github.io/mantine-datatable/examples/infinite-scrolling/). To scroll the table programmatically, use `scrollViewportRef` to get hold of the scrollable viewport element (see scrolling a row into view: https://icflorescu.github.io/mantine-datatable/examples/scrolling-a-row-into-view/). ## Example: Scrolling a row into view Source: https://icflorescu.github.io/mantine-datatable/examples/scrolling-a-row-into-view/ There are two ways to scroll a specific row into view in a Mantine DataTable. Both rely on adding `data-*` attributes to the rows with [`customRowAttributes`](https://icflorescu.github.io/mantine-datatable/examples/custom-row-or-cell-attributes/), then using DOM methods to find the desired row and scroll it into view. ### Using scrollIntoView The simplest way is to call the [`scrollIntoView`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) method of the row element itself. Keep in mind: - this method also scrolls the page (and any other scrollable ancestors) as needed to bring the row into view, which may be a drawback when the table is part of a more complex layout; - since the `DataTable` header is sticky, `scrollIntoView()` with no arguments (or the equivalent `scrollIntoView({ block: 'start' })`) hides the row underneath the header; use `scrollIntoView(false)` or the equivalent `scrollIntoView({ block: 'end' })` instead. ```tsx const scrollRowIntoView = (selector: string) => { document.querySelector(selector)?.scrollIntoView({ block: 'end', behavior: 'smooth' }); // 👇 if you don't want smooth scrolling, you could simply use this instead: // document.querySelector(selector)?.scrollIntoView(false); }; return ( <> ({ 'data-row-first-name': firstName, 'data-row-index': recordIndex, })} // more table props... /> ); ``` ### Using table viewport scrollTo To avoid scrolling the page when bringing a row into view, get hold of the table’s scrollable viewport element with the `scrollViewportRef` property and call its [`scrollTo`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo) method, taking into account the height of the sticky header: ```tsx const scrollToRow = (selector: string) => { const viewport = viewportRef.current; // 👇 look for the row inside this table only const row = viewport?.querySelector(selector); if (!viewport || !row) return; // 👇 account for the sticky header, so that the row isn't hidden underneath it const headerHeight = viewport.querySelector('thead')?.offsetHeight ?? 0; viewport.scrollTo({ top: row.offsetTop - headerHeight, behavior: 'smooth' }); }; return ( <> ({ 'data-row-first-name': firstName, 'data-row-index': recordIndex, })} // more table props... /> ); ``` ## Example: Empty state Source: https://icflorescu.github.io/mantine-datatable/examples/empty-state/ When the `records` property is an empty array, the `DataTable` component indicates its empty state by displaying a customizable icon and text. If your table is not [vertically scrollable](https://icflorescu.github.io/mantine-datatable/examples/scrollable-vs-auto-height/), set a `minHeight` large enough to accommodate the empty state icon and text. The empty state is not displayed while the table is [loading data](https://icflorescu.github.io/mantine-datatable/examples/asynchronous-data-loading/) (i.e. when `fetching` is `true`), so it won’t flash while waiting for the first batch of records. ```tsx return ( ); ``` Change the displayed text (`No records` by default) with the `noRecordsText` property: ```tsx return ( ); ``` Replace the icon with the `noRecordsIcon` property: `CustomIconAndText.tsx`: ```tsx return ( } noRecordsText="No records found" // ... /> ); ``` `CustomIconAndText.module.css`: ```css .noRecordsBox { font-size: 0; color: light-dark(var(--mantine-colors-dark-3), var(--mantine-colors-gray-5)); border: 2px solid light-dark(var(--mantine-colors-gray-4), var(--mantine-colors-dark-5)); border-radius: var(--mantine-radius-md); background: light-dark(var(--mantine-colors-gray-1), var(--mantine-colors-dark-6)); } ``` To replace the standard empty state indicator entirely, set the `emptyState` property; the provided content is overlaid and centered automatically. `emptyState` and `noRecordsIcon` are mutually exclusive, and `noRecordsText` is ignored when `emptyState` is set: ```tsx return ( No data found... No data found } // ... /> ); ``` ### Interactive custom empty state content The empty state overlay has `pointer-events: none`, so that it doesn’t get in the way of the table underneath. If you add interactive content to a custom empty state, re-enable pointer events on the interactive elements by setting their `pointer-events` CSS property to `all`: ```tsx return ( No data found... } // ... /> ); ``` ## Example: Pagination Source: https://icflorescu.github.io/mantine-datatable/examples/pagination/ To enable pagination, set the following Mantine DataTable properties: - `page: number` → the current page number (1-based); - `onPageChange: (page: number) => void` → invoked with the new page number when the user navigates to another page; - `totalRecords: number | undefined` → the total number of records in the dataset (used to compute the number of pages); - `recordsPerPage: number` → the number of records per page. Keep in mind: Mantine DataTable does not slice your data. The `records` property should only contain the records of the current page. Typically, you’d fetch them from a server API whenever `page` changes, setting `fetching` to `true` while the request is in flight (see the [asynchronous data loading example](https://icflorescu.github.io/mantine-datatable/examples/asynchronous-data-loading/) and the [complex usage scenario](https://icflorescu.github.io/mantine-datatable/examples/complex-usage-scenario/)). Optional properties to customize the default pagination behavior and appearance: - `loadingText: string` → text displayed in the pagination footer while records are loading; defaults to `...`; - `noRecordsText: string` → text displayed when there are no records; defaults to `No records` (also used by the empty state indicator); - `paginationText` → a callback receiving `{ from: number; to: number; totalRecords: number }` and returning a `ReactNode`; by default the text looks like `1 - 15 / 500`; - `paginationSize: 'xs' | 'sm' | 'md' | 'lg' | 'xl'` → defaults to `sm`; - `paginationWrapBreakpoint: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | (string & {}) | number` → breakpoint below which the pagination footer content wraps on multiple lines; defaults to `sm`; a string such as `'300px'` or `'20rem'` is also accepted, and a number is interpreted as pixels and converted to `rem`; - `paginationActiveTextColor: MantineColor | { light: MantineColor; dark: MantineColor }` → text color of the active page button; defaults to white; - `paginationActiveBackgroundColor: MantineColor | { light: MantineColor; dark: MantineColor }` → background color of the active page button; defaults to the theme’s primary color. Both color properties accept a `MantineColor` (a key of `theme.colors` or any valid CSS color string), or an object with `light` and `dark` keys to use different colors for each color scheme. `PaginationExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import dayjs from 'dayjs'; import { useEffect, useState } from 'react'; import employees from '~/data/employees.json'; const PAGE_SIZE = 15; export default function PaginationExample() { const [page, setPage] = useState(1); const [records, setRecords] = useState(employees.slice(0, PAGE_SIZE)); useEffect(() => { const from = (page - 1) * PAGE_SIZE; const to = from + PAGE_SIZE; setRecords(employees.slice(from, to)); }, [page]); return ( dayjs(birthDate).format('MMM D YYYY'), }, ]} totalRecords={employees.length} recordsPerPage={PAGE_SIZE} page={page} onPageChange={(p) => setPage(p)} // 👇 uncomment the next line to use a custom pagination size // paginationSize="md" // 👇 uncomment the next line to use a custom loading text // loadingText="Loading..." // 👇 uncomment the next line to display a custom text when no records were found // noRecordsText="No records found" // 👇 uncomment the next line to use a custom pagination text // paginationText={({ from, to, totalRecords }) => `Records ${from} - ${to} of ${totalRecords}`} // 👇 uncomment the next lines to use custom pagination colors // paginationActiveBackgroundColor="green" // paginationActiveTextColor="#e6e348" /> ); } ``` ### Displaying a page size selector To let the user choose the page size, set: - `recordsPerPageOptions: number[]` → the page sizes to choose from; - `onRecordsPerPageChange: (recordsPerPage: number) => void` → invoked with the new page size; - `recordsPerPageLabel: string` → the selector label; defaults to `'Records per page'`. When the page size changes, the current page may no longer exist, so it’s usually a good idea to reset it to `1`: `PaginationExampleWithPageSizeSelector.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import dayjs from 'dayjs'; import { useEffect, useState } from 'react'; import employees from '~/data/employees.json'; const PAGE_SIZES = [10, 15, 20]; export default function PaginationExampleWithPageSizeSelector() { const [pageSize, setPageSize] = useState(PAGE_SIZES[1]); const [page, setPage] = useState(1); const [records, setRecords] = useState(employees.slice(0, pageSize)); // biome-ignore lint/correctness/useExhaustiveDependencies: reset page when pageSize changes useEffect(() => { setPage(1); }, [pageSize]); useEffect(() => { const from = (page - 1) * pageSize; const to = from + pageSize; setRecords(employees.slice(from, to)); }, [page, pageSize]); return ( dayjs(birthDate).format('MMM D YYYY'), }, ]} totalRecords={employees.length} paginationActiveBackgroundColor="grape" recordsPerPage={pageSize} page={page} onPageChange={(p) => setPage(p)} recordsPerPageOptions={PAGE_SIZES} onRecordsPerPageChange={setPageSize} /> ); } ``` ### Showing first and last page buttons The pagination component shows “previous page” and “next page” buttons by default. Set `paginationWithEdges` to also show “first page” and “last page” buttons, or set `paginationWithControls` to `false` to hide the “previous” and “next” buttons: `PaginationExampleWithEdges.tsx`: ```tsx ``` ### Customizing pagination controls Fully customize the pagination footer by providing a `renderPagination` callback. It receives a context object with: - `state` → the current pagination state (`page`, `totalPages`, `totalRecords`, `recordsPerPage`, `from`, `to`, `fetching`, `paginationSize`, `isWrapped`, etc.); - `actions` → `setPage` and, when the page size selector is enabled, `setRecordsPerPage`; - `Controls` → factories for the default controls: `Controls.Text` (the pagination text), `Controls.PageSizeSelector` (the page size selector, if enabled) and `Controls.Pagination` (the [Mantine Pagination](https://mantine.dev/core/pagination/) component); each accepts optional props to override the defaults. This lets you render the controls in any order, inject your own elements, or override their props. The example below adds a “jump to page” input between the page size selector and the pagination component: `PaginationCustomRenderExample.tsx`: ```tsx ( <> Jump to page typeof p === 'number' && actions.setPage(p)} min={1} max={max} value={page} /> )} /> ``` ### Passing props to pagination buttons Pass additional props to the pagination buttons with these callbacks: - `getPaginationControlProps` → receives the control type (`'first'`, `'last'`, `'previous'` or `'next'`) and returns an object of props for that control; - `getPaginationItemProps` → receives a page number and returns an object of props for the corresponding page button. For instance, to override the default accessibility labels: `PaginationExampleWithControlProps.tsx`: ```tsx { if (control === 'previous') { const title = 'Go to previous page'; return { title, 'aria-label': title }; } else if (control === 'next') { const title = 'Go to next page'; return { title, 'aria-label': title }; } return {}; }} getPaginationItemProps={(pageNumber) => { const title = `Go to page ${pageNumber}`; return { title, 'aria-label': title }; }} /> ``` ## Example: Sorting Source: https://icflorescu.github.io/mantine-datatable/examples/sorting/ To enable sorting: - add a `sortable: true` property to each column that should be sortable; - set the DataTable `sortStatus` property to an object describing the current sort status (the column accessor and the sort direction, `'asc'` or `'desc'`); - provide an `onSortStatusChange` handler, invoked with the new sort status when the user clicks a sortable column header. `SortingExample.tsx`: ```tsx 'use client'; import { DataTable, type DataTableSortStatus } from 'mantine-datatable'; import sortBy from 'lodash/sortBy'; import { useEffect, useState } from 'react'; import { type Company, companies } from '~/data'; export default function SortingExample() { const [sortStatus, setSortStatus] = useState>({ columnAccessor: 'name', direction: 'asc', }); const [records, setRecords] = useState(sortBy(companies, 'name')); useEffect(() => { const data = sortBy(companies, sortStatus.columnAccessor) as Company[]; setRecords(sortStatus.direction === 'desc' ? data.reverse() : data); }, [sortStatus]); return ( ); } ``` Keep in mind: Mantine DataTable doesn’t sort the records for you; it only renders the sort indicators and reports the user’s intent. You are responsible for sorting the `records` you pass to the component, either on the client (as in the examples on this page) or on the server, by passing the sort status to your API (see the [complex usage scenario](https://icflorescu.github.io/mantine-datatable/examples/complex-usage-scenario/)). Clicking the header of the currently sorted column toggles the sort direction, while clicking another sortable column keeps the current direction. When sorting is enabled, consider [disabling text selection](https://icflorescu.github.io/mantine-datatable/examples/disabling-text-selection/); otherwise, repeatedly clicking the same column header will select the column title text. ### Sorting by a different key When the value you want to sort by is not the one designated by the column accessor (e.g. a column rendering a value computed from several record properties), set the column’s `sortKey` property; it is included in the sort status object received by `onSortStatusChange`, so you can use it in your sorting logic. In the example below, the *Name* column renders the employee’s full name but is sorted by last name. Dot-notation accessors such as `department.name` can be sortable too: `SortingExampleSortKey.tsx`: ```tsx 'use client'; import { DataTable, type DataTableSortStatus } from 'mantine-datatable'; import dayjs from 'dayjs'; import sortBy from 'lodash/sortBy'; import { useMemo, useState } from 'react'; import { type Employee, employees } from '~/data'; const initialRecords = employees.slice(0, 50); export default function SortingExampleSortKey() { const [sortStatus, setSortStatus] = useState>({ columnAccessor: 'name', sortKey: 'lastName', direction: 'asc', }); const records = useMemo(() => { // 👇 use the sort key if present, otherwise fall back to the column accessor const data = sortBy(initialRecords, sortStatus.sortKey ?? sortStatus.columnAccessor); return sortStatus.direction === 'desc' ? data.reverse() : data; }, [sortStatus]); return ( `${firstName} ${lastName}`, sortable: true, sortKey: 'lastName', // 👈 sort by last name }, // 👇 dot-notation accessors work too { accessor: 'department.name', title: 'Department', sortable: true }, { accessor: 'birthDate', textAlign: 'right', render: ({ birthDate }) => dayjs(birthDate).format('MMM D YYYY'), sortable: true, }, ]} sortStatus={sortStatus} onSortStatusChange={setSortStatus} /> ); } ``` ### Custom sort icons To use custom sort icons, pass an object with `sorted` and `unsorted` keys to the `sortIcons` property. The `sorted` icon should point up; it is flipped vertically automatically when the sort direction is descending: `SortingExampleCustomIcons.tsx`: ```tsx 'use client'; import type { DataTableSortStatus } from 'mantine-datatable'; import { DataTable } from 'mantine-datatable'; import { IconChevronUp, IconSelector } from '@tabler/icons-react'; import sortBy from 'lodash/sortBy'; import { useEffect, useState } from 'react'; import { type Company, companies } from '~/data'; export default function SortingExampleCustomIcons() { const [sortStatus, setSortStatus] = useState>({ columnAccessor: 'name', direction: 'asc', }); const [records, setRecords] = useState(sortBy(companies, 'name')); useEffect(() => { const data = sortBy(companies, sortStatus.columnAccessor) as Company[]; setRecords(sortStatus.direction === 'desc' ? data.reverse() : data); }, [sortStatus]); return ( , unsorted: , }} /> ); } ``` ## Example: Column dragging and toggling Source: https://icflorescu.github.io/mantine-datatable/examples/column-dragging-and-toggling/ Mantine DataTable lets users reorder columns by drag-and-drop and show or hide them as needed. The column order and visibility are persisted in the browser’s local storage, so they survive page reloads. ### Column drag-and-drop reordering To enable column dragging: - add a `storeColumnsKey: 'your_key'` property to the DataTable (the column order is persisted in local storage under this key); - add a `draggable: true` property to each dragging candidate column; - use the `useDataTableColumns()` hook with the same `key` to get the effective (sorted) columns and pass them to the DataTable. `DraggingExample.tsx`: ```tsx 'use client'; import { DataTable, useDataTableColumns } from 'mantine-datatable'; import { Button, Group, Stack } from '@mantine/core'; import { type Company, companies } from '~/data'; export default function DraggingExample() { const key = 'draggable-example'; const { effectiveColumns, resetColumnsOrder } = useDataTableColumns({ key, columns: [ { accessor: 'name', width: '40%', draggable: true }, { accessor: 'streetAddress', width: '60%', draggable: true }, { accessor: 'city', width: 160, draggable: true }, { accessor: 'state', textAlign: 'right' }, ], }); return ( ); } ``` Draggable columns display a grip icon in their header. A column can only be dropped onto another draggable column, and is then moved to that column’s position. The default order is the order in which columns are defined in the `columns` prop; restore it at any time by calling the `resetColumnsOrder()` function returned by the hook. ### Column toggling In the example below, the first 3 columns are toggleable (the first one hidden by default), while the last column is not toggleable and always visible. Hover over a toggleable column header and click its × button to hide the column, or right-click anywhere on the table header to open a menu where you can show or hide each toggleable column. To enable column toggling: - add a `storeColumnsKey: 'your_key'` property to the DataTable (the column visibility is persisted in local storage under this key); - add a `toggleable: true` property to each toggling candidate column; - use the `useDataTableColumns()` hook with the same `key` to get the effective columns and pass them to the DataTable. `TogglingExample.tsx`: ```tsx 'use client'; import { DataTable, useDataTableColumns } from 'mantine-datatable'; import { Button, Group, Stack, Text } from '@mantine/core'; import { IconBuildingCommunity, IconBuildingSkyscraper, IconMap, IconRoadSign } from '@tabler/icons-react'; import { companies } from '~/data'; export default function TogglingExample() { const key = 'toggleable-example'; const { effectiveColumns, resetColumnsToggle } = useDataTableColumns({ key, columns: [ { accessor: 'name', title: ( Company ), width: '40%', toggleable: true, defaultToggle: false, }, { accessor: 'streetAddress', title: ( Street Address ), width: '60%', toggleable: true, }, { accessor: 'city', title: ( City ), width: 160, toggleable: true, }, { accessor: 'state', textAlign: 'right', title: ( ), }, ], }); return ( ); } ``` Toggleable columns are visible by default. To hide a column initially, set its `defaultToggle` property to `false`. Call `resetColumnsToggle()` to restore the default visibility of all columns. ### Custom column visibility controls Besides the built-in header menu, you can build your own column visibility controls. The `useDataTableColumns()` hook returns the current visibility state as `columnsToggle` (an array of `{ accessor, toggleable, toggled }` objects) and a `setColumnsToggle()` function to update it: `TogglingWithCustomControlsExample.tsx`: ```tsx 'use client'; import { DataTable, humanize, useDataTableColumns } from 'mantine-datatable'; import { Button, Checkbox, Group, Stack } from '@mantine/core'; import { type Company, companies } from '~/data'; export default function TogglingWithCustomControlsExample() { const key = 'toggling-with-custom-controls-example'; const { effectiveColumns, columnsToggle, setColumnsToggle, resetColumnsToggle } = useDataTableColumns({ key, columns: [ { accessor: 'name', width: '40%' }, { accessor: 'streetAddress', width: '60%', toggleable: true }, { accessor: 'city', width: 160, toggleable: true }, { accessor: 'state', textAlign: 'right', toggleable: true, defaultToggle: false }, ], }); return ( {columnsToggle // 👇 only show controls for toggleable columns .filter(({ toggleable }) => toggleable) .map(({ accessor, toggled }) => ( { const { checked } = e.currentTarget; setColumnsToggle( columnsToggle.map((column) => column.accessor === accessor ? { ...column, toggled: checked } : column ) ); }} /> ))} ); } ``` Similarly, use `columnsOrder` (an array of accessors) and `setColumnsOrder()` to change the column order programmatically. ### Adding and removing columns at runtime Add or remove columns at runtime by changing the array of columns you pass to the hook. The persisted order and visibility state is kept in sync with the current column definitions: state for removed columns is discarded, and newly added columns are appended with their default settings. `DynamicColumnExample.tsx`: ```tsx 'use client'; import type { DataTableColumn } from 'mantine-datatable'; import { DataTable, useDataTableColumns } from 'mantine-datatable'; import { Button, Group, Stack, Text } from '@mantine/core'; import { IconBuildingCommunity, IconBuildingSkyscraper, IconMap, IconRoadSign } from '@tabler/icons-react'; import { useRef, useState } from 'react'; import { companies } from '~/data'; export default function DynamicColumnExample() { const key = 'dynamic-column-example'; const [columns, setColumns] = useState([ { accessor: 'name', title: ( Company ), width: '40%', toggleable: true, defaultToggle: false, }, { accessor: 'streetAddress', title: ( Street Address ), width: '60%', toggleable: true, }, { accessor: 'city', title: ( City ), width: 160, toggleable: true, }, { accessor: 'state', textAlign: 'right', title: ( ), }, ]); const initialColumns = useRef(columns); const { effectiveColumns, resetColumnsToggle, columnsToggle, setColumnsToggle } = useDataTableColumns({ key, columns, }); function resetAll() { setColumns(initialColumns.current); resetColumnsToggle(); } // Show / hide the Mission Statement column. If it's currently hidden via the // header X (but still in `columns`), un-hide it; otherwise add or remove the // column definition itself. function toggleColumnMissionStatement() { const inColumns = columns.some((col) => col.accessor === 'missionStatement'); const hiddenByToggle = inColumns && columnsToggle?.find((t) => t.accessor === 'missionStatement')?.toggled === false; if (hiddenByToggle) { setColumnsToggle(columnsToggle.map((t) => (t.accessor === 'missionStatement' ? { ...t, toggled: true } : t))); return; } const newColumns = columns.filter((col) => col.accessor !== 'missionStatement'); if (columns.length === newColumns.length) { newColumns.push({ accessor: 'missionStatement', title: ( Mission Statement ), width: '40%', toggleable: true, defaultToggle: true, }); } setColumns(newColumns); } return ( ); } ``` ### Resetting the column state from a context menu The reset functions returned by the hook can be called from anywhere, for example from a [row context menu](https://icflorescu.github.io/mantine-datatable/examples/using-with-mantine-contextmenu/): `DraggingTogglingResetExample.tsx`: ```tsx 'use client'; import { DataTable, useDataTableColumns } from 'mantine-datatable'; import { IconColumnRemove, IconColumns3 } from '@tabler/icons-react'; import { useContextMenu } from 'mantine-contextmenu'; import { companies } from '~/data'; export default function DraggingTogglingResetExample() { const { showContextMenu } = useContextMenu(); const key = 'toggleable-reset-example'; const { effectiveColumns, resetColumnsOrder, resetColumnsToggle } = useDataTableColumns({ key, columns: [ { accessor: 'name', width: '40%', toggleable: true, draggable: true }, { accessor: 'streetAddress', width: '60%', toggleable: true, draggable: true }, { accessor: 'city', width: 160, toggleable: true, draggable: true }, { accessor: 'state', textAlign: 'right' }, ], }); return ( showContextMenu([ { key: 'reset-toggled-columns', icon: , onClick: resetColumnsToggle, }, { key: 'reset-columns-order', icon: , onClick: resetColumnsOrder, }, ])(event) } /> ); } ``` ### Combining dragging and toggling with sorting Column dragging and toggling work seamlessly with other features, such as [sorting](https://icflorescu.github.io/mantine-datatable/examples/sorting/): `DraggingTogglingComplexExample.tsx`: ```tsx 'use client'; import { DataTable, type DataTableSortStatus, useDataTableColumns } from 'mantine-datatable'; import { IconColumnRemove, IconColumns3 } from '@tabler/icons-react'; import sortBy from 'lodash/sortBy'; import { useContextMenu } from 'mantine-contextmenu'; import { useEffect, useState } from 'react'; import { type Company, companies } from '~/data'; export default function DraggingTogglingComplexExample() { const { showContextMenu } = useContextMenu(); const [sortStatus, setSortStatus] = useState>({ columnAccessor: 'name', direction: 'asc', }); const [records, setRecords] = useState(sortBy(companies, 'name')); useEffect(() => { const data = sortBy(companies, sortStatus.columnAccessor) as Company[]; setRecords(sortStatus.direction === 'desc' ? data.reverse() : data); }, [sortStatus]); const key = 'dragging-toggling-complex-example'; const { effectiveColumns, resetColumnsOrder, resetColumnsToggle } = useDataTableColumns({ key, columns: [ { accessor: 'name', width: '40%', toggleable: true, draggable: true, sortable: true }, { accessor: 'streetAddress', width: '60%', toggleable: true, draggable: true }, { accessor: 'city', width: 160, toggleable: true, draggable: true }, { accessor: 'state', textAlign: 'right' }, ], }); return ( showContextMenu([ { key: 'reset-toggled-columns', icon: , onClick: resetColumnsToggle, }, { key: 'reset-columns-order', icon: , onClick: resetColumnsOrder, }, ])(event) } /> ); } ``` The `useDataTableColumns()` hook stores its state in local storage under keys derived from the one you provide (`your_key-columns-order`, `your_key-columns-toggle`, `your_key-columns-width` and `your_key-columns-pinning`), so make sure each table on your site uses a unique key. The stored values are read after the component mounts, to avoid server-side rendering hydration mismatches; if you only render the table on the client, pass `getInitialValueInEffect: false` to the hook to read them synchronously. See also [column resizing](https://icflorescu.github.io/mantine-datatable/examples/column-resizing/) and [pinning arbitrary columns](https://icflorescu.github.io/mantine-datatable/examples/pinning-arbitrary-columns/), which rely on the same hook. ## Example: Row dragging Source: https://icflorescu.github.io/mantine-datatable/examples/row-dragging/ Mantine DataTable doesn’t include a drag-and-drop implementation of its own, but it exposes the building blocks needed to make rows draggable with a library such as [@hello-pangea/dnd](https://github.com/hello-pangea/dnd). ### How it works The implementation relies on the following DataTable features: - `tableWrapper` → a function receiving the table as `children` and returning a React node that wraps it; use it to wrap the table in a `Droppable` container; - `rowFactory` → a function that takes over the rendering of each row. It receives an object with the current `record`, its `index`, the default `rowProps` (class name, style, event handlers, etc.), the row cells as `children` and, when using [row expansion](https://icflorescu.github.io/mantine-datatable/examples/expanding-rows/), the `expandedElement` to render after the row. Use it to wrap each row in a `Draggable` component; - `DataTableDraggableRow` → a table row component exported by Mantine DataTable. Pass it the `rowProps` received by `rowFactory`, along with an `isDragging` flag. While a row is being dragged, the component fixes its cell widths to match the header cells, so that the row keeps its shape, and sets `data-is-dragging="true"` on it, which you can use for styling. Since the drag handle is rendered by `rowFactory` in an extra cell, add a matching column with `hiddenContent` set to `true`, so that the header and body cells stay aligned. Giving the columns explicit widths and setting the table layout to `fixed` prevents them from changing size while dragging. `RowDraggingExample.tsx`: ```tsx 'use client'; import type { DataTableColumn } from 'mantine-datatable'; import { DataTable, DataTableDraggableRow } from 'mantine-datatable'; import { DragDropContext, Draggable, Droppable, type DropResult } from '@hello-pangea/dnd'; import { Center, TableTd } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { IconGripVertical } from '@tabler/icons-react'; import clsx from 'clsx'; import { useState } from 'react'; import companies from '~/data/companies.json'; import classes from './RowDraggingExample.module.css'; interface RecordData { id: string; name: string; streetAddress: string; city: string; state: string; missionStatement: string; } export function RowDraggingExample() { const [records, setRecords] = useState(companies); const handleDragEnd = (result: DropResult) => { if (!result.destination) return; const items = Array.from(records); const sourceIndex = result.source.index; const destinationIndex = result.destination.index; const [reorderedItem] = items.splice(sourceIndex, 1); items.splice(destinationIndex, 0, reorderedItem); setRecords(items); notifications.show({ title: 'Table reordered', message: `The company named “${reorderedItem.name}” has been moved from position ${sourceIndex + 1} to ${destinationIndex + 1}.`, color: 'blue', }); }; const columns: DataTableColumn[] = [ // add empty header column for the drag handle { accessor: '', hiddenContent: true, width: 30 }, { accessor: 'name', width: 150 }, { accessor: 'streetAddress', width: 150 }, { accessor: 'city', width: 150 }, { accessor: 'state', width: 150 }, ]; return ( columns={columns} records={records} height={400} withTableBorder withColumnBorders tableWrapper={({ children }) => ( {(provided) => (
{children} {provided.placeholder}
)}
)} styles={{ table: { tableLayout: 'fixed' } }} rowFactory={({ record, index, rowProps, children }) => ( {(provided, snapshot) => ( {/** 👇 custom drag handle */}
{children}
)}
)} />
); } ``` `RowDraggingExample.module.css`: ```css .draggableRow[data-is-dragging='true'] td { opacity: 0.75; border: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); } ``` (`companies.json` is the same as in the "Example: Basic usage" section above.) Heads up: `@hello-pangea/dnd` is not a dependency of Mantine DataTable, so you’ll need to install it separately. Also remember to update your records in the `onDragEnd` handler; the DataTable simply renders the records you pass to it, in the given order. ## Example: Column resizing Source: https://icflorescu.github.io/mantine-datatable/examples/column-resizing/ Mantine DataTable lets users resize columns by dragging a handle at the edge of a column header. Resized widths can optionally be persisted in the browser’s local storage, so they survive page reloads. To enable column resizing with persistent widths: - add a `resizable: true` property to each resizing candidate column; - add a `storeColumnsKey: 'your_key'` property to the DataTable (the column widths are persisted in local storage under this key); - use the `useDataTableColumns()` hook with the same `key` to get the effective columns and the `resetColumnsWidth()` function. `ResizingExample.tsx`: ```tsx 'use client'; import { DataTable, useDataTableColumns } from 'mantine-datatable'; import { Button, Group, Stack, Switch } from '@mantine/core'; import { useState } from 'react'; import { type Company, companies } from '~/data'; export default function ResizingExample() { const key = 'resize-example'; const [resizable, setResizable] = useState(true); const [withTableBorder, setWithTableBorder] = useState(true); const [withColumnBorders, setWithColumnBorders] = useState(true); const { effectiveColumns, resetColumnsWidth } = useDataTableColumns({ key, columns: [ { accessor: 'name', resizable }, { accessor: 'streetAddress', resizable, ellipsis: true }, { accessor: 'city', resizable, ellipsis: true }, { accessor: 'state', resizable }, { accessor: 'missionStatement', resizable, ellipsis: true }, ], }); return ( setResizable(event.currentTarget.checked)} labelPosition="left" label="Resizable" /> setWithTableBorder(event.currentTarget.checked)} labelPosition="left" label="Table Border" /> setWithColumnBorders(event.currentTarget.checked)} labelPosition="left" label="Column Borders" /> ); } ``` How resizing works: - dragging a handle changes the width of the column and of the column next to it, so the overall table width stays the same; - a column can’t be made narrower than 50 pixels; - the last column has no resize handle, since there’s no column after it; - double-clicking a handle resets the two adjacent columns to their default widths; - calling `resetColumnsWidth()` restores all columns to the `width` they were defined with in the `columns` prop (or to automatic width, if they don’t have one). ### Resizing without persistence If you don’t need to remember the column widths, simply add `resizable: true` to the columns you want to make resizable; no `storeColumnsKey` or `useDataTableColumns()` hook required. The widths are reset to their defaults when the component is remounted: `ResizingWithoutPersistenceExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { companies } from '~/data'; export default function ResizingWithoutPersistenceExample() { return ( ); } ``` The functions returned by the `useDataTableColumns()` hook (such as `resetColumnsWidth()`) communicate with the DataTable through local storage, so they only take effect when the hook `key` matches the DataTable `storeColumnsKey`. ### Combining resizing with other column features Resizing can be combined with [sorting](https://icflorescu.github.io/mantine-datatable/examples/sorting/), [records selection](https://icflorescu.github.io/mantine-datatable/examples/records-selection/) and [column dragging and toggling](https://icflorescu.github.io/mantine-datatable/examples/column-dragging-and-toggling/). Widths, order and visibility are persisted under the same `storeColumnsKey`, and each can be reset independently. The example below avoids repetition by spreading a shared object into each column definition: `ResizingComplexExample.tsx`: ```tsx 'use client'; import { DataTable, type DataTableSortStatus, useDataTableColumns } from 'mantine-datatable'; import { Button, Group, Stack, Switch } from '@mantine/core'; import { sortBy } from 'lodash'; import { useEffect, useState } from 'react'; import { type Company, companies } from '~/data'; export default function ResizingComplexExample() { const key = 'resize-complex-example'; const [sortStatus, setSortStatus] = useState>({ columnAccessor: 'name', direction: 'asc', }); const [records, setRecords] = useState(sortBy(companies, 'name')); const [selectedRecords, setSelectedRecords] = useState([]); useEffect(() => { const data = sortBy(companies, sortStatus.columnAccessor); setRecords(sortStatus.direction === 'desc' ? data.reverse() : data); }, [sortStatus]); const [withTableBorder, setWithTableBorder] = useState(true); const [withColumnBorders, setWithColumnBorders] = useState(true); const props = { resizable: true, sortable: true, toggleable: true, draggable: true, }; const { effectiveColumns, resetColumnsWidth, resetColumnsOrder, resetColumnsToggle } = useDataTableColumns({ key, columns: [ { accessor: 'name', ellipsis: true, ...props }, { accessor: 'streetAddress', ellipsis: true, ...props }, { accessor: 'city', ellipsis: true, ...props }, { accessor: 'state', textAlign: 'right', ...props }, ], }); return ( setWithTableBorder(event.currentTarget.checked)} labelPosition="left" label="Table Border" /> setWithColumnBorders(event.currentTarget.checked)} labelPosition="left" label="Column Borders" /> ); } ``` ## Example: Infinite scrolling Source: https://icflorescu.github.io/mantine-datatable/examples/infinite-scrolling/ The DataTable invokes the `onScrollToTop`, `onScrollToBottom`, `onScrollToLeft` and `onScrollToRight` callbacks when the user scrolls to the top, bottom, left or right edge of the table, respectively. Each callback is invoked once, when the corresponding edge is reached; scrolling away and back again triggers it anew. There’s also an `onScroll` callback, invoked with the current scroll position (`{ x: number; y: number }`) whenever the table is scrolled. To implement infinite scrolling: - make the table [vertically scrollable](https://icflorescu.github.io/mantine-datatable/examples/scrollable-vs-auto-height/) by setting its `height` or placing it inside a height-restricted container; - load the next batch of records in the `onScrollToBottom` handler and append it to the existing records; - set the `fetching` property to `true` while loading, to display the [loader](https://icflorescu.github.io/mantine-datatable/examples/asynchronous-data-loading/); - optionally, use the `scrollViewportRef` property to get hold of the scrollable viewport element, e.g. to scroll back to the top when the records are reset. `InfiniteScrollingExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { Button, Group, Paper, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { useEffect, useRef, useState } from 'react'; import { employees } from '~/data'; export function InfiniteScrollingExample() { const batchSize = 100; const [loading, setLoading] = useState(false); const [records, setRecords] = useState(employees.slice(0, batchSize)); const scrollViewportRef = useRef(null); const timeoutRef = useRef>(null); const loadMoreRecords = () => { if (records.length < employees.length) { setLoading(true); timeoutRef.current = setTimeout(() => { setRecords(employees.slice(0, records.length + batchSize)); setLoading(false); }, 1000); } else { notifications.show({ title: 'No more records', message: 'All records have been loaded.', }); } }; const reset = () => { setRecords(employees.slice(0, batchSize)); // Make sure to scroll to top after resetting records scrollViewportRef.current?.scrollTo(0, 0); }; // Clear timeout on unmount useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); return ( <> Showing {records.length} records of {employees.length} {records.length < employees.length && ', scroll to bottom to load more'} ); } ``` ## Example: Searching and filtering Source: https://icflorescu.github.io/mantine-datatable/examples/searching-and-filtering/ Mantine DataTable doesn’t filter your data; to implement searching and filtering, adjust the array of `records` you pass to the component according to your own logic. To help you build column-based filtering UIs, Mantine DataTable supports the following [column properties](https://icflorescu.github.io/mantine-datatable/examples/column-properties-and-styling/): - `filter` → the content of a popover that opens when the user clicks the filter button added to the column header; either a `ReactNode` or a function receiving an object with a `close` method (to close the popover programmatically, e.g. after resetting a filter) and returning a `ReactNode`; - `filtering: boolean` → when `true`, the filter button is highlighted to indicate that a filter is in effect; - `filterPopoverProps` → additional [Mantine Popover props](https://mantine.dev/core/popover/?t=props) to customize the filter popover (position, width, shadow, etc.); - `filterPopoverDisableClickOutside: boolean` → when `true`, disables the click-outside handler Mantine DataTable attaches to the filter popover dropdown; combine it with `filterPopoverProps={{ closeOnClickOutside: false }}` to keep the popover open until the user closes it explicitly. Here’s a possible (rather naive) implementation: `SearchingAndFilteringExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { ActionIcon, Button, Checkbox, MultiSelect, Stack, TextInput } from '@mantine/core'; import { DatePicker, type DatesRangeValue } from '@mantine/dates'; import { useDebouncedValue } from '@mantine/hooks'; import { IconSearch, IconX } from '@tabler/icons-react'; import dayjs from 'dayjs'; import { useEffect, useMemo, useState } from 'react'; import { employees } from '~/data'; const initialRecords = employees.slice(0, 100); export function SearchingAndFilteringExample() { const [records, setRecords] = useState(initialRecords); const departments = useMemo(() => { const departments = new Set(employees.map((e) => e.department.name)); return [...departments]; }, []); const [query, setQuery] = useState(''); const [selectedDepartments, setSelectedDepartments] = useState([]); const [birthdaySearchRange, setBirthdaySearchRange] = useState(); const [seniors, setSeniors] = useState(false); const [debouncedQuery] = useDebouncedValue(query, 200); useEffect(() => { setRecords( initialRecords.filter(({ firstName, lastName, department, birthDate }) => { if ( debouncedQuery !== '' && !`${firstName} ${lastName}`.toLowerCase().includes(debouncedQuery.trim().toLowerCase()) ) return false; if ( birthdaySearchRange?.[0] && birthdaySearchRange[1] && (dayjs(birthdaySearchRange[0]).isAfter(birthDate, 'day') || dayjs(birthdaySearchRange[1]).isBefore(birthDate, 'day')) ) return false; if (selectedDepartments.length && !selectedDepartments.some((d) => d === department.name)) return false; if (seniors && dayjs().diff(birthDate, 'y') < 70) return false; return true; }) ); }, [debouncedQuery, birthdaySearchRange, selectedDepartments, seniors]); return ( `${firstName} ${lastName}`, filter: ( } rightSection={ setQuery('')}> } value={query} onChange={(e) => setQuery(e.currentTarget.value)} /> ), filtering: query !== '', }, { accessor: 'department.name', filter: ( } comboboxProps={{ withinPortal: false }} clearable searchable /> ), filtering: selectedDepartments.length > 0, }, { accessor: 'department.company.name', title: 'Company' }, { accessor: 'birthDate', textAlign: 'right', render: ({ birthDate }) => dayjs(birthDate).format('MMM DD YYYY'), filter: ({ close }) => ( ), filtering: Boolean(birthdaySearchRange), }, { accessor: 'age', textAlign: 'right', render: ({ birthDate }) => dayjs().diff(birthDate, 'y'), filter: () => ( { setSeniors((current) => !current); }} /> ), filtering: seniors, }, ]} /> ); } ``` If your filter uses Mantine components that render a dropdown (such as `Select`, `MultiSelect` or `DatePickerInput`), render their dropdowns without a portal (e.g. `comboboxProps={{ withinPortal: false }}`, as in the example above); otherwise, clicking inside the dropdown will close the filter popover. See the Mantine documentation on [nested popovers](https://mantine.dev/core/popover/#nested-popovers). If you’re combining filtering with [pagination](https://icflorescu.github.io/mantine-datatable/examples/pagination/), remember to reset the current page to `1` whenever the filter criteria change. Why no built-in “Excel-like” searching and filtering? There’s no way for Mantine DataTable to accommodate every possible usage scenario: you’ll usually also deal with pagination, sorting and asynchronous loading, may need to place search boxes or filter selectors elsewhere in your application, and in real life the actual searching and filtering is most often done by a server API. Fitting all this into a standard component design would needlessly constrain the developer. ## Example: Records selection Source: https://icflorescu.github.io/mantine-datatable/examples/records-selection/ Mantine DataTable supports Gmail-like batch selection of records: **shift-clicking a row selection checkbox selects or deselects all records between the last clicked record and the current one**. To enable records selection, set these DataTable properties: - `selectedRecords` → the array of currently selected records (same type as the items in `records`); - `onSelectedRecordsChange` → a callback invoked with the new array of selected records when the user changes the selection. With both set, the table renders a selection checkbox column. Clicking a row checkbox toggles that record; clicking the header checkbox selects or deselects all visible records (selected records not currently visible, e.g. on other pages, are left untouched); shift-clicking a row checkbox toggles the whole range since the last clicked record. Keep in mind: records are compared by their IDs, as designated by the `idAccessor` property (defaults to `'id'`; see https://icflorescu.github.io/mantine-datatable/examples/non-standard-record-ids/), not by object reference. `RecordsSelectionExample.tsx`: ```tsx const [selectedRecords, setSelectedRecords] = useState([]); return ( <> {/* delete button... */} ); ``` `columns.ts`: ```ts const columns: DataTableColumn[] = [ { accessor: 'name', width: '40%' }, { accessor: 'streetAddress', width: '60%' }, { accessor: 'city', width: 160 }, { accessor: 'state', width: 80, textAlign: 'right' }, ]; ``` ### Disable selection of certain records Provide an `isRecordSelectable` function; it receives a record and its index and should return `false` for records that can’t be selected. Their checkboxes are disabled, and both the header checkbox and shift-click range selection skip them: `DisabledRecordsExample.tsx`: ```tsx const [selectedRecords, setSelectedRecords] = useState([]); return ( record.name.length < 14} /> ); ``` (`columns.ts` is the same as above.) ### Customizing the selection column className and style By default the selection column has a fixed width of `44px`. To change its width or otherwise customize it, use the `selectionColumnClassName` and/or `selectionColumnStyle` properties: ```tsx const [selectedRecords, setSelectedRecords] = useState([]); return ( ); ``` ### Additional selection checkbox props Pass additional Mantine Checkbox props (https://mantine.dev/core/checkbox/?t=props) to the selection checkboxes with: - `selectionCheckboxProps` → props applied to all selection checkboxes (header and rows); - `allRecordsSelectionCheckboxProps` → props applied to the column header checkbox; - `getRecordSelectionCheckboxProps` → a function receiving a record and its index and returning props for that record’s checkbox. The example below renders smaller checkboxes with custom `aria-label` attributes: `CheckboxPropsExample.tsx`: ```tsx const [selectedRecords, setSelectedRecords] = useState([]); return ( ({ 'aria-label': `Select ${record.name}` })} /> ); ``` (`columns.ts` is the same as above.) ### Selecting all records on all pages With pagination, the best practice is to only load the current page’s records from the server, so Mantine DataTable can’t (and shouldn’t) know about the entire dataset; the “select all” checkbox only affects the records on the current page. To let users “select all records on all pages” (like in Gmail), use `allRecordsSelectionCheckboxProps` to implement your own selection mechanism. The example below also uses the `uniqBy` and `differenceBy` utility functions exported by Mantine DataTable: ```tsx const PAGE_SIZE = 10; const [page, setPage] = useState(1); const [records, setRecords] = useState(employees.slice(0, PAGE_SIZE)); const [allRecordsSelected, setAllRecordsSelected] = useState(false); const [selectedRecords, setSelectedRecords] = useState([]); const [unselectedRecords, setUnselectedRecords] = useState([]); const handleSelectedRecordsChange = (newSelectedRecords: Employee[]) => { if (allRecordsSelected) { const recordsToUnselect = records.filter((record) => !newSelectedRecords.includes(record)); setUnselectedRecords( // 👇 `uniqBy` is a utility function provided by Mantine DataTable uniqBy([...unselectedRecords, ...recordsToUnselect], (r) => r.id).filter((r) => !newSelectedRecords.includes(r)) ); } else { setSelectedRecords(newSelectedRecords); } }; const handleAllRecordsSelectionCheckboxChange = () => { if (allRecordsSelected) { setAllRecordsSelected(false); setSelectedRecords([]); setUnselectedRecords([]); } else { setAllRecordsSelected(true); } }; useEffect(() => { const from = (page - 1) * PAGE_SIZE; const to = from + PAGE_SIZE; const currentRecords = employees.slice(from, to); setRecords(currentRecords); if (allRecordsSelected) { setSelectedRecords( // 👇 `differenceBy` is a utility function provided by Mantine DataTable differenceBy(currentRecords, unselectedRecords, (r) => r.id) ); } }, [allRecordsSelected, page, unselectedRecords]); return ( <> setPage(p)} // other table props... /> You have selected {allRecordsSelected ? employees.length - unselectedRecords.length : selectedRecords.length}{' '} records of a total of {employees.length}. ); ``` ### Horizontal scrolling behavior When the table is horizontally scrollable, the selection column stays pinned to the left side, so the selection checkboxes are always visible: ```tsx const [selectedRecords, setSelectedRecords] = useState([]); return ( ); ``` ### Maximizing the selection trigger area to the entire cell By default, selection is triggered by clicking the checkbox. Set `selectionTrigger` to `'cell'` (default `'checkbox'`) to make a click anywhere in the cell holding the checkbox toggle the record: ```tsx ``` ## Example: Handling row clicks Source: https://icflorescu.github.io/mantine-datatable/examples/handling-row-clicks/ To react when the user clicks a row, pass an `onRowClick` handler to the DataTable. It receives an object with: - `record` → the clicked record; - `index` → the index of the clicked record in the `records` array; - `event` → the original React mouse event (useful, for instance, to check whether a modifier key was pressed). `HandlingRowClicksExample.tsx`: ```tsx { openModal({ title: 'Company information', children: ( You clicked on row[{index}], referring to company {record.name}.
{event.shiftKey && ( <> You pressed the Shift key when clicking.
)} {event.ctrlKey && ( <> You pressed the Ctrl key when clicking.
)} {event.altKey && ( <> You pressed the Alt key when clicking.
)} {event.metaKey && ( <> You pressed the Meta key when clicking.
)}
), }); }} /> ``` When a row click handler is provided, rows display a `pointer` cursor. ### Double-clicks The `onRowDoubleClick` handler receives the same arguments: `HandlingRowDoubleClicksExample.tsx`: ```tsx { // process double-click event... }} /> ``` Combining single and double clicks: a double-click is preceded by two regular `click` events, so if you provide both `onRowClick` and `onRowDoubleClick`, `onRowClick` is called twice before `onRowDoubleClick` fires. Make sure the two behaviors don’t interfere. ### Right-clicks Use the `onRowContextMenu` handler (same arguments) to react to right-clicks. Rows with a context menu handler display a `context-menu` cursor; call `event.preventDefault()` to suppress the browser’s own context menu: `HandlingRowContextMenuExample.tsx`: ```tsx { event.preventDefault(); // 👈 prevent the browser’s own context menu from showing up showNotification({ withBorder: true, title: 'Row right-clicked', message: `You right-clicked on row[${index}], referring to company ${record.name}`, }); }} /> ``` To display an actual context menu, see https://icflorescu.github.io/mantine-datatable/examples/using-with-mantine-contextmenu/. Heads up: when handling row clicks you might want to disable text selection (https://icflorescu.github.io/mantine-datatable/examples/disabling-text-selection/). If you combine clickable rows with links, buttons, row action cells or other clickable components inside cells, intercept the `click` event on those components and call its `.stopPropagation()` method (see https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells/). If you need to know which cell was clicked, use the `onCellClick`, `onCellDoubleClick` and `onCellContextMenu` handlers instead (https://icflorescu.github.io/mantine-datatable/examples/handling-cell-clicks/). ## Example: Handling cell clicks Source: https://icflorescu.github.io/mantine-datatable/examples/handling-cell-clicks/ To know not only which row but also which column was clicked, pass an `onCellClick` handler to the DataTable. It receives an object with: - `record` → the clicked record; - `index` → the index of the clicked record in the `records` array; - `column` → the clicked column’s definition; - `columnIndex` → the index of the clicked column in display order (which can differ from its position in your `columns` array when columns are reordered, pinned or grouped); - `event` → the original React mouse event. `HandlingCellClicksExample.tsx`: ```tsx { openModal({ title: 'Cell click information', children: ( You clicked on row[{index}], column[{columnIndex}] with accessor {column.accessor}.
The clicked row refers to company {record.name}.
{event.shiftKey && ( <> You pressed the Shift key when clicking.
)} {event.ctrlKey && ( <> You pressed the Ctrl key when clicking.
)} {event.altKey && ( <> You pressed the Alt key when clicking.
)} {event.metaKey && ( <> You pressed the Meta key when clicking.
)}
), }); }} /> ``` ### Double-clicks The `onCellDoubleClick` handler receives the same arguments: `HandlingCellDoubleClicksExample.tsx`: ```tsx { // process double-click event... }} /> ``` ### Right-clicks Use the `onCellContextMenu` handler to react to right-clicks on a cell; call `event.preventDefault()` to suppress the browser’s own context menu. See how to use it to display a cell context menu with Mantine ContextMenu: https://icflorescu.github.io/mantine-datatable/examples/using-with-mantine-contextmenu/. Heads up: - Cell events bubble up to the row, so you can combine cell handlers with row handlers such as `onRowClick`; the cell handler is called first. A double-click is preceded by two regular `click` events. - When handling cell clicks you might want to disable text selection (https://icflorescu.github.io/mantine-datatable/examples/disabling-text-selection/). - If you combine clickable cells with links, buttons, row action cells or other clickable components inside cells, intercept the `click` event on those components and call its `.stopPropagation()` method (see https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells/). ## Example: Using with Mantine ContextMenu Source: https://icflorescu.github.io/mantine-datatable/examples/using-with-mantine-contextmenu/ Mantine doesn’t include a context-menu component, but Mantine DataTable works seamlessly with Mantine ContextMenu (https://icflorescu.github.io/mantine-contextmenu), a library by the same author. Important: - Wrap your application in a `ContextMenuProvider` and include the necessary CSS files **in the correct order** (see https://icflorescu.github.io/mantine-contextmenu/getting-started). - With a scrollable Mantine DataTable, close the context menu when the user scrolls the table (see “Using with scrollable tables” below). - On touch devices, consider disabling text selection on the table (https://icflorescu.github.io/mantine-datatable/examples/disabling-text-selection/). ### Row context-menu Use the `onRowContextMenu` property to show a context menu when the user right-clicks a row. The handler receives the current `record`, its `index` and the mouse `event`, which you pass to the function returned by `showContextMenu`: `RowContextMenuExample.tsx`: ```tsx 'use client'; // 👆 Since 'useContextMenu' is a hook, don't forget to add the 'use client' directive // at the top of your file if you're using it in a RSC context import { DataTable } from 'mantine-datatable'; import { useMediaQuery } from '@mantine/hooks'; import { showNotification } from '@mantine/notifications'; import { IconEdit, IconEye, IconTrash } from '@tabler/icons-react'; import { useContextMenu } from 'mantine-contextmenu'; import companies from '~/data/companies.json'; export function RowContextMenuExample() { const { showContextMenu } = useContextMenu(); const isTouch = useMediaQuery('(pointer: coarse)'); return ( showContextMenu([ { key: 'view-company-details', icon: , onClick: () => showNotification({ message: `Clicked on view context-menu action for ${record.name} company`, withBorder: true, }), }, { key: 'edit-company-information', icon: , onClick: () => showNotification({ message: `Clicked on edit context-menu action for ${record.name} company`, withBorder: true, }), }, { key: 'divider' }, { key: 'delete-company', icon: , color: 'red', onClick: () => showNotification({ color: 'red', message: `Clicked on delete context-menu action for ${record.name} company`, withBorder: true, }), }, ])(event) } /> ); } ``` ### Using with scrollable tables By default, a Mantine ContextMenu menu places an overlay over the page and closes on overlay click, `Escape`, page scroll or window resize. However, the user can still scroll a scrollable DataTable (with the mouse wheel or arrow keys), leaving the menu detached from its row. To prevent this, pass the `hideContextMenu` function returned by the `useContextMenu` hook to the DataTable `onScroll` property: `RowContextMenuOnScrollableTableExample.tsx`: ```tsx const { showContextMenu, hideContextMenu } = useContextMenu(); const isTouch = useMediaQuery('(pointer: coarse)'); return ( showContextMenu([ // context menu items... ])(event) } // 👇 make sure the context-menu is closed when the user scrolls the table onScroll={hideContextMenu} /> ); ``` ### Cell context-menu Use the `onCellContextMenu` property to create context menus for individual cells. Besides the `record` and the `event`, its handler also receives the `column` and its index, so you can decide which cells get a menu. Here only the `name` column cells have one: `CellContextMenuExample.tsx`: ```tsx const { showContextMenu } = useContextMenu(); const isTouch = useMediaQuery('(pointer: coarse)'); return ( { if (column.accessor === 'name') { return showContextMenu([ // context menu items... ])(event); } }} /> ); ``` ### Mixing row and cell context-menus Right-clicking a `name` cell shows a cell-specific menu, while right-clicking anywhere else on the row shows the row menu: `RowAndCellContextMenuExample.tsx`: ```tsx const { showContextMenu } = useContextMenu(); const isTouch = useMediaQuery('(pointer: coarse)'); return ( showContextMenu([ // row context menu items... ])(event) } onCellContextMenu={({ record, column, event }) => { if (column.accessor === 'name') { return showContextMenu([ // 'name' cell context menu items... ])(event); } }} /> ); ``` ### Showing a context-menu on left-click To show a context menu on left-click, use the `onRowClick` and `onCellClick` properties instead of `onRowContextMenu` and `onCellContextMenu`: `ContextMenuOnClickExample.tsx`: ```tsx const { showContextMenu } = useContextMenu(); const isTouch = useMediaQuery('(pointer: coarse)'); return ( showContextMenu([ // context menu items... ])(event) } /> ); ``` ### Using context-menus inside modals Context menus also work for tables rendered inside modals: `RowContextMenuInsideModalExample.tsx`: ```tsx const { showContextMenu } = useContextMenu(); const isTouch = useMediaQuery('(pointer: coarse)'); return ( ); ``` ### Submenus, disabled items and custom titles Context menu items support all Mantine ContextMenu features, for instance: - a custom `title` (otherwise one is generated by “humanizing” the item `key`, which is why the examples above don’t specify titles); - a submenu, by providing an `items` array instead of an `onClick` handler; - disabling or hiding items based on the current state, with the `disabled` and `hidden` properties. In the example below, the menu has a submenu and an item that is only enabled when some rows are selected: `AdvancedContextMenuExample.tsx`: ```tsx const { showContextMenu } = useContextMenu(); const isTouch = useMediaQuery('(pointer: coarse)'); const [selectedRecords, setSelectedRecords] = useState([]); return ( showContextMenu([ { key: 'view', icon: , title: `View ${record.name} details`, // 👈 custom title instead of the humanized key onClick: () => showNotification({ message: `Clicked on view context-menu action for ${record.name} company`, withBorder: true, }), }, { key: 'export', icon: , // 👇 providing `items` instead of `onClick` renders a submenu items: [ { key: 'csv', title: 'CSV', icon: }, { key: 'json', title: 'JSON', icon: }, { key: 'pdf', title: 'PDF', icon: }, ].map(({ key, title, icon }) => ({ key, title, icon, onClick: () => showNotification({ message: `Clicked on export as ${title} context-menu action for ${record.name} company`, withBorder: true, }), })), }, { key: 'divider' }, { key: 'delete-selected', icon: , color: 'red', title: selectedRecords.length ? `Delete ${selectedRecords.length} selected ${selectedRecords.length === 1 ? 'company' : 'companies'}` : 'Delete selected companies', disabled: selectedRecords.length === 0, // 👈 disable the item when no records are selected onClick: () => showNotification({ color: 'red', message: `Clicked on delete context-menu action for ${selectedRecords.length} selected ${selectedRecords.length === 1 ? 'company' : 'companies'}`, withBorder: true, }), }, ])(event) } /> ); ``` Instead of an array of items, `showContextMenu` also accepts a function that receives a `close` callback and returns completely custom content. See the Mantine ContextMenu documentation (https://icflorescu.github.io/mantine-contextmenu) for all options. ## Example: Expanding rows Source: https://icflorescu.github.io/mantine-datatable/examples/expanding-rows/ The `rowExpansion` property lets you display additional content below a row when the user clicks it (“row expansion”). ### Basic usage At minimum, provide a `content` function returning the content to be *lazily rendered* when a row is expanded. It receives an object with the current `record`, its `index` and a `collapse` callback. Heads up: styling the expanded content is up to you. Don’t forget to set the `idAccessor` property if your unique record key is not `'id'` (see https://icflorescu.github.io/mantine-datatable/examples/non-standard-record-ids/). `RowExpansionExampleSimple.tsx`: ```tsx return ( (
Postal address:
{record.streetAddress}, {record.city}, {record.state}
Mission statement:
“{record.missionStatement}”
), }} /> ); ``` `RowExpansionExampleSimple.module.css`: ```css .details { font-size: var(--mantine-font-size-sm); background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); } .label { width: rem(130px); } ``` ### Specifying collapse properties The expanded content is rendered inside a Mantine `Collapse` component (https://mantine.dev/core/collapse/). Customize its `animateOpacity`, `transitionDuration` and `transitionTimingFunction` properties with a `collapseProps` object: `RowExpansionExampleCollapseProps.tsx`: ```tsx return ( ); ``` ### Allowing multiple rows to be expanded at once By default only one row can be expanded at a time (expanding a row collapses the previously expanded one). Set `allowMultiple: true` to change this: `RowExpansionExampleMultipleExpandedRows.tsx`: ```tsx return ( ); ``` ### Specifying which rows are expandable Use the `expandable` function to specify which rows can be expanded. It receives an object with the current `record` and its `index` and returns a boolean: `RowExpansionExampleExpandableRows.tsx`: ```tsx return ( state === 'WY', // 👈 enable expansion only on rows where state is WY // ... }} /> ); ``` ### Specifying which rows are initially expanded Use the `initiallyExpanded` function to specify which rows are expanded on first render. Unless `allowMultiple` is `true`, only the first matching row is expanded: `RowExpansionExampleInitiallyExpandedRows.tsx`: ```tsx return ( state === 'WY', // 👈 expand rows where state is WY // ... }} /> ); ``` ### Always expand all rows To lock all rows in their expanded state, set the row expansion `trigger` property to `'always'` (possible values: `'click'` (default), `'always'`, `'never'`): `RowExpansionExampleTriggerAlways.tsx`: ```tsx return ( ); ``` ### Using the collapse function in row expansion content The `content` function also receives a `collapse` callback you can call to collapse the expanded row, e.g. when the user saves or cancels changes in an inline editor: `RowExpansionExampleWithInlineEditor.tsx`: ```tsx type CompanyEditorProps = { initialData: Company; onDone: (data: Company) => void; onCancel: () => void; }; function CompanyEditor({ initialData, onDone, onCancel }: CompanyEditorProps) { const [name, setName] = useState(initialData.name); const [city, setCity] = useState(initialData.city); const [state, setState] = useState(initialData.state); const [streetAddress, setStreetAddress] = useState(initialData.streetAddress); const [missionStatement, setMissionStatement] = useState(initialData.missionStatement); return ( setName(e.currentTarget.value)} /> {/* other fields... */} ); } export function RowExpansionExampleWithInlineEditor() { const [companies, setCompanies] = useState(initialRecords); return ( ( { const index = companies.findIndex((c) => c.id === data.id); setCompanies([...companies.slice(0, index), data, ...companies.slice(index + 1)]); collapse(); // 👈 collapse the row after editing }} onCancel={collapse} // 👈 collapse the row if editing is cancelled /> ), }} /> ); } ``` ### Lazy-loading row expansion data Since the `content` function is lazily executed when a row is expanded (avoiding unnecessary DOM elements), you can use it to load data that doesn’t come from the table `records` only when a row is expanded: `RowExpansionExampleWithLazyLoading.tsx`: ```tsx function CompanyDetails({ companyId }: { companyId: string }) { const isMounted = useIsMounted(); const [loading, setLoading] = useState(true); const [numberOfDepartments, setNumberOfDepartments] = useState(null); const [numberOfEmployees, setNumberOfEmployees] = useState(null); useEffect(() => { // simulate expensive async loading operation (async () => { setLoading(true); const delay = { min: 800, max: 1200 }; const [departments, employees] = await Promise.all([ countCompanyDepartmentsAsync({ companyId, delay }), countCompanyEmployeesAsync({ companyId, delay }), ]); if (isMounted()) { setNumberOfDepartments(departments); setNumberOfEmployees(employees); setLoading(false); } })(); }, [companyId, isMounted]); return (
Number of departments: {numberOfDepartments ?? 'loading...'} Number of employees: {numberOfEmployees ?? 'loading...'}
); } export function RowExpansionExampleWithLazyLoading() { return ( }} /> ); } ``` ### Controlled mode To control which rows are expanded from outside the table, set `rowExpansion.expanded` to an object containing: - `recordIds` → an array with the IDs of the currently expanded records; - `onRecordIdsChange` → a callback invoked with the new array of expanded record IDs when the user expands or collapses a row. `RowExpansionExampleControlledMode.tsx`: ```tsx const [expandedRecordIds, setExpandedRecordIds] = useState([]); const expandFirstAndThirdRow = () => { setExpandedRecordIds([firstRowId, thirdRowId]); }; const expandSecondAndFourthRow = () => { setExpandedRecordIds([secondRowId, fourthRowId]); }; const collapseAllRows = () => { setExpandedRecordIds([]); }; return ( <> {/* buttons triggering the above callbacks... */} index + 1 }, { accessor: 'name', width: '100%' }, { accessor: 'city', ellipsis: true }, { accessor: 'state' }, ]} records={records} rowExpansion={{ // trigger: 'never', // 👈 uncomment this if you want to disable expanding/collapsing on click allowMultiple: true, expanded: { recordIds: expandedRecordIds, onRecordIdsChange: setExpandedRecordIds, }, content: ({ record }) => ( // expansion content... ), }} /> ); ``` ### Expanding rows with a toggle button If you don’t want rows to expand or collapse when clicked, set `rowExpansion.trigger` to `'never'` and control the expansion state yourself, for instance with a button in a dedicated column: `RowExpansionExampleToggleButton.tsx`: ```tsx const [expandedRecordIds, setExpandedRecordIds] = useState([]); return ( { const expanded = expandedRecordIds.includes(id); return ( setExpandedRecordIds((ids) => (expanded ? ids.filter((i) => i !== id) : [...ids, id]))} > ); }, }, { accessor: 'name' }, { accessor: 'city' }, { accessor: 'state' }, ]} records={records} rowExpansion={{ trigger: 'never', // 👈 clicking on a row does nothing allowMultiple: true, expanded: { recordIds: expandedRecordIds, onRecordIdsChange: setExpandedRecordIds, }, content: ({ record }) => ( // expansion content... ), }} /> ); ``` `RowExpansionExampleToggleButton.module.css`: ```css .details { font-size: var(--mantine-font-size-sm); background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); } .label { width: rem(130px); } .chevron { transition: transform 200ms ease; } .expanded { transform: rotate(90deg); } ``` Since `trigger: 'never'` disables expanding on click, the toggle button handler doesn’t need to call `event.stopPropagation()`; the row itself doesn’t react to clicks. If you combine expanding rows on click with links, buttons, row action cells or other clickable components inside cells, intercept the `click` event on those components and call its `.stopPropagation()` method (see https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells/). See https://icflorescu.github.io/mantine-datatable/examples/nested-tables/ for using row expansion to display nested tables. ## Example: Nested tables Source: https://icflorescu.github.io/mantine-datatable/examples/nested-tables/ There is no dedicated “tree table” component, but you can build nested tables by combining the row expansion feature (https://icflorescu.github.io/mantine-datatable/examples/expanding-rows/) with the `noHeader` property: each expanded row renders a header-less DataTable whose columns line up with those of its parent. Warning: nested tables do not work with column pinning (`pinFirstColumn` and `pinLastColumn`). Implementation notes: - Each level keeps its expanded record IDs in state and passes them to `rowExpansion.expanded` (`recordIds` and `onRecordIdsChange`). Controlling the expansion state lets the column `render` functions know which rows are expanded, so they can rotate the chevron icons. - `allowMultiple: true` lets the user expand several companies or departments at once. - Nested tables use the same fixed `width` for the last column as their parent so columns stay aligned, while a left margin on the first cell content indents each level. - If some records have no children, use the `rowExpansion.expandable` function to prevent expanding them. `NestedTablesExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { Box } from '@mantine/core'; import { IconBuilding, IconChevronRight, IconUser, IconUsers } from '@tabler/icons-react'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { useState } from 'react'; import { employees } from '~/data'; import { companies, departments } from '~/data/nested'; import classes from './NestedTablesExample.module.css'; export function NestedTablesExample() { const [expandedCompanyIds, setExpandedCompanyIds] = useState([]); const [expandedDepartmentIds, setExpandedDepartmentIds] = useState([]); return ( ( <> {name} ), }, { accessor: 'employees', title: 'Employees / Birth date', textAlign: 'right', width: 200 }, ]} records={companies} rowExpansion={{ allowMultiple: true, expanded: { recordIds: expandedCompanyIds, onRecordIdsChange: setExpandedCompanyIds }, content: (company) => ( ( {name} ), }, { accessor: 'employees', textAlign: 'right', width: 200 }, ]} records={departments.filter((department) => department.company.id === company.record.id)} rowExpansion={{ allowMultiple: true, expanded: { recordIds: expandedDepartmentIds, onRecordIdsChange: setExpandedDepartmentIds }, content: (department) => ( ( {firstName} {lastName} ), }, { accessor: 'birthDate', render: ({ birthDate }) => dayjs(birthDate).format('DD MMM YYYY'), textAlign: 'right', width: 200, }, ]} records={employees.filter((employee) => employee.department.id === department.record.id)} /> ), }} /> ), }} /> ); } ``` `NestedTablesExample.module.css`: ```css .icon { width: rem(13px); height: auto; vertical-align: rem(-1px); margin-right: rem(8px); } .expandIcon { transition: transform 0.2s; } .expandIconRotated { transform: rotate(90deg); } ``` `data.ts`: ```ts import { companies as companyData, departments as departmentData, employees } from '.'; // Departments with employees count export const departments = departmentData.map((department) => ({ ...department, employees: employees.filter((employee) => employee.department.id === department.id)?.length || 0, })); // Companies with employees count export const companies = companyData.map((company) => ({ ...company, employees: departments .filter((department) => department.company.id === company.id) .reduce((sum, department) => sum + department.employees, 0), })); ``` ## Example: Nested tables with async data loading Source: https://icflorescu.github.io/mantine-datatable/examples/nested-tables-with-async-data-loading/ The row expansion `content` function is lazily executed (only when a row is expanded), so you can load the data of nested tables on demand. Warning: nested tables do not work with column pinning (`pinFirstColumn` and `pinLastColumn`). Compared to the synchronous version (https://icflorescu.github.io/mantine-datatable/examples/nested-tables/), each nested level is a separate component (`DepartmentsTable` and `EmployeesTable`) that loads its own records when it mounts and passes the loading state to the `fetching` property. A `minHeight` on the nested tables leaves enough room for the loader to be visible while data is being fetched. Good to know: the content of a collapsed row is unmounted once the collapse animation ends, so expanding the same row again triggers a new request. To avoid that, cache the results with a data-fetching library such as TanStack Query (see https://icflorescu.github.io/mantine-datatable/examples/complex-usage-scenario/). `NestedTablesAsyncExample.tsx`: ```tsx 'use client'; import { DataTable } from 'mantine-datatable'; import { Box } from '@mantine/core'; import { IconBuilding, IconChevronRight, IconUser, IconUsers } from '@tabler/icons-react'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { useState } from 'react'; import { companies } from '~/data/nested'; import { useDepartmentsAsync, useEmployeesAsync } from '~/data/nestedAsync'; import classes from './NestedTablesAsyncExample.module.css'; function EmployeesTable({ departmentId }: { departmentId: string }) { const { records, loading } = useEmployeesAsync({ departmentId }); return ( ( {firstName} {lastName} ), }, { accessor: 'birthDate', render: ({ birthDate }) => dayjs(birthDate).format('DD MMM YYYY'), textAlign: 'right', width: 200, }, ]} records={records} fetching={loading} /> ); } function DepartmentsTable({ companyId }: { companyId: string }) { const { records, loading } = useDepartmentsAsync({ companyId }); const [expandedRecordIds, setExpandedRecordIds] = useState([]); return ( ( {name} ), }, { accessor: 'employees', textAlign: 'right', width: 200 }, ]} records={records} fetching={loading} rowExpansion={{ allowMultiple: true, expanded: { recordIds: expandedRecordIds, onRecordIdsChange: setExpandedRecordIds }, content: ({ record }) => , }} /> ); } export function NestedTablesAsyncExample() { const [expandedRecordIds, setExpandedRecordIds] = useState([]); return ( ( <> {name} ), }, { accessor: 'employees', title: 'Employees / Birth date', textAlign: 'right', width: 200 }, ]} records={companies} rowExpansion={{ allowMultiple: true, expanded: { recordIds: expandedRecordIds, onRecordIdsChange: setExpandedRecordIds }, content: ({ record }) => , }} /> ); } ``` `NestedTablesAsyncExample.module.css`: ```css .icon { width: rem(13px); height: auto; vertical-align: rem(-1px); margin-right: rem(8px); } .expandIcon { transition: transform 0.2s; } .expandIconRotated { transform: rotate(90deg); } ``` `dataAsync.ts`: ```ts import { sortBy } from 'lodash'; import { useEffect, useState } from 'react'; import { delay, useIsMounted } from '~/lib/examples'; import type { DataTableSortStatus } from '~/package'; import { type Company, companies as companyData, type Department, departments as departmentData, type Employee, employees, } from '.'; export type DepartmentWithEmployeeCount = Department & { employees: number }; export type CompanyWithEmployeeCount = Company & { employees: number }; // Departments with employees count export const departments = departmentData.map((department) => ({ ...department, employees: employees.filter((employee) => employee.department.id === department.id)?.length || 0, })); // Companies with employees count export const companies = companyData.map((company) => ({ ...company, employees: departments .filter((department) => department.company.id === company.id) .reduce((sum, department) => sum + department.employees, 0), })); // Hook simulating async companies fetching export function useCompaniesAsync({ sortStatus }: { sortStatus: DataTableSortStatus }) { const isMounted = useIsMounted(); const [records, setRecords] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { if (isMounted()) { (async () => { setLoading(true); await delay({ min: 500, max: 800 }); if (isMounted()) { const newRecords = sortBy( companies, sortStatus.columnAccessor === 'details' ? 'employees' : sortStatus.columnAccessor ); if (sortStatus.direction === 'desc') newRecords.reverse(); setRecords(newRecords); setLoading(false); } })(); } }, [isMounted, sortStatus]); return { records, loading }; } // Hook simulating async departments fetching by company id export function useDepartmentsAsync({ companyId, sortStatus, }: { companyId: string; sortStatus?: DataTableSortStatus; }) { const isMounted = useIsMounted(); const [records, setRecords] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { if (isMounted()) { (async () => { setLoading(true); await delay({ min: 500, max: 800 }); if (isMounted()) { let newRecords = departments.filter((department) => department.company.id === companyId); if (sortStatus) { newRecords = sortBy( newRecords, sortStatus.columnAccessor === 'details' ? 'employees' : sortStatus.columnAccessor ); if (sortStatus.direction === 'desc') newRecords.reverse(); } setRecords(newRecords); setLoading(false); } })(); } }, [companyId, isMounted, sortStatus]); return { records, loading }; } // Hook simulating async employees fetching by department id export function useEmployeesAsync({ departmentId, sortStatus, }: { departmentId: string; sortStatus?: DataTableSortStatus; }) { const isMounted = useIsMounted(); const [records, setRecords] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { if (isMounted()) { (async () => { setLoading(true); await delay({ min: 500, max: 800 }); if (isMounted()) { let newRecords = employees.filter((employee) => employee.department.id === departmentId); if (sortStatus) { newRecords = sortBy( newRecords, sortStatus.columnAccessor === 'name' ? ({ firstName, lastName }) => `${firstName} ${lastName}` : 'birthDate' ); if (sortStatus.direction === 'desc') newRecords.reverse(); } setRecords(newRecords); setLoading(false); } })(); } }, [departmentId, isMounted, sortStatus]); return { records, loading }; } ``` ## Example: Nested tables with async data loading and sorting Source: https://icflorescu.github.io/mantine-datatable/examples/nested-tables-with-async-data-loading-and-sorting/ Building on the previous example, here is how to combine asynchronous data loading with sorting in nested tables. Warning: nested tables do not work with column pinning (`pinFirstColumn` and `pinLastColumn`). How it works: - Only the top-level table has a header, so it owns the `sortStatus` state and is the only one receiving an `onSortStatusChange` handler. - The sort status is passed down as a prop to the nested table components, which include it in their data requests, so expanded departments and employees are re-sorted as soon as the user clicks a column header. Each data source decides how to interpret the sorted column (e.g. the second column sorts companies and departments by employee count and employees by birth date). - The nested tables use `fetching={loading && !records.length}`, so the loader is only shown during the initial load; when re-sorting, the current rows stay visible until the newly sorted ones arrive, avoiding a flood of loaders across all expanded levels. `NestedTablesAsyncSortingExample.tsx`: ```tsx 'use client'; import type { DataTableSortStatus } from 'mantine-datatable'; import { DataTable } from 'mantine-datatable'; import { Box } from '@mantine/core'; import { IconBuilding, IconChevronRight, IconUser, IconUsers } from '@tabler/icons-react'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { useState } from 'react'; import { type CompanyWithEmployeeCount, useCompaniesAsync, useDepartmentsAsync, useEmployeesAsync, } from '~/data/nestedAsync'; import classes from './NestedTablesAsyncSortingExample.module.css'; function EmployeesTable({ departmentId, sortStatus }: { departmentId: string; sortStatus: DataTableSortStatus }) { const { records, loading } = useEmployeesAsync({ departmentId, sortStatus }); return ( ( {firstName} {lastName} ), }, { accessor: 'birthDate', render: ({ birthDate }) => dayjs(birthDate).format('DD MMM YYYY'), textAlign: 'right', width: 200, }, ]} records={records} fetching={loading && !records.length} /> ); } function DepartmentsTable({ companyId, sortStatus }: { companyId: string; sortStatus: DataTableSortStatus }) { const { records, loading } = useDepartmentsAsync({ companyId, sortStatus }); const [expandedRecordIds, setExpandedRecordIds] = useState([]); return ( ( {name} ), }, { accessor: 'employees', textAlign: 'right', width: 200 }, ]} records={records} fetching={loading && !records.length} rowExpansion={{ allowMultiple: true, expanded: { recordIds: expandedRecordIds, onRecordIdsChange: setExpandedRecordIds }, content: ({ record }) => , }} /> ); } export function NestedTablesAsyncSortingExample() { const [expandedRecordIds, setExpandedRecordIds] = useState([]); const [sortStatus, setSortStatus] = useState>({ columnAccessor: 'name', direction: 'asc', }); const { records, loading } = useCompaniesAsync({ sortStatus }); return ( ( <> {name} ), }, { accessor: 'details', sortable: true, title: 'Employees / Birth date', render: ({ employees }) => employees, textAlign: 'right', width: 200, }, ]} records={records} fetching={loading} rowExpansion={{ allowMultiple: true, expanded: { recordIds: expandedRecordIds, onRecordIdsChange: setExpandedRecordIds }, content: ({ record }) => ( ), }} /> ); } ``` `NestedTablesAsyncSortingExample.module.css`: ```css .icon { width: rem(13px); height: auto; vertical-align: rem(-1px); margin-right: rem(8px); } .expandIcon { transition: transform 0.2s; } .expandIconRotated { transform: rotate(90deg); } ``` (`dataAsync.ts` is the same as in the "Example: Nested tables with async data loading" section above.) ## Example: Row actions cell Source: https://icflorescu.github.io/mantine-datatable/examples/row-actions-cell/ If row or cell click handlers aren’t enough and context menus aren’t your thing, implement a row actions cell with a column `render` function returning a group of action buttons: ```tsx return ( Row actions, textAlign: 'right', render: (company) => ( showModal({ company, action: 'view' })} > showModal({ company, action: 'edit' })} > showModal({ company, action: 'delete' })} > ), }, ]} records={records} /> ); ``` Heads up: if you combine row actions with clickable rows, clickable cells, expandable rows or row context menus triggered by `click`, intercept the `click` event on the action buttons and call its `.stopPropagation()` method (see https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells/). ### Constraining the actions column width To make the actions column no wider than its content, set its `width` to `'0%'` and, if you have more than one action icon, wrap the icons in a Mantine `Group` (https://mantine.dev/core/group/) with `wrap="nowrap"`: ```tsx Actions, width: '0%', // 👈 set width to 0% textAlign: 'right', render: (company) => ( // 👇 make sure the actions don't wrap {/* action icons... */} ), }, ]} records={records} /> ``` If your table is horizontally scrollable, you might also want to pin the actions column (https://icflorescu.github.io/mantine-datatable/examples/pinning-the-last-column/) to keep the actions visible at all times. ## Example: Pinning the last column Source: https://icflorescu.github.io/mantine-datatable/examples/pinning-the-last-column/ When using records selection and the table scrolls horizontally, the selection checkbox column is always visible, because it is pinned to the left side of the table. In the same way, when a table has many columns, you can keep its last column visible at all times (for instance, a row actions column) by pinning it to the right side of the table. Set the `pinLastColumn` DataTable prop to `true`: ```tsx return ( ); ``` `pinLastColumn` pins the last **visible** column. If that column is hidden (e.g. by column toggling or a `visibleMediaQuery`), the previous visible column is pinned instead. ### Using with column grouping Warning: when using this feature with column grouping, **make sure the last group contains only one column**. Otherwise only the last column is pinned, while its group header scrolls away with the rest of the table. In the example below, the last group contains only one column, and an absolutely positioned custom title component creates the illusion that the “Actions” title is vertically centered: ```tsx return ( Actions ), render: (employee) => ( showModal({ employee, action: 'view' })} > showModal({ employee, action: 'edit' })} > showModal({ employee, action: 'delete' })} > ), }, ], }, ]} // other table props... /> ); ``` To pin more than one column, or a whole group of columns, set the `pinned` property on individual columns instead (see https://icflorescu.github.io/mantine-datatable/examples/pinning-arbitrary-columns/). ## Example: Pinning the first column Source: https://icflorescu.github.io/mantine-datatable/examples/pinning-the-first-column/ When a table has many columns and scrolls horizontally, you may want its first column to always stay visible (for instance, to keep the record name or the row actions in view). Set the `pinFirstColumn` DataTable prop to `true`: ```tsx return ( ); ``` `pinFirstColumn` pins the first **visible** column. If that column is hidden (e.g. by column toggling or a `visibleMediaQuery`), the next visible column is pinned instead. The feature can be combined with records selection, in which case both the selection checkbox column and the first user-provided column are pinned to the left side of the table: ```tsx return ( ); ``` You can also use records selection and pin both the first and the last column (`pinLastColumn`) at the same time: ```tsx return ( ); ``` ### Using with column grouping Warning: when using this feature with column grouping, **make sure the first group contains only one column**. Otherwise only the first column is pinned, while its group header scrolls away with the rest of the table. In the example below, the first group contains only one column, and an absolutely positioned custom title component creates the illusion that the “Name” title is vertically centered: ```tsx return ( Name ), noWrap: true, render: ({ firstName, lastName }) => `${firstName} ${lastName}`, }, ], }, // other column groups... }, ]} // other table props... /> ); ``` To pin more than one column, or a whole group of columns, set the `pinned` property on individual columns instead. Group headers are pinned too when all of their columns are pinned to the same side. See https://icflorescu.github.io/mantine-datatable/examples/pinning-arbitrary-columns/. ## Example: Pinning arbitrary columns Source: https://icflorescu.github.io/mantine-datatable/examples/pinning-arbitrary-columns/ Besides pinning the first and the last column with the `pinFirstColumn` and `pinLastColumn` DataTable props, you can pin **arbitrary columns** to the left or right side of the table by setting the `pinned` property (`'left' | 'right'`) on individual column definitions. ### Pinning multiple columns to the left Set `pinned: 'left'` on every column you want pinned to the left side of the table: ```tsx export function PinMultipleLeftColumnsExample() { return ( ); } ``` Pinned columns are always rendered at the edges of the table: left-pinned columns first, then the unpinned ones, then the right-pinned ones. Within each area, columns keep their original order, so pinning a column from the middle of the `columns` array moves it to the corresponding side of the table. ### Pinning columns to both sides Left-pinned and right-pinned columns can be combined. Below, the first two columns are pinned to the left and the actions column to the right; this also works with records selection: ```tsx export function PinLeftAndRightColumnsExample() { const [selectedRecords, setSelectedRecords] = useState([]); return ( Row actions, textAlign: 'right', pinned: 'right', render: () => ( ), }, ]} records={records} selectedRecords={selectedRecords} onSelectedRecordsChange={setSelectedRecords} /> ); } ``` ### Interactive pinning Set `pinnable: true` on columns to let users pin and unpin them interactively. A pin icon appears in the column header on hover; clicking it opens a dropdown allowing the user to pin the column to the left or right, or to unpin it. For pinnable columns, `pinned` sets the **initial** pinning state. Interactive pinning relies on the same mechanism as column dragging and toggling: a `storeColumnsKey` DataTable prop is **required**, since the pinning state is kept in local storage under this key. To restore the initial pinning state, call the `useDataTableColumns()` hook with the same `key` and use the `resetColumnsPinning()` function it returns. Pinning also works in right-to-left layouts, where the “left” and “right” sides are mirrored (the example includes a direction switch): ```tsx export function InteractivePinningExample() { const key = 'interactive-pinning-example'; const [direction, setDirection] = useState<'ltr' | 'rtl'>('ltr'); const { effectiveColumns, resetColumnsPinning } = useDataTableColumns({ key, columns: [ { accessor: 'firstName', noWrap: true, pinnable: true, pinned: 'left' }, { accessor: 'lastName', noWrap: true, pinnable: true }, { accessor: 'department.name', title: 'Department', pinnable: true }, { accessor: 'department.company.name', title: 'Company', noWrap: true, pinnable: true }, { accessor: 'department.company.city', title: 'City', noWrap: true, pinnable: true }, { accessor: 'department.company.state', title: 'State', pinnable: true }, { accessor: 'department.company.streetAddress', title: 'Address', noWrap: true, pinnable: true }, { accessor: 'department.company.missionStatement', title: 'Mission statement', noWrap: true, pinnable: true }, ], }); return ( Direction: setDirection(value as 'ltr' | 'rtl')} data={[ { label: 'LTR', value: 'ltr' }, { label: 'RTL', value: 'rtl' }, ]} /> ); } ``` ### Combining pinning with column toggling `pinnable` can be combined with `toggleable` on the same columns, so users can both pin/unpin and show/hide them (right-click the table header to open the column visibility menu). In this example some columns are hidden by default with `defaultToggle: false`; the `resetColumnsToggle` and `resetColumnsPinning` functions reset visibility and pinning independently: ```tsx export function PinningAndTogglingExample() { const key = 'pinning-and-toggling-example'; const { effectiveColumns, resetColumnsPinning, resetColumnsToggle } = useDataTableColumns({ key, columns: [ { accessor: 'firstName', noWrap: true, pinnable: true, toggleable: true, pinned: 'left' }, { accessor: 'lastName', noWrap: true, pinnable: true, toggleable: true }, { accessor: 'email', noWrap: true, pinnable: true, toggleable: true }, { accessor: 'birthDate', title: 'Birth date', pinnable: true, toggleable: true }, { accessor: 'department.name', title: 'Department', pinnable: true, toggleable: true }, { accessor: 'department.company.name', title: 'Company', noWrap: true, pinnable: true, toggleable: true }, { accessor: 'department.company.city', title: 'City', noWrap: true, pinnable: true, toggleable: true }, { accessor: 'department.company.state', title: 'State', pinnable: true, toggleable: true }, { accessor: 'department.company.streetAddress', title: 'Address', noWrap: true, pinnable: true, toggleable: true, }, { accessor: 'department.company.missionStatement', title: 'Mission statement', noWrap: true, width: 300, pinnable: true, toggleable: true, }, ], }); return ( <> ); } ``` ### Pinning with column groups With column groups and interactive pinning, individual columns within groups can be pinned. A group header is pinned only when **all columns in the group** are pinned to the same side; if the columns have different pinning states (some pinned left, some unpinned, or mixed), the group header is not pinned. Since pinned columns are moved to the edges of the table, a column pinned to the side opposite to its group (e.g. one of the “Employee” group columns pinned to the right) is moved away from the rest of its group. In that case the group header is split in two: one part stays above the remaining columns, the other follows the pinned column. ```tsx export function PinningWithColumnGroupsExample() { const key = 'pinning-with-column-groups-example'; const { effectiveColumns, resetColumnsPinning } = useDataTableColumns({ key, columns: [ { accessor: 'firstName', noWrap: true, pinnable: true, pinned: 'left' }, { accessor: 'lastName', noWrap: true, pinnable: true, pinned: 'left' }, { accessor: 'department.name', title: 'Department', pinnable: true }, { accessor: 'department.company.name', title: 'Company', noWrap: true, pinnable: true }, { accessor: 'department.company.city', title: 'City', noWrap: true, pinnable: true }, { accessor: 'department.company.state', title: 'State', pinnable: true }, { accessor: 'department.company.streetAddress', title: 'Address', noWrap: true, pinnable: true }, { accessor: 'department.company.missionStatement', title: 'Mission statement', noWrap: true, pinnable: true }, ], }); // Build groups from effectiveColumns, preserving pinning state const employeeColumns = effectiveColumns.filter((c) => c.accessor === 'firstName' || c.accessor === 'lastName'); const workplaceColumns = effectiveColumns.filter( (c) => c.accessor === 'department.name' || c.accessor === 'department.company.name' || c.accessor === 'department.company.city' || c.accessor === 'department.company.state' || c.accessor === 'department.company.streetAddress' || c.accessor === 'department.company.missionStatement' ); return ( ); } ``` ## Example: RTL support Source: https://icflorescu.github.io/mantine-datatable/examples/rtl-support/ Since **v8.3.9**, Mantine DataTable fully supports right-to-left (RTL) layouts, for languages such as Arabic, Hebrew, Persian and Urdu (contributed by Reem Assaf in https://github.com/icflorescu/mantine-datatable/pull/781/). There is no RTL-specific property to set: all DataTable features automatically adapt to the text direction of the surrounding document. ### Setting up RTL Set up RTL in your application as described in the Mantine RTL documentation (https://mantine.dev/styles/rtl/), which means: - setting the `dir="rtl"` attribute on an ancestor element, usually the root `` element; Mantine DataTable relies on the computed CSS direction for its layout, borders, scroll shadows and pinned columns; - wrapping your application in Mantine’s `DirectionProvider`, which some Mantine components rely on to determine the text direction; Mantine DataTable also uses it to make column resizing handles work in the right direction. The example below sets both locally, on a wrapper element around each table, so you can switch between LTR and RTL with a toggle. It showcases a basic table, column dragging, and a table with pinned columns, row selection, sorting, pagination and column resizing. Column pinning in RTL mode: “left” and “right” should be read as “start” and “end”. `pinFirstColumn` pins the first column to the right edge of the table, `pinLastColumn` pins the last column to the left edge, and the same goes for arbitrary columns pinned with `pinned: 'left'` or `pinned: 'right'`. `RTLSupportExample.tsx`: ```tsx 'use client'; import { DataTable, type DataTableSortStatus, useDataTableColumns } from 'mantine-datatable'; import { ActionIcon, Box, Button, DirectionProvider, Group, SegmentedControl, Stack, Text } from '@mantine/core'; import { IconColumns3, IconEdit, IconEye, IconTrash } from '@tabler/icons-react'; import clsx from 'clsx'; import { useMemo, useState } from 'react'; import { companies, type Employee, employees } from '~/data'; import classes from './RTLSupportExample.module.css'; // Example 1: Basic table without pinned columns export function RTLBasicExample({ direction }: { direction: 'ltr' | 'rtl' }) { return ( ); } // Example 2: With column dragging export function RTLDraggingExample({ direction }: { direction: 'ltr' | 'rtl' }) { const key = 'rtl-dragging-example'; const { effectiveColumns, resetColumnsOrder } = useDataTableColumns({ key, columns: [ { accessor: 'name', width: 200, draggable: true }, { accessor: 'streetAddress', width: 250, draggable: true }, { accessor: 'city', width: 150, draggable: true }, { accessor: 'state', width: 100 }, ], }); return ( ); } // Example 3: With pinned columns, selection, sorting, pagination, and resizing export function RTLFullFeaturedExample({ direction }: { direction: 'ltr' | 'rtl' }) { const PAGE_SIZES = [5, 10, 20]; const [page, setPage] = useState(1); const [recordsPerPage, setRecordsPerPage] = useState(PAGE_SIZES[0]); const [selectedRecords, setSelectedRecords] = useState([]); const [sortStatus, setSortStatus] = useState>({ columnAccessor: 'firstName', direction: 'asc', }); const sortedRecords = useMemo(() => { const sorted = [...employees].sort((a, b) => { const accessor = sortStatus.columnAccessor as keyof Employee; const aValue = a[accessor]; const bValue = b[accessor]; if (typeof aValue === 'string' && typeof bValue === 'string') { return sortStatus.direction === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue); } return 0; }); return sorted; }, [sortStatus]); const paginatedRecords = useMemo(() => { const start = (page - 1) * recordsPerPage; return sortedRecords.slice(start, start + recordsPerPage); }, [sortedRecords, page, recordsPerPage]); return ( ( ), }, ]} records={paginatedRecords} totalRecords={employees.length} recordsPerPage={recordsPerPage} onRecordsPerPageChange={setRecordsPerPage} recordsPerPageOptions={PAGE_SIZES} page={page} onPageChange={setPage} sortStatus={sortStatus} onSortStatusChange={setSortStatus} selectedRecords={selectedRecords} onSelectedRecordsChange={setSelectedRecords} /> ); } // Main example component with direction toggle export function RTLSupportExample() { const [direction, setDirection] = useState<'ltr' | 'rtl'>('ltr'); return ( Direction: setDirection(value as 'ltr' | 'rtl')} data={[ { label: 'LTR (Left to Right)', value: 'ltr' }, { label: 'RTL (Right to Left)', value: 'rtl' }, ]} /> Basic table: Column dragging: Full-featured: pinned columns, selection, sorting, pagination, resizing: ); } ``` `RTLSupportExample.module.css`: ```css .buttonSectionRtl { margin-left: var(--mantine-spacing-xs); margin-right: 0; } ``` ## Example: Links or buttons inside clickable rows or cells Source: https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells/ Click events bubble up the DOM tree, so when the user clicks a link, button or other interactive component placed inside a clickable row (`onRowClick`), clickable cell (`onCellClick`), expandable row, or a row with a context menu triggered by `click` instead of right-click, the row or cell reacts to the click as well. To prevent this, intercept the `click` event on the interactive component and call its `.stopPropagation()` method. The table below implements both expandable rows and a row actions cell. Clicking a row expands it, while clicking an action button only opens a modal dialog, without toggling the row expansion: `LinksOrButtonsInsideClickableRowsOrCellsExample.tsx`: ```tsx return ( Row actions, textAlign: 'right', render: (company) => ( { e.stopPropagation(); showModal({ company, action: 'edit' }); }} > { e.stopPropagation(); showModal({ company, action: 'delete' }); }} > ), }, ]} rowExpansion={{ content: ({ record }) => ( // ... ), }} /> ); ``` The same technique applies to regular links: add an `onClick` handler calling `event.stopPropagation()` to the anchor element (or Mantine `Anchor` component), and the browser will follow the link without triggering the row or cell handlers. ## Example: Disabling text selection Source: https://icflorescu.github.io/mantine-datatable/examples/disabling-text-selection/ Set the `textSelectionDisabled` property (`boolean`) to prevent users from selecting text inside the DataTable. This improves usability whenever clicking on the table is meant to trigger an action rather than select text, for instance if you: - work with records selection; - handle row clicks or cell clicks, especially double-clicks, which would otherwise select the clicked word; - use expandable rows; - use context menus on touch devices; - use sorting. `DisablingTextSelectionExample.tsx`: ```tsx return ( ); ``` ## Example: Asynchronous data loading Source: https://icflorescu.github.io/mantine-datatable/examples/asynchronous-data-loading/ When loading data asynchronously, set the `fetching` property to `true` while the request is in flight. The DataTable indicates the loading state by overlaying a Mantine `Loader` (https://mantine.dev/core/loader/) over its rows: ```tsx return ( ); ``` You can customize the loader appearance with the following properties: - `loaderSize: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | (string & {}) | number` → the loader size; you can also pass a number (in pixels) or any valid CSS size string; - `loaderType: 'oval' | 'bars' | 'dots'` → the loader type; defaults to the loader type set in your Mantine theme; - `loaderColor` → the loader color (https://mantine.dev/theming/colors/); defaults to the theme’s primary color; - `loaderBackgroundBlur: number` → the loader background blur, in pixels; no blur by default. ```tsx return ( ); ``` Keep in mind: if your DataTable is not vertically scrollable and has no initial data, set its `minHeight` to accommodate the loader and minimize the “content jump” when the records arrive. When using pagination, the pagination footer displays the `loadingText` (`...` by default) while fetching and the total number of records is unknown. The empty state indicator is not displayed while fetching. ### Using a custom loader component To use something other than the standard Mantine `Loader` types, pass your own component to the `customLoader` property. In this case you can’t set the `loaderSize`, `loaderType` and `loaderColor` properties (TypeScript will complain if you do), but you can still use `loaderBackgroundBlur`: ```tsx return ( {/* some svg elements... */} } /> ); ``` ## Example: Custom row or cell attributes Source: https://icflorescu.github.io/mantine-datatable/examples/custom-row-or-cell-attributes/ Custom attributes on table rows or cells can be useful for testing, for analytics, or for handling events the DataTable doesn’t expose a dedicated property for. To add custom attributes to table rows, use the `customRowAttributes` DataTable property. It accepts a function receiving the current record and its index as arguments, and returning an object whose properties are applied as attributes to the corresponding row. Similarly, each column accepts a `customCellAttributes` property, which works the same way but applies the returned attributes to the column’s cells: ```tsx return ( ({ 'data-cy-city': city }) }, { accessor: 'state', customCellAttributes: ({ state }) => ({ 'data-cy-state': state }) }, ]} records={records} customRowAttributes={({ id, name }, recordIndex) => ({ 'data-cy-id': id, 'data-cy-name': name, 'data-cy-index': recordIndex, })} /> ); ``` ### Handling middle-clicks Since the returned object can also contain event handlers, you can use `customRowAttributes` to handle middle-clicks on table rows: ```tsx return ( ({ onMouseDown: (e: React.MouseEvent) => { if (e.button === 1) { showNotification({ message: `Middle-click on row ${name}`, withBorder: true }); } }, })} /> ); ``` Be careful what you override: the attributes returned by `customRowAttributes` are applied **after** the ones the DataTable sets on each row, so they take precedence. Returning a `className`, `style`, `onClick` or `onContextMenu` property would replace the default one, breaking features such as row styling, row click handling or row expansion. Use the dedicated DataTable properties for those, and keep `customRowAttributes` for everything else. ## Example: Using bodyRef with AutoAnimate Source: https://icflorescu.github.io/mantine-datatable/examples/using-with-auto-animate/ The `bodyRef` property gives you access to the underlying `` element of the table. You can hand it over to the AutoAnimate library (https://auto-animate.formkit.com/) to smoothly animate rows as they are added, removed or reordered. AutoAnimate tracks rows by their DOM elements, which Mantine DataTable keys by record ID. Make sure each record has a unique, stable ID (see https://icflorescu.github.io/mantine-datatable/examples/non-standard-record-ids/ if your records don’t have an `id` property); otherwise reordered rows are simply re-rendered in place, without animation. Warning: AutoAnimate does not play well with column pinning. Rows won’t animate on a table with pinned columns, because the pinning logic measures column widths synchronously after every render, and these layout reads interfere with AutoAnimate’s observation of the same ``. Heads up: in React strict mode (enabled by default in Next.js development builds), the official `useAutoAnimate()` hook double-invokes its ref callback on mount, which corrupts AutoAnimate’s per-element state and breaks animations after client-side navigation. To work around this, the example calls `autoAnimate(node)` directly from a stable ref callback that runs exactly once per `` element. Production builds are not affected, so the official hook works fine there. `UsingWithAutoAnimateExample.tsx`: ```tsx // ... // React strict mode (Next.js dev) double-invokes ref callbacks on mount, // which corrupts AutoAnimate's per-parent enabled state when using the // official `useAutoAnimate` adapter. We sidestep it by calling `autoAnimate` // directly, exactly once per real tbody mount. Production builds are unaffected. const autoAnimateInitialized = useRef(false); const bodyRef = useCallback((node: HTMLTableSectionElement | null) => { if (node && !autoAnimateInitialized.current) { autoAnimate(node); autoAnimateInitialized.current = true; } }, []); return ( <> ), render: (_, index) => ( moveUserUp(index)} > moveUserDown(index)} > ), }, { accessor: 'name', width: '100%' }, { accessor: 'age', textAlign: 'right' }, { accessor: 'delete', title: ( ), render: ({ id }) => ( deleteUser(id)}> ), }, ]} records={records} bodyRef={bodyRef} /> {/* ... */} ); ``` ## Example: Complex usage scenario Source: https://icflorescu.github.io/mantine-datatable/examples/complex-usage-scenario/ A more realistic example bringing together many Mantine DataTable features in a single table: - asynchronous data loading with TanStack Query (https://tanstack.com/query/latest), combined with server-side sorting and pagination; - custom column definitions with custom cell rendering, and columns hidden on small screens via `visibleMediaQuery`; - multiple records selection; - row expansion showing additional employee details; - a row actions cell in a pinned last column, opening a Mantine modal or showing a notification; - a row context menu whose items depend on the current selection. Since the example uses TanStack Query, everything has to be wrapped in a `QueryClientProvider`: `ComplexUsageExampleWrapper.tsx`: ```tsx 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; export function ComplexUsageExampleWrapper({ children }: React.PropsWithChildren) { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { // With SSR, we usually want to set some default staleTime // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, }, }, }) ); return {children}; } ``` The sort status and the page number are part of the query key, so changing either of them triggers a new request; `placeholderData: keepPreviousData` keeps the current records on screen while the next ones are loading. Changing the sort order resets the table to the first page. The table itself, its styles, and the data helpers it uses: `ComplexUsageExample.tsx`: ```tsx 'use client'; import type { DataTableColumn, DataTableProps, DataTableSortStatus } from 'mantine-datatable'; import { DataTable } from 'mantine-datatable'; import type { MantineTheme } from '@mantine/core'; import { ActionIcon, Button, Center, Flex, Group, Image, rem, Text, TextInput } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; import { IconClick, IconEdit, IconMessage, IconTrash, IconTrashX } from '@tabler/icons-react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { useContextMenu } from 'mantine-contextmenu'; import { useCallback, useState } from 'react'; import type { Employee } from '~/data'; import { getEmployeesAsync } from '~/data/async'; import classes from './ComplexUsageExample.module.css'; const PAGE_SIZE = 100; export function ComplexUsageExample() { const { showContextMenu, hideContextMenu } = useContextMenu(); const [page, setPage] = useState(1); const [sortStatus, setSortStatus] = useState>({ columnAccessor: 'name', direction: 'asc', }); const { data, isFetching } = useQuery({ queryKey: ['employees', sortStatus.columnAccessor, sortStatus.direction, page], queryFn: () => getEmployeesAsync({ recordsPerPage: PAGE_SIZE, page, sortStatus, delay: { min: 300, max: 500 } }), placeholderData: keepPreviousData, // 👈 keep showing the current page while the next one is loading }); const [selectedRecords, setSelectedRecords] = useState([]); const handleSortStatusChange = (status: DataTableSortStatus) => { setPage(1); setSortStatus(status); }; const editRecord = useCallback(({ firstName, lastName }: Employee) => { showNotification({ withBorder: true, title: 'Editing record', message: `In a real application we could show a popup to edit ${firstName} ${lastName}, but this is just a demo, so we're not going to do that`, }); }, []); const deleteRecord = useCallback(({ firstName, lastName }: Employee) => { showNotification({ withBorder: true, color: 'red', title: 'Deleting record', message: `Should delete ${firstName} ${lastName}, but we're not going to, because this is just a demo`, }); }, []); const deleteSelectedRecords = useCallback(() => { showNotification({ withBorder: true, color: 'red', title: 'Deleting multiple records', message: `Should delete ${selectedRecords.length} records, but we're not going to do that because deleting data is bad... and this is just a demo anyway`, }); }, [selectedRecords.length]); const sendMessage = useCallback(({ firstName, lastName }: Employee) => { showNotification({ withBorder: true, title: 'Sending message', message: `A real application could send a message to ${firstName} ${lastName}, but this is just a demo and we're not going to do that because we don't have a backend`, color: 'green', }); }, []); const renderActions: DataTableColumn['render'] = (record) => ( { e.stopPropagation(); // 👈 prevent triggering the row click function openModal({ title: `Send message to ${record.firstName} ${record.lastName}`, classNames: { header: classes.modalHeader, title: classes.modalTitle }, children: ( <> ), }); }} > { e.stopPropagation(); // 👈 prevent triggering the row click function editRecord(record); }} > ); const rowExpansion: DataTableProps['rowExpansion'] = { allowMultiple: true, content: ({ record: { id, sex, firstName, lastName, birthDate, department } }) => ( {`${firstName} {firstName} {lastName}, born on {dayjs(birthDate).format('MMM D YYYY')}, works in {department.name} department at {department.company.name}.
{sex === 'male' ? 'His' : 'Her'} office address is {department.company.streetAddress},{' '} {department.company.city}, {department.company.state}.
), }; const handleContextMenu: DataTableProps['onRowContextMenu'] = ({ record, event }) => showContextMenu([ { key: 'edit', icon: , title: `Edit ${record.firstName} ${record.lastName}`, onClick: () => editRecord(record), }, { key: 'delete', title: `Delete ${record.firstName} ${record.lastName}`, icon: , color: 'red', onClick: () => deleteRecord(record), }, { key: 'divider' }, { key: 'deleteMany', hidden: selectedRecords.length <= 1 || !selectedRecords.map((r) => r.id).includes(record.id), title: `Delete ${selectedRecords.length} selected records`, icon: , color: 'red', onClick: deleteSelectedRecords, }, ])(event); const now = dayjs(); const aboveXs = (theme: MantineTheme) => `(min-width: ${theme.breakpoints.xs})`; const columns: DataTableProps['columns'] = [ { accessor: 'name', noWrap: true, sortable: true, render: ({ firstName, lastName }) => `${firstName} ${lastName}`, }, { accessor: 'email', sortable: true, }, { accessor: 'department.company.name', title: 'Company', noWrap: true, sortable: true, visibleMediaQuery: aboveXs, }, { accessor: 'department.name', title: 'Department', sortable: true, visibleMediaQuery: aboveXs, }, { accessor: 'department.company.city', title: 'City', noWrap: true, visibleMediaQuery: aboveXs, }, { accessor: 'department.company.state', title: 'State', visibleMediaQuery: aboveXs, }, { accessor: 'age', width: 80, textAlign: 'right', sortable: true, render: ({ birthDate }) => now.diff(birthDate, 'years'), visibleMediaQuery: aboveXs, }, { accessor: 'actions', title: (
), width: '0%', // 👈 use minimal width render: renderActions, }, ]; return ( ); } ``` `ComplexUsageExample.module.css`: ```css .modalHeader { border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); } .modalTitle { color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-2)); font-weight: 700; } ``` `data/async.ts`: ```ts import type { DataTableSortStatus } from 'mantine-datatable'; import dayjs, { type Dayjs } from 'dayjs'; import { get, sortBy } from 'lodash'; import { type DelayOptions, delay } from '~/lib/examples'; import { companies, departments, type Employee, employees } from '.'; export async function getCompaniesAsync({ count, delay: delayOptions = { min: 1000, max: 2000 }, }: { count: number; delay?: DelayOptions; }) { await delay(delayOptions); return companies.slice(0, count); } export async function getEmployeesAsync({ page, recordsPerPage, sortStatus: { columnAccessor: sortAccessor, direction: sortDirection }, delay: delayOptions = { min: 1000, max: 2000 }, }: { page: number; recordsPerPage: number; sortStatus: DataTableSortStatus; delay?: DelayOptions; }) { await delay(delayOptions); let now: Dayjs; if (sortAccessor === 'age') now = dayjs(); let result = sortBy(employees, (employee) => sortAccessor === 'name' ? `${employee.firstName} ${employee.lastName}` : sortAccessor === 'age' ? now.diff(employee.birthDate) : get(employee, sortAccessor) ); if (sortDirection === 'desc') result.reverse(); const total = result.length; const skip = (page - 1) * recordsPerPage; result = result.slice(skip, skip + recordsPerPage); return { total, employees: result as Employee[] }; } export async function countCompanyDepartmentsAsync({ companyId, delay: delayOptions = { min: 1000, max: 2000 }, }: { companyId: string; delay: DelayOptions; }) { await delay(delayOptions); return departments.filter((department) => department.company.id === companyId).length; } export async function countCompanyEmployeesAsync({ companyId, delay: delayOptions = { min: 1000, max: 2000 }, }: { companyId: string; delay: DelayOptions; }) { await delay(delayOptions); return employees.filter((employee) => employee.department.company.id === companyId).length; } ``` (`data/index.ts` is the same as in the "Example: Column properties and styling" section above.) ## Type definitions Source: https://icflorescu.github.io/mantine-datatable/type-definitions/ Mantine DataTable is written in TypeScript and its properties are documented with JSDoc annotations. ### Using the types in your code `DataTable` is a generic component that infers the type of your records from the `records` property. When you declare columns inline, render functions and event handlers are typed automatically, and accessors are autocompleted with your record keys (any string is still accepted, to allow dot-notation paths and virtual columns, so accessor typos aren’t caught by the compiler). All the types listed below are exported by the package, so you can also use them explicitly; for instance, when declaring column definitions outside of the component, or when typing the sorting state (`DataTableSortStatus`): `TypedColumnsExample.tsx`: ```tsx 'use client'; import { DataTable, type DataTableColumn } from 'mantine-datatable'; import { type Employee, employees } from '~/data'; // 👇 Column definitions declared outside the component are typed explicitly, // so that render functions are checked against the Employee type and accessors are autocompleted const columns: DataTableColumn[] = [ { accessor: 'name', render: ({ firstName, lastName }) => `${firstName} ${lastName}`, }, { accessor: 'email' }, // 👇 Dot-notation accessors for nested properties work as well { accessor: 'department.name', title: 'Department' }, ]; export function TypedColumnsExample() { return ; } ``` ### Type files `DataTableCellClickHandler.ts`: ```ts import type { DataTableColumn } from './DataTableColumn'; export type DataTableCellClickHandler> = (params: { /** * Click event. */ event: React.MouseEvent; /** * Clicked record. */ record: T; /** * Clicked record index. */ index: number; /** * Clicked column information. */ column: DataTableColumn; /** * Clicked column index. */ columnIndex: number; }) => void; ``` `DataTableColorProps.ts`: ```ts import type { MantineColor } from '@mantine/core'; export type DataTableColorProps = { /** * Data table text color. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. */ c?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Data table background color. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. */ backgroundColor?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Color of table borders, applied to all borders except row borders. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. */ borderColor?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Color of row borders. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. */ rowBorderColor?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Background color of striped rows. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. */ stripedColor?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Background color of hover-highlighted row. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. */ highlightOnHoverColor?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Background color of selected rows. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. * Defaults to the light variant of the theme's primary color. */ selectionColor?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Data table row text color. * A function that accepts row data and returns color. * The returned color can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. */ rowColor?: (record: T, index: number) => MantineColor | undefined | { light: MantineColor; dark: MantineColor }; /** * Data table row background color. * A function that accepts row data and returns a background color. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. */ rowBackgroundColor?: ( record: T, index: number ) => MantineColor | undefined | { light: MantineColor; dark: MantineColor }; }; ``` `DataTableColumn.ts`: ```ts import type { MantineStyleProp, MantineTheme, PopoverProps } from '@mantine/core'; import type { DataTableColumnTextAlign } from './DataTableColumnTextAlign'; export type DataTableColumn> = { /** * Column accessor. * You can use dot-notation for nested objects property drilling. * (i.e. `department.name` or `department.company.name`) */ accessor: keyof T | (string & NonNullable); /** * Optional column header title. * If not present, one will be generated by "humanizing" the provided column accessor. * (i.e. `firstName` -> `First name`; `user.firstName` -> `User first name`) */ title?: React.ReactNode; /** * Custom cell data render function. * Accepts the current record and its index in `records` as arguments and returns a React node * (remember that a string is a valid React node too). */ render?: (record: T, index: number) => React.ReactNode; /** * Column text alignment. * @default `left` */ textAlign?: DataTableColumnTextAlign; /** * If true, column will be sortable. */ sortable?: boolean; /** * Optional key passed to `onSortStatusChange` as `sortKey`, useful when the value * to sort by differs from the column accessor (e.g. a database field name). */ sortKey?: string; /** * If set to true, the column can be dragged. */ draggable?: boolean; /** * If set to true, the column can be toggled. */ toggleable?: boolean; /** * If set to true, the column can be resized. */ resizable?: boolean; /** * If set, the column will be pinned to the specified side of the table. * If `pinnable` is false or undefined, this is a static pin (not changeable by user). * If `pinnable` is true, this is the initial pinning state (user can change it). */ pinned?: 'left' | 'right'; /** * If set to true, the column can be pinned/unpinned by the user via UI. * When true, `pinned` acts as the initial pinning state. */ pinnable?: boolean; /** * If set to true, the column will be toggled by default. */ defaultToggle?: boolean; /** * Optional node providing the user with filtering options. * If present, a filter button will be added to the column's header. Upon clicking that button, * a pop-over showing the provided node will be opened. * * Alternatively, a function returning a node can be provided. The function receives props with a `close` * method which allows programmatically closing the pop-over. * * ```tsx * // … * columns={[ * { * accessor: 'name', * filter: ({ close }) => { * return * * * }, * } * ]} * // … * ``` * * Note: this property only takes care of rendering the node which provides the filtering options. * It is assumed that the actual filtering is performed somewhere in user code. */ filter?: React.ReactNode | ((params: { close: () => void }) => React.ReactNode); /** * Filter popover props; override if necessary. */ filterPopoverProps?: PopoverProps; /** * Disables the use of the Mantine useClickOutside hook inside the filter popover */ filterPopoverDisableClickOutside?: boolean; /** * If true, filter icon will be styled differently to indicate the filter is in effect. */ filtering?: boolean; /** * Desired column width. */ width?: string | number; /** * If true, column will not be visible. */ hidden?: boolean; /** * If true, tbody cells will not be visible. */ hiddenContent?: boolean; /** * If set, the column will only be visible according to the specified media query. * Can be a string, or a function receiving the current theme and returning a string. */ visibleMediaQuery?: string | ((theme: MantineTheme) => string); /** * Optional class name passed to the column title */ titleClassName?: string; /** * Optional style passed to the column title. * Either a style object, or a function receiving the current theme and returning a style object. */ titleStyle?: MantineStyleProp; /** * Optional class name passed to each data cell in the column. * Can be a string, or a function receiving the current record and its index * as arguments and returning a string. */ cellsClassName?: string | ((record: T, index: number) => string | undefined); /** * Optional style passed to each data cell in the column. * A function that receives the current record and its index as arguments and returns either * a style object, or a function that accepts theme and returns a style object. */ cellsStyle?: (record: T, index: number) => MantineStyleProp | undefined; /** * Optional function returning an object of custom attributes to be applied to each cell in the column. * Receives the current record and its index as arguments. * Useful for adding data attributes, handling middle-clicks, etc. */ customCellAttributes?: (record: T, index: number) => Record; /** * Optional column footer content. * If at least one column has a footer, the table will display a footer row. */ footer?: React.ReactNode; /** * Optional class name passed to the column footer. */ footerClassName?: string; /** * Optional style passed to the column footer. */ footerStyle?: MantineStyleProp; } & ( | { /** * If true, cell content in this column will be truncated with ellipsis as needed and will not wrap * to multiple lines (i.e. `overflow: hidden; text-overflow: ellipsis`; `white-space: nowrap`). * On a column, you can either set this property or `noWrap`, but not both. */ ellipsis?: boolean; noWrap?: never; } | { ellipsis?: never; /** * If true, cell content in this column will not wrap to multiple lines (i.e. `white-space: nowrap`). * This is useful for columns containing long strings. * On a column, you can either set this property or `ellipsis`, but not both. */ noWrap?: boolean; } ); ``` `DataTableColumnGroup.ts`: ```ts import type { MantineStyleProp } from '@mantine/core'; import type { DataTableColumn } from './DataTableColumn'; import type { DataTableColumnTextAlign } from './DataTableColumnTextAlign'; export type DataTableColumnGroup> = { /** * Used as the `key` prop for the created ``. */ id: string; /** * Component to render inside the column group header. */ title?: React.ReactNode; /** * Text alignment of the column group header. * @default `left` */ textAlign?: DataTableColumnTextAlign; /** * Columns which are part of the group. * Optional when groups are provided for multilevel grouping. */ columns?: DataTableColumn[]; /** * Nested column groups for multilevel grouping. * When provided, columns should be omitted. */ groups?: DataTableColumnGroup[]; /** * Optional className to apply to the column group header. */ className?: string; /** * Optional style to apply to the column group header. * Can be a style object or a function which receives the current theme and * returns a style object. */ style?: MantineStyleProp; }; ``` `DataTableColumnProps.ts`: ```ts import type { DataTableColumn } from './DataTableColumn'; import type { DataTableColumnGroup } from './DataTableColumnGroup'; export type DataTableColumnProps> = | { /** * Grouped columns. */ groups: DataTableColumnGroup[]; columns?: never; } | { groups?: never; /** * Visible columns. */ columns: DataTableColumn[]; }; ``` `DataTableColumnTextAlign.ts`: ```ts export type DataTableColumnTextAlign = 'left' | 'center' | 'right'; ``` `DataTableDefaultColumnProps.ts`: ```ts import type { DataTableColumn } from './DataTableColumn'; export type DataTableDefaultColumnProps> = Omit< DataTableColumn, 'accessor' | 'hidden' | 'visibleMediaQuery' | 'render' >; ``` `DataTableDraggableRowProps.ts`: ```ts import type { TableTrProps } from '@mantine/core'; export type DataTableDraggableRowProps = { /** * Optional class name. */ className?: string; /** * Current dragging status. */ isDragging?: boolean; } & TableTrProps; ``` `DataTableEmptyStateProps.ts`: ```ts export type DataTableEmptyStateProps = | { /** * Content to show when no records are available. * The provided content will be overlaid and centered automatically. * You can either provide this property or `noRecordsIcon`, but not both. */ emptyState?: React.ReactNode; noRecordsIcon?: never; } | { emptyState?: never; /** * Icon to show when no records are available. * The provided icon will be overlaid and centered automatically. * You can either provide this property or `emptyState`, but not both. */ noRecordsIcon?: React.ReactNode; }; ``` `DataTableLoaderProps.ts`: ```ts import type { MantineColor, MantineLoader, MantineSize } from '@mantine/core'; export type DataTableLoaderProps = { /** * Loader background blur (in pixels). */ loaderBackgroundBlur?: number; } & ( | { loaderSize?: never; loaderType?: never; loaderColor?: never; /** * Custom loader component to use instead of default one. */ customLoader?: React.ReactNode; } | { /** * Loader size. * @default `md` (Mantine Loader default) */ loaderSize?: MantineSize | (string & NonNullable) | number; /** * Loader type. */ loaderType?: MantineLoader; /** * Loader color. */ loaderColor?: MantineColor; customLoader?: never; } ); ``` `DataTableOuterBorderProps.ts`: ```ts import type { MantineSize } from '@mantine/core'; export type DataTableOuterBorderProps = | { withTableBorder?: never; borderRadius?: never; } | { /** * If true, table will have border. */ withTableBorder: boolean; /** * Table border radius. */ borderRadius?: MantineSize | (string & NonNullable) | number; }; ``` `DataTablePageSizeSelectorProps.ts`: ```ts export type DataTablePageSizeSelectorProps = | { onRecordsPerPageChange?: never; recordsPerPageOptions?: never; recordsPerPageLabel?: never; } | { /** * Callback fired a new page size is selected. * Receives new page size as argument. */ onRecordsPerPageChange: (recordsPerPage: number) => void; /** * Array of page sizes (numbers) to show in records per page selector. */ recordsPerPageOptions: number[]; /** * Label for records per page selector. */ recordsPerPageLabel?: string; }; ``` `DataTablePaginationProps.ts`: ```ts import type { MantineColor, MantineSize } from '@mantine/core'; import type { DataTablePageSizeSelectorProps } from './DataTablePageSizeSelectorProps'; import type { DataTablePaginationRenderContext } from './DataTablePaginationRenderContext'; export type DataTablePaginationProps = ( | { paginationWithEdges?: never; paginationWithControls?: never; page?: never; onPageChange?: never; totalRecords?: never; recordsPerPage?: never; paginationActiveTextColor?: never; paginationActiveBackgroundColor?: never; paginationSize?: never; loadingText?: never; paginationText?: never; paginationWrapBreakpoint?: never; getPaginationControlProps?: never; getPaginationItemProps?: never; renderPagination?: never; } | { /** * Whether to show first and last page navigation buttons. */ paginationWithEdges?: boolean; /** * Whether to show next and previous page navigation buttons. */ paginationWithControls?: boolean; /** * Current page number (1-based). * If provided, a pagination component is shown. */ page: number; /** * Callback fired after page change. * Receives the new page number as argument. */ onPageChange: (page: number) => void; /** * Total number of records in the dataset. */ totalRecords: number | undefined; /** * Number of records per page. */ recordsPerPage: number; /** * Pagination component size. * @default `sm` */ paginationSize?: MantineSize; /** * Color applied to active page button text. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. * Defaults to white. */ paginationActiveTextColor?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Color applied to active page button background. * Can be a `MantineColor` (key of `theme.colors` or any valid CSS color string), * or an object with `light` and `dark` keys and `MantineColor` values. * Defaults to primary theme color. */ paginationActiveBackgroundColor?: MantineColor | { light: MantineColor; dark: MantineColor }; /** * Text to show while records are loading. */ loadingText?: string; /** * Pagination text. Defaults to ```({ from, to, totalRecords }) => `${from}-${to}/${totalRecords}` * ``` */ paginationText?: (params: { from: number; to: number; totalRecords: number }) => React.ReactNode; /** * Pagination wrap breakpoints. * Below this breakpoint the content will be displayed on multiple lines, * above it the content will be displayed on a single line. * @default `sm` */ paginationWrapBreakpoint?: MantineSize | (string & NonNullable) | number; /** * Function that returns props object for pagination control. * Useful for improving accessibility. */ getPaginationControlProps?: (control: 'first' | 'last' | 'previous' | 'next') => Record; /** * Function that returns props object for pagination item (page number button). */ getPaginationItemProps?: (page: number) => Record; /** * Optional render function to replace the entire pagination component. */ renderPagination?: (ctx: DataTablePaginationRenderContext) => React.ReactNode; } ) & DataTablePageSizeSelectorProps; ``` `DataTablePaginationRenderContext.tsx`: ```tsx import type { MantineSize, PaginationProps, TextProps } from '@mantine/core'; import type { JSX } from 'react'; import type { DataTablePageSizeSelectorProps } from './DataTablePageSizeSelectorProps'; export type DataTablePaginationRenderContext = { state: { paginationSize: MantineSize; page: number; totalPages: number; totalRecords: number | undefined; recordsPerPage: number | undefined; recordsLength: number | undefined; fetching: boolean | undefined; from?: number; to?: number; isWrapped: boolean; }; actions: { setPage: (page: number) => void; setRecordsPerPage?: (n: number) => void; }; Controls: { Text: (props?: Partial) => JSX.Element; PageSizeSelector: (props?: Partial) => JSX.Element | null; Pagination: (props?: Partial) => JSX.Element; }; }; ``` `DataTableProps.ts`: ```ts import type { MantineShadow, MantineStyleProp, ScrollAreaProps, StylesRecord, TableProps, TableTrProps, } from '@mantine/core'; import type { DataTableCellClickHandler } from './DataTableCellClickHandler'; import type { DataTableColorProps } from './DataTableColorProps'; import type { DataTableColumnProps } from './DataTableColumnProps'; import type { DataTableDefaultColumnProps } from './DataTableDefaultColumnProps'; import type { DataTableEmptyStateProps } from './DataTableEmptyStateProps'; import type { DataTableLoaderProps } from './DataTableLoaderProps'; import type { DataTableOuterBorderProps } from './DataTableOuterBorderProps'; import type { DataTablePaginationProps } from './DataTablePaginationProps'; import type { DataTableRowClickHandler } from './DataTableRowClickHandler'; import type { DataTableRowExpansionProps } from './DataTableRowExpansionProps'; import type { DataTableScrollProps } from './DataTableScrollProps'; import type { DataTableSelectionProps } from './DataTableSelectionProps'; import type { DataTableSortProps } from './DataTableSortProps'; import type { DataTableVerticalAlign } from './DataTableVerticalAlign'; export type DataTableProps> = { /** * Data table container class name. */ className?: string; /** * Data table container style. * Either a style object or a function that accepts current theme and returns a style object. */ style?: MantineStyleProp; /** * Data table elements class names. * An object with `root`, `table`, `header`, `footer` and `pagination` keys and class names * as values. */ classNames?: Partial>; /** * Data table elements styles. * An object with `root`, `table`, `header`, `footer` and `pagination` keys and * either style objects, or functions that accept current theme and return style objects, as values. */ styles?: StylesRecord<'root' | 'table' | 'header' | 'footer' | 'pagination', MantineStyleProp>; /** * Table height. * @default '100%' */ height?: string | number; /** * Minimum table height. */ minHeight?: string | number; /** * Maximum table height. */ maxHeight?: string | number; /** * DataTable component shadow. */ shadow?: MantineShadow; /** * If true, the user will not be able to select text. */ textSelectionDisabled?: boolean; /** * Vertical alignment for row cells. * @default `center` */ verticalAlign?: DataTableVerticalAlign; /** * If true, will show a loader with semi-transparent background, centered over the table. */ fetching?: boolean; /** * If true, the first visible column will be pinned to the left side of the table (to the right side in RTL). */ pinFirstColumn?: boolean; /** * If true, the last visible column will be pinned to the right side of the table (to the left side in RTL). */ pinLastColumn?: boolean; /** * Default column props; will be merged with column props provided to each column */ defaultColumnProps?: DataTableDefaultColumnProps; /** * Unique key used to persist the columns state (order, visibility, widths and pinning) in localStorage. * Required if you want users' column dragging, toggling, resizing or pinning to survive page reloads. */ storeColumnsKey?: string | undefined; /** * A default render function for all columns. * Accepts the current record, its index in `records` and the column `accessor` as * arguments and returns a React node (remember that a string is a valid React node too). */ defaultColumnRender?: ( record: T, index: number, accessor: keyof T | (string & NonNullable) ) => React.ReactNode; /** * Accessor to use as unique record key. * Can be a string representing a property name or a function receiving the current record * and returning a unique value. * If you're providing a string, you can use dot-notation for nested objects property drilling * (i.e. `department.name` or `department.company.name`). * @default `id` */ idAccessor?: (keyof T | (string & NonNullable)) | ((record: T) => React.Key); /** * Visible records. * The component will try to infer its row type from here. */ records?: T[]; /** * Text to show on empty state and pagination footer when no records are available. */ noRecordsText?: string; /** * If true, the table will not show the header with column titles. */ noHeader?: boolean; /** * Function to call when a row cell is clicked. * Receives an object with the current record, its index in `records`, the current column, * its index in `columns` and the click event as properties. */ onCellClick?: DataTableCellClickHandler; /** * Function to call when a row cell is double-clicked. * Receives an object with the current record, its index in `records`, the current column, * its index in `columns` and the click event as properties. */ onCellDoubleClick?: DataTableCellClickHandler; /** * Function to call when the user right-clicks on a row cell. * Receives an object with the current record, its index in `records`, the current column, * its index in `columns` and the click event as properties. */ onCellContextMenu?: DataTableCellClickHandler; /** * Function to call when a row is clicked. * Receives an object with the current record, its index in `records` and the click event * as properties. */ onRowClick?: DataTableRowClickHandler; /** * Function to call when a row is double-clicked. * Receives an object with the current record, its index in `records` and the click event * as properties. */ onRowDoubleClick?: DataTableRowClickHandler; /** * Function to call when the user right-clicks on a row. * Receives an object with the current record, its index in `records` and the click event * as properties. */ onRowContextMenu?: DataTableRowClickHandler; /** * Defines the row expansion behavior. */ rowExpansion?: DataTableRowExpansionProps; /** * Optional class name passed to each row. * Can be a string or a function receiving the current record and its index as arguments and returning a string. */ rowClassName?: string | ((record: T, index: number) => string | undefined); /** * Optional style passed to each row. * A function receiving the current record and its index as arguments and returning either * a style object, or a function that accepts theme and returns a style object. */ rowStyle?: (record: T, index: number) => MantineStyleProp | undefined; /** * Optional function rendering a custom row. * Receives the current record, its index, the default row props, the row cells (`children`) * and the expanded element as arguments, and returns a React node representing the row. */ rowFactory?: (props: { record: T; index: number; children: React.ReactNode; rowProps: TableTrProps; expandedElement?: React.ReactNode; }) => React.ReactNode; /** * Optional function returning a React node representing the table wrapper. * If not provided, no wrapper will be used. * * For example, it can be used together with `rowFactory` to provide a drag-and-drop context. */ tableWrapper?: ({ children }: { children: React.ReactNode }) => React.ReactNode; /** * Optional function returning an object of custom attributes to be applied to each row in the table. * Receives the current record and its index as arguments. * Useful for adding data attributes, handling middle-clicks, etc. */ customRowAttributes?: (record: T, index: number) => Record; /** * Ref pointing to the scrollable viewport element. * Useful for imperative scrolling. */ scrollViewportRef?: React.RefObject; /** * Additional props passed to the underlying `ScrollArea` element. */ scrollAreaProps?: Omit; /** * Ref pointing to the table element. */ tableRef?: ((instance: HTMLTableElement | null) => void) | React.RefObject; /** * Ref pointing to the table body element. */ bodyRef?: ((instance: HTMLTableSectionElement | null) => void) | React.RefObject; } & Omit< TableProps, | 'onScroll' | 'className' | 'classNames' | 'style' | 'styles' | 'p' | 'px' | 'py' | 'pt' | 'pb' | 'layout' | 'captionSide' | 'c' | 'color' | 'borderColor' | 'stripedColor' | 'highlightOnHoverColor' | 'stickyHeader' | 'stickyHeaderOffset' | 'onDragEnd' > & DataTableColorProps & DataTableColumnProps & DataTableOuterBorderProps & DataTableLoaderProps & DataTableEmptyStateProps & DataTablePaginationProps & DataTableSortProps & DataTableScrollProps & DataTableSelectionProps; ``` `DataTableRowClickHandler.ts`: ```ts export type DataTableRowClickHandler> = (params: { /** * Click event. */ event: React.MouseEvent; /** * Clicked record. */ record: T; /** * Clicked record index. */ index: number; }) => void; ``` `DataTableRowExpansionCollapseProps.ts`: ```ts import type { CollapseProps } from '@mantine/core'; export type DataTableRowExpansionCollapseProps = Pick< CollapseProps, 'animateOpacity' | 'transitionDuration' | 'transitionTimingFunction' >; ``` `DataTableRowExpansionProps.ts`: ```ts import type { DataTableRowExpansionCollapseProps } from './DataTableRowExpansionCollapseProps'; export type DataTableRowExpansionProps> = { /** * Function defining which records can be expanded. * Accepts an object with `record` and `index` properties and returns a boolean specifying * whether the row should be expandable. */ expandable?: (params: { record: T; index: number }) => boolean; /** * Defines when rows should expand. * @default `click` */ trigger?: 'click' | 'always' | 'never'; /** * If true, multiple rows can be expanded at the same time. */ allowMultiple?: boolean; /** * Function defining which records will be initially expanded. * Accepts an object with `record` and `index` properties and returns a boolean specifying * whether the row should be expanded initially. * Does nothing if `trigger === 'always'`. */ initiallyExpanded?: (options: { record: T; index: number }) => boolean; /** * Additional properties passed to the Mantine Collapse component wrapping the custom content. */ collapseProps?: DataTableRowExpansionCollapseProps; /** * An object defining the row expansion behavior in controlled mode. */ expanded?: { /** * Currently expanded record IDs. */ recordIds: unknown[]; /** * Callback fired when expanded records change. * Receives an array containing the newly expanded record IDs. */ // biome-ignore lint/suspicious/noExplicitAny: Any is fine here onRecordIdsChange?: React.Dispatch> | ((recordIds: unknown[]) => void); }; /** * Function returning the custom content to be lazily rendered for an expanded row. * Accepts an object with properties containing the current record, its index, * and a `collapse()` callback that can be used to collapse the expanded row. * Must return a React node. */ content: (params: { record: T; index: number; collapse: () => void }) => React.ReactNode; }; ``` `DataTableScrollProps.ts`: ```ts export type DataTableScrollProps = { /** * Function to call when the DataTable is scrolled. */ onScroll?: (position: { x: number; y: number }) => void; /** * Function to call when the DataTable is scrolled to top. */ onScrollToTop?: () => void; /** * Function to call when the DataTable is scrolled to bottom. */ onScrollToBottom?: () => void; /** * Function to call when the DataTable is scrolled to left. */ onScrollToLeft?: () => void; /** * Function to call when the DataTable is scrolled to right. */ onScrollToRight?: () => void; }; ``` `DataTableSelectionProps.ts`: ```ts import type { CheckboxProps, MantineStyleProp } from '@mantine/core'; import type { DataTableSelectionTrigger } from './DataTableSelectionTrigger'; export type DataTableSelectionProps> = | { selectionTrigger?: never; selectedRecords?: never; onSelectedRecordsChange?: never; isRecordSelectable?: never; selectionCheckboxProps?: never; getRecordSelectionCheckboxProps?: never; allRecordsSelectionCheckboxProps?: never; selectionColumnClassName?: never; selectionColumnStyle?: never; } | { /** * Defines how selection is triggered. * @default 'checkbox' */ selectionTrigger?: DataTableSelectionTrigger; /** * Currently-selected records. */ selectedRecords?: T[]; /** * Callback fired when selected records change. * Receives an array of selected records as argument. */ onSelectedRecordsChange?: (selectedRecords: T[]) => void; /** * Optional class name applied to selection column. */ selectionColumnClassName?: string; /** * Optional style applied to selection column. */ selectionColumnStyle?: MantineStyleProp; /** * A function used to determine whether a certain record is selectable. * if the function returns false, the row selection checkbox is disabled. * Accepts the current record and index as arguments and returns a boolean. */ isRecordSelectable?: (record: T, index: number) => boolean; /** * Props for the selection checkboxes, applied to header and all rows. */ selectionCheckboxProps?: CheckboxProps; /** * A function used to determine additional props of the row selection checkboxes. * Accepts the current record and its index as arguments and returns an object. */ getRecordSelectionCheckboxProps?: (record: T, index: number) => CheckboxProps; /** * Additional props for the header checkbox that toggles selection of all records. */ allRecordsSelectionCheckboxProps?: CheckboxProps; }; ``` `DataTableSelectionTrigger.ts`: ```ts export type DataTableSelectionTrigger = 'cell' | 'checkbox'; ``` `DataTableSortProps.ts`: ```ts import type { DataTableSortStatus } from './DataTableSortStatus'; export type DataTableSortProps> = ( | { sortStatus?: never; onSortStatusChange?: never; } | { /** * Current sort status (sort column accessor & direction). */ sortStatus: DataTableSortStatus; /** * Callback fired after change of sort status. * Receives the new sort status as argument. */ onSortStatusChange?: (sortStatus: DataTableSortStatus) => void; } ) & { /** * Custom sort icons. */ sortIcons?: { /** * Icon to display when column is sorted ascending. * Will be flipped vertically for descending sort */ sorted: React.ReactNode; /** * Icon to display when column is not sorted. */ unsorted: React.ReactNode; }; }; ``` `DataTableSortStatus.ts`: ```ts export type DataTableSortStatus> = { /** * Sort column key for nested values. * @type {string} */ sortKey?: string; /** * Sort column accessor. * You can use dot-notation for nested objects property drilling * (i.e. `department.name` or `department.company.name`). */ columnAccessor: keyof T | (string & NonNullable); /** * Sort direction - `asc` for ascending, `desc` for descending. */ direction: 'asc' | 'desc'; }; ``` `DataTableVerticalAlign.ts`: ```ts export type DataTableVerticalAlign = 'top' | 'center' | 'bottom'; ``` `utils.ts`: ```ts export type WithOptionalProperty = Pick, K> & Omit; export type WithRequiredProperty = Type & { [Property in Key]-?: Type[Property]; }; ``` ## Mantine V6 support Source: https://icflorescu.github.io/mantine-datatable/mantine-v6-support/ Mantine DataTable V7+ requires Mantine V7 or newer, since Mantine V7 migrated from CSS-in-JS (Emotion) to native CSS. Since then, the major version of Mantine DataTable has followed the major version of Mantine it supports. If you’re still using Mantine V6, use Mantine DataTable V6 instead (documentation: https://icflorescu.github.io/mantine-datatable-v6).