Type something to search...
How to Style Tables with Modern CSS

How to Style Tables with Modern CSS

Tables have a complicated history on the web. For years they were abused for page layout, then CSS layout arrived and developers overcorrected, treating tables as something to avoid entirely. But for tabular data, such as invoices, comparison charts, analytics reports, schedules, and API parameter references, a real <table> is still the right tool. It gives screen readers the row and column relationships they need to announce each cell in context, and it's the most robust layout for grid-shaped data.

The problem is that default table styling is plain and sometimes awkward. This guide covers how to turn a raw table into something clean and readable using modern CSS: sensible spacing, borders, rounded corners, zebra striping, sticky headers and first columns, number alignment, row highlighting with :has(), and responsive strategies that don't sacrifice accessibility.

Start with Good Markup

Styling is much easier when the HTML is structured properly. Here's a small invoice table we'll style throughout this guide:

<div
  class="table-wrap"
  role="region"
  aria-labelledby="invoice-caption"
  tabindex="0"
>
  <table class="data-table">
    <caption id="invoice-caption">
      Invoice #1042, September 2026
    </caption>
    <thead>
      <tr>
        <th scope="col">Item</th>
        <th scope="col">Category</th>
        <th scope="col" class="num">Qty</th>
        <th scope="col" class="num">Unit price</th>
        <th scope="col" class="num">Total</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">Website redesign</th>
        <td>Design</td>
        <td class="num">1</td>
        <td class="num">$4,800.00</td>
        <td class="num">$4,800.00</td>
      </tr>
      <tr>
        <th scope="row">Hosting (annual)</th>
        <td>Infrastructure</td>
        <td class="num">1</td>
        <td class="num">$240.00</td>
        <td class="num">$240.00</td>
      </tr>
      <tr>
        <th scope="row">Content migration</th>
        <td>Development</td>
        <td class="num">12</td>
        <td class="num">$85.00</td>
        <td class="num">$1,020.00</td>
      </tr>
    </tbody>
    <tfoot>
      <tr>
        <th scope="row" colspan="4">Total due</th>
        <td class="num">$6,060.00</td>
      </tr>
    </tfoot>
  </table>
</div>

A few things are worth noting:

  • <caption> gives the table an accessible name and a visible title. It's announced when a screen reader enters the table.
  • <thead>, <tbody>, and <tfoot> group rows semantically, and they give CSS convenient hooks.
  • scope="col" and scope="row" tell assistive technology which cells a header applies to. Row headers are especially helpful in data tables where the first column identifies each row.
  • The wrapper div handles horizontal scrolling on small screens. The role="region", label, and tabindex="0" make that scrollable area reachable and understandable for keyboard and screen reader users.

The Foundation: Collapse, Width, and Spacing

Browsers render tables with border-collapse: separate by default, which puts a gap between every cell's borders. For most data tables, you want collapsed borders and a consistent base style:

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.95rem;
  line-height: 1.45;
  color: #0f172a;
}

.data-table caption {
  caption-side: top;
  padding-bottom: 0.75rem;
  font-weight: 700;
  font-size: 1.1rem;
  text-align: left;
}

.data-table th,
.data-table td {
  padding: 0.75rem 1rem;
  text-align: left;
  vertical-align: top;
}

.data-table thead th {
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #475569;
  background: #f8fafc;
  border-bottom: 2px solid #e2e8f0;
}

.data-table tbody tr + tr {
  border-top: 1px solid #e2e8f0;
}

Horizontal dividers between rows, with no vertical lines, are the most readable default for data tables. Vertical borders add visual noise; good alignment and spacing separate columns more gracefully.

Row headers inside the body shouldn't look like the column headers. Reset them to regular body text:

.data-table tbody th {
  font-weight: 500;
}

Aligning Numbers Properly

Numeric columns are easiest to compare when they're right-aligned and every digit has the same width:

.data-table .num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

tabular-nums switches the font to fixed-width digits, if the font provides them, so decimal points and thousands separators line up vertically. white-space: nowrap keeps values like "$4,800.00" from wrapping onto two lines in narrow layouts.

Make sure the header cells of numeric columns are right-aligned as well. That's why the num class appears on the <th> elements too. A left-aligned header above right-aligned numbers is a common and easily avoided mistake.

