Skip to contents
This page is an adaptation of the related MUI Material UI documentation page.

Table

Tables display sets of data. They can be fully customized.

Tables display information in a way that’s easy to scan, so that users can look for patterns and insights. They can be embedded in primary content, such as cards. They can include:

  • A corresponding visualization
  • Navigation
  • Tools to query and manipulate data

Introduction

Tables are implemented using a collection of related components:

In R, the rows usually come from a data frame and are generated with lapply(). TableContainer(component = ...) expects a component, so Paper is passed as a JavaScript reference with JS():

desserts <- data.frame(
  name = c("Frozen yoghurt", "Ice cream sandwich", "Eclair", "Cupcake", "Gingerbread"),
  calories = c(159, 237, 262, 305, 356),
  fat = c(6.0, 9.0, 16.0, 3.7, 16.0),
  carbs = c(24, 37, 24, 67, 49),
  protein = c(4.0, 4.3, 6.0, 4.3, 3.9)
)
headers <- c("Dessert (100g serving)", "Calories", "Fat (g)", "Carbs (g)", "Protein (g)")
PaperComponent <- JS("jsmodule['@mui/material'].Paper")

# One TableRow per data frame row: the first column is the row header
dessertRows <- function(data, cell = TableCell, row = TableRow) {
  lapply(seq_len(nrow(data)), function(i) {
    row(
      sx = list("&:last-child td, &:last-child th" = list(border = 0)),
      cell(component = "th", scope = "row", data$name[i]),
      lapply(c("calories", "fat", "carbs", "protein"), function(col) cell(align = "right", data[[col]][i]))
    )
  })
}
dessertHead <- function(cell = TableCell) {
  TableHead(TableRow(cell(headers[1]), lapply(headers[-1], function(h) cell(align = "right", h))))
}

Basic table

A simple example with no frills.

muiMaterialPage(
  CssBaseline(),
  TableContainer(
    component = PaperComponent,
    Table(
      sx = list(minWidth = 650),
      `aria-label` = "simple table",
      dessertHead(),
      TableBody(dessertRows(desserts))
    )
  )
)
JS code
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';

function createData(name, calories, fat, carbs, protein) {
  return { name, calories, fat, carbs, protein };
}

const rows = [
  createData('Frozen yoghurt', 159, 6.0, 24, 4.0),
  createData('Ice cream sandwich', 237, 9.0, 37, 4.3),
  createData('Eclair', 262, 16.0, 24, 6.0),
  createData('Cupcake', 305, 3.7, 67, 4.3),
  createData('Gingerbread', 356, 16.0, 49, 3.9),
];

