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 :
<!-- 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 :
<!-- 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) :
<!-- 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.
<!-- 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.
<!-- 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>.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.
@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;
}
}<!-- 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 (scopeouheaders/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
- Tables Tutorial — W3C WAI
- Multi-level Headers — W3C WAI
- Tables, CSS Display Properties, and ARIA — Adrian Roselli
- Screen Reader User Survey #10 — WebAIM, 2024
- The
<table>element — WHATWG - The
<table>element — MDN