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

Resources · 17 min read

Zebra Striping Tables: CSS, Excel & Accessibility Guide

Learn how to zebra stripe tables in CSS, Excel & Google Sheets. Little Saints covers WCAG contrast, dark mode & hover states. Read the full guide.
Zebra Striping Tables: CSS, Excel & Accessibility Guide

Key Takeaways

  • Zebra striping uses subtle alternating row colors to help readers track across wide, dense tables. It remains a design choice rather than a UX requirement.
  • Scope CSS striping to tbody tr:nth-child(even). Then confirm 4.5:1 text contrast on both row colors and 3:1 non-text contrast for the stripe when WCAG requires it.
  • Use the Selectors Level 4 of syntax (:nth-child(even of :not([hidden]))) to keep stripes continuous when rows are filtered or hidden.
  • Skip zebra striping on interactive tables with hover states, expandable rows, or selectable elements. Rely on hover effects or subtle dividers instead to avoid visual conflicts.

How To Zebra Stripe A Table

This step-by-step checklist walks through selector scope and basic accessibility checks.

  1. Scope the stripe to tbody so the header row is not counted in the sequence.
  2. Apply tbody tr:nth-child(even) with a background color.
  3. Confirm the stripe color reaches a contrast ratio of at least 3:1 against the color or colors immediately next to it, not only the base row color. This requirement applies when the stripe is visual information needed to identify a user interface component or understand graphical content, as required by WCAG 1.4.11 Non-text Contrast, Level AA.
  4. Confirm body text still reaches 4.5:1 on both row colors, satisfying WCAG 1.4.3 Contrast Minimum, Level AA.
  5. When rows are filtered or hidden, switch to :nth-child(even of :not([hidden])) or toggle a class in the render loop.
  6. For dynamic tables with interactive features such as expandable rows, hover highlights, or selectable rows, skip zebra striping. Use subtle horizontal dividers or row hover effects instead to avoid visual noise.

How To Zebra Stripe A Table With CSS Nth-Child

The core CSS technique for zebra striping tables is tbody tr:nth-child(even) { background-color: ... } applied to rows inside the tbody. Using nth-child(even) on rows inside the tbody is preferred over nth-of-type. The nth-of-type selector targets all sibling tr elements regardless of which tbody they belong to. This behavior breaks when a table has multiple tbody sections or a tfoot. The following example shows a complete pattern with semantic markup.

