This store requires javascript to be enabled for some features to work correctly.

Resources · 13 min read

Zebra Striping HTML: The Fixes Every Tutorial Skips

Stop broken zebra striping in HTML. Fix striped headers, filtered rows & dark mode contrast. Brought to you by Little Saints — get the real fixes.
Zebra Striping HTML: The Fixes Every Tutorial Skips

Key Takeaways

  • Scope the zebra stripe selector to tbody instead of table so the header row keeps its own background.
  • Exclude hidden rows from the :nth-child count with the Selectors Level 4 syntax :nth-child(even of :not([hidden])).
  • Handle JavaScript-inserted rows with either the of S CSS syntax or a JavaScript class toggle to re-apply stripes.
  • Reserve column striping for narrow comparison tables, because it conflicts with row striping and does not help screen readers.
  • Little Saints provides non-alcoholic cocktails and spirits designed for the evening wind-down after the build is complete.

Shop Little Saints

The Canonical Pattern Scoped To Tbody

Use this complete, copy-pasteable starting point for reliable zebra striping:

<!-- HTML --> <table> <thead> <tr> <th scope="col">Name</th> <th scope="col">Role</th> <th scope="col">Status</th> </tr> </thead> <tbody> <tr><td>Alice</td><td>Engineer</td><td>Active</td></tr> <tr><td>Bob</td><td>Designer</td><td>Active</td></tr> <tr><td>Carol</td><td>Manager</td><td>Away</td></tr> <tr><td>Dan</td><td>Engineer</td><td>Active</td></tr> </tbody> </table> /* CSS — even rows */ tbody tr:nth-child(even) { background-color: #f5f5f5; } /* CSS — odd rows (use one or the other, not both) */ tbody tr:nth-child(odd) { background-color: #f5f5f5; } 

MDN's :nth-child documentation defines the full An+B syntax and browser support matrix. This article focuses on the failure modes that reference does not cover in depth.

The Problem: Why Your Header Row Is Striped

Here is the broken version that stripes the header row:

/* BROKEN — do not use */ table tr:nth-child(even) { background-color: #f5f5f5; } 

Here is the fixed version that stripes only body rows:

/* FIXED */ tbody tr:nth-child(even) { background-color: #f5f5f5; } 

:nth-child() indexes against the parent's full element-child list. When the selector is scoped to table instead of tbody, the <thead> row occupies position 1 and the first data row lands at position 2, which is even, and receives the stripe color instead of the header. Scoping to tbody resets the count to the body rows only, because :nth-child() counts siblings within the same parent, and tbody is a separate parent from thead.

A secondary trap appears when browsers automatically insert an implicit <tbody> after you write <tr> elements directly inside <table>. In that case, a selector like table > tr silently fails because the browser has already wrapped the rows.

The Solution For Hidden, Filtered, Or Dynamically Added Rows

Why display: none Still Counts

A row hidden with display: none or the hidden attribute still occupies its position in the :nth-child count. MDN documents this directly. Hiding a third row causes the second and fourth rows to both count as even, so two adjacent visible rows share the same background and the alternating effect collapses.

Why JavaScript-Appended Rows Re-Index The Pattern

When a row is inserted using insertBefore() before a reference node, the reference node and all subsequent siblings shift to later positions in the DOM. Rows appended via insertRow(-1) or appendChild(), however, are added at the end without shifting existing siblings. The :nth-child pattern recalculates against the new DOM order, so a row that was odd before the insert can become even after it. Any extra element sibling, including dynamically inserted rows, changes which rows are considered even or odd.

The Fix With :nth-child(even of :not([hidden]))

Use the Selectors Level 4 filtered form to exclude hidden rows from the count:

/* Selectors Level 4 — hidden rows excluded from the count */ tbody tr:nth-child(even of :not([hidden])) { background-color: #f5f5f5; } 

The of <selector> syntax filters the sibling list before applying the positional formula. The browser first builds a counted set of only the rows matching :not([hidden]). It then applies even to that filtered set, so hidden rows do not participate in the count at all.

Browser support caveat: The of S syntax is supported in Chrome 111, Edge 111, Firefox 113, Opera 97, and Safari 9. It holds Baseline 2023 status, which makes it safe for most production audiences. Internet Explorer and Opera Mini have no support, and global coverage sits at roughly 92%.

The Fallback With A JavaScript Class Toggle

For controlled browser ranges that cannot rely on the of S syntax, re-apply striping with a class toggled after each filter or insert:

/* CSS */ tbody tr.stripe { background-color: #f5f5f5; } /* JavaScript — run after every filter or DOM mutation */ function restripe(tbody) { let visibleIndex = 0; tbody.querySelectorAll('tr').forEach(row => { if (row.hidden) { row.classList.remove('stripe'); } else { row.classList.toggle('stripe', visibleIndex % 2 === 1); visibleIndex++; } }); } 

Troubleshooting Checklist For Broken Stripes

Use this checklist when the stripe pattern looks wrong:

  1. Confirm the selector is scoped to tbody, not table or a bare tr.
  2. Confirm no rows are hidden with display: none or the hidden attribute while using plain :nth-child(even). Use the of :not([hidden]) form instead.
  3. Confirm dynamically inserted rows trigger a re-index, either via the of S CSS fix or by re-running the JavaScript class toggle.
  4. Confirm no rowspan attribute crosses stripe boundaries and creates a visual mismatch between the spanned cell and the row background.
  5. Confirm the stripe color is not being overridden by a more specific rule. A cell-level background-color on td or th can override a row-level background, since a more specific cell-level selector can override a row-level background color.

The Problem With Striping Columns Instead Of Rows

Column striping targets cells by their position within each row:

/* Column striping */ tbody td:nth-child(even) { background-color: #f5f5f5; } 

The limits are significant. Column striping conflicts with row striping, because applying both simultaneously produces a checkerboard that helps no one. On wide tables with many columns, the eye has to jump between stripes to read across a single row, which undermines the readability goal. Positional selectors style based on DOM order and do not change how assistive technology interprets the document, so column striping carries no semantic meaning for screen reader users.

Column striping works for one main case: narrow comparison tables with two to four columns where the user compares values vertically. In that layout, a column stripe helps the eye stay in the correct column.

Zebra Striping Html Without Css

Maintainable zebra striping without CSS is not realistic. The pre-CSS approach used the deprecated bgcolor presentational attribute on individual <tr> elements, for example <tr bgcolor="#f5f5f5">, applied manually to every other row. MDN lists bgcolor among the deprecated <table> attributes that should no longer be used and recommends background-color in CSS instead. Inline bgcolor is inaccessible, unsupported in modern HTML, and requires manual updates on every row whenever the table changes.

If CSS is genuinely unavailable, such as in a plain-text email renderer or a legacy CMS that strips stylesheets, practical alternatives include borders between rows, a monospaced fixed-width layout, or a different data format entirely. CSS remains the right tool, and no viable substitute matches its flexibility.

Striped Tables In Bootstrap And Other Frameworks

Bootstrap's .table-striped class applies zebra striping via the selector .table-striped > tbody > tr:nth-of-type(odd) > *. It sets --bs-table-bg-type to var(--bs-table-striped-bg), where $table-striped-order defaults to odd. To override it without fighting specificity, adjust the custom property:

/* Override Bootstrap stripe color */ .table-striped > tbody > tr:nth-child(odd) > td, .table-striped > tbody > tr:nth-child(odd) > th { --bs-table-striped-bg: #e8f4f8; } 

Tailwind CSS implements zebra striping through the even: variant, for example even:bg-gray-50 on a <tr>, which compiles to a scoped &:nth-child(even) rule at build time. For dark mode, pair it with dark:even:bg-gray-800/50.

Component libraries such as KendoReact Grid apply alternating rows via a .k-table-alt-row class rather than :nth-child. This approach keeps the stripe stable through filtering and virtual scrolling, but overriding it requires targeting the framework's own class instead of a positional selector.

Dark Mode And Contrast For Zebra Striping

A #f5f5f5 stripe on a black (#000000) background produces a 19.26:1 WCAG contrast ratio, which is AAA-level and highly visible. Use a prefers-color-scheme media query with a separate dark-mode stripe:

/* Light mode */ tbody tr:nth-child(even) { background-color: #f5f5f5; } /* Dark mode */ @media (prefers-color-scheme: dark) { body { background-color: #1a1a1a; color: #e0e0e0; } tbody tr:nth-child(even) { background-color: #2a2a2a; } } 

WCAG 2.2 Success Criterion 1.4.3 (Contrast Minimum) requires a contrast ratio of 4.5:1 for normal text and 3:1 for large text, measured against the background immediately behind the text. That threshold applies independently to text on striped rows and text on unstriped rows, so both must pass. The stripe changes the background color, so every text-on-stripe pairing must be verified separately.

Use a stripe that creates a subtle luminance shift. The color should distinguish rows without creating a high-contrast band that competes with the text. Testing text against each individual stripe color, not just the default row background, is the correct approach.

Zebra Striping Vs Borders Vs Hover Highlighting

Choose between row striping, borders, and hover highlighting based on the table's dimensions and the user's task:

  • Row striping helps users track across a wide table with many rows. The alternating background gives the eye a lane to follow.
  • Borders work better for dense tables with few columns, where the cell boundaries themselves carry the structure and a stripe would add visual noise.
  • Hover highlighting suits interactive tables where the user actively scans for a specific row. A tbody tr:hover rule adds a highlight only when needed, keeping the resting state clean.

The accessibility debate is honest. Some readability research suggests striping can improve comprehension for users with dyslexia or when scanning large datasets. Other analysis notes that a high-contrast stripe can distract on tables with low row counts or high cell density. The effective choice depends on row count, column count, and cell density, so no universal pattern fits every table.

The table below summarizes how each approach behaves across common scenarios.

Approach Best For Fails When Accessibility Note
Row striping (:nth-child(even)) Wide tables, many rows, horizontal scanning Rows are hidden, filtered, or dynamically inserted without the of S fix or a JS re-index ↗ Text on each stripe color must independently meet WCAG 2.2 SC 1.4.3 (4.5:1 normal text)
Cell borders (border: 1px solid) Dense tables, few columns, vertical scanning Table is wide and borders multiply, which creates visual clutter ↗ Border color must meet WCAG 2.2 SC 1.4.11 non-text contrast (3:1)
Hover highlight (tr:hover) Interactive tables, row-level actions, user-driven scanning Touch devices rely on unreliable hover states, and keyboard users need a visible focus indicator instead ↗ Hover and focus states must meet WCAG 2.2 SC 1.4.3. Avoid relying on color alone.

Frequently Asked Questions

Can You Zebra Stripe A Table Without Css?

Maintainable zebra striping without CSS is not practical. The pre-CSS approach used the deprecated bgcolor attribute on individual <tr> elements, which is obsolete in modern HTML, inaccessible, and requires manual updates on every row. CSS with tbody tr:nth-child(even) remains the correct tool for this job.

Why Is My Header Row Striped?

The selector is scoped to table instead of tbody. As explained earlier, :nth-child() counts all element children of the same parent, so a table tr:nth-child(even) rule counts the <thead> row as position 1. Changing the selector to tbody tr:nth-child(even) resets the count to body rows only.

Why Do My Stripes Break When Rows Are Filtered?

A hidden row still occupies its position in the :nth-child count. When a row is hidden with display: none or the hidden attribute, two adjacent visible rows can share the same even or odd position, which collapses the alternating pattern. The fix is tbody tr:nth-child(even of :not([hidden])), which counts only visible rows. For older browsers, re-apply a .stripe class via JavaScript after every filter operation.

How Do I Zebra Stripe A Table In Bootstrap?

Add the .table-striped class to the <table> element. Bootstrap applies the stripe through the selector and custom property described above, so override that variable rather than writing a competing background-color rule.

Does Zebra Striping Help Accessibility?

Effectiveness depends on the table. Striping can improve readability for users scanning large datasets or for users with dyslexia by giving the eye a lane to follow across a wide row. On small tables with few rows, the stripe often adds visual noise without benefit. In all cases, the stripe color must independently meet the contrast requirements discussed earlier. Striping complements proper <th> elements, scope attributes, and a logical table structure.

A Note From Our Sponsor: Little Saints Evening Wind-Down

The build is done, the stripe is correct, and the browser tab with the table is finally closed. That specific end-of-session moment, when the day's work is finished and the evening has not started yet, is the moment Little Saints targets.

Little Saints makes non-alcoholic cocktails and spirits for the evening drinking occasion. The drinks offer classic cocktail flavor profiles with zero sugar and a functional mushroom and botanical blend that delivers what the brand calls the Little Saints Lift, a sense of clarity and ease that feels subtly lifted and ready to exhale.

The canned cocktails, including Classic Spritz, Hugo Spritz, Classic Margarita, Paloma, Old Fashioned, Mojito, and Negroni Spritz, are finished drinks ready to pour over ice or drink straight from the can. They contain zero grams of sugar, five calories, and use monk fruit as a sweetener. The non-alcoholic spirits, St. Ember, St. Juniper, and St. Oak, are built for mixing. St. Ember mixes like a tequila or a mezcal, St. Juniper mixes like a gin, and St. Oak mixes like a whiskey. Two ounces replace two ounces in any recipe because the oil-based craft extracts hold up in a build without over-pouring.

Little Saints' Nature's Neuroscience platform comprises organic Reishi, organic Lion's Mane, Damiana, and botanical terpenes, with the specific blend varying by product. The mushroom extracts contain a minimum of 70% beta glucan, the fraction of the mushroom associated with adaptogenic properties, dosed at a stated 100mg per serving and documented by certificates of analysis from the supplier. The mushrooms are USDA Organic, grown in California, and extracted with ultrasound instead of chemical solvents. The extract holds GRAS status, the FDA's standard of generally recognized as safe for an ingredient used in food. The products are non-intoxicating, non-psychoactive, and 100% legal.

The evening wind-down has always needed a drink worth reaching for, and Little Saints fills that role.

These statements have not been evaluated by the Food and Drug Administration. This product is not intended to diagnose, treat, cure, or prevent any disease.

Explore The Evening Wind-Down

Conclusion: Keeping Your Stripe Correct In Production

tbody tr:nth-child(even) { background-color: #f5f5f5; } takes thirty seconds to write. Keeping that stripe correct across a header row, a filtered dataset, a JavaScript-appended row, a dark mode switch, and a specificity collision from a framework stylesheet takes more care.

Start by scoping the selector to tbody so the header row stays unstriped. If your table filters rows, switch to :nth-child(even of :not([hidden])) so hidden rows drop out of the count. For older browsers, re-index with JavaScript instead.

Once the pattern is stable, verify contrast on both striped and unstriped rows against WCAG 2.2 SC 1.4.3. Then choose between striping, borders, and hover highlighting based on the table's dimensions and the user's task.

When the last bug is closed and the build is done, Little Saints is ready for the evening wind-down.

Find Your Flavor


The statements made on our website have not been evaluated by the Food and Drug Administration, Our products are not intended to diagnose, treat, cure or prevent any disease. If you are pregnant, nursing, taking any medications or have any medical conditions, consult your doctor before use.