English

Les tableaux ne sont pas l'ennemi

Table des matières

Le balancier est allé trop loin

Dans les années 2000, le web était construit en tableaux. Des <table> pour la mise en page, des <td> pour les colonnes, des <tr> imbriqués dans des <tr>. Un cauchemar sémantique qui fonctionnait visuellement mais qui ne disait rien au navigateur.

Puis le CSS est arrivé. Flexbox, Grid, media queries. La communauté a banni les tableaux de mise en page, à raison. Mais le balancier est allé trop loin. Aujourd'hui, des développeurs évitent <table> même pour afficher des données tabulaires. Ils construisent des grilles en <div> + CSS Grid, des listes de cards en flex, des dashboards en composants. Et le lecteur d'écran ne voit qu'une soupe de <div> sans structure.

Le problème n'a jamais été le <table>. C'était son utilisation pour la mise en page. Pour des données qui ont des lignes, des colonnes et des en-têtes, <table> reste la seule réponse correcte. Et la plupart des sites le font mal, ou ne le font plus du tout.

Quand utiliser un tableau

La règle est simple : si tes données ont besoin de lignes ET de colonnes ET que les en-têtes donnent du sens aux cellules, c'est un tableau. Si retirer les en-têtes rend les données incompréhensibles, c'est un tableau.

Un comparatif de prix entre trois forfaits ? Tableau. Un calendrier d'événements avec dates, lieux et horaires ? Tableau. Un récapitulatif de commande avec produit, quantité et prix ? Tableau.

Une liste de cards produit sur un site e-commerce ? Pas un tableau, c'est une liste d'éléments autonomes, chacun avec sa propre structure. Un dashboard avec des blocs de statistiques ? Pas un tableau, ce sont des sections indépendantes qui se trouvent côte à côte.

Le test décisif : est-ce qu'une cellule a besoin de son en-tête de colonne ET de son en-tête de ligne pour être comprise ? "85,95 €" tout seul ne veut rien dire. "85,95 €" à l'intersection de "Forfait Pro" et "Prix mensuel", ça veut tout dire. C'est un tableau.

Si la réponse est non, si chaque élément se suffit à lui-même, c'est probablement une liste (<ul>, <ol>) ou une série d'articles (<article>). Et c'est très bien comme ça.

Anatomie d'un tableau accessible

Prenons un cas concret : un comparatif de forfaits. Voici ce qu'on retrouve couramment :

pricing-grid.html
<!-- Comparatif de forfaits classique — ressemble à un tableau, n'en est pas un -->
<section class="pricing">
  <h2>Nos forfaits</h2>
  <ul class="pricing-grid">
    <li class="plan">
      <h3>Starter</h3>
      <p class="price">9 €<span>/mois</span></p>
      <ul>
        <li>1 utilisateur</li>
        <li>Support email</li>
      </ul>
    </li>
    <li class="plan">
      <h3>Pro</h3>
      <p class="price">29 €<span>/mois</span></p>
      <ul>
        <li>10 utilisateurs</li>
        <li>Support chat</li>
      </ul>
    </li>
    <li class="plan">
      <h3>Enterprise</h3>
      <p class="price">99 €<span>/mois</span></p>
      <ul>
        <li>Utilisateurs illimités</li>
        <li>Support dédié</li>
      </ul>
    </li>
  </ul>
</section>

Ce n'est pas du mauvais HTML. Les <h3>, les <ul>, les <li> sont là. Le problème est plus subtil : cette structure présente chaque forfait comme un bloc indépendant. Un lecteur d'écran lit "Starter, 9 €/mois, 1 utilisateur, Support email" puis "Pro, 29 €/mois, 10 utilisateurs, Support chat". Il ne peut pas comparer. Il n'y a aucune relation entre "9 €" et "29 €". Aucun moyen de savoir qu'ils répondent à la même question (le prix) pour des colonnes différentes (les forfaits).

C'est exactement le cas où un tableau est la bonne réponse :

pricing-table.html
<!-- Les mêmes données, avec des relations -->
<table>
  <caption>Comparatif des forfaits</caption>
  <thead>
    <tr>
      <td></td>
      <th scope="col">Starter</th>
      <th scope="col">Pro</th>
      <th scope="col">Enterprise</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Prix mensuel</th>
      <td>9 €</td>
      <td>29 €</td>
      <td>99 €</td>
    </tr>
    <tr>
      <th scope="row">Utilisateurs</th>
      <td>1</td>
      <td>10</td>
      <td>Illimité</td>
    </tr>
    <tr>
      <th scope="row">Support</th>
      <td>Email</td>
      <td>Chat</td>
      <td>Dédié</td>
    </tr>
  </tbody>
