CSS Tables: Styling, Responsive Tricks and 16 Examples
A table that still sizes itself from its content after you set table-layout: fixed is a common thing to run into. The cause is usually boring. The browser falls back to the automatic algorithm when the table has no explicit width, according to MDN Web Docs (2026), so the fixed keyword quietly does nothing.
Table CSS controls how rows, columns and cells get sized, bordered, spaced and aligned, with no presentational attributes left in the markup. It turns up in pricing grids, reports and dashboards. On a narrow screen, the choices around border-collapse, table-layout, caption placement, sticky headers and overflow wrappers decide whether anyone can still read the data. The properties aren't tied to the table element, either. Any element with a table display value, such as table-row or table-cell, accepts them.
Below are 16 CSS table examples you can try in place and copy: styled data tables, responsive patterns you can resize, tables that highlight, select, expand and sort, and two document layouts. Need the markup first? The HTML table generator builds a clean table you can style with any of them. The guide after the examples covers borders, column widths, alignment, sticky headers and the limits of styling tables.
Table designs for reports, dashboards, pricing and admin screens. Every one uses a real table element with a caption or headers that screen readers can follow.
Clean report table
caption
font-variant-numeric
scope
Quarterly revenue by region (USD, thousands)
Region
Q1
Q2
Q3
Change
North America
1,240
1,318
1,402
+13.1%
Europe
980
1,012
996
+1.6%
Asia Pacific
640
702
785
+22.7%
Latin America
210
198
205
-2.4%
A quiet default for financial and analytics data. Numbers are right-aligned with tabular figures so the digits line up, row labels are th elements with scope="row", and the caption names the table and its unit.
border-radius does not work on a table with border-collapse: collapse, so this one switches to separate with zero spacing, which allows rounded corners and overflow clipping. Even rows get a tint and the hovered row a stronger one.
Each progress bar reads its width from a --p custom property set on the element, and the percentage is also written as text, so the value is never shown by color or length alone. Status badges pair a color dot with a word for the same reason.
Full gridlines, a gray header row and a numbered first column give a table the look of a spreadsheet. The selected cell uses an outline with a negative offset, which draws inside the cell and does not push the gridlines around.
border-spacing: 0 10px opens a gap between rows so each one reads as a card. Rows cannot take a border radius, so the first and last cell of every row get the rounded corners and side borders instead. Hovering a row outlines the whole card.
A col element in the colgroup tints the whole recommended column with one rule. The check marks are drawn with CSS, and each cell also contains visually hidden text that says Included or Not included, so the table reads correctly without the icons.
Four ways to keep a table readable on a narrow screen. The frames marked as resizable can be dragged narrower from their bottom-right corner to see the layout change.
Horizontal scroll with edge shadows
overflow-x: auto
background-attachment: local
role="region"
Monthly signups by channel
Channel
Jan
Feb
Mar
Apr
May
Jun
Total
Organic search
820
790
905
960
1,040
1,120
5,635
Newsletter
310
335
298
360
402
415
2,120
Social
140
162
188
171
195
230
1,086
Referral
95
88
110
126
118
141
678
Drag the corner to resize
The table keeps its natural width inside a scrolling wrapper. Shadows on the left and right edges appear only when there is more to scroll, a trick that layers local and scroll background attachments. The wrapper is a focusable, labelled region so keyboard users can scroll it with the arrow keys.
Below 560px of container width each row turns into a card and every cell shows its column name from a data-label attribute. Changing display on table elements makes some browsers drop the table semantics, so the markup restores them with role="table", "row", "columnheader" and "cell".
Inside a box that scrolls both ways, the header cells stick to the top and the first column sticks to the left. The top-left cell sticks in both directions and gets the highest z-index, so the other sticky cells slide under it.
Each column carries a priority class. As the container narrows, the least important columns disappear first, so the table never needs to scroll and the essentials stay. Only hide columns that are truly optional, or offer the full data elsewhere.
Highlighting, selection, expanding rows and sorting. Three of these are CSS only, and the sortable table adds a short script.
Row and column crosshair highlight
:has()
:nth-child()
tr:hover
Cupping scores by origin (out of 10)
Origin
Acidity
Body
Sweetness
Aroma
Ethiopia
9.1
6.8
8.4
9.3
Colombia
7.9
7.6
8.1
8.0
Kenya
9.0
7.2
7.8
8.7
Brazil
6.4
8.5
8.6
7.4
Guatemala
8.2
7.9
8.0
8.3
Hovering a cell lights up its row and its column at once. The row is plain tr:hover, and the column comes from :has(), which checks whether a cell in a given column is hovered and then tints every cell in that column. There is one rule per data column.
Checking a box highlights its row through tr:has(:checked), and a CSS counter adds up the checked boxes and prints the total in the footer, with no JavaScript. The tfoot comes after the tbody in the markup so the counter has already counted every row when it is displayed.
Adds a system-aware dark theme, cuts search time by caching the index, and fixes the export dialog on Safari.
v3.3
Sep 30
New billing page
Moves invoices into their own page, adds CSV export and shows the next payment date at the top.
v3.2
Sep 9
Keyboard shortcuts
Every main action now has a shortcut, listed in a panel that opens with the question mark key.
Extra information lives in a details element in the last cell, so it opens without a script and the expanded state is announced. tr:has(details[open]) tints the row while it is open, which helps the eye keep track of which line the extra text belongs to.
<table class="tb-expand">
<thead>
<tr><th scope="col">Release</th><th scope="col">Date</th><th scope="col">Changes</th></tr>
</thead>
<tbody>
<tr><td>v3.4</td><td>Oct 21</td><td><details><summary>Dark mode and faster search</summary><p>Adds a system-aware dark theme, cuts search time by caching the index, and fixes the export dialog on Safari.</p></details></td></tr>
<tr><td>v3.3</td><td>Sep 30</td><td><details><summary>New billing page</summary><p>Moves invoices into their own page, adds CSV export and shows the next payment date at the top.</p></details></td></tr>
<tr><td>v3.2</td><td>Sep 9</td><td><details><summary>Keyboard shortcuts</summary><p>Every main action now has a shortcut, listed in a panel that opens with the question mark key.</p></details></td></tr>
</tbody>
</table>
Sortable columns
aria-sort
button in th
JavaScript (20 lines)
Archivo
18
412 KB
Bricolage
7
268 KB
Literata
16
530 KB
Manrope
7
190 KB
Space Grotesk
5
156 KB
Each header holds a real button, so sorting works with the keyboard. The script sets aria-sort to ascending or descending on the active column, and the same attribute draws the arrow, so screen readers and sighted users get the same state. Numbers sort as numbers through a data-value attribute.
<table class="tb-sort">
<thead>
<tr>
<th scope="col" aria-sort="ascending"><button type="button">Font</button></th>
<th scope="col"><button type="button">Styles</button></th>
<th scope="col"><button type="button">File size</button></th>
</tr>
</thead>
<tbody>
<tr><td>Archivo</td><td data-value="18">18</td><td data-value="412">412 KB</td></tr>
<tr><td>Bricolage</td><td data-value="7">7</td><td data-value="268">268 KB</td></tr>
<tr><td>Literata</td><td data-value="16">16</td><td data-value="530">530 KB</td></tr>
<tr><td>Manrope</td><td data-value="7">7</td><td data-value="190">190 KB</td></tr>
<tr><td>Space Grotesk</td><td data-value="5">5</td><td data-value="156">156 KB</td></tr>
</tbody>
</table>
<script>
document.querySelectorAll('.tb-sort').forEach(function (table) {
var heads = table.querySelectorAll('th');
heads.forEach(function (th, index) {
th.querySelector('button').addEventListener('click', function () {
var dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
heads.forEach(function (other) { other.removeAttribute('aria-sort'); });
th.setAttribute('aria-sort', dir);
var body = table.tBodies[0];
var rows = Array.from(body.rows);
rows.sort(function (a, b) {
var x = a.cells[index].dataset.value || a.cells[index].textContent;
var y = b.cells[index].dataset.value || b.cells[index].textContent;
var result = isNaN(x) ? x.localeCompare(y) : x - y;
return dir === 'ascending' ? result : -result;
});
rows.forEach(function (row) { body.appendChild(row); });
});
});
});
</script>
Schedules and documents
Tables that act as a layout for a real document, using rowspan, colspan and a print stylesheet.
Weekly class schedule
rowspan
colspan
fixed table-layout
Studio classes, week of November 2
Time
Mon
Tue
Wed
Thu
Fri
9:00
Morning stretch
10:00
Figma basics
Figma basics
Portfolio review
11:00
CSS layout lab
CSS layout lab
12:00
A timetable where events span rows for their length and columns when they repeat across days. table-layout: fixed gives every day the same width no matter what is written inside, which keeps the grid even.
Line items go in tbody and the subtotal, tax and total in tfoot, so the structure matches how an invoice is read. When a long table breaks across printed pages, browsers can repeat the thead row at the top of each page. The print rules drop the background tint and keep rows from splitting across a page break.
The name gets used for two different things. One is styling real table markup, meaning rules on table, caption, thead, tbody, tr, th and td. The other is giving any element a table display value: table, inline-table, table-row, table-cell, table-caption, table-column, table-column-group, table-row-group, table-header-group or table-footer-group.
The markup side starts with the table element in HTML, where rows, header cells and data cells carry the structure. Every rule that sizes, borders or aligns those cells is plain CSS.
The CSS table model
The W3C CSS 2.2 specification defines 7 table parts. Tables, captions and cells are the obvious ones, and rows, row groups (header and footer groups included), columns and column groups round it out.
It also lets authors map any document element onto those parts through the display property.
HTML tables come with a catch. The same specification lets user agents ignore display table values on HTML table elements, since those elements can use older, backwards compatible rendering.
MDN Web Docs says the properties of the table module are not limited to table elements. They work on anything with a table-related display value, and the module groups border-collapse, border-spacing, caption-side, empty-cells, table-layout and vertical-align together.
The CSS Table Module Level 3 draft builds on the 2.2 rules. The two readings also differ in meaning, since an HTML table carries row and column semantics while display table values on other elements change layout only.
How Do border-collapse and border-spacing Control Table Borders?
Styling HTML tables with CSS · dcode
Everything starts with border-collapse. With collapse, adjacent cell borders merge into one shared line. Separate keeps every cell’s border on its own and hands the gap between cells over to border-spacing.
Borders go on table, th and td. The model decides whether neighboring cells double up, and browsers render separate by default, which is why a plain border on td shows doubled lines.
Behavior
collapse
separate
Cell borders
Shared between neighbors
Distinct on each cell
border-spacing
Ignored
Sets the gap
empty-cells
Ignored
Shows or hides empty cell borders
inset and outset styles
Render as ridge and groove
Render as written
Collapsed borders
Adjacent cells share one border, which removes the doubled lines.
The border-collapse property applies to table and inline-table elements. In the collapsed model, MDN Web Docs notes that inset behaves like ridge and outset behaves like groove.
There is no gap between cells, so border-spacing and empty-cells do nothing here. Padding on th and td becomes the only way to open the cells up.
Separated borders and cell gaps
With separate, border-spacing controls the distance between cell borders.
One value sets horizontal and vertical spacing together. Give it two and the first is the horizontal gap (between columns) and the second is the vertical one (between rows). The initial value is 0, and along the outer edge the spacing gets added to the table’s own padding.
The property replaces the deprecated cellspacing attribute and adds the optional second value.
Empty cells
empty-cells takes show or hide, and it only works under separate. A hidden empty cell loses its border and background, though it still takes up space.
Baseline lists border-collapse, border-spacing, empty-cells, table-layout and caption-side (its top and bottom values) as widely available, supported across browsers since July 2015 (MDN Web Docs, 2026).
For data tables with ruled lines I’d go with collapse. Separate makes more sense when you want each cell to look like its own tile, gaps and all.
How Does table-layout Decide Column Width?
Auto is the initial value and what most browsers use by default. Columns size from the cell content, so the table and its cells stretch to fit whatever is inside, and nothing ever gets clipped. Fixed ignores content and sizes columns from explicit widths alone, which lays out faster and keeps the columns predictable.
Under fixed, a col element with an explicit width wins. Failing that, a first-row cell with an explicit width sets the column, and if neither exists the column takes a share of whatever horizontal space is left.
Fixed also needs an explicit table width. If width is auto or missing, the browser falls back to the automatic algorithm and the fixed keyword has no effect, according to the MDN entry. Setting width: 100% makes the table fill its container and gives fixed something to work from.
Since widths come from the first row, rendering can start as soon as that row is parsed. Later rows may not fit the widths already set.
Cells use overflow to clip extra content, but only when the table has a known width.
MDN’s own example makes the difference concrete. A fixed table at width 120px, with td set to overflow hidden, white-space nowrap and text-overflow ellipsis, cuts long names with an ellipsis, while under auto the same table grows past 120px.
Auto’s appeal is that there are no widths to declare. Columns fit their content, which makes it a safe default for small tables with unpredictable text. The downside is that the table can outgrow its set width, and column widths shift whenever the content changes. On long tables the browser also has to measure every cell before it can lay anything out, and that gets slow.
Fixed makes horizontal layout depend only on table width, column widths, borders and spacing, so columns stay put when content changes. It has its own problems. Without an explicit table width it silently reverts to auto, and long content in later rows can overflow or truncate.
I’d use auto for small tables with unknown content. Fixed suits long data tables with known columns, and the widths belong on col elements so the first row isn’t doing double duty.
How to Align Content, Add Padding and Place the Caption
HTML Tables Tutorial with CSS Styling · FollowAndrew
Padding, text-align and vertical-align control readability inside each cell. Auto side margins center the table itself, and caption-side puts the caption above or below the grid.
Padding on th and td opens up the cell, and once borders collapse it’s the only spacing control left. text-align handles horizontal position (left, center or right), while vertical-align sets top, middle or bottom for content inside a cell. To center the whole table in its container, margin: 0 auto does it.
Numbers read better right-aligned. Text sits fine on the left.
For rows with mixed content heights, top alignment keeps labels and long text on the same line.
Where does the caption go?
Top or bottom, and it defaults to top. caption-side accepts those two values and applies to table-caption elements.
The values follow the table’s writing mode. The CSS Logical Properties module also defines inline-start and inline-end for the inline axis, but MDN Web Docs says no browser supports them (MDN Web Docs, 2026). Style the caption element with text-align for its horizontal position, and keep its text short, since it works as the table’s title.
How to Add Zebra Stripes and Hover Highlights
tbody tr:nth-child(even) with a background color stripes every second body row, and tbody tr:hover highlights the row under the pointer.
Scope both selectors to tbody so the header row stays clean. Swapping even for odd in the first selector flips which rows carry the color.
Keep the stripe close to the page background in lightness. A heavy stripe makes data harder to scan, not easier.
Text has to stay readable on the stripe and on the hover color, so test both pairs for enough color contrast.
Grouped rows are trickier. rowspan cells cut across row-level stripes, so the spanning cell sits over alternating colors.
Put each group in its own tbody and stripe the groups with tbody:nth-of-type(even). nth-of-type matters here because nth-child also counts the caption, colgroup and thead siblings, which offsets the pattern.
How to Make a CSS Table Responsive
Tables do not adapt to narrow screens on their own, so you have to pick an approach. A scrolling wrapper is the simplest. Stacked cells under a media query and container queries on a wrapper element are the other routes.
Wrap the table in a div with overflow-x: auto. Prefer auto over scroll, because scroll draws the scrollbar even when nothing overflows (Tania Rascia’s horizontal scroll write-up).
The table keeps its real display value and its semantics, and wide tables scroll sideways inside the wrapper instead of dragging the page along. A first column given position: sticky and left: 0 stays visible while the rest scrolls (Web Design Tuts+ pattern).
The sticky column only helps when that column holds the row label. Otherwise it eats screen width for nothing.
Stacked rows with media queries
How to create a responsive HTML table · Kevin Powell
Stacking turns every row into a block of labeled lines.
LogRocket’s DEV Community guide builds it inside a query written with media queries, and the breakpoint belongs where the widest row stops fitting.
Hide thead, since a stacked layout has no column row to show.
Set tr and td to display: block so each cell takes a full line.
Restore each cell’s label, usually from a data attribute read by a pseudo-element.
Switching display values on table elements carries an accessibility cost, covered under display table values below.
Container queries
MDN Web Docs lists size container queries as Baseline Widely available, supported across browsers since February 2023.
Set container-type: inline-size on the wrapper, not on the table, because a query restyles the descendants of its container. The table then reacts to the width of its slot instead of the viewport, which suits tables dropped into sidebars, cards and dashboards.
Avoid container-type: size unless the wrapper has a known height. Without a known block size the box collapses.
How to Keep a Table Header Visible While Scrolling
Set position: sticky and top: 0 on each th in thead, give the cells an opaque background, and make sure the nearest scroll container actually scrolls.
Wrap the table in a div only if it needs horizontal scrolling.
Give that wrapper a height (for example calc(100vh – 100px)) so it scrolls vertically too.
Set position: sticky and top: 0 on every th inside thead.
Set a solid background on those th cells so rows do not show through.
Scroll the wrapper to confirm the header pins.
The height in step 2 is the part people miss. Inside an overflow wrapper, sticky pins to that wrapper, so without a height the header is ignored, according to Tania Rascia’s walkthrough.
Which element gets the sticky rule is a separate question. Oliver Guenther’s 2017 write-up found that Chrome supported sticky on table cells (support began in Chrome 56) but not on the thead itself. Chrome 91 later added sticky thead support, according to Adrian Roselli’s fixed-header write-up. A GitLab merge request (MR 151905) then moved its offset to thead after noting the Chrome bug had been fixed, and th stays the safer target.
GitLab’s vulnerability report also shows the failure mode of a hard-coded top value. Filters and grouping changed the height above the table, and so did error banners, so the team switched to a ResizeObserver feeding a custom property.
Sources disagree on two-axis stickiness with overflow-x: auto and overflow-y: clip. modern-css.com describes a newer overflow spec change that lets one wrapper scroll on a single axis, so the header tracks the page while the first column tracks the wrapper, and it flags support as limited. MDN Web Docs and the December 2021 W3C CSS Overflow Module Level 3 working draft say clip computes to hidden when the other axis is neither visible nor clip, and hidden creates a scroll container again.
The first source describes a change newer than the draft the second cites. Until your target browsers support it, use the wrapper with a height.
When Should display: table Replace a Real Table Element?
For tabular data, almost never. Use real table elements for data, and reach for CSS Grid or Flexbox before table display values when the goal is layout.
The W3C CSS 2.2 specification points the same way. For layout-only use of table display values, authors should not use table-related elements in the document language, and should apply the CSS to the relevant structural elements instead.
Method
Keeps table semantics
Use it for
HTML table element
Yes
Tabular data
display: table on divs
No
Layout in legacy markup
CSS Grid
No
Two-dimensional layout
Flexbox
No
Row-based layout
Adrian Roselli’s 2018 post on tables, CSS display properties and ARIA states that display: table does not impart HTML table semantics, and that laying out an HTML table with Grid does not make it a table semantically either.
He also asked developers to stop using table display values just for vertical centering. Flexbox and Grid handle that.
Steve Faulkner of TPGi documented that display: block, grid or flex on the table element removes the table from the accessibility tree and drops the row semantics with it.
The behavior has moved since. Roselli’s tracking notes record that Chrome 80 stopped dropping semantics for flex, grid, inline-block and contents, while Safari dropped them for every display value at the time. His December 2022 update says Chrome is fine and the last Firefox bug was closing, but Safari was still the problem.
Check your target browsers in the DevTools accessibility tree. Where semantics are lost, restore them with the table, row, columnheader, rowheader and cell roles from ARIA, which fixes the announcement but not a broken layout.
A real table element with a caption and th cells remains the base of accessible tables, and display overrides are what break it.
Why Do Table Styles Fail? Common Limits and Fixes
Most broken table styles trace back to a short list of causes: rounded corners that get ignored, sticky headers that never pin, sticky borders that vanish, semantics lost after display overrides, and stripes that misalign on grouped rows.
Symptom
Cause
Fix
Rounded corners ignored
border-collapse: collapse
Use separate with border-spacing: 0, or round a wrapper
Sticky header never pins
Overflow wrapper without a height
Give the wrapper a height
Header borders vanish in Firefox
Sticky th with collapsed borders
Use separate borders on sticky-header tables
Table announced as plain text
display block, grid or flex on table parts
Keep native display or add ARIA roles
Stripes cut through grouped rows
rowspan with row-level striping
Stripe tbody groups with nth-of-type
Rounded corners under collapse
Sources diverge on how much border-radius is ignored. MDN Web Docs says it does not apply to table elements when border-collapse is collapse, and a Mozilla web-platform test asserts that user agents ignore it on internal table elements.
A 2008 CSS Working Group thread took a narrower line, which was to ignore it on internal elements but allow it on the table element itself. The behavior of the outer table under collapse is therefore not uniform.
The reliable fix is separate borders with border-spacing: 0 and radius on the corner cells, or a wrapper with overflow: hidden and a radius. A CSS border radius generator gives the corner values to paste.
Browser-specific sticky bugs
Mozilla’s tracker holds Bug 1727594, filed against Firefox 91, reporting that borders on sticky th cells disappear when borders are collapsed.
WebKit Bug 229442, reported in August 2021 against Safari 14, described th cells inside a sticky thead moving the wrong way in an overflow wrapper. It was closed in September 2021 because the report no longer reproduced in Safari 15.
Both cases are why cross-browser compatibility testing belongs on any sticky-header table, with collapsed and separate borders compared side by side.
CSS Tables FAQ
How do you remove the default borders from a table?
Set border: none on table, th and td.
If gaps still separate the cells, add border-spacing: 0 or switch to border-collapse: collapse.
Which CSS properties work on col and colgroup?
Only 4 apply, and they are background, border, width and visibility. Border takes effect only when the table has border-collapse: collapse (MDN Web Docs).
Text color, padding and alignment have to go on the cells.
Why is the background on my colgroup not showing?
A colgroup background is painted beneath the columns, row groups, rows and cells.
It shows only where every layer above it is transparent, so remove opaque backgrounds from tr, th and td, or put the color on the cells.
Can CSS hide a table column?
Yes. Set visibility: collapse on the col or colgroup, and the cells in those columns are not rendered, with cells that span into other columns clipped.
Other visibility values have no effect on columns.
Does width on colgroup set an exact column width?
No. On a colgroup, width defines a minimum width for its columns, as if min-width were set (MDN Web Docs).
When columns must hold exact widths, pair explicit widths with table-layout: fixed.
What to Set First on a Production Table
Border model and table width go first, since later rules depend on both. Padding, alignment and the caption come next, then the wrapper height and sticky header, and finally whichever responsive pattern fits the layout.
As of October 12, 2026, Mozilla’s Bug 1727594 on sticky th cells with collapsed borders is still unconfirmed. Its June 2024 triage found that all browsers render the case the same way, so the problem is wider than the Firefox label on the bug suggests.
The advice changes only if a spec change lands that every browser agrees on.
Dropping collapsed borders on a sticky-header table costs the single shared line, so draw bottom borders on cells only to avoid doubling.
Start from clean markup, then apply that sequence.
Create clean, accessible HTML tables without writing code. The HTML table generator builds the markup this guide styles. This intuitive tool simplifies table creation for websites, documents, and emails. Simply specify rows, columns, and content, and it handles the markup.
What makes our generator different:
Real-time preview shows exactly how your table will appear
Copy-ready HTML code with proper semantic structure
Customizable headers for better organization and accessibility
Optional table captions for context
Perfect for developers looking to save time and non-coders who need professional tables. Educators, content creators, and data analysts will appreciate the straightforward interface. No complex configuration needed.