CSS Styling Tables

Browsers give HTML tables a plain, boxy default look: thin single borders around cells, a bit of internal padding, and column widths guessed from content. CSS lets you take full control of that appearance — merging or spacing borders, striping rows for readability, controlling how column widths are calculated, and making wide tables usable on small screens. Because a table is really a grid of independent boxes (rows, cells, and an invisible table object) laid out by its own algorithm, styling it well means understanding a few rules that don’t apply anywhere else in CSS.

Overview / How it works

A table isn’t just a block of nested boxes stacked the normal way. When the browser sees a <table> (along with <thead>, <tbody>, <tr>, <th>, and <td>), it switches to a dedicated table layout algorithm controlled by the display values table, table-row, table-cell, and friends — these are the computed display values the browser assigns to those elements even before you write any CSS. That algorithm decides column widths, row heights, and — critically — how borders between adjacent cells are drawn.

The single most important table-specific property is border-collapse. By default it’s separate: every cell has its own independent border box, and there is a gap between cells controlled by border-spacing (2px by default in most browsers). Set border-collapse: collapse and adjacent cell borders merge into a single line, with the browser resolving conflicting widths/styles/colors using a priority order (a wider border wins; if widths tie, styles are prioritized: double > solid > dashed > dotted > ridge > outset > groove > inset). This is why almost every real-world styled table starts with border-collapse: collapse; — it removes the doubled-up border look and the odd gaps.

The other big decision is table-layout. The default, auto, makes the browser look at the content of every cell before deciding column widths — this gives nicely proportioned columns but forces the browser to wait until it has parsed enough of the table to measure content, which is slow for large tables. table-layout: fixed tells the browser to size columns from the first row (or explicit <col> widths / the widths on the first row’s cells) and ignore the rest of the content, which renders faster and makes column widths predictable and controllable with plain CSS widths.

Table cells also participate in the normal box model — padding, background, color, and (when borders are separate) border-radius all work as expected on <td> and <th>. Text alignment inside cells uses the regular text-align (horizontal) and vertical-align (vertical — table cells are one of the few contexts where vertical-align reliably does what people expect, since a cell’s height is determined by its row).

Syntax

table {
  border-collapse: collapse | separate;
  border-spacing: length length;
  table-layout: auto | fixed;
  caption-side: top | bottom;
  empty-cells: show | hide;
}
td, th {
  border: width style color;
  padding: length;
  text-align: left | center | right;
  vertical-align: top | middle | bottom;
}
Property Applies to Purpose
border-collapse table Merges adjacent cell borders into one line instead of separate, spaced boxes.
border-spacing table Gap between cells when borders are separate (ignored under collapse).
table-layout table Whether column widths are computed from all content (auto) or the first row/explicit widths (fixed).
caption-side table Places the <caption> above or below the table.
empty-cells table Whether borders/background show on cells with no content (separate model only).

Examples

Example 1: A clean bordered table

HTML: a simple <table> with a <thead> row and two <tbody> rows of <td> cells.

table {
  width: 100%;
  border-collapse: collapse;
  font-family: system-ui, sans-serif;
}

th, td {
  border: 1px solid #ccc;
  padding: 10px 14px;
  text-align: left;
}

thead th {
  background-color: #1f2937;
  color: #ffffff;
  font-weight: 600;
}

Result: The table spans the full width of its container. Every cell has a single 1px light-gray border with no doubled or gapped lines, since collapsing merged the adjacent borders. The header row has a dark background with white bold text, and 14px of horizontal padding keeps text from touching the borders.

This is the baseline pattern for almost any styled table: collapse the borders first, then style the header distinctly from the body so scanning columns is easy.

Example 2: Zebra striping and row hover

table {
  border-collapse: collapse;
  width: 100%;
}

td, th {
  padding: 8px 12px;
  border-bottom: 1px solid #e5e7eb;
}

tbody tr:nth-child(even) {
  background-color: #f9fafb;
}

tbody tr:hover {
  background-color: #e0f2fe;
  transition: background-color 0.15s ease-in-out;
}

Result: Rows have only a bottom border, so the table reads as horizontal lines rather than a full grid. Every even-numbered row in the body gets a very light gray background, creating alternating “zebra” stripes that make it easier to track a row across many columns. When the mouse hovers over any row, its background smoothly fades to light blue over 0.15 seconds.

:nth-child(even) is scoped to tbody tr so the header row (inside <thead>) is never accidentally striped. The transition on background-color is what makes the hover state feel smooth instead of an instant snap.

Example 3: A responsive, fixed-layout table with a sticky header

.table-wrapper {
  overflow-x: auto;
  max-width: 100%;
}

table {
  table-layout: fixed;
  border-collapse: collapse;
  min-width: 640px;
  width: 100%;
}