</table>

Quand un lecteur d'écran atteint la cellule "29 €", il annonce : "Pro, Prix mensuel, 29 €". L'utilisateur peut comparer, le tableau crée les relations que la liste de cards ne peut pas exprimer.

Ce que chaque balise apporte

<caption> : le titre du tableau. C'est la première chose que le lecteur d'écran annonce. Sans elle, l'utilisateur entend "tableau, 4 colonnes, 4 lignes" mais ne sait pas de quoi il s'agit. Avec elle, il entend "Comparatif des forfaits, tableau, 4 colonnes, 4 lignes".

<thead> : sépare les en-têtes du contenu. Le navigateur sait que cette ligne n'est pas une donnée. Certains lecteurs d'écran répètent les en-têtes quand l'utilisateur navigue dans le corps du tableau, <thead> leur dit quoi répéter.

<th> : déclare une cellule comme en-tête. Sans <th>, le navigateur traite toutes les cellules comme des données équivalentes. La hiérarchie disparaît.

<th scope="col"> : cet en-tête s'applique à toute la colonne en dessous. "Starter" est le nom du forfait pour chaque ligne.

<th scope="row"> : cet en-tête s'applique à toute la ligne à droite. "Prix mensuel" est la catégorie pour chaque cellule de cette ligne.

<tbody> : encadre le contenu principal du tableau.

La cellule <td> vide en haut à gauche — ce n'est ni un en-tête de colonne ni un en-tête de ligne, c'est l'intersection. Un <td> vide est correct ici, pas un <th>.

L'attribut scope est le héros silencieux. Sans lui, le navigateur doit deviner quel en-tête s'applique à quelle cellule. Sur un tableau simple, il devine souvent juste. Sur un tableau complexe, il se trompe. scope supprime l'ambiguïté. Une ligne de code qui change tout pour l'accessibilité, et que presque personne n'écrit.

Les tableaux complexes

Tous les tableaux ne sont pas aussi simples qu'un comparatif de forfaits. Certains ont des en-têtes sur plusieurs niveaux, des cellules fusionnées, des sous-groupes. Et c'est là que l'accessibilité se complique, et que la tentation de tout refaire en <div> revient.

En-têtes multiples avec headers et id

Quand scope ne suffit plus, typiquement quand les en-têtes s'empilent sur plusieurs niveaux, il faut associer chaque cellule à ses en-têtes explicitement. L'exemple ci-dessous montre la mécanique sur un cas volontairement simple (où scope seul suffirait encore) :

complex-table.html
<!-- Budget avec deux niveaux d'en-têtes -->
<table>
  <caption>Budget par département — T1 2026</caption>
  <thead>
    <tr>
      <td></td>
      <th id="marketing" scope="col">Marketing</th>
      <th id="engineering" scope="col">Engineering</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th id="salaries" scope="row">Salaires</th>
      <td headers="marketing salaries">120 000 €</td>
      <td headers="engineering salaries">340 000 €</td>
    </tr>
    <tr>
      <th id="tools" scope="row">Outils</th>
      <td headers="marketing tools">15 000 €</td>
      <td headers="engineering tools">45 000 €</td>
    </tr>
  </tbody>
</table>

Le lecteur d'écran annonce "Marketing, Salaires, 120 000 €". Chaque cellule sait exactement à quels en-têtes elle appartient, pas de devinette.

Cellules fusionnées — avec prudence

colspan et rowspan permettent de fusionner des cellules. Ça fonctionne, mais chaque fusion est un risque : le lecteur d'écran doit recalculer la grille, et certains le font mal.

colspan.html
<!-- Un en-tête fusionné sur deux colonnes -->
<table>
  <caption>Dépenses salariales, T1 2026</caption>
  <colgroup><col></colgroup>
  <colgroup span="2"></colgroup>
  <thead>
    <tr>
      <td></td>
      <th colspan="2" scope="colgroup" id="q1">T1 2026</th>
    </tr>
    <tr>
      <td></td>
      <th scope="col" id="jan">Janvier</th>
      <th scope="col" id="feb">Février</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" id="sal">Salaires</th>
      <td headers="q1 jan sal">40 000 €</td>
      <td headers="q1 feb sal">41 000 €</td>
    </tr>
  </tbody>
