skip to Main Content

I have a single table with rows that are grouped, and there should be a header above each group. What is the correct markup for this when it comes to semantics and accessibility?

I’m aware of the scope attribute for <th> elements, and I’m thinking the rowgroup and colgroup might be related to this, but I don’t understand how to actually apply it properly, or whether the <td> or <tbody> elements need some attributes applied to them as well.

Here is an example table, without any accessibility attributes:

table { border-collapse: collapse; text-align: center }
thead { background: #ccc }
th, td { padding: 0.25em 0.5em; border: 1px solid #ddd }
tbody th { background: #eee }
<table>
  <thead>
    <tr>
      <th>A</th>
      <th>B</th>
      <th>C</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th colspan="3">Group 1</th>
    </tr>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>3</td>
    </tr>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>3</td>
    </tr>
  </tbody>
  <tbody>
    <tr>
      <th colspan="3">Gruppe 2</th>
    </tr>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>3</td>
    </tr>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>3</td>
    </tr>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>3</td>
    </tr>
  </tbody>
  <tbody>
    <tr>
      <th colspan="3">Gruppe 3</th>
    </tr>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>3</td>
    </tr>
  </tbody>
</table>

2

Answers


  1. scope="rowgroup" would be the correct markup.

    You already did a great job and used several <tbody> elements to group rows.

    The HTML Spec mentions for rowgroup:

    The row group state means the header cell applies to all the remaining cells in the row group. A th element’s scope attribute must not be in the row group state if the element is not anchored in a row group.

    So how exactly does one mark up a row group?

    ARIA in HTML mentions that the the default element for the rowgroup role is <tbody>, as it’s already used in the question.

      <tbody>
        <tr>
          <th colspan="3" scope="rowgroup">Group 2</th>
        </tr>
    

    The next question, then, is how browsers and screen readers actually expose this rowgroup. For concepts like section roles, the boundaries are announced, so when you navigate inside a new group, its name is announced. rowgroup is derived from section, so this behaviour would seem appropriate as well.

    I doubt that a lot of screen readers actually announce this. I will look into this, if anybody has results to share already, that would be great.

    Login or Signup to reply.
  2. You have divided the data into row groups with tbody elements.

    Use scope="rowgroup" on the th element to apply that header only to the remaining data of the same row group.

    Or (as very verbose alternative), explicitly associate headers to data cells by listing each header by its ID in the data cell’s headers attribute.


    Row group headers apply to all remaining data cells in the same row group, where "remaining" means: The cells whose slots’ x- and y-coordinates are greater than or equal to the header’s slots’.

    Or in other words: All cells that are part of the rectangle bounding the header cell and the row group’s ‘highest’ slot (where ‘highest’ means: hightest x- and y-coordinate); the row group’s slot in the bottom-right corner (in left-to-right top-to-bottom writing direction).

    Section 4.9.10 The th element of the HTML specification contains a note showing which headers apply to which slots, including row group headers. For visual reference, see the bright-green branching arrows:

    A table with row groups and row group headers, with arrows indicating which headers apply to which slots.

    Or see this interactive table which highlights all affected cells for a clicked header:

    const table = document.querySelector("table");
    table.addEventListener("click", evt => {
      const header = evt.target.closest("th");
      if (!header) return;
      
      const headerCoordinates = getCoordinatesOf(header);
      
      // Specific to this table
      const isColumnHeader = headerCoordinates.y === 0;  
      const isRowHeader = !isColumnHeader && headerCoordinates.x === 1;
      const isRowGroupHeader = header.getAttribute("scope") === "rowgroup";
      
      for (let row of table.rows) {
        for (let cell of row.cells) {
          const cellCoordinates = getCoordinatesOf(cell);
          
          const followsColumnHeader = isColumnHeader && cellCoordinates.x === headerCoordinates.x
            && cellCoordinates.y >= headerCoordinates.y;
          const followsRowHeader = isRowHeader && cellCoordinates.y === headerCoordinates.y
            && cellCoordinates.x >= headerCoordinates.x;
          
          // Specific to this table
          const hasSameRowGroup = cell.closest("tbody") === header.closest("tbody");
          const followsHeader = cellCoordinates.x >= headerCoordinates.x
            && cellCoordinates.y >= headerCoordinates.y;
          const followsRowGroupHeader = hasSameRowGroup && isRowGroupHeader && followsHeader;
          
          const shouldHighlight = followsColumnHeader || followsRowHeader || followsRowGroupHeader;
          
          cell.classList.toggle("highlight", shouldHighlight);
          cell.classList.toggle("rowgroup", followsRowGroupHeader);
          cell.classList.toggle("column", !followsRowGroupHeader && followsColumnHeader);
          cell.classList.toggle("row", !followsRowGroupHeader && followsRowHeader);
        }
      }
    });
    
    function getCoordinatesOf(cell) {
      return {
        x: cell.cellIndex,
        y: Array.from(cell.closest("table").rows).indexOf(cell.parentElement)
      };
    }
    
    // Allow keyboard interaction
    for (let row of table.rows) {
      for (let cell of row.cells) {
        if (cell.tagName !== "TH") continue;
        cell.tabIndex = 0;
      }
    }
    table.addEventListener("keydown", evt => {
      if (evt.code === "Enter") evt.target.click();
    });
    table.addEventListener("keyup", evt => {
      if (evt.code === "Space") evt.target.click();
    });
    th, td {
      border: 1px solid black;
      font-size: large;
      font-family: sans-serif;
    }
    th {cursor: default}
    
    .highlight.column {background-color: orange}
    .highlight.rowgroup {background-color: lightgreen}
    .highlight.row {background-color: aquamarine}
    <table>
     <thead>
      <tr> <th> ID <th> Measurement <th> Average <th> Maximum
     <tbody>
      <tr> <td> <th scope=rowgroup> Cats <td> <td>
      <tr> <td> 93 <th> Legs <td> 3.5 <td> 4
      <tr> <td> 10 <th> Tails <td> 1 <td> 1
     <tbody>
      <tr> <td> <th scope=rowgroup> English speakers <td> <td>
      <tr> <td> 32 <th> Legs <td> 2.67 <td> 4
      <tr> <td> 35 <th> Tails <td> 0.33 <td> 1
    </table>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search