Rounded Corners and an Outer Border

Rounded corners are one of the first things people try, and one of the first things that doesn't work. border-radius on a table has no visible effect when borders are collapsed. The fix is to switch back to separate borders with zero spacing, then draw borders on the cells carefully:

.data-table--rounded {
  border-collapse: separate;
  border-spacing: 0;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  overflow: hidden;
}

.data-table--rounded tbody tr + tr > * {
  border-top: 1px solid #e2e8f0;
}

With border-collapse: separate, borders on <tr> elements are not drawn at all, which is why the divider moves to the cells (> * selects every th and td in the row). overflow: hidden clips the header background to the rounded corners.

One caution: overflow: hidden on the table itself will break sticky headers inside it. If you need both, put the border and radius on the wrapper instead.

Zebra Striping

Alternating row backgrounds help the eye track across wide tables:

.data-table tbody tr:nth-child(even) {
  background: #f8fafc;
}

If you use striping, you can often drop the row dividers, since the color change already separates rows. Pick one or the other for a cleaner look. For short tables with only a handful of rows, simple dividers are usually enough.

Hover and Focus Highlighting

Highlighting the row under the pointer helps people read across:

.data-table tbody tr:hover {
  background: #eff6ff;
}

With :has(), you can also highlight a row when anything inside it has keyboard focus, which matters for tables with links, checkboxes, or buttons in their cells:

.data-table tbody tr:has(:focus-visible) {
  background: #eff6ff;
}

And you can style a row based on its contents, such as a selected checkbox:

.data-table tbody tr:has(input[type="checkbox"]:checked) {
  background: #e0e7ff;
}

That used to require JavaScript to toggle a class on the row. :has() is supported in all current major browsers.

Styling the Footer

Totals rows should stand apart from the data:

.data-table tfoot th,
.data-table tfoot td {
  font-weight: 700;
  border-top: 2px solid #0f172a;
}

.data-table tfoot th {
  text-align: right;
}

Because the "Total due" cell spans four columns, right-aligning it places the label right next to the value it describes.

Sticky Headers

For long tables, keeping the header row visible while scrolling is a big usability win. position: sticky works on table header cells:

.table-wrap {
  max-height: 28rem;
  overflow: auto;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
}

.data-table thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: #f8fafc;
}

Two details matter:

  • Apply sticky to the th cells, not to <thead> or <tr>. Cells are the most reliable target across browsers.
  • Give the header an opaque background. Otherwise the body rows scroll visibly underneath it.

A sticky element sticks relative to its nearest scrolling ancestor. Here that's .table-wrap, which scrolls in both directions. If the wrapper didn't scroll vertically (no max-height), the header would stick to the page viewport instead, as long as no ancestor sets overflow to something other than visible.

When borders are collapsed, the header's bottom border may scroll away with the body in some browsers, because collapsed borders are shared between cells. If that happens, draw the line with a box shadow instead:

.data-table thead th {
  border-bottom: 0;
  box-shadow: inset 0 -2px 0 #e2e8f0;
}

Sticky First Column

For wide tables that scroll horizontally, freezing the first column keeps row labels in view:

.data-table th[scope="row"] {
  position: sticky;
  left: 0;
  z-index: 1;
  background: #fff;
}

.data-table thead th:first-child {
  position: sticky;
  left: 0;
  z-index: 2;
}

.data-table tbody tr:nth-child(even) th[scope="row"] {
  background: #f8fafc;
}

The top-left header cell sticks in both directions, and a higher z-index keeps it above both the sticky header row and the sticky column. Remember to match the striped background on the sticky cells, or the sticky column will look out of place on even rows.

A subtle shadow on the sticky column edge helps signal that content is scrolling underneath:

.data-table th[scope="row"] {
  box-shadow: inset -1px 0 0 #e2e8f0;
}

Controlling Column Widths

By default, browsers size columns based on their content, which can cause layouts to shift as data changes. table-layout: fixed tells the browser to size columns from the first row or from <col> elements, which is faster and more predictable:

<table class="data-table data-table--fixed">
  <colgroup>
    <col style="width: 40%" />
    <col style="width: 20%" />
    <col style="width: 10%" />
    <col style="width: 15%" />
    <col style="width: 15%" />
  </colgroup>
  ...