</table>

Deux détails que presque tout le monde oublie : scope="colgroup" n'est valide que si le tableau déclare les <colgroup> correspondants, et les id ne servent que si des cellules les référencent via headers.

Ça fonctionne. Mais pose-toi la question : est-ce que deux tableaux simples ne seraient pas plus clairs qu'un tableau fusionné ? Un tableau que tu dois expliquer est un tableau trop complexe. Le W3C le recommande explicitement : restructurer l'information pour réduire la complexité plutôt que de sur-baliser un tableau alambiqué.

La règle d'or

Si tu as besoin de headers + id sur chaque cellule, c'est que ton tableau est complexe. Si tu as besoin de colspan + rowspan + headers + id, c'est que ton tableau est probablement trop complexe. Simplifie les données d'abord. Le balisage suivra.

Tableaux et mobile

C'est LA raison pour laquelle les développeurs abandonnent <table>. Un tableau de 6 colonnes sur un écran de 375 pixels, ça déborde. Et la réponse instinctive, tout reconstruire en <div> + flexbox. Sacrifie l'accessibilité pour résoudre un problème de mise en page.

Il existe des solutions qui gardent le <table> intact.

Le scroll horizontal

La plus simple et souvent la meilleure : le tableau reste un tableau, dans un conteneur qui scrolle.

Pourquoi ne pas mettre le scroll directement sur le <table> ? Parce qu'un tableau s'élargit pour accueillir son contenu. Pour qu'overflow déclenche un défilement, il faudrait contraindre la largeur du <table> en changeant son display, et changer le display d'un élément de tableau détruit sa sémantique pour les lecteurs d'écran (on y revient plus bas avec l'empilement). Un conteneur est nécessaire, et <figure> remplit ce rôle. Si le tableau est un contenu véritablement indépendant (un rapport, un jeu de données), <article> est aussi un choix valide.

scroll-table.html
<!-- figure englobe le tableau — scroll et focus -->
<figure class="table-scroll" tabindex="0" role="region"
        aria-labelledby="caption-pricing">
  <table>
    <caption id="caption-pricing">Comparatif des forfaits</caption>
    <!-- ... -->
  </table>
</figure>
scroll-table.css
.table-scroll {
  overflow-x: auto;
}
 
.table-scroll:focus-visible {
  outline: var(--outline-width) solid currentColor;
  outline-offset: var(--outline-offset);
}

Le tabindex="0" rend le conteneur focusable au clavier. L'utilisateur peut scroller avec les flèches. Le role="region" et le aria-labelledby pointé sur la <caption> du tableau font annoncer "région, Comparatif des forfaits" à l'arrivée du focus : l'utilisateur sait ce que contient la zone scrollable. Sans ça, l'utilisateur clavier ne sait même pas qu'il y a du contenu caché à droite.

L'empilement sur mobile

Pour les tableaux où chaque ligne est un "objet" (un produit, un utilisateur, une transaction), tu peux empiler les cellules verticalement sur mobile. Chaque ligne devient une card, chaque cellule affiche son en-tête à côté de sa valeur.

stack-table.css
@media (max-width: 40em) {
  table, thead, tbody, tr, th, td {
    display: block;
  }
 
  thead {
    /* Masqué visuellement mais accessible */
    position: absolute;
    width: 1px;
    height: 1px;
    clip: rect(1px, 1px, 1px, 1px);
  }
 
  td::before {
    content: attr(data-label);
    font-weight: 650;
    display: block;
  }
}
stack-table.html
<!-- Chaque cellule porte son libellé, et les rôles ARIA
     restaurent la sémantique que display: block détruit -->
<table role="table">
  <thead role="rowgroup">
    <tr role="row">
      <th role="columnheader" scope="col">Forfait</th>
      <!-- ... -->
    </tr>
  </thead>
  <tbody role="rowgroup">
    <tr role="row">
      <th role="rowheader" scope="row">Forfait Pro</th>
      <td role="cell" data-label="Prix mensuel">29 €</td>
      <td role="cell" data-label="Utilisateurs">10</td>
      <td role="cell" data-label="Support">Chat</td>
    </tr>
  </tbody>
</table>

