Les tableaux en HTML : table, thead, fusion de cellules et version mobile

Un tableau HTML se construit avec la balise <table> : chaque ligne est un <tr>, chaque cellule de données un <td>, et chaque cellule d'en-tête un <th>. Le titre du tableau va dans <caption>, les lignes d'en-tête dans <thead>, et deux attributs, colspan et rowspan, permettent à une cellule de s'étendre sur plusieurs colonnes ou plusieurs lignes.

Un tableau sert à présenter des données qui se lisent en lignes et en colonnes : un planning, des tarifs, un relevé de notes. Pour placer des blocs les uns à côté des autres, ce n'est plus l'outil : la mise en page se fait en CSS, avec CSS Grid ou Flexbox.

Cette page part de zéro et va jusqu'à la version mobile. Chaque affirmation sur ce que fait le navigateur a été vérifiée sur des pages de test ouvertes dans Chromium 158 (via Playwright 1.65.0, sous Windows 11) ; la méthode et le script sont en fin d'article.

Le tableau minimal, et la balise que le navigateur ajoute sans vous le dire

Voici un relevé de notes de deux matières. La première ligne porte les en-têtes, les deux suivantes les données :

<table>
  <tr>
    <th>Matière</th>
    <th>Note</th>
  </tr>
  <tr>
    <td>HTML</td>
    <td>16</td>
  </tr>
  <tr>
    <td>CSS</td>
    <td>13</td>
  </tr>
</table>

Ce code est valide, mais ce n'est pas l'arbre que le navigateur construit. En lisant un <tr> placé directement dans un <table>, l'analyseur HTML insère de lui-même un élément <tbody> et y range les lignes : c'est écrit dans la spécification HTML, au mode d'insertion « in table ». Dans la page de test, le premier enfant du tableau est bien un TBODY.

La conséquence se voit en CSS. Avec la règle table > tr { background: yellow; }, aucune ligne ne devient jaune : le sélecteur table > tr trouve 0 élément, alors que table > tbody > tr en trouve 3, et le fond calculé de la première ligne reste rgba(0, 0, 0, 0), c'est-à-dire transparent. Écrire le <tbody> vous-même ne change rien à ce sélecteur, puisque les lignes restent dans le <tbody>. Deux façons d'éviter le piège : cibler table > tbody > tr, en écrivant de préférence le <tbody> dans le code pour qu'il ne soit pas une surprise, ou cibler les lignes avec table tr, sans le signe >.

th ou td en gras : la différence ne se voit pas, elle se mesure

Une tentation fréquente consiste à faire les en-têtes avec des <td> mis en gras. À l'écran, le résultat est presque le même :

<table>
  <tr>
    <td><strong>Matière</strong></td>
    <td><strong>Note</strong></td>
  </tr>
  <tr>
    <td>HTML</td>
    <td>16</td>
  </tr>
  <tr>
    <td>CSS</td>
    <td>13</td>
  </tr>
</table>

La différence est dans l'arbre d'accessibilité, la version de la page que le navigateur transmet aux technologies d'assistance comme les lecteurs d'écran. Voici ce que Chromium construit pour la première version, avec de vrais <th> :

table
  row
    columnheader "Matière"
    columnheader "Note"
  row
    cell "HTML"
    cell "16"

Et voici la version aux <td> en gras :

LayoutTable
  LayoutTableRow
    LayoutTableCell "Matière"
    LayoutTableCell "Note"
  LayoutTableRow
    LayoutTableCell "HTML"
    LayoutTableCell "16"

Les deux en-têtes de colonne ont disparu (0 columnheader, et 6 cellules LayoutTableCell sur 6), et le tableau lui-même a changé de rôle : sans <th>, Chromium 158 l'a classé comme un tableau de mise en page (LayoutTable), pas comme un tableau de données. Rien de cela ne se voit à l'œil. Le <th> n'est donc pas une question de style : c'est lui qui dit au navigateur que la cellule est un titre.

caption, thead, tbody et tfoot : un tableau qui se présente

Un tableau complet ajoute un titre et range ses lignes en trois groupes : l'en-tête, le corps et le pied. Ici, les dépenses d'une semaine, avec leur total :

