HTML table zebra striping

One selector does the work. The decisions worth making are how faint the tint should be, what happens in dark mode, and whether a wide table is better served by column rules instead.

An HTML table zebra stripe is one selector: tint every second row with tbody tr:nth-child(even) and leave the rest alone.

tbody tr:nth-child(even) {
  background: rgba(0, 0, 0, 0.03);
}

That is the whole effect. The reason it is worth a page is that the default values people reach for are too dark, break in dark mode, and collide with sticky columns.

The same twelve row table twice, unstriped on the left and striped on the right.
The same twelve row table twice, unstriped on the left and striped on the right.

What striping is actually for

A wide table asks the eye to travel a long way from the row label to the value. Somewhere in that journey the eye drops a line, and you read the wrong number.

An alternating tint gives each row a boundary that needs no borders. It is a reading aid, not decoration, which is why faint works better than strong.

If your table is four columns wide and eight rows deep, striping adds nothing. Save it for tables where the trip across is long.

Odd or even, and why it matters

nth-child(odd) tints the first body row, so the table starts with a band of colour directly under the header. The header then has to work harder to look like a header.

nth-child(even) leaves the first row matching the page, and the table reads as text with a rhythm rather than a coloured block. That is the default worth keeping.

Scope the rule to tbody. Without it, a <thead> row counts in the sequence and the parity flips.

How faint is faint enough

Tint Against white Reads as
rgba(0,0,0,0.02) Near invisible Works on large screens, lost on dim laptop panels
rgba(0,0,0,0.03) Just perceptible The usual right answer
rgba(0,0,0,0.05) Clearly visible Fine for dense financial tables
#f2f2f2 or darker A grey band Competes with the text, dates it

Using rgba rather than a fixed grey matters. A translucent black tints whatever is behind it, so the same rule survives a change of page background and keeps working in a dark theme.

Dark mode

A hard-coded #f9f9f9 on a dark page produces a white stripe across black text. Two ways out.

The translucent route works with no extra code, because rgba(0,0,0,0.03) on a dark background is a slightly darker dark.

The explicit route gives you more control:

@media (prefers-color-scheme: dark) {
  tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.04); }
}

White at low alpha lifts the row rather than sinking it, which reads better on very dark backgrounds. Dark mode CSS covers the wider pattern.

The striped table on a dark background. The alternating rows are lifted, not whitened.
The striped table on a dark background. The alternating rows are lifted, not whitened.

Striping plus hover

A hover tint on top of a stripe needs to be distinguishable from the stripe itself, or the pointer appears to do nothing on plain rows.

tbody tr:hover { background: rgba(0, 90, 200, 0.06); }

A tinted hue rather than more grey solves it. The hover colour then reads as a different thing, not as a slightly wrong stripe. Skip hover entirely on a table that will be read on a phone, where there is no pointer.

Where striping collides with other table CSS

Three interactions to know about.

  • Sticky columns. A pinned cell needs an opaque background, and a flat one wipes the stripe. Set the pinned background inside the same parity rule. See sticky first column.
  • Borders. Stripes and full horizontal rules do the same job twice. If you stripe, drop the row borders and keep only a rule under the header. Border collapse covers the mechanics.
  • Printing. Browsers drop background colours in print by default, so stripes vanish on paper. Add -webkit-print-color-adjust: exact; if they matter, or rely on borders for the printed version. Print stylesheets go through it.

Grouped striping for tables with repeated keys

Some tables have several rows per subject: three invoices for one client, four months for one region. Alternating every row then cuts across the groups and works against the reader.

The fix is to stripe the group rather than the row. Put a class on the first row of each group in the markup and alternate that class as you generate the table.

tbody tr.group-b { background: rgba(0, 0, 0, 0.03); }

The tint then marks a subject instead of a line, which is what the reader is tracking. Keep a rule above each group as well if the groups are more than four rows deep.

A table where the tint changes per client group rather than per row.
A table where the tint changes per client group rather than per row.

Column striping for very wide tables

Sometimes the harder job is tracking a column downwards, not a row across. The same selector works on cells.

tbody td:nth-child(even) { background: rgba(0, 0, 0, 0.02); }

Do not use both at once. Row and column tints multiply at the intersections and produce a chequerboard that is worse than either alone.

A wide numeric table with alternating column tints instead of row tints.
A wide numeric table with alternating column tints instead of row tints.

Checking it, and getting it to a reader

Stripes are the first thing to disappear when the table is copied into another page, because the receiving CSS may already set a row background.

Open the file on its own before you decide it works. The HTML file opener renders it with nothing inherited from your project.

To hand the table to someone, paste the HTML into a NOS document. It renders as written, striping and dark theme included, at an address of its own. Share, then Share link, then Create link produces a link that opens in one click.

Values can be corrected later by clicking the text in the document. The address stays the same, so the link you sent still points at the current table.

If you want to type into the cells directly, the editable HTML table tool does that without touching the markup.

Questions people ask

What is zebra striping in a table?

Alternating background tints on table rows, so every second row is slightly darker than the one above it. The point is to help the eye track a single row from left to right on a wide table without slipping onto the neighbouring line.

Should I stripe odd rows or even rows?

Even rows, in almost every case. The first row of the body then matches the page background, which keeps the table from looking like a block of colour, and the header row stays visually separate from the data below it.

How dark should the stripe be?

Barely visible. A tint of about 2 to 4 percent against the page background is enough for the eye to follow a line. Anything darker starts to compete with the text and makes the numbers on tinted rows harder to read than the ones on plain rows.

Why did my stripes disappear on the pinned column?

A sticky column needs an opaque background of its own, and that background is usually set as a single flat colour, which overrides the stripe. Set the pinned cell background inside the same nth-child rule so the pinned column carries the tint too.

Keep reading