export default function BasicTable() {
  return (
    <TableContainer component={Paper}>
      <Table sx={{ minWidth: 650 }} aria-label="simple table">
        <TableHead>
          <TableRow>
            <TableCell>Dessert (100g serving)</TableCell>
            <TableCell align="right">Calories</TableCell>
            <TableCell align="right">Fat&nbsp;(g)</TableCell>
            <TableCell align="right">Carbs&nbsp;(g)</TableCell>
            <TableCell align="right">Protein&nbsp;(g)</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {rows.map((row) => (
            <TableRow
              key={row.name}
              sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
            >
              <TableCell component="th" scope="row">
                {row.name}
              </TableCell>
              <TableCell align="right">{row.calories}</TableCell>
              <TableCell align="right">{row.fat}</TableCell>
              <TableCell align="right">{row.carbs}</TableCell>
              <TableCell align="right">{row.protein}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
}

Data table

The Table component has a close mapping to the native <table> elements. This constraint makes building rich data tables challenging.

The MUI X DataGrid component is designed for use-cases that are focused on handling large amounts of tabular data. While it comes with a more rigid structure, in exchange, you gain more powerful features, such as sorting, filtering, pagination and row selection out of the box. It is available in R with the muiDataGrid package.

Dense table

A simple example of a dense table with no frills.

muiMaterialPage(
  CssBaseline(),
  TableContainer(
    component = PaperComponent,
    Table(
      sx = list(minWidth = 650),
      size = "small",
      `aria-label` = "a dense table",
      dessertHead(),
      TableBody(dessertRows(desserts))
    )
  )
)
JS code
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';

function createData(name, calories, fat, carbs, protein) {
  return { name, calories, fat, carbs, protein };
}

const rows = [
  createData('Frozen yoghurt', 159, 6.0, 24, 4.0),
  createData('Ice cream sandwich', 237, 9.0, 37, 4.3),
  createData('Eclair', 262, 16.0, 24, 6.0),
  createData('Cupcake', 305, 3.7, 67, 4.3),
  createData('Gingerbread', 356, 16.0, 49, 3.9),
];

export default function DenseTable() {
  return (
    <TableContainer component={Paper}>
      <Table sx={{ minWidth: 650 }} size="small" aria-label="a dense table">
        <TableHead>
          <TableRow>
            <TableCell>Dessert (100g serving)</TableCell>
            <TableCell align="right">Calories</TableCell>
            <TableCell align="right">Fat&nbsp;(g)</TableCell>
            <TableCell align="right">Carbs&nbsp;(g)</TableCell>
            <TableCell align="right">Protein&nbsp;(g)</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {rows.map((row) => (
            <TableRow
              key={row.name}
              sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
            >
              <TableCell component="th" scope="row">
                {row.name}
              </TableCell>
              <TableCell align="right">{row.calories}</TableCell>
              <TableCell align="right">{row.fat}</TableCell>
              <TableCell align="right">{row.carbs}</TableCell>
              <TableCell align="right">{row.protein}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
}

Sorting

The MUI “Sorting & selecting” demo uses the TableSortLabel component to sort columns, with Checkbox() rows for selection and a TablePagination(). The demo below keeps the sorting part. The sort order is kept in the URL with reactRouter: each column and direction is a route (#/sort/<column>/<asc|desc>), and the data frame is sorted in R for each route.

sortColumns <- c("name", "calories", "fat", "carbs", "protein")

sortedTable <- function(orderBy = "calories", order = "asc") {
  data <- desserts[order(desserts[[orderBy]], decreasing = order == "desc"), ]
  TableContainer(
    component = PaperComponent,
    Table(
      sx = list(minWidth = 650),
      `aria-labelledby` = "tableTitle",
      TableHead(TableRow(
        lapply(seq_along(sortColumns), function(i) {
          col <- sortColumns[i]
          active <- col == orderBy
          nextOrder <- if (active && order == "asc") "desc" else "asc"
          TableCell(
            align = if (i == 1) "left" else "right",
            sortDirection = if (active) order else FALSE,
            TableSortLabel(
              active = active,
              direction = if (active) order else "asc",
              component = "a",
              href = sprintf("#/sort/%s/%s", col, nextOrder),
              headers[i]
            )
          )
        })
      )),
      TableBody(dessertRows(data))
    )
  )
}

muiMaterialPage(
  CssBaseline(),
  RouterProvider(
    router = createHashRouter(
      unlist(lapply(sortColumns, function(col) {
        lapply(c("asc", "desc"), function(order) {
          Route(path = sprintf("sort/%s/%s", col, order), element = sortedTable(col, order))
        })
      }), recursive = FALSE),
      Route(path = "*", element = sortedTable())
    )
  )
)
JS code
import * as React from 'react';
import PropTypes from 'prop-types';
import { alpha } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';
import TableSortLabel from '@mui/material/TableSortLabel';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import DeleteIcon from '@mui/icons-material/Delete';
import FilterListIcon from '@mui/icons-material/FilterList';
import { visuallyHidden } from '@mui/utils';

function createData(id, name, calories, fat, carbs, protein) {
  return {
    id,
    name,
    calories,
    fat,
    carbs,
    protein,
  };
}

const rows = [
  createData(1, 'Cupcake', 305, 3.7, 67, 4.3),
  createData(2, 'Donut', 452, 25.0, 51, 4.9),
  createData(3, 'Eclair', 262, 16.0, 24, 6.0),
  createData(4, 'Frozen yoghurt', 159, 6.0, 24, 4.0),
  createData(5, 'Gingerbread', 356, 16.0, 49, 3.9),
  createData(6, 'Honeycomb', 408, 3.2, 87, 6.5),
  createData(7, 'Ice cream sandwich', 237, 9.0, 37, 4.3),
  createData(8, 'Jelly Bean', 375, 0.0, 94, 0.0),
  createData(9, 'KitKat', 518, 26.0, 65, 7.0),
  createData(10, 'Lollipop', 392, 0.2, 98, 0.0),
  createData(11, 'Marshmallow', 318, 0, 81, 2.0),
  createData(12, 'Nougat', 360, 19.0, 9, 37.0),
  createData(13, 'Oreo', 437, 18.0, 63, 4.0),
];

function descendingComparator(a, b, orderBy) {
  if (b[orderBy] < a[orderBy]) {
    return -1;
  }
  if (b[orderBy] > a[orderBy]) {
    return 1;
  }
  return 0;
}

function getComparator(order, orderBy) {
  return order === 'desc'
    ? (a, b) => descendingComparator(a, b, orderBy)
    : (a, b) => -descendingComparator(a, b, orderBy);
}

const headCells = [
  {
    id: 'name',
    numeric: false,
    disablePadding: true,
    label: 'Dessert (100g serving)',
  },
  {
    id: 'calories',
    numeric: true,
    disablePadding: false,
    label: 'Calories',
  },
  {
    id: 'fat',
    numeric: true,
    disablePadding: false,
    label: 'Fat (g)',
  },
  {
    id: 'carbs',
    numeric: true,
    disablePadding: false,
    label: 'Carbs (g)',
  },
  {
    id: 'protein',
    numeric: true,
    disablePadding: false,
    label: 'Protein (g)',
  },
];

function EnhancedTableHead(props) {
  const { onSelectAllClick, order, orderBy, numSelected, rowCount, onRequestSort } =
    props;
  const createSortHandler = (property) => (event) => {
    onRequestSort(event, property);
  };

  return (
    <TableHead>
      <TableRow>
        <TableCell padding="checkbox">
          <Checkbox
            color="primary"
            indeterminate={numSelected > 0 && numSelected < rowCount}
            checked={rowCount > 0 && numSelected === rowCount}
            onChange={onSelectAllClick}
            slotProps={{
              input: { 'aria-label': 'select all desserts' },
            }}
          />
        </TableCell>
        {headCells.map((headCell) => (
          <TableCell
            key={headCell.id}
            align={headCell.numeric ? 'right' : 'left'}
            padding={headCell.disablePadding ? 'none' : 'normal'}
            sortDirection={orderBy === headCell.id ? order : false}
          >
            <TableSortLabel
              active={orderBy === headCell.id}
              direction={orderBy === headCell.id ? order : 'asc'}
              onClick={createSortHandler(headCell.id)}
            >
              {headCell.label}
              {orderBy === headCell.id ? (
                <Box component="span" sx={visuallyHidden}>
                  {order === 'desc' ? 'sorted descending' : 'sorted ascending'}
                </Box>
              ) : null}
            </TableSortLabel>
          </TableCell>
        ))}
      </TableRow>
    </TableHead>
  );
}

EnhancedTableHead.propTypes = {
  numSelected: PropTypes.number.isRequired,
  onRequestSort: PropTypes.func.isRequired,
  onSelectAllClick: PropTypes.func.isRequired,
  order: PropTypes.oneOf(['asc', 'desc']).isRequired,
  orderBy: PropTypes.string.isRequired,
  rowCount: PropTypes.number.isRequired,
};

function EnhancedTableToolbar(props) {
  const { numSelected } = props;
  return (
    <Toolbar
      sx={[
        {
          pl: { sm: 2 },
          pr: { xs: 1, sm: 1 },
        },
        numSelected > 0 && {
          bgcolor: (theme) =>
            alpha(theme.palette.primary.main, theme.palette.action.activatedOpacity),
        },
      ]}
    >
      {numSelected > 0 ? (
        <Typography
          variant="subtitle1"
          component="div"
          sx={{
            color: 'inherit',
            flex: '1 1 100%',
          }}
        >
          {numSelected} selected
        </Typography>
      ) : (
        <Typography
          sx={{ flex: '1 1 100%' }}
          variant="h6"
          id="tableTitle"
          component="div"
        >
          Nutrition
        </Typography>
      )}
      {numSelected > 0 ? (
        <Tooltip title="Delete">
          <IconButton>
            <DeleteIcon />
          </IconButton>
        </Tooltip>
      ) : (
        <Tooltip title="Filter list">
          <IconButton>
            <FilterListIcon />
          </IconButton>
        </Tooltip>
      )}
    </Toolbar>
  );
}

EnhancedTableToolbar.propTypes = {
  numSelected: PropTypes.number.isRequired,
};

export default function EnhancedTable() {
  const [order, setOrder] = React.useState('asc');
  const [orderBy, setOrderBy] = React.useState('calories');
  const [selected, setSelected] = React.useState([]);
  const [page, setPage] = React.useState(0);
  const [dense, setDense] = React.useState(false);
  const [rowsPerPage, setRowsPerPage] = React.useState(5);

  const handleRequestSort = (event, property) => {
    const isAsc = orderBy === property && order === 'asc';
    setOrder(isAsc ? 'desc' : 'asc');
    setOrderBy(property);
  };

  const handleSelectAllClick = (event) => {
    if (event.target.checked) {
      const newSelected = rows.map((n) => n.id);
      setSelected(newSelected);
      return;
    }
    setSelected([]);
  };

  const handleClick = (event, id) => {
    const selectedIndex = selected.indexOf(id);
    let newSelected = [];

    if (selectedIndex === -1) {
      newSelected = newSelected.concat(selected, id);
    } else if (selectedIndex === 0) {
      newSelected = newSelected.concat(selected.slice(1));
    } else if (selectedIndex === selected.length - 1) {
      newSelected = newSelected.concat(selected.slice(0, -1));
    } else if (selectedIndex > 0) {
      newSelected = newSelected.concat(
        selected.slice(0, selectedIndex),
        selected.slice(selectedIndex + 1),
      );
    }
    setSelected(newSelected);
  };

  const handleChangePage = (event, newPage) => {
    setPage(newPage);
  };

  const handleChangeRowsPerPage = (event) => {
    setRowsPerPage(parseInt(event.target.value, 10));
    setPage(0);
  };

  const handleChangeDense = (event) => {
    setDense(event.target.checked);
  };

  // Avoid a layout jump when reaching the last page with empty rows.
  const emptyRows =
    page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0;

  const visibleRows = React.useMemo(
    () =>
      [...rows]
        .sort(getComparator(order, orderBy))
        .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage),
    [order, orderBy, page, rowsPerPage],
  );

  return (
    <Box sx={{ width: '100%' }}>
      <Paper sx={{ width: '100%', mb: 2 }}>
        <EnhancedTableToolbar numSelected={selected.length} />
        <TableContainer>
          <Table
            sx={{ minWidth: 750 }}
            aria-labelledby="tableTitle"
            size={dense ? 'small' : 'medium'}
          >
            <EnhancedTableHead
              numSelected={selected.length}
              order={order}
              orderBy={orderBy}
              onSelectAllClick={handleSelectAllClick}
              onRequestSort={handleRequestSort}
              rowCount={rows.length}
            />
            <TableBody>
              {visibleRows.map((row, index) => {
                const isItemSelected = selected.includes(row.id);
                const labelId = `enhanced-table-checkbox-${index}`;

                return (
                  <TableRow
                    hover
                    onClick={(event) => handleClick(event, row.id)}
                    role="checkbox"
                    aria-checked={isItemSelected}
                    tabIndex={-1}
                    key={row.id}
                    selected={isItemSelected}
                    sx={{ cursor: 'pointer' }}
                  >
                    <TableCell padding="checkbox">
                      <Checkbox
                        color="primary"
                        checked={isItemSelected}
                        slotProps={{
                          input: { 'aria-labelledby': labelId },
                        }}
                      />
                    </TableCell>
                    <TableCell
                      component="th"
                      id={labelId}
                      scope="row"
                      padding="none"
                    >
                      {row.name}
                    </TableCell>
                    <TableCell align="right">{row.calories}</TableCell>
                    <TableCell align="right">{row.fat}</TableCell>
                    <TableCell align="right">{row.carbs}</TableCell>
                    <TableCell align="right">{row.protein}</TableCell>
                  </TableRow>
                );
              })}
              {emptyRows > 0 && (
                <TableRow
                  style={{
                    height: (dense ? 33 : 53) * emptyRows,
                  }}
                >
                  <TableCell colSpan={6} />
                </TableRow>
              )}
            </TableBody>
          </Table>
        </TableContainer>
        <TablePagination
          rowsPerPageOptions={[5, 10, 25]}
          component="div"
          count={rows.length}
          rowsPerPage={rowsPerPage}
          page={page}
          onPageChange={handleChangePage}
          onRowsPerPageChange={handleChangeRowsPerPage}
        />
      </Paper>
      <FormControlLabel
        control={<Switch checked={dense} onChange={handleChangeDense} />}
        label="Dense padding"
      />
    </Box>
  );
}

Customization

Here is an example of customizing the component. You can learn more about this in the overrides documentation page. The styled() table cells and rows of the original demo become sx values:

StyledTableCell <- function(...) {
  TableCell(
    sx = list(
      "&.MuiTableCell-head" = list(backgroundColor = "common.black", color = "common.white"),
      "&.MuiTableCell-body" = list(fontSize = 14)
    ),
    ...
  )
}
StyledTableRow <- function(...) {
  TableRow(
    sx = list(
      "&:nth-of-type(odd)" = list(backgroundColor = "action.hover"),
      # hide last border
      "&:last-child td, &:last-child th" = list(border = 0)
    ),
    ...
  )
}

muiMaterialPage(
  CssBaseline(),
  TableContainer(
    component = PaperComponent,
    Table(
      sx = list(minWidth = 700),
      `aria-label` = "customized table",
      dessertHead(cell = StyledTableCell),
      TableBody(
        lapply(seq_len(nrow(desserts)), function(i) {
          StyledTableRow(
            StyledTableCell(component = "th", scope = "row", desserts$name[i]),
            lapply(c("calories", "fat", "carbs", "protein"), function(col) {
              StyledTableCell(align = "right", desserts[[col]][i])
            })
          )
        })
      )
    )
  )
)
JS code
import { styled } from '@mui/material/styles';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell, { tableCellClasses } from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';

const StyledTableCell = styled(TableCell)(({ theme }) => ({
  [`&.${tableCellClasses.head}`]: {
    backgroundColor: theme.palette.common.black,
    color: theme.palette.common.white,
  },
  [`&.${tableCellClasses.body}`]: {
    fontSize: 14,
  },
}));

const StyledTableRow = styled(TableRow)(({ theme }) => ({
  '&:nth-of-type(odd)': {
    backgroundColor: theme.palette.action.hover,
  },
  // hide last border
  '&:last-child td, &:last-child th': {
    border: 0,
  },
}));

function createData(name, calories, fat, carbs, protein) {
  return { name, calories, fat, carbs, protein };
}

const rows = [
  createData('Frozen yoghurt', 159, 6.0, 24, 4.0),
  createData('Ice cream sandwich', 237, 9.0, 37, 4.3),
  createData('Eclair', 262, 16.0, 24, 6.0),
  createData('Cupcake', 305, 3.7, 67, 4.3),
  createData('Gingerbread', 356, 16.0, 49, 3.9),
];

export default function CustomizedTables() {
  return (
    <TableContainer component={Paper}>
      <Table sx={{ minWidth: 700 }} aria-label="customized table">
        <TableHead>
          <TableRow>
            <StyledTableCell>Dessert (100g serving)</StyledTableCell>
            <StyledTableCell align="right">Calories</StyledTableCell>
            <StyledTableCell align="right">Fat&nbsp;(g)</StyledTableCell>
            <StyledTableCell align="right">Carbs&nbsp;(g)</StyledTableCell>
            <StyledTableCell align="right">Protein&nbsp;(g)</StyledTableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {rows.map((row) => (
            <StyledTableRow key={row.name}>
              <StyledTableCell component="th" scope="row">
                {row.name}
              </StyledTableCell>
              <StyledTableCell align="right">{row.calories}</StyledTableCell>
              <StyledTableCell align="right">{row.fat}</StyledTableCell>
              <StyledTableCell align="right">{row.carbs}</StyledTableCell>
              <StyledTableCell align="right">{row.protein}</StyledTableCell>
            </StyledTableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
}

Custom pagination options

It’s possible to customize the options shown in the “Rows per page” select using the rowsPerPageOptions prop. You should either provide a vector of:

  • numbers, each number will be used for the option’s label and value.

    TablePagination(rowsPerPageOptions = c(10, 50))
  • lists, the value and label keys will be used respectively for the value and label of the option (useful for language strings such as ‘All’).

    TablePagination(rowsPerPageOptions = list(10, 50, list(value = -1, label = "All")))

Here is an example of a table with scrollable rows and fixed column headers. It leverages the stickyHeader prop.

The current page is kept in the URL (#/countries/page/<n>), and onPageChange navigates between pages. The rows of each page are selected in R.

countries <- data.frame(
  name = c("India", "China", "Italy", "United States", "Canada", "Australia", "Germany", "Ireland",
           "Mexico", "Japan", "France", "United Kingdom", "Russia", "Nigeria", "Brazil"),
  code = c("IN", "CN", "IT", "US", "CA", "AU", "DE", "IE", "MX", "JP", "FR", "GB", "RU", "NG", "BR"),
  population = c(1324171354, 1403500365, 60483973, 327167434, 37602103, 25475400, 83019200, 4857000,
                 126577691, 126317000, 67022000, 67545757, 146793744, 200962417, 210147125),
  size = c(3287263, 9596961, 301340, 9833520, 9984670, 7692024, 357578, 70273,
           1972550, 377973, 640679, 242495, 17098246, 923768, 8515767)
)
countries$density <- countries$population / countries$size

columns <- list(
  list(id = "name", label = "Name", minWidth = 170),
  list(id = "code", label = "ISO Code", minWidth = 100),
  list(id = "population", label = "Population", minWidth = 170, align = "right", format = function(x) format(x, big.mark = ",")),
  list(id = "size", label = "Size (km²)", minWidth = 170, align = "right", format = function(x) format(x, big.mark = ",")),
  list(id = "density", label = "Density", minWidth = 170, align = "right", format = function(x) sprintf("%.2f", x))
)
rowsPerPage <- 10
pages <- seq(0, ceiling(nrow(countries) / rowsPerPage) - 1)

countryRows <- function(page) {
  data <- countries[seq(page * rowsPerPage + 1, min((page + 1) * rowsPerPage, nrow(countries))), ]
  lapply(seq_len(nrow(data)), function(i) {
    TableRow(
      hover = TRUE,
      role = "checkbox",
      tabIndex = -1,
      lapply(columns, function(column) {
        value <- data[[column$id]][i]
        TableCell(align = if (is.null(column$align)) "left" else column$align, if (is.null(column$format)) value else column$format(value))
      })
    )
  })
}
countryPagination <- function(page) {
  TablePagination(
    rowsPerPageOptions = list(rowsPerPage),
    component = "div",
    count = nrow(countries),
    rowsPerPage = rowsPerPage,
    page = page,
    onPageChange = JS("(event, newPage) => { window.location.hash = '/countries/page/' + newPage; }")
  )
}

stickyHeadTable <- function(page) {
  Paper(
    sx = list(width = "100%", overflow = "hidden"),
    TableContainer(
      sx = list(maxHeight = 440),
      Table(
        stickyHeader = TRUE,
        `aria-label` = "sticky table",
        TableHead(TableRow(lapply(columns, function(column) {
          TableCell(align = if (is.null(column$align)) "left" else column$align, style = list(minWidth = column$minWidth), column$label)
        }))),
        TableBody(countryRows(page))
      )
    ),
    countryPagination(page)
  )
}

muiMaterialPage(
  CssBaseline(),
  RouterProvider(
    router = createHashRouter(
      lapply(pages, function(page) Route(path = paste0("countries/page/", page), element = stickyHeadTable(page))),
      Route(path = "*", element = stickyHeadTable(0))
    )
  )
)
JS code
import * as React from 'react';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';

const columns = [
  { id: 'name', label: 'Name', minWidth: 170 },
  { id: 'code', label: 'ISO\u00a0Code', minWidth: 100 },
  {
    id: 'population',
    label: 'Population',
    minWidth: 170,
    align: 'right',
    format: (value) => value.toLocaleString('en-US'),
  },
  {
    id: 'size',
    label: 'Size\u00a0(km\u00b2)',
    minWidth: 170,
    align: 'right',
    format: (value) => value.toLocaleString('en-US'),
  },
  {
    id: 'density',
    label: 'Density',
    minWidth: 170,
    align: 'right',
    format: (value) => value.toFixed(2),
  },
];

function createData(name, code, population, size) {
  const density = population / size;
  return { name, code, population, size, density };
}

const rows = [
  createData('India', 'IN', 1324171354, 3287263),
  createData('China', 'CN', 1403500365, 9596961),
  createData('Italy', 'IT', 60483973, 301340),
  createData('United States', 'US', 327167434, 9833520),
  createData('Canada', 'CA', 37602103, 9984670),
  createData('Australia', 'AU', 25475400, 7692024),
  createData('Germany', 'DE', 83019200, 357578),
  createData('Ireland', 'IE', 4857000, 70273),
  createData('Mexico', 'MX', 126577691, 1972550),
  createData('Japan', 'JP', 126317000, 377973),
  createData('France', 'FR', 67022000, 640679),
  createData('United Kingdom', 'GB', 67545757, 242495),
  createData('Russia', 'RU', 146793744, 17098246),
  createData('Nigeria', 'NG', 200962417, 923768),
  createData('Brazil', 'BR', 210147125, 8515767),
];

export default function StickyHeadTable() {
  const [page, setPage] = React.useState(0);
  const [rowsPerPage, setRowsPerPage] = React.useState(10);

  const handleChangePage = (event, newPage) => {
    setPage(newPage);
  };

  const handleChangeRowsPerPage = (event) => {
    setRowsPerPage(+event.target.value);
    setPage(0);
  };

  return (
    <Paper sx={{ width: '100%', overflow: 'hidden' }}>
      <TableContainer sx={{ maxHeight: 440 }}>
        <Table stickyHeader aria-label="sticky table">
          <TableHead>
            <TableRow>
              {columns.map((column) => (
                <TableCell
                  key={column.id}
                  align={column.align}
                  style={{ minWidth: column.minWidth }}
                >
                  {column.label}
                </TableCell>
              ))}
            </TableRow>
          </TableHead>
          <TableBody>
            {rows
              .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
              .map((row) => {
                return (
                  <TableRow hover role="checkbox" tabIndex={-1} key={row.code}>
                    {columns.map((column) => {
                      const value = row[column.id];
                      return (
                        <TableCell key={column.id} align={column.align}>
                          {column.format && typeof value === 'number'
                            ? column.format(value)
                            : value}
                        </TableCell>
                      );
                    })}
                  </TableRow>
                );
              })}
          </TableBody>
        </Table>
      </TableContainer>
      <TablePagination
        rowsPerPageOptions={[10, 25, 100]}
        component="div"
        count={rows.length}
        rowsPerPage={rowsPerPage}
        page={page}
        onPageChange={handleChangePage}
        onRowsPerPageChange={handleChangeRowsPerPage}
      />
    </Paper>
  );
}

Column grouping

You can group column headers by rendering multiple table rows inside a table head:

muiMaterialPage(
  CssBaseline(),
  Paper(
    sx = list(width = "100%"),
    TableContainer(
      sx = list(maxHeight = 440),
      Table(
        stickyHeader = TRUE,
        `aria-label` = "sticky table",
        TableHead(
          TableRow(
            TableCell(align = "center", colSpan = 2, "Country"),
            TableCell(align = "center", colSpan = 3, "Details")
          ),
          TableRow(lapply(columns, function(column) {
            TableCell(align = if (is.null(column$align)) "left" else column$align, style = list(top = 57, minWidth = column$minWidth), column$label)
          }))
        ),
        TableBody(countryRows(0))
      )
    )
  )
)
JS code
import * as React from 'react';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';

const columns = [
  { id: 'name', label: 'Name', minWidth: 170 },
  { id: 'code', label: 'ISO\u00a0Code', minWidth: 100 },
  {
    id: 'population',
    label: 'Population',
    minWidth: 170,
    align: 'right',
    format: (value) => value.toLocaleString('en-US'),
  },
  {
    id: 'size',
    label: 'Size\u00a0(km\u00b2)',
    minWidth: 170,
    align: 'right',
    format: (value) => value.toLocaleString('en-US'),
  },
  {
    id: 'density',
    label: 'Density',
    minWidth: 170,
    align: 'right',
    format: (value) => value.toFixed(2),
  },
];

function createData(name, code, population, size) {
  const density = population / size;
  return { name, code, population, size, density };
}

const rows = [
  createData('India', 'IN', 1324171354, 3287263),
  createData('China', 'CN', 1403500365, 9596961),
  createData('Italy', 'IT', 60483973, 301340),
  createData('United States', 'US', 327167434, 9833520),
  createData('Canada', 'CA', 37602103, 9984670),
  createData('Australia', 'AU', 25475400, 7692024),
  createData('Germany', 'DE', 83019200, 357578),
  createData('Ireland', 'IE', 4857000, 70273),
  createData('Mexico', 'MX', 126577691, 1972550),
  createData('Japan', 'JP', 126317000, 377973),
  createData('France', 'FR', 67022000, 640679),
  createData('United Kingdom', 'GB', 67545757, 242495),
  createData('Russia', 'RU', 146793744, 17098246),
  createData('Nigeria', 'NG', 200962417, 923768),
  createData('Brazil', 'BR', 210147125, 8515767),
];

export default function ColumnGroupingTable() {
  const [page, setPage] = React.useState(0);
  const [rowsPerPage, setRowsPerPage] = React.useState(10);

  const handleChangePage = (event, newPage) => {
    setPage(newPage);
  };

  const handleChangeRowsPerPage = (event) => {
    setRowsPerPage(+event.target.value);
    setPage(0);
  };

  return (
    <Paper sx={{ width: '100%' }}>
      <TableContainer sx={{ maxHeight: 440 }}>
        <Table stickyHeader aria-label="sticky table">
          <TableHead>
            <TableRow>
              <TableCell align="center" colSpan={2}>
                Country
              </TableCell>
              <TableCell align="center" colSpan={3}>
                Details
              </TableCell>
            </TableRow>
            <TableRow>
              {columns.map((column) => (
                <TableCell
                  key={column.id}
                  align={column.align}
                  style={{ top: 57, minWidth: column.minWidth }}
                >
                  {column.label}
                </TableCell>
              ))}
            </TableRow>
          </TableHead>
          <TableBody>
            {rows
              .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
              .map((row) => {
                return (
                  <TableRow hover role="checkbox" tabIndex={-1} key={row.code}>
                    {columns.map((column) => {
                      const value = row[column.id];
                      return (
                        <TableCell key={column.id} align={column.align}>
                          {column.format && typeof value === 'number'
                            ? column.format(value)
                            : value}
                        </TableCell>
                      );
                    })}
                  </TableRow>
                );
              })}
          </TableBody>
        </Table>
      </TableContainer>
      <TablePagination
        rowsPerPageOptions={[10, 25, 100]}
        component="div"
        count={rows.length}
        rowsPerPage={rowsPerPage}
        page={page}
        onPageChange={handleChangePage}
        onRowsPerPageChange={handleChangeRowsPerPage}
      />
    </Paper>
  );
}

Collapsible table

An example of a table with expandable rows, revealing more information. It utilizes the Collapse component. The expanded row is kept in the URL (#/dessert/<row>); the arrow links to it, or back to #/ to close it.

prices <- c(3.99, 4.99, 3.79, 2.5, 1.5)
history <- data.frame(date = c("2020-01-05", "2020-01-02"), customerId = c("11091700", "Anonymous"), amount = c(3, 1))

collapsibleRow <- function(i, open) {
  tagList(
    TableRow(
      sx = list("& > .MuiTableCell-root" = list(borderBottom = "unset")),
      TableCell(
        IconButton(
          `aria-label` = "expand row",
          size = "small",
          href = if (open) "#/" else paste0("#/dessert/", i),
          Icon(if (open) "keyboard_arrow_up" else "keyboard_arrow_down")
        )
      ),
      TableCell(component = "th", scope = "row", desserts$name[i]),
      lapply(c("calories", "fat", "carbs", "protein"), function(col) TableCell(align = "right", desserts[[col]][i]))
    ),
    TableRow(
      TableCell(
        style = list(paddingBottom = 0, paddingTop = 0),
        colSpan = 6,
        Collapse(
          `in` = open,
          appear = TRUE,
          timeout = "auto",
          unmountOnExit = TRUE,
          Box(
            sx = list(margin = 1),
            Typography(variant = "h6", gutterBottom = TRUE, component = "div", "History"),
            Table(
              size = "small",
              `aria-label` = "purchases",
              TableHead(TableRow(
                TableCell("Date"), TableCell("Customer"),
                TableCell(align = "right", "Amount"), TableCell(align = "right", "Total price ($)")
              )),
              TableBody(lapply(seq_len(nrow(history)), function(h) {
                TableRow(
                  TableCell(component = "th", scope = "row", history$date[h]),
                  TableCell(history$customerId[h]),
                  TableCell(align = "right", history$amount[h]),
                  TableCell(align = "right", round(history$amount[h] * prices[i], 2))
                )
              }))
            )
          )
        )
      )
    )
  )
}

collapsibleTable <- function(openRow = 0) {
  TableContainer(
    component = PaperComponent,
    Table(
      `aria-label` = "collapsible table",
      TableHead(TableRow(TableCell(), TableCell(headers[1]), lapply(headers[-1], function(h) TableCell(align = "right", h)))),
      TableBody(lapply(seq_len(nrow(desserts)), function(i) collapsibleRow(i, i == openRow)))
    )
  )
}

muiMaterialPage(
  useMaterialIconsFilled = TRUE,
  CssBaseline(),
  RouterProvider(
    router = createHashRouter(
      lapply(seq_len(nrow(desserts)), function(i) Route(path = paste0("dessert/", i), element = collapsibleTable(i))),
      Route(path = "*", element = collapsibleTable())
    )
  )
)
JS code
import * as React from 'react';
import PropTypes from 'prop-types';
import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';

function createData(name, calories, fat, carbs, protein, price) {
  return {
    name,
    calories,
    fat,
    carbs,
    protein,
    price,
    history: [
      {
        date: '2020-01-05',
        customerId: '11091700',
        amount: 3,
      },
      {
        date: '2020-01-02',
        customerId: 'Anonymous',
        amount: 1,
      },
    ],
  };
}

function Row(props) {
  const { row } = props;
  const [open, setOpen] = React.useState(false);

  return (
    <React.Fragment>
      <TableRow sx={{ '& > .MuiTableCell-root': { borderBottom: 'unset' } }}>
        <TableCell>
          <IconButton
            aria-label="expand row"
            size="small"
            onClick={() => setOpen(!open)}
          >
            {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
          </IconButton>
        </TableCell>
        <TableCell component="th" scope="row">
          {row.name}
        </TableCell>
        <TableCell align="right">{row.calories}</TableCell>
        <TableCell align="right">{row.fat}</TableCell>
        <TableCell align="right">{row.carbs}</TableCell>
        <TableCell align="right">{row.protein}</TableCell>
      </TableRow>
      <TableRow>
        <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
          <Collapse in={open} timeout="auto" unmountOnExit>
            <Box sx={{ margin: 1 }}>
              <Typography variant="h6" gutterBottom component="div">
                History
              </Typography>
              <Table size="small" aria-label="purchases">
                <TableHead>
                  <TableRow>
                    <TableCell>Date</TableCell>
                    <TableCell>Customer</TableCell>
                    <TableCell align="right">Amount</TableCell>
                    <TableCell align="right">Total price ($)</TableCell>
                  </TableRow>
                </TableHead>
                <TableBody>
                  {row.history.map((historyRow) => (
                    <TableRow key={historyRow.date}>
                      <TableCell component="th" scope="row">
                        {historyRow.date}
                      </TableCell>
                      <TableCell>{historyRow.customerId}</TableCell>
                      <TableCell align="right">{historyRow.amount}</TableCell>
                      <TableCell align="right">
                        {Math.round(historyRow.amount * row.price * 100) / 100}
                      </TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </Box>
          </Collapse>
        </TableCell>
      </TableRow>
    </React.Fragment>
  );
}

Row.propTypes = {
  row: PropTypes.shape({
    calories: PropTypes.number.isRequired,
    carbs: PropTypes.number.isRequired,
    fat: PropTypes.number.isRequired,
    history: PropTypes.arrayOf(
      PropTypes.shape({
        amount: PropTypes.number.isRequired,
        customerId: PropTypes.string.isRequired,
        date: PropTypes.string.isRequired,
      }),
    ).isRequired,
    name: PropTypes.string.isRequired,
    price: PropTypes.number.isRequired,
    protein: PropTypes.number.isRequired,
  }).isRequired,
};

const rows = [
  createData('Frozen yoghurt', 159, 6.0, 24, 4.0, 3.99),
  createData('Ice cream sandwich', 237, 9.0, 37, 4.3, 4.99),
  createData('Eclair', 262, 16.0, 24, 6.0, 3.79),
  createData('Cupcake', 305, 3.7, 67, 4.3, 2.5),
  createData('Gingerbread', 356, 16.0, 49, 3.9, 1.5),
];

export default function CollapsibleTable() {
  return (
    <TableContainer component={Paper}>
      <Table aria-label="collapsible table">
        <TableHead>
          <TableRow>
            <TableCell />
            <TableCell>Dessert (100g serving)</TableCell>
            <TableCell align="right">Calories</TableCell>
            <TableCell align="right">Fat&nbsp;(g)</TableCell>
            <TableCell align="right">Carbs&nbsp;(g)</TableCell>
            <TableCell align="right">Protein&nbsp;(g)</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {rows.map((row) => (
            <Row key={row.name} row={row} />
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
}

Spanning table

A simple example with spanning rows & columns.

taxRate <- 0.07
invoice <- data.frame(desc = c("Paperclips (Box)", "Paper (Case)", "Waste Basket"), qty = c(100, 10, 2), unit = c(1.15, 45.99, 17.99))
invoice$price <- invoice$qty * invoice$unit
invoiceSubtotal <- sum(invoice$price)
invoiceTaxes <- taxRate * invoiceSubtotal
ccyFormat <- function(x) sprintf("%.2f", x)

muiMaterialPage(
  CssBaseline(),
  TableContainer(
    component = PaperComponent,
    Table(
      sx = list(minWidth = 700),
      `aria-label` = "spanning table",
      TableHead(
        TableRow(TableCell(align = "center", colSpan = 3, "Details"), TableCell(align = "right", "Price")),
        TableRow(TableCell("Desc"), TableCell(align = "right", "Qty."), TableCell(align = "right", "Unit"), TableCell(align = "right", "Sum"))
      ),
      TableBody(
        lapply(seq_len(nrow(invoice)), function(i) {
          TableRow(
            TableCell(invoice$desc[i]),
            TableCell(align = "right", invoice$qty[i]),
            TableCell(align = "right", invoice$unit[i]),
            TableCell(align = "right", ccyFormat(invoice$price[i]))
          )
        }),
        TableRow(TableCell(rowSpan = 3), TableCell(colSpan = 2, "Subtotal"), TableCell(align = "right", ccyFormat(invoiceSubtotal))),
        TableRow(TableCell("Tax"), TableCell(align = "right", sprintf("%.0f %%", taxRate * 100)), TableCell(align = "right", ccyFormat(invoiceTaxes))),
        TableRow(TableCell(colSpan = 2, "Total"), TableCell(align = "right", ccyFormat(invoiceSubtotal + invoiceTaxes)))
      )
    )
  )
)
JS code
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';

const TAX_RATE = 0.07;

function ccyFormat(num) {
  return `${num.toFixed(2)}`;
}

function priceRow(qty, unit) {
  return qty * unit;
}

function createRow(desc, qty, unit) {
  const price = priceRow(qty, unit);
  return { desc, qty, unit, price };
}

function subtotal(items) {
  return items.map(({ price }) => price).reduce((sum, i) => sum + i, 0);
}

const rows = [
  createRow('Paperclips (Box)', 100, 1.15),
  createRow('Paper (Case)', 10, 45.99),
  createRow('Waste Basket', 2, 17.99),
];

const invoiceSubtotal = subtotal(rows);
const invoiceTaxes = TAX_RATE * invoiceSubtotal;
const invoiceTotal = invoiceTaxes + invoiceSubtotal;

export default function SpanningTable() {
  return (
    <TableContainer component={Paper}>
      <Table sx={{ minWidth: 700 }} aria-label="spanning table">
        <TableHead>
          <TableRow>
            <TableCell align="center" colSpan={3}>
              Details
            </TableCell>
            <TableCell align="right">Price</TableCell>
          </TableRow>
          <TableRow>
            <TableCell>Desc</TableCell>
            <TableCell align="right">Qty.</TableCell>
            <TableCell align="right">Unit</TableCell>
            <TableCell align="right">Sum</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {rows.map((row) => (
            <TableRow key={row.desc}>
              <TableCell>{row.desc}</TableCell>
              <TableCell align="right">{row.qty}</TableCell>
              <TableCell align="right">{row.unit}</TableCell>
              <TableCell align="right">{ccyFormat(row.price)}</TableCell>
            </TableRow>
          ))}
          <TableRow>
            <TableCell rowSpan={3} />
            <TableCell colSpan={2}>Subtotal</TableCell>
            <TableCell align="right">{ccyFormat(invoiceSubtotal)}</TableCell>
          </TableRow>
          <TableRow>
            <TableCell>Tax</TableCell>
            <TableCell align="right">{`${(TAX_RATE * 100).toFixed(0)} %`}</TableCell>
            <TableCell align="right">{ccyFormat(invoiceTaxes)}</TableCell>
          </TableRow>
          <TableRow>
            <TableCell colSpan={2}>Total</TableCell>
            <TableCell align="right">{ccyFormat(invoiceTotal)}</TableCell>
          </TableRow>
        </TableBody>
      </Table>
    </TableContainer>
  );
}

Virtualized table

The MUI documentation shows how to use react-virtuoso with the Table component to render large tables. That library is not bundled with muiMaterial. For large data, use the muiDataGrid package, which virtualizes rows and supports server-side pagination.

Accessibility

(WAI tutorial: https://www.w3.org/WAI/tutorials/tables/)

Caption

A caption functions like a heading for a table. Most screen readers announce the content of captions. Captions help users to find a table and understand what it’s about and decide if they want to read it.

muiMaterialPage(
  CssBaseline(),
  TableContainer(
    component = PaperComponent,
    Table(
      sx = list(minWidth = 650),
      `aria-label` = "caption table",
      tags$caption("A basic table example with a caption"),
      dessertHead(),
      TableBody(dessertRows(desserts[1:3, ]))
    )
  )
)
JS code
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';

function createData(name, calories, fat, carbs, protein) {
  return { name, calories, fat, carbs, protein };
}

const rows = [
  createData('Frozen yoghurt', 159, 6.0, 24, 4.0),
  createData('Ice cream sandwich', 237, 9.0, 37, 4.3),
  createData('Eclair', 262, 16.0, 24, 6.0),
];

export default function AccessibleTable() {
  return (
    <TableContainer component={Paper}>
      <Table sx={{ minWidth: 650 }} aria-label="caption table">
        <caption>A basic table example with a caption</caption>
        <TableHead>
          <TableRow>
            <TableCell>Dessert (100g serving)</TableCell>
            <TableCell align="right">Calories</TableCell>
            <TableCell align="right">Fat&nbsp;(g)</TableCell>
            <TableCell align="right">Carbs&nbsp;(g)</TableCell>
            <TableCell align="right">Protein&nbsp;(g)</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {rows.map((row) => (
            <TableRow key={row.name}>
              <TableCell component="th" scope="row">
                {row.name}
              </TableCell>
              <TableCell align="right">{row.calories}</TableCell>
              <TableCell align="right">{row.fat}</TableCell>
              <TableCell align="right">{row.carbs}</TableCell>
              <TableCell align="right">{row.protein}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
}

Tables in Shiny apps

Build the table from a reactive data frame with renderUI(). The pattern is the same as above:

library(shiny)
library(muiMaterial)

ui <- muiMaterialPage(
  CssBaseline(),
  Box(
    sx = list(p = 2),
    Slider.shinyInput("n", value = 5, min = 1, max = 15, valueLabelDisplay = "auto"),
    uiOutput("table")
  )
)

server <- function(input, output, session) {
  output$table <- renderUI({
    data <- head(mtcars[, 1:4], input$n)
    TableContainer(
      component = JS("jsmodule['@mui/material'].Paper"),
      Table(
        size = "small",
        TableHead(TableRow(TableCell("Car"), lapply(names(data), function(col) TableCell(align = "right", col)))),
        TableBody(lapply(seq_len(nrow(data)), function(i) {
          TableRow(
            TableCell(component = "th", scope = "row", rownames(data)[i]),
            lapply(names(data), function(col) TableCell(align = "right", data[[col]][i]))
          )
        }))
      )
    )
  })
}

shinyApp(ui, server)

For interactive data tables (sorting, filtering, selection, large data), use the muiDataGrid package.