<table>
  <caption>Dépenses de la semaine</caption>
  <thead>
    <tr>
      <th scope="col">Jour</th>
      <th scope="col">Poste</th>
      <th scope="col">Montant</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Lundi</td>
      <td>Transport</td>
      <td>12 €</td>
    </tr>
    <tr>
      <td>Mardi</td>
      <td>Repas</td>
      <td>9 €</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row" colspan="2">Total</th>
      <td>21 €</td>
    </tr>
  </tfoot>
</table>

Chaque élément a un rôle précis :

Pour un tableau simple, Chromium devine déjà que les <th> de la première ligne sont des en-têtes de colonne : c'était le cas dans le relevé de notes, écrit sans scope. L'attribut devient utile dès qu'un tableau a des en-têtes sur ses lignes comme sur ses colonnes, et le tutoriel du W3C sur les tableaux à deux en-têtes recommande alors de le poser explicitement.

Fusionner des cellules avec colspan et rowspan

colspan="2" étend une cellule sur deux colonnes, rowspan="2" sur deux lignes. Le planning suivant utilise les deux : un projet qui occupe lundi et mardi matin, et des exercices qui prennent l'après-midi et la soirée du lundi.

<table>
  <caption>Planning de la semaine</caption>
  <thead>
    <tr>
      <th scope="col">Créneau</th>
      <th scope="col">Lundi</th>
      <th scope="col">Mardi</th>
      <th scope="col">Mercredi</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Matin</th>
      <td colspan="2">Projet portfolio</td>
      <td>Révisions</td>
    </tr>
    <tr>
      <th scope="row">Après-midi</th>
      <td rowspan="2">Exercices CSS</td>
      <td>Lecture</td>
      <td>Sport</td>
    </tr>
    <tr>
      <th scope="row">Soir</th>
      <td>Repos</td>
      <td>Quiz</td>
    </tr>
  </tbody>
</table>

Le tableau a 4 colonnes, mais les lignes n'écrivent pas toutes 4 cellules : 4 pour l'en-tête, 3 pour le matin, 4 pour l'après-midi, 3 pour le soir. La règle qui réconcilie ces chiffres : le navigateur place chaque cellule dans la première case libre de sa ligne, et une cellule fusionnée occupe d'avance les cases qu'elle recouvre, y compris dans les lignes suivantes. C'est le modèle de tableau de la spécification HTML.

CréneauLundiMardiMercredi MatinProjet portfolioRévisions Après-midiExercicesCSSLectureSport SoirReposQuiz colspan="2"rowspan="2" cellules 4343
Les cases encadrées en couleur sont fusionnées. À droite, le nombre de cellules écrites dans le code de chaque ligne.

Un détail en découle, qui piège souvent quand on manipule le tableau en JavaScript : la position d'une cellule dans le code n'est pas sa colonne. « Repos » est la deuxième cellule de sa ligne en comptant l'en-tête « Soir » (son cellIndex vaut 1, la numérotation partant de 0), mais elle s'affiche sous « Mardi », la troisième colonne, parce que la case de lundi est déjà prise.

L'erreur classique consiste à écrire la ligne du soir comme si la fusion n'existait pas, avec une cellule par jour :

<tr>
  <th scope="row">Soir</th>
  <td>Repos</td>
  <td>Quiz</td>
  <td>Jeu</td>
</tr>

Le navigateur ne signale rien. Il crée une cinquième colonne, sans en-tête, pour loger « Jeu » : dans Chromium, la cellule commence à droite de la dernière cellule d'en-tête, et le même code, analysé avec parse5 (une bibliothèque qui applique les règles d'analyse de la spécification) puis passé dans un petit script qui reprend le placement des cellules du modèle de tableau, donne aussi 5 colonnes. Le réflexe qui évite cette erreur : compter, ligne par ligne, les colonnes couvertes par les cellules écrites (une cellule colspan="2" compte pour deux) et les cases déjà occupées par un rowspan venu d'au-dessus ; le total doit être le même partout, ici 4.

Sur mobile, laisser défiler le tableau plutôt que la page