th, td {
  padding: 10px;
  border: 1px solid #d1d5db;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

thead th {
  position: sticky;
  top: 0;
  background-color: #ffffff;
  z-index: 1;
}

Result: On a narrow screen, the table itself stays at least 640px wide (so columns never crush their content), while the surrounding .table-wrapper scrolls horizontally with a scrollbar — nothing overflows the page layout. Because table-layout is fixed, all columns get equal width from the available space rather than being resized per cell. Long text in any cell is clipped with an ellipsis (…) instead of wrapping or overflowing. As the wrapper is scrolled vertically (if it has its own scroll area) the header row stays pinned to the top thanks to position: sticky.

This pattern is the standard fix for tables with many columns on mobile: don’t try to shrink the table to fit the viewport — let it scroll horizontally inside a contained wrapper instead.

How it works step by step

  1. The browser builds an internal table box from the <table> element and generates anonymous boxes for any missing structural pieces (for example, a <tbody> is inserted automatically around bare <tr> children if you didn’t write one).
  2. It resolves table-layout: with auto, it scans cell content (and any explicit widths) across the whole table to compute proportional column widths; with fixed, it only looks at the first row and any column widths, then divides remaining space evenly.
  3. It resolves borders. Under separate, each cell keeps its own border box and border-spacing is inserted between every pair of cells (and around the outer edge unless overridden). Under collapse, adjacent borders from neighboring cells, rows, row groups, columns, and the table itself are compared, and the single winning border (by width, then style) is drawn exactly once on the shared edge.
  4. Row heights are computed: a row’s height is the maximum of its cells’ content heights plus padding and borders, so every cell in a row is stretched to match the tallest cell — this is why vertical-align on a short cell in a tall row visibly does something.
  5. Finally, backgrounds and borders are painted in a specific stacking order: table background, then row-group, then row, then column, then cell — so a background set on tr can be hidden by an opaque background set on td, but a transparent cell background lets the row’s color show through.

Common Mistakes

Mistake 1: Forgetting border-collapse

table {
  width: 100%;
}
td {
  border: 1px solid #ccc;
}

Why it’s wrong: Without border-collapse: collapse, the table uses the default separate border model. Every cell keeps its own full border box, and the browser inserts border-spacing (2px by default) between them. The result is a grid of doubled, gapped lines rather than one clean line between cells — it looks like each cell has a visible seam around it.

table {
  width: 100%;
  border-collapse: collapse;
}
td {
  border: 1px solid #ccc;
}

Adding border-collapse: collapse merges the shared edges into single lines and removes the default spacing entirely.

Mistake 2: Invalid declaration syntax

td {
  border 1px solid #ccc;
  padding: 10px
}

Why it’s wrong: The border declaration is missing its colon (border 1px solid #ccc instead of border: 1px solid #ccc), and the padding declaration is missing its terminating semicolon before the closing brace on some minifiers/parsers. A missing colon makes the whole declaration invalid and it is dropped silently by the browser — the border simply never appears, with no error shown anywhere in the page.

td {
  border: 1px solid #ccc;
  padding: 10px;
}

Always double-check that every declaration has the form property: value; — a missing colon or semicolon can silently discard a whole rule.

Best Practices

  • Start every table stylesheet with border-collapse: collapse; unless you specifically want spaced, separated cell borders (for example, rounded individual cell boxes).
  • Use table-layout: fixed with explicit column widths (via <col> or first-row cell widths) for large tables — it renders faster and keeps columns predictable, especially with dynamic data.
  • Style <thead> distinctly from <tbody> (background, font-weight, border) so the header is instantly recognizable while scrolling.
  • Wrap wide tables in a div with overflow-x: auto rather than shrinking font size or column widths to force-fit small screens.
  • Use :nth-child(even) / :nth-child(odd) scoped to tbody tr for zebra striping, never on the whole table, so the header row is unaffected.
  • Set text-align explicitly on numeric columns (usually right) — the browser default of left for <td> makes numbers harder to compare at a glance.
  • Avoid relying on table borders alone for accessibility cues — pair visual structure with proper <th scope> markup (an HTML concern) so assistive technology understands the table too.

Practice Exercises

  1. Build a table with collapsed borders, 12px of cell padding, a dark header background with white text, and right-aligned numeric cells in the last column.
  2. Take the table from Exercise 1 and add zebra striping to the body rows only, plus a hover background color with a smooth transition.
  3. Wrap a wide table (at least 6 columns) in a scrollable container, set table-layout: fixed with a sensible min-width, and make the header row stick to the top of the scroll area using position: sticky.

Summary

  • Tables use a dedicated layout algorithm — border-collapse and table-layout are table-only properties with no equivalent elsewhere in CSS.
  • border-collapse: collapse merges adjacent cell borders into single lines and removes default cell spacing; separate (the default) keeps each cell’s border independent, spaced by border-spacing.
  • table-layout: fixed computes column widths from the first row only (fast, predictable); auto measures all content (slower, content-fitting).
  • Row height is driven by the tallest cell in that row, which is why vertical-align is meaningful inside table cells.
  • Backgrounds paint in table → row-group → row → column → cell order, so an opaque cell background always wins over a row background.
  • For responsive tables, scroll a wrapper horizontally rather than shrinking the table to fit — combine with position: sticky headers for long tables.