mui table style manage hook

11/18/2025


import { SxProps, Theme } from '@mui/material';
import { useMemo } from 'react';

interface UseTableStylesParams {
  /** Index of the column to stick (null for no sticky) */
  stickyColumn?: number | null;
  /** Whether to stick the header */
  stickyHeader?: boolean;
  /** Header background color */
  headerBackgroundColor?: string;
  /** Header text color */
  headerTextColor?: string;
  /** Body background color */
  bodyBackgroundColor?: string;
  /** Border color */
  borderColor?: string;
  /** Hover background color */
  hoverBackgroundColor?: string;
  /** TableContainer max height */
  maxHeight?: string;
  /** TableContainer min height */
  minHeight?: string;
  // Custom sx
  customSx?: {
    container?: SxProps<Theme>;
    table?: SxProps<Theme>;
    tableHead?: SxProps<Theme>;
    headerCell?: SxProps<Theme>;
    bodyCell?: SxProps<Theme>;
    tableRow?: SxProps<Theme>;
  };
}

interface TableStyles {
  container: SxProps<Theme>;
  table: SxProps<Theme>;
  tableHead: SxProps<Theme>;
  headerCell: (index: number) => SxProps<Theme>;
  bodyCell: (index: number) => SxProps<Theme>;
  tableRow: SxProps<Theme>;
}

const DEFAULT_PARAMS: Required<Omit<UseTableStylesParams, 'customSx'>> = {
  stickyColumn: null,
  stickyHeader: true,
  headerBackgroundColor: '#F2F4F6',
  headerTextColor: '#5C5F66',
  bodyBackgroundColor: '#FFFFFF',
  borderColor: '#e5e5e5',
  hoverBackgroundColor: '#def6e5',
  maxHeight: '50vh',
  minHeight: '30vh',
};

/**
 * Hook for using table styles consistently
 * @param params - Style configuration parameters
 * @returns Style objects to apply to table components
 *
 * @example
 * const styles = useTableStyles({
 *   stickyColumn: 0,
 *   stickyHeader: true,
 *   customSx: {
 *     table: {
 *       minWidth: 1000,
 *       th: { height: 40 },
 *     },
 *     container: {
 *       maxHeight: '70vh',
 *     },
 *   }
 * });
 *
 * <TableContainer sx={styles.container}>
 *   <Table sx={styles.table}>
 *     ...
 *   </Table>
 * </TableContainer>
 */
export const useTableStyles = (params: UseTableStylesParams = {}): TableStyles => {
  const config = { ...DEFAULT_PARAMS, ...params };
  const { customSx = {} } = params;

  const styles = useMemo<TableStyles>(
    () => ({
      container: {
        maxHeight: config.maxHeight,
        minHeight: config.minHeight,
        overflowX: 'auto',
        borderRadius: 'none',
        '&::-webkit-scrollbar': {
          width: '0.85rem',
          height: '0.85rem',
        },
        '&::-webkit-scrollbar-thumb': {
          width: '0.5rem',
          background: '#fff',
          borderRadius: '.5rem',
          cursor: 'pointer',
        },
        '&::-webkit-scrollbar-track': {
          background: '#efefef',
        },
        '&::-webkit-scrollbar-corner': {
          background: '#efefef',
        },
        ...customSx.container,
      } as SxProps<Theme>,
      table: {
        borderCollapse: 'unset',
        borderTop: `1px solid ${config.borderColor}`,
        borderLeft: `1px solid ${config.borderColor}`,

        ...customSx.table,
      } as SxProps<Theme>,
      tableHead: {
        position: config.stickyHeader ? 'sticky' : 'static',
        top: '0',
        zIndex: 3,
        backgroundColor: config.headerBackgroundColor,
        overflow: 'inherit',
        ...customSx.tableHead,
      } as SxProps<Theme>,
      headerCell: (index: number) => {
        const isStickyColumn = config.stickyColumn !== null && config.stickyColumn >= index;

        return {
          borderRight: `1px solid ${config.borderColor}`,
          borderBottom: `1px solid ${config.borderColor}`,
          boxSizing: 'border-box' as const,
          backgroundColor: config.headerBackgroundColor,
          color: config.headerTextColor,
          fontSize: '.825rem',
          whiteSpace: 'nowrap',
          ...(isStickyColumn && {
            position: 'sticky' as const,
            left: 0,
            zIndex: 4,
            backgroundColor: config.headerBackgroundColor,
          }),
          ...customSx.headerCell,
        } as SxProps<Theme>;
      },
      bodyCell: (index: number) => {
        const isStickyColumn = config.stickyColumn !== null && config.stickyColumn >= index;

        return {
          borderBottom: `1px solid ${config.borderColor}`,
          borderRight: `1px solid ${config.borderColor}`,
          boxSizing: 'border-box' as const,
          whiteSpace: 'nowrap',
          ...(isStickyColumn && {
            position: 'sticky' as const,
            left: 0,
            zIndex: 1,
            backgroundColor: config.bodyBackgroundColor,
          }),
          ...customSx.bodyCell,
        } as SxProps<Theme>;
      },
      tableRow: {
        '&:hover': {
          background: config.hoverBackgroundColor,
          cursor: 'pointer',
        },
        ...customSx.tableRow,
      } as SxProps<Theme>,
    }),
    [config, customSx]
  );

  return styles;
};

in the jsx

    <TableContainer component='div' sx={tableStyles.container}>
      <Table sx={tableStyles.table}>
        <TableHead sx={tableStyles.tableHead}>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header, index) => (
                <TableCell key={header.id} sx={tableStyles.headerCell(index)}>
                  {flexRender(header.column.columnDef.header, header.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableHead>
        <TableBody>
          {table.getRowModel().rows.map((row) => (
            <TableRow key={row.id} sx={tableStyles.tableRow} onClick={(event) => handleRowClick(event, row.original)}>
              {row.getVisibleCells().map((cell, index) => (
                <TableCell key={cell.id} sx={tableStyles.bodyCell(index)}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>

© 2025 Mingu Kim. All rights reserved.