htmlaccessibilitywai-ariasemantic-markupwcag

Accessible HTML table markup with multiple total rows and section headers


My client wants to have a data table with a structure like this: enter image description here

The idea being that the table header will be sticky for the whole table, and the account headers will be sticky just below the table header, but only for their own sections.

Is this achievable in a way that doesn't break the experience for screen readers? Ideally there's a way to do this with semantic HTML that doesn't require any hacks, but I'm open to all solutions. The main goal here is keeping this experience accessible for keyboard users and screen readers alike.

The headers will allow the user to sort the whole table. For example, sorting by Cost ascending will change the order of transactions under each account to be Transaction 3, 2, 1 and Transaction 6, 5, 4 in that order respectively, but the account sections would remain in the same location. I'm not sure if this changes things.


Solution

  • Here is an example of a well-formatted <table>, from the HTML specification:

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

    Visual description of headers providing a label to cells. Column headers label the remaining column, row headers the remaining row; row group and column group headers label the remaining row group or column group, respectively.

    As you can see, a table can have multiple <tbody> elements corresponding to multiple row groups. And headers with scope="rowgroup" provide a label for the remaining row group.

    For a well-formatted and accessible table, we can provide column and row headers as usual. More importantly, we can mark up each "Account" section as a <tbody>, and provide a row group header for each section:

    table {
      border-collapse: collapse;
    }
    th, td {
      padding: .5rem 1.25rem;
      border: 1px solid black;
    }
    
    thead th {
      background-color: #d3deea;
    }
    tbody th {
      text-align: start;
    }
    tbody > tr:first-child > th {
      background-color: #a7e3e4;
    }
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Cost</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 123</th>
        </tr>
        <tr>
          <td>Trx 1</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 456</th>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 3</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
    </table>

    However, we cannot naïvely make the cells sticky, because the borders would remain in place:

    /* Sticky */
    thead > tr > th {
      position: sticky;
      top: 0;
    }
    tbody > tr:first-child > th {
      position: sticky;
      top: calc(1rem + 1lh + 1px); /* Offset by height of `thead > tr` */
    }
    
    /* Styling */
    table {
      border-collapse: collapse;
    }
    th, td {
      padding: .5rem 1.25rem;
      border: 1px solid black;
    }
    
    thead th {
      background-color: #d3deea;
    }
    tbody th {
      text-align: start;
    }
    tbody > tr:first-child > th {
      background-color: #a7e3e4;
    }
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Cost</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 123</th>
        </tr>
        <tr>
          <td>Trx 1</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 456</th>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 3</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 123</th>
        </tr>
        <tr>
          <td>Trx 1</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 456</th>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 3</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 123</th>
        </tr>
        <tr>
          <td>Trx 1</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 456</th>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 3</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 123</th>
        </tr>
        <tr>
          <td>Trx 1</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 456</th>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 3</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 123</th>
        </tr>
        <tr>
          <td>Trx 1</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 456</th>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 3</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 123</th>
        </tr>
        <tr>
          <td>Trx 1</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 456</th>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 3</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
    </table>

    To "fix" the border issue, absolutely position a "border" on top. A simple solution would be to use ::after pseudo-elements for the border:

    /* Sticky */
    thead > tr > th {
      position: sticky;
      top: 0;
    }
    tbody > tr:first-child > th {
      position: sticky;
      top: calc(1rem + 1lh + 1px); /* Offset by height of `thead > tr` */
    }
    
    /* Border of sticky headers */
    thead > tr > th::after,
    tbody > tr:first-child > th::after {
      content: "";
      position: absolute;
      top: -1px;
      left: -1px;
      width: 100%;
      height: 100%;
      border: 1px solid black;
      display: block;
      pointer-events: none;
    }
    
    /* Styling */
    table {
      border-collapse: collapse;
    }
    th, td {
      padding: .5rem 1.25rem;
      border: 1px solid black;
    }
    
    thead th {
      background-color: #d3deea;
    }
    tbody th {
      text-align: start;
    }
    tbody > tr:first-child > th {
      background-color: #a7e3e4;
    }
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Cost</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 12</th>
        </tr>
        <tr>
          <td>Trx 1</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 2</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 34</th>
        </tr>
        <tr>
          <td>Trx 3</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 4</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 56</th>
        </tr>
        <tr>
          <td>Trx 5</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 6</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 78</th>
        </tr>
        <tr>
          <td>Trx 7</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 8</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 910</th>
        </tr>
        <tr>
          <td>Trx 9</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 10</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 1112</th>
        </tr>
        <tr>
          <td>Trx 11</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 12</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 1314</th>
        </tr>
        <tr>
          <td>Trx 13</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 14</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 1516</th>
        </tr>
        <tr>
          <td>Trx 15</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 16</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 1718</th>
        </tr>
        <tr>
          <td>Trx 17</td>
          <td>20</td>
        </tr>
        <tr>
          <td>Trx 18</td>
          <td>15</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>35</td>
        </tr>
      </tbody>
      <tbody>
        <tr>
          <th colspan="2" scope="rowgroup">Account 1920</th>
        </tr>
        <tr>
          <td>Trx 19</td>
          <td>30</td>
        </tr>
        <tr>
          <td>Trx 20</td>
          <td>20</td>
        </tr>
        <tr>
          <th>Total</th>
          <td>50</td>
        </tr>
      </tbody>
    </table>

    Note: Without pointer-events: none, the ::after pseudo-element—"extending" its parent cell's area—would catch pointer events, preventing the events from reaching the cell's children such as buttons.