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.

By Bogdan Sandu · Updated October 26, 2026

Styled data 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)
RegionQ1Q2Q3Change
North America1,2401,3181,402+13.1%
Europe9801,012996+1.6%
Asia Pacific640702785+22.7%
Latin America210198205-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.

View code
.tb-clean {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14.5px;
  font-variant-numeric: tabular-nums;
}
.tb-clean caption {
  padding-bottom: 12px;
  color: #080c14;
  font-size: 15.5px;
  font-weight: 700;
  text-align: left;
  caption-side: top;
}
.tb-clean th,
.tb-clean td {
  padding: 12px 14px;
  border-bottom: 1px solid #e3e6ee;
  text-align: right;
}
.tb-clean thead th {
  border-bottom: 2px solid #080c14;
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
}
.tb-clean th:first-child {
  text-align: left;
}
.tb-clean tbody th {
  color: #080c14;
  font-weight: 600;
}
.tb-clean .tb-up {
  color: #12a84a;
}
.tb-clean .tb-down {
  color: #d93025;
}

Zebra stripes with rounded corners

  • nth-child(even)
  • border-collapse: separate
  • overflow: hidden
NameRoleTeamLocationStarted
Maya ChenProduct designerDesignAustinMar 2025
Luis OrtegaFrontend engineerWebMadridJan 2025
Priya RamanData analystGrowthRemoteFeb 2025
Tom BeckerEngineering managerWebBerlinNov 2024
Sara LindContent strategistMarketingStockholmApr 2025
Kofi MensahQA engineerWebAccraJun 2025

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.

View code
.tb-zebra {
  width: 100%;
  overflow: hidden;
  border: 1px solid #dfe3ec;
  border-collapse: separate;
  border-radius: 14px;
  border-spacing: 0;
  background: #fff;
  color: #232a3b;
  font-size: 14.5px;
}
.tb-zebra th,
.tb-zebra td {
  padding: 12px 16px;
  text-align: left;
}
.tb-zebra thead th {
  background: #080c14;
  color: #fff;
  font-size: 13px;
  font-weight: 600;
}
.tb-zebra tbody tr:nth-child(even) {
  background: #f5f7fb;
}
.tb-zebra tbody tr {
  transition: background .15s ease;
}
.tb-zebra tbody tr:hover {
  background: #e6edff;
}
.tb-zebra td:first-child {
  color: #080c14;
  font-weight: 600;
}

Dark dashboard table with status badges

  • custom properties
  • inline-flex badges
  • progress bars
ProjectOwnerStatusProgressDue
Checkout redesignMaya C.On track72%Nov 14
Mobile onboardingLuis O.At risk41%Nov 28
Billing migrationPriya R.Blocked18%Dec 5
Design tokensSara L.Done100%Oct 30

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.

View code
.tb-dash {
  width: 100%;
  border-collapse: collapse;
  color: #c9cfdb;
  font-size: 14px;
}
.tb-dash th,
.tb-dash td {
  padding: 14px 16px;
  border-bottom: 1px solid #1f2637;
  text-align: left;
  white-space: nowrap;
}
.tb-dash thead th {
  color: #8892a4;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: .05em;
  text-transform: uppercase;
}
.tb-dash td:first-child {
  color: #fff;
  font-weight: 600;
}
.tb-dash tbody tr:hover {
  background: #121a2b;
}
.tb-badge {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 4px 10px;
  border-radius: 999px;
  background: #1a2233;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-badge::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
}
.tb-badge--ok {
  color: #2cff6e;
}
.tb-badge--warn {
  color: #ffd166;
}
.tb-badge--stop {
  color: #ff6b6b;
}
.tb-badge--done {
  color: #7aa2ff;
}
.tb-bar {
  display: inline-block;
  width: 90px;
  height: 6px;
  margin-right: 10px;
  overflow: hidden;
  border-radius: 6px;
  background: #1f2637;
  vertical-align: middle;
}
.tb-bar::after {
  content: "";
  display: block;
  width: var(--p);
  height: 100%;
  border-radius: inherit;
  background: #2cff6e;
}

Spreadsheet grid

  • border-collapse: collapse
  • outline-offset
  • monospace digits
ABCD
1ItemQtyPriceTotal
2Notebooks124.5054.00
3Pens301.2036.00
4Folders82.7522.00
5Tape53.1015.50
6Sum127.50

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.

View code
.tb-sheet {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14px;
}
.tb-sheet th,
.tb-sheet td {
  height: 36px;
  padding: 0 10px;
  border: 1px solid #d7dbe5;
}
.tb-sheet thead th,
.tb-sheet tbody th,
.tb-sheet thead td {
  background: #f1f3f8;
  color: #6b7385;
  font-size: 12px;
  font-weight: 600;
  text-align: center;
}
.tb-sheet tbody th {
  width: 40px;
}
.tb-sheet td:nth-child(n+3) {
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  text-align: right;
}
.tb-sheet tbody tr:first-child td {
  font-weight: 600;
}
.tb-sheet .is-active {
  outline: 2px solid #1847f0;
  outline-offset: -2px;
  background: #eef3ff;
}

Card rows with spacing

  • border-spacing
  • first-child / last-child radius
  • tr:hover td
OrderCustomerItemsTotalStatus
#1042Hannah Moore3$184.00Shipped
#1041Daniel Kim1$62.50Processing
#1040Amara Okafor5$310.20Delivered
#1039Lucas Ferreira2$96.00Refunded

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.

View code
.tb-cards {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0 10px;
  color: #232a3b;
  font-size: 14.5px;
}
.tb-cards th {
  padding: 0 18px;
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
  text-align: left;
}
.tb-cards td {
  padding: 16px 18px;
  border-top: 1px solid #e3e6ee;
  border-bottom: 1px solid #e3e6ee;
  background: #fff;
  transition: background .15s ease, border-color .15s ease;
}
.tb-cards td:first-child {
  border-left: 1px solid #e3e6ee;
  border-radius: 12px 0 0 12px;
  color: #1847f0;
  font-weight: 700;
}
.tb-cards td:last-child {
  border-right: 1px solid #e3e6ee;
  border-radius: 0 12px 12px 0;
  font-weight: 600;
}
.tb-cards tbody tr:hover td {
  border-color: #1847f0;
  background: #f7f9ff;
}

Pricing comparison table

  • colgroup
  • col background
  • visually hidden text
FeaturesStarter $0Pro $12 / monthTeam $29 / month
Projects3UnlimitedUnlimited
Custom domainNot includedIncludedIncluded
Version history7 days90 daysUnlimited
Shared workspaceNot includedNot includedIncluded
Priority supportNot includedIncludedIncluded

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.

View code
.tb-price {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14.5px;
  text-align: center;
}
.tb-price__pick {
  background: #eef3ff;
}
.tb-price th,
.tb-price td {
  padding: 14px 16px;
  border-bottom: 1px solid #e3e6ee;
}
.tb-price thead th {
  padding-top: 20px;
  color: #080c14;
  font-size: 16px;
  font-weight: 700;
  vertical-align: bottom;
}
.tb-price thead th span {
  display: block;
  margin-top: 4px;
  color: #8892a4;
  font-size: 13px;
  font-weight: 500;
}
.tb-price th:first-child {
  text-align: left;
}
.tb-price tbody th {
  font-weight: 600;
}
.tb-yes,
.tb-no {
  display: inline-block;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  vertical-align: middle;
}
.tb-yes {
  background: #1847f0;
}
.tb-yes::after {
  content: "";
  display: block;
  width: 6px;
  height: 11px;
  margin: 4px 0 0 8px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
.tb-no {
  background: #e3e6ee;
}
.tb-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

Responsive tables

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
ChannelJanFebMarAprMayJunTotal
Organic search8207909059601,0401,1205,635
Newsletter3103352983604024152,120
Social1401621881711952301,086
Referral9588110126118141678

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.

View code
.tb-scroll {
  overflow-x: auto;
  border: 1px solid #e3e6ee;
  border-radius: 12px;
  background:
    linear-gradient(to right, #fff 30%, rgba(255, 255, 255, 0)) left center / 40px 100% no-repeat local,
    linear-gradient(to left, #fff 30%, rgba(255, 255, 255, 0)) right center / 40px 100% no-repeat local,
    radial-gradient(farthest-side at 0 50%, rgba(8, 12, 20, .2), transparent) left center / 14px 100% no-repeat scroll,
    radial-gradient(farthest-side at 100% 50%, rgba(8, 12, 20, .2), transparent) right center / 14px 100% no-repeat scroll;
  background-color: #fff;
}
.tb-scroll:focus-visible {
  outline: 3px solid #1847f0;
  outline-offset: 2px;
}
.tb-scroll table {
  min-width: 720px;
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.tb-scroll caption {
  padding: 14px 16px 4px;
  color: #080c14;
  font-weight: 700;
  text-align: left;
}
.tb-scroll th,
.tb-scroll td {
  padding: 11px 16px;
  border-bottom: 1px solid #eef0f5;
  text-align: right;
  white-space: nowrap;
}
.tb-scroll th:first-child {
  text-align: left;
}
.tb-scroll thead th {
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-scroll td:last-child {
  color: #080c14;
  font-weight: 700;
}

Rows that stack into cards

  • @container
  • content: attr(data-label)
  • ARIA table roles
FlightFromToDepartsStatus
NW 218BostonDenver08:40Boarding
NW 431ChicagoSeattle09:15On time
NW 702AtlantaMiami10:05Delayed 25 min

Drag the corner to resize

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".

View code
.tb-stack-wrap {
  container-type: inline-size;
}
.tb-stack {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14.5px;
}
.tb-stack th,
.tb-stack td {
  padding: 12px 14px;
  border-bottom: 1px solid #e3e6ee;
  text-align: left;
}
.tb-stack thead th {
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-stack td:first-child {
  color: #080c14;
  font-weight: 700;
}
@container (max-width: 560px) {
  .tb-stack thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }
  .tb-stack tr {
    display: block;
    margin-bottom: 12px;
    padding: 4px 0;
    border: 1px solid #e3e6ee;
    border-radius: 12px;
    background: #fff;
  }
  .tb-stack td {
    display: flex;
    justify-content: space-between;
    gap: 16px;
    padding: 8px 14px;
    border: 0;
  }
  .tb-stack td::before {
    content: attr(data-label);
    color: #8892a4;
    font-weight: 600;
  }
}

Sticky header row and first column

  • position: sticky
  • top and left
  • z-index corner
Average response time by region (ms)
Region00:0004:0008:0012:0016:0020:00
Virginia423955615847
Oregon484552666351
Ohio444157596049
Frankfurt8884971049991
Dublin817990989586
Singapore162158171180176165
Tokyo141137150158154146
Sydney190186198207203194
São Paulo122118131140136127

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.

View code
.tb-sticky {
  max-height: 300px;
  overflow: auto;
  border: 1px solid #e3e6ee;
  border-radius: 12px;
}
.tb-sticky:focus-visible {
  outline: 3px solid #1847f0;
  outline-offset: 2px;
}
.tb-sticky table {
  min-width: 640px;
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  color: #232a3b;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.tb-sticky caption {
  padding: 14px 16px;
  color: #080c14;
  font-weight: 700;
  text-align: left;
}
.tb-sticky th,
.tb-sticky td {
  padding: 11px 16px;
  border-bottom: 1px solid #eef0f5;
  background: #fff;
  text-align: right;
  white-space: nowrap;
}
.tb-sticky thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: #f1f3f8;
  color: #5b6378;
  font-size: 12.5px;
}
.tb-sticky tbody th {
  position: sticky;
  left: 0;
  z-index: 1;
  border-right: 1px solid #e3e6ee;
  text-align: left;
}
.tb-sticky thead th:first-child {
  left: 0;
  z-index: 3;
  border-right: 1px solid #e3e6ee;
  text-align: left;
}

Priority columns that hide as space runs out

  • @container
  • priority classes
  • display: none
PackageVersionLicenseSizeUpdatedWeekly installs
grid-kit4.2.0MIT18 KB2 days ago48,210
tiny-motion1.9.3MIT6 KB1 week ago21,044
form-guard2.0.1Apache-2.011 KB3 weeks ago9,871

Drag the corner to resize

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.

View code
.tb-pri-wrap {
  container-type: inline-size;
}
.tb-pri {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14px;
}
.tb-pri th,
.tb-pri td {
  padding: 12px 14px;
  border-bottom: 1px solid #e3e6ee;
  text-align: left;
  white-space: nowrap;
}
.tb-pri thead th {
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-pri td:first-child {
  color: #1847f0;
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-weight: 600;
}
.tb-pri td:last-child,
.tb-pri th:last-child {
  text-align: right;
}
@container (max-width: 640px) {
  .tb-p3 {
    display: none;
  }
}
@container (max-width: 440px) {
  .tb-p2 {
    display: none;
  }
}

Interactive tables

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)
OriginAcidityBodySweetnessAroma
Ethiopia9.16.88.49.3
Colombia7.97.68.18.0
Kenya9.07.27.88.7
Brazil6.48.58.67.4
Guatemala8.27.98.08.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.

View code
.tb-cross {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14.5px;
  font-variant-numeric: tabular-nums;
  text-align: center;
}
.tb-cross caption {
  padding-bottom: 12px;
  color: #080c14;
  font-weight: 700;
  text-align: left;
}
.tb-cross th,
.tb-cross td {
  padding: 12px 14px;
  border-bottom: 1px solid #e3e6ee;
  transition: background .12s ease;
}
.tb-cross th:first-child {
  text-align: left;
}
.tb-cross thead th {
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-cross tbody tr:hover {
  background: #f2f5ff;
}
.tb-cross tbody td:hover {
  background: #1847f0;
  color: #fff;
}
.tb-cross:has(td:nth-child(2):hover) tr > :nth-child(2),
.tb-cross:has(td:nth-child(3):hover) tr > :nth-child(3),
.tb-cross:has(td:nth-child(4):hover) tr > :nth-child(4),
.tb-cross:has(td:nth-child(5):hover) tr > :nth-child(5) {
  background: #f2f5ff;
}
.tb-cross:has(td:nth-child(2):hover) td:nth-child(2):hover,
.tb-cross:has(td:nth-child(3):hover) td:nth-child(3):hover,
.tb-cross:has(td:nth-child(4):hover) td:nth-child(4):hover,
.tb-cross:has(td:nth-child(5):hover) td:nth-child(5):hover {
  background: #1847f0;
}

Selectable rows with a live count

  • :has(:checked)
  • CSS counters
  • tfoot
SelectFileTypeSize
brand-guide.pdfPDF4.2 MB
hero-desktop.webpImage380 KB
pricing.csvSpreadsheet12 KB
launch-video.mp4Video86 MB

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.

View code
.tb-select {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14.5px;
  counter-reset: tb-selected;
}
.tb-select th,
.tb-select td {
  padding: 12px 14px;
  border-bottom: 1px solid #e3e6ee;
  text-align: left;
}
.tb-select thead th {
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-select th:first-child,
.tb-select td:first-child {
  width: 44px;
}
.tb-select input {
  width: 18px;
  height: 18px;
  accent-color: #1847f0;
  cursor: pointer;
}
.tb-select input:checked {
  counter-increment: tb-selected;
}
.tb-select tbody tr {
  transition: background .15s ease;
}
.tb-select tbody tr:has(:checked) {
  background: #eef3ff;
}
.tb-select tbody tr:has(:checked) td:nth-child(2) {
  color: #1847f0;
  font-weight: 600;
}
.tb-select tfoot td {
  border-bottom: 0;
  color: #5b6378;
  font-size: 13.5px;
}
.tb-select tfoot td::after {
  content: counter(tb-selected) " selected";
}
.tb-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

Rows with expandable details

  • <details> in a cell
  • :has([open])
  • summary styling
ReleaseDateChanges
v3.4Oct 21
Dark mode and faster search

Adds a system-aware dark theme, cuts search time by caching the index, and fixes the export dialog on Safari.

v3.3Sep 30
New billing page

Moves invoices into their own page, adds CSV export and shows the next payment date at the top.

v3.2Sep 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.

View code
.tb-expand {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14.5px;
}
.tb-expand th,
.tb-expand td {
  padding: 12px 14px;
  border-bottom: 1px solid #e3e6ee;
  text-align: left;
  vertical-align: top;
}
.tb-expand thead th {
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-expand td:first-child {
  color: #080c14;
  font-weight: 700;
  white-space: nowrap;
}
.tb-expand td:nth-child(2) {
  white-space: nowrap;
}
.tb-expand tbody tr {
  transition: background .15s ease;
}
.tb-expand tbody tr:has(details[open]) {
  background: #f5f7fb;
}
.tb-expand summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  font-weight: 600;
  list-style: none;
  cursor: pointer;
}
.tb-expand summary::-webkit-details-marker {
  display: none;
}
.tb-expand summary::after {
  content: "+";
  flex: none;
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 6px;
  background: #e8ecf5;
  color: #1847f0;
  font-weight: 700;
  transition: transform .2s ease;
}
.tb-expand details[open] summary::after {
  transform: rotate(45deg);
}
.tb-expand details p {
  margin: 8px 0 2px;
  color: #5b6378;
  font-size: 14px;
  line-height: 1.6;
}

Sortable columns

  • aria-sort
  • button in th
  • JavaScript (20 lines)
Archivo18412 KB
Bricolage7268 KB
Literata16530 KB
Manrope7190 KB
Space Grotesk5156 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.

View code
.tb-sort {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14.5px;
}
.tb-sort th,
.tb-sort td {
  padding: 12px 14px;
  border-bottom: 1px solid #e3e6ee;
  text-align: left;
}
.tb-sort th {
  padding: 4px 8px;
}
.tb-sort th button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 40px;
  padding: 0 8px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: #5b6378;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.tb-sort th button:hover,
.tb-sort th button:focus-visible {
  background: #f1f3f8;
  color: #080c14;
}
.tb-sort th button::after {
  content: "";
  width: 0;
  height: 0;
  border: 5px solid transparent;
  border-top-color: #c5cad6;
  transform: translateY(3px);
}
.tb-sort th[aria-sort] button {
  color: #1847f0;
}
.tb-sort th[aria-sort="ascending"] button::after {
  border-top-color: transparent;
  border-bottom-color: #1847f0;
  transform: translateY(-3px);
}
.tb-sort th[aria-sort="descending"] button::after {
  border-top-color: #1847f0;
}
.tb-sort td:first-child {
  color: #080c14;
  font-weight: 600;
}

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
TimeMonTueWedThuFri
9:00Morning stretch
10:00Figma basicsFigma basicsPortfolio review
11:00CSS layout labCSS 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.

View code
.tb-week {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 13.5px;
  table-layout: fixed;
}
.tb-week caption {
  padding-bottom: 12px;
  color: #080c14;
  font-size: 15px;
  font-weight: 700;
  text-align: left;
}
.tb-week th,
.tb-week td {
  height: 52px;
  padding: 6px;
  border: 1px solid #e3e6ee;
  vertical-align: top;
}
.tb-week thead th {
  height: auto;
  padding: 10px 6px;
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-week th[scope="row"] {
  width: 64px;
  color: #8892a4;
  font-size: 12.5px;
  font-weight: 600;
}
.tb-ev {
  border-left-width: 4px;
  font-weight: 600;
  line-height: 1.3;
}
.tb-ev--a {
  border-left-color: #2cff6e;
  background: #eafff1;
}
.tb-ev--b {
  border-left-color: #1847f0;
  background: #eef3ff;
}
.tb-ev--c {
  border-left-color: #ffb020;
  background: #fff6e5;
}

Printable invoice table

  • tfoot totals
  • @media print
  • tabular-nums
DescriptionQtyRateAmount
Website design1$2,400.00$2,400.00
Logo refresh1$650.00$650.00
Hosting, 12 months12$15.00$180.00
Subtotal$3,230.00
Tax (8%)$258.40
Total due$3,488.40

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.

View code
.tb-invoice {
  width: 100%;
  border-collapse: collapse;
  color: #232a3b;
  font-size: 14.5px;
  font-variant-numeric: tabular-nums;
}
.tb-invoice th,
.tb-invoice td {
  padding: 12px 14px;
  text-align: right;
}
.tb-invoice thead th {
  border-bottom: 2px solid #080c14;
  color: #080c14;
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
}
.tb-invoice th:first-child,
.tb-invoice td:first-child {
  text-align: left;
}
.tb-invoice tbody td {
  border-bottom: 1px solid #e3e6ee;
}
.tb-invoice tfoot th {
  color: #5b6378;
  font-weight: 500;
  text-align: right;
}
.tb-invoice tfoot td {
  color: #080c14;
  font-weight: 600;
}
.tb-invoice__total th,
.tb-invoice__total td {
  border-top: 2px solid #080c14;
  background: #f5f7fb;
  color: #080c14;
  font-size: 16px;
  font-weight: 800;
}
@media print {
  .tb-invoice__total th,
  .tb-invoice__total td {
    background: none;
  }
  .tb-invoice tr {
    break-inside: avoid;
  }
}

What Are CSS Tables?

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.

Behaviorcollapseseparate
Cell bordersShared between neighborsDistinct on each cell
border-spacingIgnoredSets the gap
empty-cellsIgnoredShows or hides empty cell borders
inset and outset stylesRender as ridge and grooveRender 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.

  1. Hide thead, since a stacked layout has no column row to show.
  2. Set tr and td to display: block so each cell takes a full line.
  3. 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.

  1. Wrap the table in a div only if it needs horizontal scrolling.
  2. Give that wrapper a height (for example calc(100vh – 100px)) so it scrolls vertically too.
  3. Set position: sticky and top: 0 on every th inside thead.
  4. Set a solid background on those th cells so rows do not show through.
  5. 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.

MethodKeeps table semanticsUse it for
HTML table elementYesTabular data
display: table on divsNoLayout in legacy markup
CSS GridNoTwo-dimensional layout
FlexboxNoRow-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.

SymptomCauseFix
Rounded corners ignoredborder-collapse: collapseUse separate with border-spacing: 0, or round a wrapper
Sticky header never pinsOverflow wrapper without a heightGive the wrapper a height
Header borders vanish in FirefoxSticky th with collapsed bordersUse separate borders on sticky-header tables
Table announced as plain textdisplay block, grid or flex on table partsKeep native display or add ARIA roles
Stripes cut through grouped rowsrowspan with row-level stripingStripe 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.

WPDean HTML table generator with rows, columns and a live table preview

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.