<!-- Semantic table with scoped zebra striping --> <table class="data-table"> <thead> <tr> <th scope="col">Name</th> <th scope="col">Role</th> <th scope="col">Department</th> <th scope="col">Start Date</th> </tr> </thead> <tbody> <tr><td>Alice</td><td>Engineer</td><td>Platform</td><td>2022-03-01</td></tr> <tr><td>Bob</td><td>Designer</td><td>Product</td><td>2021-11-15</td></tr> <tr><td>Carol</td><td>Analyst</td><td>Data</td><td>2023-06-20</td></tr> <tr><td>Dan</td><td>Manager</td><td>Operations</td><td>2020-01-08</td></tr> </tbody> </table> <style> /* Base table reset */ .data-table { border-collapse: collapse; width: 100%; font-size: 0.875rem; /* 14px, readable for dense data */ } /* Header: excluded from stripe sequence by scoping to tbody */ .data-table thead th { background-color: #1e293b; /* dark slate */ color: #ffffff; text-align: left; padding: 0.75rem 1rem; } /* Base row */ .data-table tbody tr { background-color: #ffffff; } /* Stripe: even rows only within tbody */ .data-table tbody tr:nth-child(even) { background-color: #f8fafc; /* zinc-50 equivalent, subtle */ } /* Cell padding */ .data-table td { padding: 0.75rem 1rem; color: #0f172a; /* near-black, passes 4.5:1 on both #ffffff and #f8fafc */ } </style>

Why :nth-child Breaks On Filtered Or Hidden Rows. MDN explains that :nth-child() matches elements by their 1-based index among all siblings in the parent. A row with the hidden attribute still occupies a position in that count. When client-side filtering hides or removes rows, :nth-child() zebra striping can break because the selector counts all sibling elements regardless of visibility. Adjacent visible rows can share the same background. Using :nth-child(even of :not([hidden])) avoids this problem by excluding hidden rows from the count.

The Fix With Selectors Level 4. The Selectors Level 4 of <selector> syntax filters the sibling set before the nth calculation runs. Only siblings matching the inner selector are counted. Non-matching siblings are invisible to the counter.

/* Hidden-row-safe stripe */ .data-table tbody tr:nth-child(even of :not([hidden])) { background-color: #f8fafc; }

The :nth-child() of <selector> syntax reached Baseline Widely available status on 2025-11-09, with support in Chrome 111+, Edge 111+, Firefox 113+, and Safari 9+. Before the CSS :nth-child(An+B of S) syntax existed, many teams used a JavaScript function, such as the stripe() function in A List Apart’s 2004 “Zebra Tables” article. That function looped through a table’s rows and assigned an odd or even class based on the row index. The of syntax removes that JavaScript dependency.

Framework Syntax In Practice. Bootstrap applies striping with the .table-striped class alongside .table on the <table> element. Tailwind uses the odd: and even: variants applied directly to <tr> elements, for example odd:bg-white even:bg-zinc-50. These map to the CSS :nth-child(odd) and :nth-child(even) pseudo-classes. Raw CSS uses tbody tr:nth-child(even) directly.

Sticky Headers And Striping. A sticky thead must sit outside the stripe pattern. Scoping to tbody handles this automatically. When a selector applies :nth-child() striping to all tr elements instead of scoping to tbody tr, the header row can receive a stripe background and the sticky header may inherit that color.

Zebra Striping In Excel And Google Sheets

Spreadsheet tools use built-in banding rules, and those rules behave differently from CSS striping. Understanding this difference helps you predict how stripes respond to row insertion and export.

Excel. Microsoft’s documented method is to select the cell range, go to Home > Styles group > Format as Table, choose a table style under Light, Medium, or Dark, and select OK. The Banded Rows check box is selected by default to shade alternate rows. The banding follows the table range as rows are added. To switch from row banding to column banding, go to Table Design, clear Banded Rows, and select Banded Columns. The conditional formatting alternative, Home > Conditional Formatting > New Rule with the formula =MOD(ROW(),2)=0, applies banding without table functionality. It does not automatically extend to rows added outside the originally formatted range. It does re-stripe correctly when rows are inserted or deleted within that range.

Google Sheets. Format > Alternating colors opens a sidebar and applies a default grey-and-white pattern. Banding works as a live formatting rule attached to the range rather than a static fill, so it automatically adjusts when rows are inserted or deleted. When sorting, the colors stay on the same row positions while the data moves, so the stripes do not follow the data. The sidebar provides header and footer checkboxes and custom color controls.

Export Behavior Across Tools. Spreadsheet banding generally does not carry into an exported HTML table. CSS striping does not carry into a spreadsheet’s own formatting. Zebra striping in a spreadsheet is applied through the spreadsheet’s built-in formatting features, such as Google Sheets’ Alternating colors tool or conditional formatting. CSS can still change how an exported spreadsheet appears in a browser. These layers remain separate and do not share state.

Surface How The Stripe Is Applied Survives Row Insertion Carries Into Exported HTML
CSS (:nth-child) Pseudo-class selector computed live by the browser CSS zebra striping via tr:nth-child(2n) is recalculated on every render, so it survives dynamic row insertion and removal from the DOM without JavaScript. When rows are only hidden with display:none instead of removed, they remain in the DOM and break the striping pattern. A CSS rule such as tbody tr:nth-child(odd) { background-color: #DCDCDC; } in a stylesheet applies to matching rows in exported HTML output, as demonstrated in Quarto HTML rendering. Quarto automatically adds its own striping classes to all tables at runtime, which can override or interact with user-defined CSS unless table processing is disabled or the CSS is made more specific.
Excel (Format as Table) Table style bound to the table range In Excel, when a range is formatted as a Table with Banded Rows enabled and no conflicting manual formatting, the zebra stripe pattern automatically extends to new rows added at the bottom or inserted inside the table. Excel’s Format as Table banding is a spreadsheet style rather than HTML markup, so it generally does not carry into exported HTML.
Google Sheets (Alternating Colors) Formatting rule applied to the selected range Google Sheets’ native Alternating Colors feature is a range-based banding rule. Inserting or deleting rows within the formatted range automatically adjusts the stripes. After sorting, the bands follow the new row order, so the alternating pattern is maintained. Rows inserted outside the original formatted range do not receive the alternating colors. Google Sheets Alternating Colors banding does not carry into exported HTML. The HTML export preserves table structure and basic formatting but drops formatting rules like banding, because banding is a Sheets formatting rule rather than HTML markup.

Zebra Striping And WCAG Contrast Requirements

WCAG 2.2 Level AA includes contrast success criteria that directly affect striped tables. The main requirements cover text contrast and non-text contrast.

WCAG 1.4.3 Contrast Minimum requires a contrast ratio of at least 4.5:1 for normal text and at least 3:1 for large text. Large text means 18pt or larger, or 14pt bold or larger, roughly 24px or 18.5px bold. This criterion applies to text and images of text. Body text in a table cell must reach a contrast ratio of at least 4.5:1 against its background. For zebra-striped tables, the text must meet 4.5:1 or stronger against both the base row color and the stripe color.

WCAG 1.4.11 Non-text Contrast requires a contrast ratio of at least 3:1 against adjacent colors for visual information required to identify user interface components and their states, and for parts of graphical objects required to understand the content. When a stripe functions as visual information, its contrast is measured against the color or colors immediately next to it. When a stripe fails the 3:1 contrast ratio and still carries information, legibility drops. Purely decorative stripes are exempt from WCAG contrast requirements.

WCAG contrast ratios are calculated so that color hue is not a primary factor, although color deficiencies can affect perceived luminance contrast somewhat. A red and green pair that looks identical to a color-blind reader can still pass WCAG 2.1 AA’s 4.5:1 contrast requirement, because the contrast ratio measures relative luminance rather than hue difference. Meeting contrast ratios is one requirement for accessible color use, but it does not guarantee full accessibility for color-blind users.

Stripe color should never be the only carrier of semantic meaning. Any information conveyed by color alone, such as category or status, must also appear through another channel like an icon, text label, or pattern. Purely structural zebra striping that only signals row separation can rely on color alone. When a row’s state, such as selected, error, or warning, appears only through its background color, screen reader and low-vision users lose that information. Semantic th and td structure with th scope="col" allows screen readers to associate cells with headers. Striping then acts as a visual aid layered on top of that structure.

Zebra Striping With Hover States And Hidden Rows

Hover-state conflicts and low-contrast stripes cause most of the debate around zebra striping. Both issues have fixes, and sometimes the cleanest fix for hover conflicts is to remove the stripe.

When a table uses Bootstrap’s .table-striped class, hovering a light-grey striped row can change it from light-grey to transparent white. The hover effect then disappears on those alternating rows. A reliable fix uses a hover background color distinct from both the base row and the stripe, such as #D2DEE8, applied via tr:hover with !important so it overrides the zebra stripe colors.

/* Hover state distinct from both base and stripe */ .data-table tbody tr { background-color: #ffffff; transition: background-color 100ms ease; /* 100ms reads as instant */ } .data-table tbody tr:nth-child(even of :not([hidden])) { background-color: #f8fafc; } /* Hover overrides both base and stripe */ .data-table tbody tr:hover { background-color: #eff6ff; /* blue-50, distinct from both white and zinc-50 */ }

For table row hover effects, keep the transition around 100-150ms so it reads as instant. Durations beyond 150ms can feel laggy when users scan rows quickly, although some guidelines allow hover-in up to 200ms.

Interactive tables with hover states, expandable rows, or color-coded row states often become hard to manage when stripes enter the mix. The stripe introduces a third background state on top of base and hover, or even more states such as selected and error. In those cases, remove the stripe and let hover and state color carry the visual hierarchy.

Zebra Striping, Dark Mode, And Theming

Light-mode stripe colors rarely translate cleanly to dark mode, because each site’s dark theme uses a different palette. Dark themes need their own stripe tokens.

For zebra-striped tables in dark mode, define the stripe color within an @media (prefers-color-scheme: dark) block and override the row background color there.

/* Custom properties for both themes */ :root { --row-base: #ffffff; --row-stripe: #f8fafc; /* zinc-50, subtle in light mode */ --row-text: #0f172a; --row-hover: #eff6ff; } @media (prefers-color-scheme: dark) { :root { --row-base: #1e293b; /* slate-800 */ --row-stripe: #263347; /* slightly lighter, avoids glare */ --row-text: #f1f5f9; /* slate-100 */ --row-hover: #1e3a5f; /* dark blue, distinct from both */ } } .data-table tbody tr { background-color: var(--row-base); color: var(--row-text); transition: background-color 100ms ease; } .data-table tbody tr:nth-child(even of :not([hidden])) { background-color: var(--row-stripe); } .data-table tbody tr:hover { background-color: var(--row-hover); }

The same WCAG contrast thresholds apply in both light and dark themes. Pages must still meet 4.5:1 for normal text and 3:1 for large text and relevant non-text elements. Under WCAG conformance rules, only one version of a page must meet these requirements, and some teams treat dark mode as an alternative version. In practice, verify contrast in both light and dark themes so each palette reaches the required ratios.

In Tailwind, dark-mode striping stacks the dark: prefix with the structural variants. For example, dark:odd:bg-zinc-900 dark:even:bg-zinc-800/50 keeps the stripe subtle in dark mode without creating a bright glare.

The Verdict: When To Stripe And When To Skip

Zebra striping works best on wide, dense, static tables where readers mainly track horizontally across a row. The stripe earns its place when the table has roughly ten or more rows, five or more columns, and no interactive states competing for background color.

Interactive and dynamic tables with expandable rows, hover highlights, or selectable elements already rely on background color to convey meaning. In those cases, striping can clash with those states and cause inconsistency as row colors shift with data updates. Adding a stripe introduces a third background state, which can overwhelm the table’s visual hierarchy.

Stripe color should never be the only carrier of row state. When a row’s meaning depends solely on its background color, users who cannot see color, including many low-vision users, cannot perceive that meaning. Screen readers also miss that state unless it appears in text or markup. Row state must be conveyed programmatically through semantic markup or ARIA attributes, such as aria-selected or aria-expanded. Any visual indicator like a stripe or color should pair with that programmatic state.

Frequently Asked Questions

What Is Zebra Striping?

Zebra striping shades alternating rows in a data table with a subtle background color on either even or odd rows. The alternating pattern helps the eye track horizontally across wide rows without losing its place. The term comes from the resemblance to a zebra’s alternating stripes.

In HTML documents, zebra striping often uses the CSS :nth-child(even) pseudo-selector scoped to tbody rows so header rows are not counted. Other approaches include :nth-child(odd), :nth-of-type selectors, and framework classes such as Bootstrap’s .table-striped. In spreadsheets, zebra striping appears through Format as Table in Excel or Format > Alternating colors in Google Sheets.

Is Zebra Striping Still Recommended In 2026?

For wide, dense, static tables with many columns and roughly ten or more rows, zebra striping can still help. The alternating background gives the eye a horizontal guide and becomes most useful when users feel unsure what they are looking for or when the type is small.

For interactive or dynamic tables with expandable rows, hover highlights, or selectable rows, zebra striping is usually discouraged. It can clash with those states and create visual noise. Subtle horizontal dividers and hover-only row highlighting work better. As covered in the WCAG section, ensure text meets 4.5:1 contrast and any stripe that carries information follows the 3:1 non-text contrast principle.

How Do I Zebra Stripe A Table In CSS?

To zebra stripe a table, scope the selector to tbody and apply a background color to even rows, for example tbody tr:nth-child(even) { background-color: #e495e4; }. For odd rows, use tbody tr:nth-child(odd) instead.

When rows are filtered or hidden, use the Selectors Level 4 of syntax to exclude hidden rows from the count.

tbody tr:nth-child(even of :not([hidden])) { background-color: #f8fafc; }

As noted earlier, this syntax became Baseline Widely available on 2025-11-09. After applying striping, confirm that body text reaches 4.5:1 against both the striped and unstriped row colors. As a best practice, keep the stripe color at or above 3:1 contrast against the base row when it functions as meaningful visual information.

How Do I Zebra Stripe In Excel And Google Sheets?

In Excel, select the cell range, go to Home > Styles group > Format as Table, choose a table style under Light, Medium, or Dark, and select OK. The Banded Rows check box is selected by default to shade alternate rows, and the banding extends automatically as rows are added. For banding without table functionality, use Home > Conditional Formatting > New Rule with the formula =MOD(ROW(),2)=0. When you convert a table to a regular range, this conditional banding does not continue automatically as rows are added, although automatic banding does continue while the range remains a table.

In Google Sheets, select the range and go to Format > Alternating colors. A custom conditional formatting rule such as =ISEVEN(ROW()) applied to the whole table range survives sorting and row insertion because it ties to row numbers rather than a static fill. Google Sheets’ built-in Format > Alternating colors bands can scramble after sorting or filtering because the colors stay attached to row positions instead of following the data.

Is Zebra Striping Accessible?

Under WCAG 2.2 Level AA, zebra striping can be accessible when contrast and semantics are handled correctly. As covered in the WCAG section, ensure text meets the 4.5:1 contrast ratio against both row colors and apply the 3:1 non-text contrast threshold when the stripe itself carries information.

Beyond contrast, the stripe should never be the only signal for row state. Any state communicated solely through background color is not perceivable by users who cannot see color and remains invisible to screen readers unless the meaning appears in text or markup. Semantic markup with th scope="col" and proper thead/tbody structure forms the accessibility foundation. Striping then acts as a visual layer on top of that structure.

What Do I Do When Nth-Child Breaks On Filtered Rows?

Use the Selectors Level 4 of <selector> syntax, for example tbody tr:nth-child(even of :not([hidden])). This syntax filters the sibling set before the nth calculation runs, so hidden rows are excluded from the count and the visible stripe pattern stays continuous.

The of S syntax in :nth-child(An+B of S) prefilters children by the selector list S before applying the An+B logic. That approach correctly handles dynamically filtered or hidden table rows, such as tr:nth-child(even of :not([hidden])), by excluding non-matching rows from the count. For frameworks or render loops that cannot use the of syntax, toggle a class on visible rows in the render loop instead of relying on :nth-child.

How Do I Handle Zebra Striping In Dark Mode?

Define the stripe color within an @media (prefers-color-scheme: dark) block and override the row background color there. A light-mode stripe color such as a near-white left unchanged in dark mode creates extreme luminance contrast that can cause halation, a glare-like glow around bright elements. Dark themes instead use softened off-whites, roughly #E0E0E0 to #F0F0F0, on dark gray surfaces, roughly #121212 to #1A1A1A.

In Stripe’s dark mode, for example, the stripe or offset surface can be a slightly lighter shade of the dark base row, such as offsetBackgroundColor #1B1E25 against colorBackground #14171D. This small luminosity difference reads as a unified table surface rather than two separate panels. Exact colors depend on each application’s palette. As covered earlier, verify the WCAG contrast thresholds in both light and dark themes so each palette independently reaches the required ratios.


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.