Un tableau large ne se resserre pas indéfiniment pour tenir dans l'écran. Pour le vérifier, j'ai pris un suivi d'exercices de sept colonnes (exercice, thème, niveau, date de début, date de fin, durée, statut) et je l'ai affiché dans une fenêtre de 390 pixels CSS de large, la largeur de téléphone que j'utilise pour mes tests. Même avec ses textes passés à la ligne, le tableau mesure 465 pixels et la page entière 473 : c'est toute la page qui défile de côté, texte compris.

La correction la plus simple consiste à entourer le tableau d'un conteneur qui défile seul, avec <div class="defilement"> et cette règle :

.defilement { overflow-x: auto; }

Mesurée dans les mêmes conditions, la page revient à 390 pixels de large, et seul le conteneur défile : 465 pixels de contenu dans 374 pixels visibles. L'arbre d'accessibilité ne change pas, le tableau garde ses 7 en-têtes de colonne. C'est d'ailleurs ce que fait ce site, en posant display: block et overflow-x: auto directement sur les tableaux de ses articles ; avec ce réglage aussi, la page reste à 390 pixels, le tableau défile seul (465 pixels de contenu pour 374 visibles) et Chromium 158 conserve le rôle table et les 7 en-têtes. Je ne l'ai mesuré que dans Chromium.

Pour aller plus loin sur l'adaptation aux petits écrans en général, le guide responsive design et media queries reprend les bases que la suite utilise.

La version en cartes, sans perdre les en-têtes

Quand chaque ligne se lit comme une fiche indépendante, on peut préférer l'empiler en carte sur mobile : chaque cellule passe sur sa propre ligne, précédée du nom de sa colonne. Une technique consiste à répéter ce nom dans un attribut data-label sur chaque cellule :

<tr>
  <td data-label="Exercice">Galerie photo</td>
  <td data-label="Thème">CSS Grid</td>
  <td data-label="Niveau">Intermédiaire</td>
  <td data-label="Commencé le">2026-11-02</td>
  <td data-label="Terminé le">2026-11-05</td>
  <td data-label="Durée">3&nbsp;h</td>
  <td data-label="Statut">Terminé</td>
</tr>

Puis on ajoute class="empile" à la balise <table> et, sous une certaine largeur, on masque l'en-tête, on passe le tableau, son titre, ses lignes et ses cellules en display: block, on espace les cartes et on affiche le libellé avec le pseudo-élément ::before :

@media (max-width: 40rem) {
  .empile thead { display: none; }
  .empile, .empile caption, .empile tbody, .empile tr, .empile td { display: block; }
  .empile tr { margin-bottom: 1rem; }
  .empile td::before { content: attr(data-label) " : "; font-weight: bold; }
}

À l'écran, chaque ligne devient un bloc de paires « libellé : valeur ». Dans l'arbre d'accessibilité de Chromium, à 390 pixels, il reste un table avec ses lignes et ses cellules, mais plus aucun en-tête de colonne : display: none retire le <thead> de l'arbre avec ses 7 <th>. Les libellés ne survivent que parce que le texte du ::before s'est collé au contenu des cellules :

  row
    cell "Exercice : Galerie photo"
    cell "Thème : CSS Grid"
    cell "Niveau : Intermédiaire"

Deux retouches rendent les en-têtes au tableau sans changer l'affichage mobile. D'abord, masquer l'en-tête visuellement seulement : à la place de display: none, des propriétés qui le réduisent à un carré de 1 pixel découpé, toujours à l'intérieur de la media query pour que l'en-tête reste visible sur grand écran. Il disparaît de l'écran mais reste dans l'arbre. Mesuré seul, ce changement ramène les 7 en-têtes de colonne, mais chaque libellé figure alors deux fois, dans l'en-tête et dans le texte de la cellule (« Exercice : Galerie photo »). Ensuite, donner au texte du ::before une alternative vide, avec la barre oblique de la propriété content. Ce bloc remplace entièrement le précédent, le HTML ne change pas :

@media (max-width: 40rem) {
  .empile thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .empile, .empile caption, .empile tbody, .empile tr, .empile td { display: block; }
  .empile tr { margin-bottom: 1rem; }
  .empile td::before {
    content: attr(data-label) " : ";
    content: attr(data-label) " : " / "";
    font-weight: bold;
  }
}