Sur mobile, chaque cellule affiche "Prix mensuel" au-dessus de "29 €". Mais ce pattern a un piège, et il est vicieux : changer le display d'un élément de tableau retire sa sémantique de l'arbre d'accessibilité dans la plupart des navigateurs. Sans contre-mesure, il n'y a plus ni tableau, ni lignes, ni en-têtes pour le lecteur d'écran : exactement la soupe que cet article dénonce, servie par le CSS au lieu du HTML. C'est le rôle des attributs role redondants ci-dessus : ils redéclarent explicitement ce que display: block a détruit, et le <thead> masqué visuellement redevient réellement accessible.

Ce pattern a deux limites. Il duplique l'information (les en-têtes existent dans le <thead> ET dans les data-label). Si les en-têtes changent, il faut mettre à jour les deux. Et les rôles ARIA sont à maintenir sur chaque élément : oublie un role="row" et la grille se désagrège en silence. Si cette maintenance te semble fragile, c'est un signal : le scroll horizontal reste l'option la plus sûre, parce qu'elle ne touche à rien.

Ce qu'il ne faut pas faire

Reconstruire le tableau en <div> à partir d'un certain breakpoint. Certains frameworks JavaScript le font : ils détectent la taille de l'écran et remplacent le <table> par une liste de cards en <div>. Le résultat est un tableau accessible sur desktop et une soupe de <div> sur mobile, exactement là où l'accessibilité compte le plus, puisque les utilisateurs de lecteurs d'écran sur mobile sont en forte croissance.

Ce que ça coûte de faire autrement

Le réflexe le plus courant : remplacer le <table> par une grille en CSS. Des <div> avec display: grid, des <span> pour les valeurs, peut-être des <h3> pour les en-têtes de colonnes. Visuellement, c'est identique. Sémantiquement, tout est perdu.

Pour le lecteur d'écran

Un <table> annonce "tableau, 4 colonnes, 4 lignes". L'utilisateur sait immédiatement à quoi il a affaire. Il navigue avec les flèches. Droite pour la cellule suivante, bas pour la ligne suivante. À chaque déplacement, le lecteur d'écran rappelle l'en-tête de colonne et l'en-tête de ligne.

Une grille en <div> annonce... rien. L'utilisateur entend une suite de valeurs : "Starter", "9 €", "1", "Email", "Pro", "29 €"... sans savoir ce qui va avec quoi. Il ne peut pas naviguer par cellule. Il ne peut pas comparer. Il lit un flux linéaire de texte, pas un tableau.

Pour le SEO

Le SEO en bénéficie directement. Google comprend la structure d'un <table>. Les en-têtes <th> lui disent ce que chaque colonne représente. Les données dans les <td> sont associées à leurs en-têtes. Cette structure alimente les featured snippets et les résultats enrichis. Google peut extraire "le forfait Pro coûte 29 €/mois" directement du balisage.

Une grille en <div> ? Google voit du texte. Il peut deviner, mais il ne sait pas.

Pour le GEO

Le GEO repose sur la même logique. Les LLM adorent les tableaux. Un <table> bien balisé est un bloc de données structurées qu'ils peuvent citer tel quel. Colonnes, lignes, relations. C'est exactement le format qu'ils recherchent pour répondre à "quel est le prix du forfait Pro ?".

J'en parle en détail dans Comment le GEO force l'accessibilité web.

Les critères d'ensemble

Le RGAA et les WCAG encadrent l'accessibilité des tableaux à travers plusieurs critères :

  • RGAA 5.1 — chaque tableau de données complexe a un résumé
  • RGAA 5.2 — ce résumé est pertinent
  • RGAA 5.3 — pour chaque tableau de mise en forme, le contenu linéarisé reste compréhensible
  • RGAA 5.4 — le titre de chaque tableau de données est correctement associé (la <caption>)
  • RGAA 5.5 — ce titre est pertinent
  • RGAA 5.6 — chaque en-tête de colonne ou de ligne est correctement déclaré avec un <th>
  • RGAA 5.7 — l'association entre les cellules <td> et leurs en-têtes <th> est correcte (scope ou headers/id)
  • WCAG 1.3.1 — les informations, la structure et les relations véhiculées par la présentation sont déterminables par un programme (niveau A)

En pratique, respecter ces critères se résume à ce que cet article décrit : un <caption> pertinent et correctement associé (5.4, 5.5), des <th> avec scope ou headers (5.6, 5.7, 1.3.1), un contenu linéarisable pour les tableaux de mise en forme (5.3), et un résumé pour les tableaux complexes (5.1, 5.2).

Sources