Skip to content

Styling Tables Using CSS Grids

Published: at 06:40 AM

Table of contents

Open Table of contents

The Markup

<table>
  <thead>
    <tr>
      <th>Heading 1</th>
      <th>Heading 2</th>
      <th>Heading 3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cell 1</td>
      <td>Cell 2</td>
      <td>Cell 3</td>
    </tr>
  </tbody>
</table>

CSS – first trivial approach

Let’s start with the container.

table {
  /* This will be set via JavaScript */
  --column-count: 0;
  display: grid;
  /* let all the columns take one free space or at least their min-content */
  grid-template-columns: repeat(var(--column-count), minmax(min-content, 1fr));
  /* if all min-contents are wider than parent, overflow gracefully */
  overflow-x: auto;
}

Now we need to tell intermediate elements to delegate positioning to tds. For that, CSS has our back with display: contents.

thead, tbody, tr { display: contents; }

This will make it as if the markup was:

<table>
  <th>Heading 1</th>
  <th>Heading 2</th>
  <th>Heading 3</th>
  <td>Cell 1</td>
  <td>Cell 2</td>
  <td>Cell 3</td>
</table>
Here’s some code for a prettier table (purely cosmetic stuff)
table { --border: 1px solid #333; }
th {
  font-weight: bold;
  background-color: #eee;
}
& th, & td {
  padding: 0.5rem;
  white-space: nowrap;
  overflow-x: auto;
  & + th, & + td { border-inline-start: var(--border); }
}
& :where(tbody tr > :is(td, th)) { border-block-start: var(--border); }

Let’s set the column count – in JS

A first trivial approach:

const table = document.querySelector("table");
const columnCount = table.querySelectorAll("thead > tr > *").length;
table.style.setProperty("--column-count", String(columnCount));

That’s it! You have your first CSS grid table. How about we go further?

Allowing a specific template

Let’s take a 4-cells-table, the first and last ones should be as small as possible, we don’t care about the 2 middle cells.

The matching grid-template-columns would be min-content 1fr 1fr min-content. Basically, min-content and 1fr are really the 2 keywords you’ll need to know. FYI you can also give fixed values like 50px, 10rem, etc…

By specifying 4 keywords, you specify rows of 4 cells, it is a simple as that.

We could provide it as an inline style, or a CSS variable to make it shorter.

<table style="--template: min-content 1fr 1fr min-content">

</table>

<style>
  table {
  --column-count: 0;
  /* Let's use a default value falling back to a column-count based template */
  --template: repeat(var(--column-count), minmax(min-content, 1fr));
  /* if the template is overridden, it will be applied */
  /* otherwise the default behavior will apply */
  grid-template-columns: var(--template);
}
</style>

Resizing a table header

TL;DR: th { resize: inline; }.

Handling colspan

No-one focuses on that… but you have to deal with colspan! Take the following HTML:

<table>
  <!-- The example is stupid but hey, a lot of requirements are. -->
  <!-- I'm just adapting here -->
  <thead>
    <tr>
      <th>Heading 1</th>
      <th colspan="2">Heading 2</th>
      <th>Heading 3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cell 1</td>
      <td>Cell 2</td>
      <td colspan="2">Cell 3</td>
    </tr>
  </tbody>
</table>

Let’s start with the CSS:

& [colspan='2'] { grid-column-end: span 2; }
& [colspan='3'] { grid-column-end: span 3; }
/* handle as many colspan-s as need here. I did not find any other way :S */
/* I usually go up to 12 */

Then the JavaScript:

const mount = (
  table: HTMLTableElement,
  template?: Array<'min-content' | `${number}${'fr' | 'px'}`>,
) => {
    const columnCount = getColumnCount(table) // 4 in our example
    // handle template scenario
    if (template) {
      assertColumnCount(template, columnCount)
      return table.style.setProperty('--template', template.join(' '))
    } else {
      table.style.setProperty('--column-count', String(columnCount))
      table.style.removeProperty('--template')
    }
  }

const assertColumnCount = (template: unknown[], columnCountInDOM: number) => {
  // fail fast – in dev – if there is any mismatch.
  if (template.length === columnCountInDOM) return
  throw new Error(
    `received a template for ${template.length} columns but got ${columnCountInDOM}`,
  )
}

const getColumnCount = (table: HTMLTableElement) => {
  const cells =
    table.querySelectorAll('thead > tr > *') ||
    table.querySelectorAll('tbody > tr:first-child > *') // fallback to first tbody row when there's no thead.

  // the sum of spans gives us the row cell count.
  return Array.from(cells).reduce((acc, element) => {
    const span =
      (element instanceof HTMLTableCellElement && element.colSpan) ||
      Number(element.ariaColSpan) ||
      1 // default span is 1
    return acc + span
  }, 0)
}

TSX – React, Solid, whatever…

I use TSX a lot – and not React, calm down React lovers – so here’s a full-featured component:

type Unit = "fr" | "rem" | "px";

interface Props extends ComponentProps<"table"> {
  /**
   * defines each column's width, can be an rem/px value, "min" or "flex"
   * @example
   * ```tsx
   * <Table columns={['15px', '1fr', '1fr', 'min-content']} … />
   * ```
   */
  template?: Array<"min-content" | "auto" | `${number}${Unit}`>;
}
export const Table = ({ template, ...props }: Props) => {
  const mount = () => {
    // cf the mount function earlier :D
    // to call with mount(tableRef, template)
  };

  return <table {...props} />;
};

Other APIs to consider

We could imagine providing the template at table-header for instance, and then infer it back from JS:

I don’t know how it plays with colspan•s though 🤔
<table default-cell-size="1fr">
  <thead>
    <tr>
      <th size="min-content">Heading 1</th>
      <th size="1fr">Heading 2</th>
      <th size="1fr">Heading 3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cell 1</td>
      <td>Cell 2</td>
      <td>Cell 3</td>
    </tr>
  </tbody>
</table>
<script>
  const table = document.querySelector("table");
  const headers = Array.from(
    document.querySelectorAll("table > thead > tr > th")
  );
  const defaultSize = table.getAttribute("default-cell-size");
  const template = headers
    .map(header => header.getAttribute("size") || defaultSize)
    .filter(Boolean)
    .join(" ")
    .trim();
  // will be empty string if no table default-cell-size nor th size is provided.
  if (template) table.style.setProperty("--template", template);
</script>