Avec les deux retouches, la page reste à 390 pixels de large et l'arbre de Chromium retrouve exactement la forme du tableau d'origine, des cellules sans libellé dupliqué et les 7 en-têtes :

  row
    cell "Galerie photo"
    cell "CSS Grid"
    cell "Intermédiaire"

Sur grand écran, rien de tout cela ne s'applique : mesuré à 1440 pixels, l'en-tête est visible, les cellules gardent leur affichage de tableau (table-cell) et le ::before ne génère aucun contenu.

La syntaxe content: … / "" est prise en charge depuis Chrome 77, Firefox 128 et Safari 17.4 selon les données de compatibilité de MDN (paquet browser-compat-data 8.1.9). Dans Chromium 158, la valeur calculée du ::before est bien "Exercice : " / "", preuve que la seconde déclaration a été retenue.

Un navigateur qui ne connaît pas cette syntaxe ignore la déclaration entière : les règles de traitement des erreurs de CSS imposent d'écarter une déclaration dont la valeur est invalide. Seule, elle ne laisserait alors aucun libellé à l'écran : c'est pourquoi la première ligne content, sans alternative, reste en place comme solution de repli.

Mon choix par défaut reste le défilement : rien à dupliquer, rien à maintenir, et les colonnes restent comparables d'un coup d'œil. Je garde les cartes pour les tableaux de peu de lignes où chaque ligne se lit seule, et alors avec les deux retouches. Sachez aussi que chaque data-label recopie un en-tête : si vous renommez une colonne, il faut changer le texte à deux endroits.

Comment j'ai vérifié chaque exemple

Chaque exemple de cette page existe sous forme de fichier HTML complet. Les mesures de mise en page et d'accessibilité ont été faites dans Chromium 158.0.8127.18, piloté par Playwright 1.65.0 sous Node 22.23.3 et Windows 11, à 1440 pixels de large pour les sections sur la structure et à 390 pour les sections sur le mobile. Les arbres affichés plus haut sortent du script ci-dessous, qui ouvre toujours la page à 390 pixels ; pour les tableaux sans media query, les rôles relevés à 1440 sont les mêmes. Les largeurs en pixels dépendent des polices installées : sur une autre machine, attendez-vous à des valeurs voisines, pas identiques. Le nombre de colonnes et la place de « Repos » ont aussi été recalculés sans navigateur, avec parse5 8.1.1 pour l'analyse du HTML et un script qui place les cellules selon le modèle de tableau.

Pour afficher l'arbre d'accessibilité d'une de vos pages, ce script suffit. Il ouvre la page dans Chromium et n'imprime que les nœuds liés aux tableaux :

// Affiche la partie « tableau » de l'arbre d'accessibilité de Chromium.
// Usage : node arbre.mjs page.html
import { chromium } from 'playwright';
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';

const navigateur = await chromium.launch();
const page = await navigateur.newPage({ viewport: { width: 390, height: 800 } });
await page.goto(pathToFileURL(resolve(process.argv[2])).href);
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const parId = new Map(nodes.map((n) => [n.nodeId, n]));

function afficher(noeud, niveau) {
  const role = noeud.role?.value ?? '';
  const garde = !noeud.ignored && /table|row|cell|header|caption/i.test(role);
  if (garde) {
    const nom = noeud.name?.value ? ` "${noeud.name.value}"` : '';
    console.log('  '.repeat(niveau) + role + nom);
  }
  for (const id of noeud.childIds ?? []) afficher(parId.get(id), garde ? niveau + 1 : niveau);
}
afficher(nodes[0], 0);
await navigateur.close();

Pour le lancer, enregistrez-le sous le nom arbre.mjs dans un dossier vide au nom simple (minuscules, sans espace ni point initial, car il devient le nom du projet), à côté de votre page :

npm init -y
npm install --save-exact playwright@1.65.0
npx playwright install chromium
node arbre.mjs ma-page.html

Les sorties citées plus haut viennent de ce script, appliqué aux fichiers d'exemple. Le script lit l'arbre de Chromium uniquement : Firefox et Safari construisent le leur, et rien sur cette page ne prétend décrire ce qu'ils en font.