</table>
.data-table--fixed {
  table-layout: fixed;
}

.data-table--fixed td,
.data-table--fixed th {
  overflow-wrap: anywhere;
}

With fixed layout, long content no longer stretches its column, so add overflow-wrap: anywhere or text-overflow: ellipsis to handle long values gracefully.

Making Tables Responsive

There's no single right answer for tables on small screens. Here are the two most reliable approaches.

Option 1: Horizontal scrolling

The wrapper we've already built is the simplest and most accessible option. The table keeps its structure, and users swipe horizontally to see more columns. Add a minimum width so columns don't get crushed:

.table-wrap {
  overflow-x: auto;
}

.data-table {
  min-width: 40rem;
}

.table-wrap:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

Combine it with the sticky first column, and wide tables become quite usable on phones.

Option 2: Stacked rows as cards

For tables with only a few columns, you can restyle each row as a card on narrow screens, with labels generated from a data-label attribute:

<tr>
  <th scope="row">Website redesign</th>
  <td data-label="Category">Design</td>
  <td data-label="Qty" class="num">1</td>
  <td data-label="Unit price" class="num">$4,800.00</td>
  <td data-label="Total" class="num">$4,800.00</td>
</tr>
@media (width < 36rem) {
  .data-table--stack thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }

  .data-table--stack tr {
    display: block;
    padding: 0.75rem 0;
    border-top: 1px solid #e2e8f0;
  }

  .data-table--stack th[scope="row"],
  .data-table--stack td {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.35rem 0;
    text-align: right;
  }

  .data-table--stack th[scope="row"] {
    font-weight: 700;
  }

  .data-table--stack td::before {
    content: attr(data-label);
    font-weight: 600;
    color: #475569;
    text-align: left;
  }
}

This looks great, but there's an accessibility cost. Changing display on table elements has historically caused some browsers to drop the table semantics, so screen readers stop announcing rows and columns. Browsers have improved here, but results still vary. If you use this pattern, you can restore semantics explicitly with ARIA roles on the elements (role="table", role="row", role="columnheader", role="rowheader", role="cell"), and test with a screen reader. The visually hidden thead keeps the header text available, rather than removing it with display: none.

Dark Mode with Custom Properties

Collecting the colors into variables keeps all of the above consistent and makes theming a small override:

.data-table {
  --t-text: #0f172a;
  --t-muted: #475569;
  --t-border: #e2e8f0;
  --t-head: #f8fafc;
  --t-stripe: #f8fafc;
  --t-hover: #eff6ff;
  --t-bg: #ffffff;
}

@media (prefers-color-scheme: dark) {
  .data-table {
    --t-text: #f1f5f9;
    --t-muted: #94a3b8;
    --t-border: #334155;
    --t-head: #1f2937;
    --t-stripe: #172033;
    --t-hover: #1e293b;
    --t-bg: #111827;
  }
}

Then replace each hardcoded color in the earlier rules with the matching variable.

Common Pitfalls

  1. Using tables for layout : Only use tables for data with a real row/column relationship. Use Grid or Flexbox for page layout.
  2. Rounded corners on a collapsed table : They won't render. Use border-collapse: separate with border-spacing: 0, or round the wrapper instead.
  3. Sticky header not sticking : An ancestor with overflow: hidden (including the table itself) can prevent it. Check every ancestor between the header and the scroll container.
  4. Transparent sticky cells : Rows scroll visibly behind them. Always set a background.
  5. Left-aligned numbers : They're hard to compare. Right-align them and use tabular-nums.
  6. Missing headers and scope : Without th and scope, screen reader users hear a stream of values without context.

Conclusion

Tables are still the best way to present tabular data on the web, and with modern CSS they can look as polished as any other component. Start with semantic markup, including a caption, header cells, and scope. Then build up with collapsed borders and generous padding, right-aligned tabular numbers, subtle striping or dividers, sticky headers and columns for large datasets, and :has() for interactive row states.

For small screens, a scrolling wrapper is the simplest and most accessible choice, while stacked card layouts can work for short tables if you test them carefully. Do all that, and your tables will be easy to scan, easy to navigate, and easy on the eyes.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading