The caption tag, colgroup and col: title a table and style its columns

A <caption> is the table's own title, and <col> lets you style a whole column in one place. Both are short to write, but col only accepts a few CSS properties.

The <caption> tag gives an HTML table its title. Put it straight after <table>, as the first child. The browser draws it above the table, and screen readers read it as the table's name.

Next to it sit <colgroup> and <col>, which style whole columns at once.

<table>
  <caption>Opening hours, Main Street branch</caption>
  <colgroup>
    <col>
    <col class="times" span="2">
  </colgroup>
  <thead>
    <tr><th>Day</th><th>Opens</th><th>Closes</th></tr>
  </thead>
  <tbody>
    <tr><td>Mon - Fri</td><td>08:00</td><td>18:00</td></tr>
  </tbody>
</table>

Try the caption first. Move it to the bottom, change its alignment and type a new title.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>caption playground</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .controls { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 13px; margin-bottom: 12px; }
  .controls fieldset { border: 0; padding: 0; margin: 0; display: flex; gap: 8px; align-items: center; }
  .controls legend { float: left; font-weight: 600; margin-right: 4px; }
  .controls input[type=text] { font: inherit; padding: 4px 6px; width: 190px; }

  table { border-collapse: collapse; background: #fff; font-size: 14px; }
  th, td { border: 1px solid #d5d9e0; padding: 6px 12px; text-align: left; }
  caption {
    caption-side: top;      /* top (default) or bottom */
    text-align: center;     /* line the title up with the table */
    padding: 6px 0;
    font-weight: 700;
  }
  .out { margin-top: 12px; font-size: 13px; background: #fff; border: 1px solid #e1e4ea; border-radius: 8px; padding: 8px 10px; }
  code { font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; }
</style>
</head>
<body>
<div class="controls">
  <fieldset><legend>caption-side</legend>
    <label><input type="radio" name="side" value="top" checked> top</label>
    <label><input type="radio" name="side" value="bottom"> bottom</label>
  </fieldset>
  <fieldset><legend>text-align</legend>
    <label><input type="radio" name="align" value="left"> left</label>
    <label><input type="radio" name="align" value="center" checked> center</label>
    <label><input type="radio" name="align" value="right"> right</label>
  </fieldset>
  <label><b>Text</b> <input type="text" id="text" value="Opening hours, Main Street branch"></label>
</div>

<table id="hours">
  <caption id="cap">Opening hours, Main Street branch</caption>
  <thead><tr><th>Day</th><th>Opens</th><th>Closes</th></tr></thead>
  <tbody>
    <tr><td>Mon - Fri</td><td>08:00</td><td>18:00</td></tr>
    <tr><td>Saturday</td><td>09:00</td><td>14:00</td></tr>
    <tr><td>Sunday</td><td>Closed</td><td></td></tr>
  </tbody>
</table>

<div class="out">
  <code>table.caption.textContent</code> = <b id="name"></b><br>
  <small>Screen readers use this caption as the table's name.</small>
</div>

<script>
  const table = document.getElementById('hours');
  const cap = document.getElementById('cap');
  const name = document.getElementById('name');

  // table.caption is the table's first <caption> child
  const show = () => { name.textContent = '"' + table.caption.textContent + '"'; };

  document.querySelectorAll('input[name=side]').forEach((r) =>
    r.addEventListener('change', () => { cap.style.captionSide = r.value; }));

  document.querySelectorAll('input[name=align]').forEach((r) =>
    r.addEventListener('change', () => { cap.style.textAlign = r.value; }));

  document.getElementById('text').addEventListener('input', (e) => {
    cap.textContent = e.target.value;
    show();
  });

  show();
</script>
</body>
</html>
Change caption-side and text-align, or edit the text. The box below reads table.caption from the page.

What the caption tag does

A caption is more than bold text over a table. It is part of the table, so it moves with the table and belongs to it in the page structure.

Screen readers use the caption text as the table's name, so a user hears the title when they reach the table.

A <p> or <h3> placed above the table looks the same on screen. It is separate text, though, and the table itself has no name. If the design needs a real heading, keep the heading and point the table at it with aria-labelledby.

In JavaScript, table.caption returns the table's first <caption> element, or null when there is none. The demo above uses it to show the current title. Page scripts cannot read the name a screen reader will announce, so the demo shows the caption text instead.

Moving and aligning the caption

Two CSS properties cover almost every layout:

  • caption-side takes top (the default) or bottom. The caption stays first in the HTML either way. Only where it is drawn changes.
  • text-align lines the title up. Browsers centre it by default, which looks odd over a left-aligned table.
The same markup with caption-side top and bottom. The caption sits outside the table's border.
The same markup with caption-side top and bottom. The caption sits outside the table's border.

Two details surprise people. The caption is drawn outside the table's border and background, so a border on table does not wrap the title. And the caption is exactly as wide as the table. On a narrow table, a long title wraps onto several lines.

colgroup and col: one element per column

Tables are built from rows, so there is no column element holding the cells. <colgroup> fills that gap. It comes after the caption and before <thead>, and each <col> inside it stands for one column, counted from the left.

span lets one <col> cover several neighbouring columns. The spans are counted like the cells in a row, so they should add up to the number of columns.

One col, then two cols with span="2", cover five columns.
One col, then two cols with span="2", cover five columns.

Which CSS works on col

This is where most attempts go wrong. Because cells are not children of a <col>, nothing inherits from it. The browser reads only a short list of properties from a column:

Property on <col> Result
background Painted behind every cell in the column
width Sets the column width
border Works only with border-collapse: collapse
visibility: collapse Removes the column and closes the gap
color, font-weight Ignored
padding Ignored
text-align Ignored

Tick the boxes to check each one. Then switch the table to border-collapse: separate with the border box on, and watch the column border disappear.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Which CSS works on col</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .toggles { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 6px 12px; font-size: 13px; margin-bottom: 12px; }
  .toggles code { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
  .ok { color: #0f5132; font-weight: 700; }
  .no { color: #9a3412; font-weight: 700; }

  table { border-collapse: collapse; background: #fff; font-size: 14px; }
  th, td { border: 1px solid #d5d9e0; padding: 6px 12px; text-align: left; }

  /* each class is one CSS property, set on the second <col> only */
  col.bg     { background: #fff3c4; }
  col.border { border: 3px solid #2563eb; }
  col.width  { width: 170px; }
  col.hide   { visibility: collapse; }
  col.color  { color: #c2410c; }
  col.pad    { padding: 20px; }
  col.bold   { font-weight: 700; }
  col.align  { text-align: right; }
  table.separate { border-collapse: separate; }
</style>
</head>
<body>
<div class="toggles" id="toggles">
  <label><input type="checkbox" value="bg"> <code>background</code> <span class="ok">works</span></label>
  <label><input type="checkbox" value="width"> <code>width</code> <span class="ok">works</span></label>
  <label><input type="checkbox" value="border"> <code>border</code> <span class="ok">collapsed only</span></label>
  <label><input type="checkbox" value="hide"> <code>visibility: collapse</code> <span class="ok">works</span></label>
  <label><input type="checkbox" value="color"> <code>color</code> <span class="no">ignored</span></label>
  <label><input type="checkbox" value="pad"> <code>padding</code> <span class="no">ignored</span></label>
  <label><input type="checkbox" value="bold"> <code>font-weight</code> <span class="no">ignored</span></label>
  <label><input type="checkbox" value="align"> <code>text-align</code> <span class="no">ignored</span></label>
  <label><input type="checkbox" id="separate"> table <code>border-collapse: separate</code></label>
</div>

<table id="t">
  <colgroup>
    <col>
    <col id="target">  <!-- the toggles add classes here -->
    <col>
  </colgroup>
  <thead><tr><th>Item</th><th>Column 2</th><th>Price</th></tr></thead>
  <tbody>
    <tr><td>Coffee</td><td>Hot</td><td>3.20</td></tr>
    <tr><td>Tea</td><td>Hot</td><td>2.80</td></tr>
    <tr><td>Juice</td><td>Cold</td><td>3.50</td></tr>
  </tbody>
</table>

<script>
  const col = document.getElementById('target');

  document.querySelectorAll('#toggles input[value]').forEach((box) =>
    box.addEventListener('change', () => col.classList.toggle(box.value, box.checked)));

  document.getElementById('separate').addEventListener('change', (e) =>
    document.getElementById('t').classList.toggle('separate', e.target.checked));
</script>
</body>
</html>
Every toggle adds one property to the middle col. The labels say what the browser does with it.
What a col applies, and what you set on the cells instead.
What a col applies, and what you set on the cells instead.

For the ignored properties, style the cells in that column directly. td:nth-child(3) selects the third cell of every row, which is the third column as long as no cell uses colspan. Rowspan and colspan explains how merged cells shift that count.

Column widths with col

width on a <col> sets the whole column in one place, instead of repeating it on every cell. Widths behave more predictably with table-layout: fixed on the table. HTML table column width covers that pattern in full, so here it is only one line:

col.label { width: 34%; }

A finished example: a pricing table

This comparison table uses all three pieces. The caption holds the title and a short note. A <col class="recommended"> paints the middle plan green and draws its border. A few lines of JavaScript highlight the column under the pointer.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing table with caption and col</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  table { border-collapse: collapse; width: 100%; max-width: 560px; background: #fff; font-size: 14px; }
  caption { caption-side: top; text-align: left; padding: 0 0 10px; }
  caption b { display: block; font-size: 17px; }
  caption span { font-size: 12.5px; color: #5b6270; }
  th, td { border-bottom: 1px solid #e1e4ea; padding: 9px 8px; text-align: center; }
  th[scope=row] { text-align: left; font-weight: 600; }
  thead th { font-size: 15px; }

  col.label { width: 34%; }
  col.recommended { background: #e8f5ec; border: 2px solid #1f9d55; }  /* border needs border-collapse */
  col.hover { background: #eef3ff; }
  col.recommended.hover { background: #d3eedc; }
  .tag { display: block; font-size: 11px; color: #1f9d55; font-weight: 700; }
</style>
</head>
<body>
<table id="plans">
  <caption>
    <b>Compare plans</b>
    <span>Prices per month. Hover or tap a column to highlight it.</span>
  </caption>
  <colgroup>
    <col class="label">
    <col>
    <col class="recommended">
    <col>
  </colgroup>
  <thead>
    <tr><td></td><th scope="col">Basic</th><th scope="col">Team<span class="tag">Recommended</span></th><th scope="col">Business</th></tr>
  </thead>
  <tbody>
    <tr><th scope="row">Price</th><td>$0</td><td>$12</td><td>$29</td></tr>
    <tr><th scope="row">Users</th><td>1</td><td>10</td><td>Unlimited</td></tr>
    <tr><th scope="row">Storage</th><td>2 GB</td><td>100 GB</td><td>1 TB</td></tr>
    <tr><th scope="row">Share links</th><td>Yes</td><td>Yes</td><td>Yes</td></tr>
    <tr><th scope="row">Support</th><td>Email</td><td>Chat</td><td>Phone</td></tr>
  </tbody>
</table>

<script>
  const table = document.getElementById('plans');
  const cols = table.querySelectorAll('col');
  let current = null;

  const clear = () => { if (current) current.classList.remove('hover'); current = null; };

  // pointerover bubbles, so one listener on the table covers every cell (and taps on phones)
  table.addEventListener('pointerover', (e) => {
    const cell = e.target.closest('td, th');
    if (!cell || cell.cellIndex === 0) return clear();  // skip the label column
    const col = cols[cell.cellIndex];  // no colspan here, so cellIndex = column number
    if (col === current) return;
    clear();
    current = col;
    col.classList.add('hover');
  });

  // a finger also "leaves" as soon as it lifts, so on touch the tapped column stays lit
  table.addEventListener('pointerleave', (e) => { if (e.pointerType !== 'touch') clear(); });
</script>
</body>
</html>
Caption as the title, a highlighted recommended column via col, and hover or tap to light up any column.

CSS has no selector for "the column under the mouse", so the script does it:

  1. Listen on the table. pointerover bubbles up, so one listener hears every cell.
  2. Find the column. cell.cellIndex gives the cell's position in its row. With no merged cells, that is the column number.
  3. Style the col. Add a hover class to cols[cellIndex], and remove it from the previous column.
const cols = table.querySelectorAll('col');

table.addEventListener('pointerover', (e) => {
  const cell = e.target.closest('td, th');
  if (!cell) return;
  cols.forEach((c) => c.classList.remove('hover'));
  cols[cell.cellIndex].classList.add('hover');
});

One paint rule matters here. The browser paints backgrounds in layers: table, then columns, then rows, then cells. A background on a <tr> or <td> covers the column colour.

If you add zebra stripes to rows, the column highlight hides under them. Stripe the cells with a see-through colour, or highlight the cells instead of the col.

When it does not work

What you see Cause Fix
The title shows, but the table has no name for screen readers The title is a <p> or heading outside the table Use <caption>, or aria-labelledby on the table
The caption is written after the rows The browser still draws it on top, but the HTML is invalid Make <caption> the first child of <table>
Text colour on a col does nothing Cells do not inherit from <col> Set color on td:nth-child(n)
Padding on a col does nothing Columns have no padding Set padding on the cells
The column border is missing The table uses border-collapse: separate Use border-collapse: collapse
The column background is hidden A tr or td background paints on top Remove it, or make it see-through
The wrong columns are styled The span values do not add up to the column count Recount: every <col> covers span columns, default 1
Blank space appears to the right of the table The spans add up to more columns than the rows have Lower the last span
The caption wraps into a narrow block The caption is exactly as wide as the table Shorten it, or give the table a wider width

Borders on the table and its cells have their own rules. Border-collapse explains how collapsed and separate borders differ.

A comparison table is easier to judge when people can try it. A screenshot cannot show the hover highlight, and an .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can hover the columns themselves. If you change the prices later, the same link shows the new version.

Questions people ask

Where does the caption tag go in an HTML table?

Directly after the opening <table> tag, as its first child, before any <colgroup>, <thead> or rows. Use caption-side: bottom in CSS if you want it drawn under the table. The HTML stays the same.

Is a caption better than a heading above the table?

A caption belongs to the table. Screen readers announce its text as the table's name when a user reaches the table. A heading above the table is separate text, so the table itself has no name unless you connect it with aria-labelledby.

Why does color on a col element do nothing?

Table cells are children of rows, not of columns, so they do not inherit from <col>. The browser reads only background, width, border (in collapsed tables) and visibility: collapse from a col. Set color, padding and alignment on the cells, for example with td:nth-child(3).

What is the difference between colgroup and col?

<colgroup> is the wrapper that sits after the caption. Each <col> inside it stands for one column, or several with span. You usually style the col elements. A colgroup can also take span on its own when it has no col inside.

Can I hide a table column with CSS?

Yes. visibility: collapse on the column's <col> removes the column and the table closes the gap. The cells stay in the HTML, so you can bring the column back by removing the style.

Keep reading