Autopsie d'une card produit — de 87 à 38 éléments, 0 div
Table des matières
Appuie sur Tab
Ouvre les outils de développement sur n'importe quel site e-commerce. Trouve une card produit, baskets, t-shirt, casserole, peu importe. Déplie le DOM.
Visuellement, tout est en place. Une image, un badge "Promo", un petit cœur pour les favoris, un titre, un prix barré, un prix soldé. Le client est content. L'utilisateur qui voit l'écran ne remarque rien.
Maintenant, compte les éléments. Sur celle que je vais disséquer aujourd'hui : 87. Dont 27 <div>. Trois niveaux de wrappers autour d'une image. Deux <div> pour contenir un bouton. Une <div> vide qui "réserve de l'espace". Une autre qui dessine une bordure au survol.
Pose la souris. Appuie sur Tab. Essaie de faire défiler les images du carousel.
Tu ne peux pas.
Cet article prend cette card, une vraie, extraite d'un site en production, et la reconstruit de zéro. De 87 à 38 éléments. De 27 div à zéro. Chaque défaut identifié, chaque correction expliquée, chaque choix sourcé.
Teste par toi-même : Les deux versions de la card, originale et reconstruite, sont disponibles sur la page de démonstration. Ouvre-la, appuie sur Tab, et compare.
Ce que la card cache
Le carousel muet
Le carousel a 5 images. Mais rien ne te le dit. Pas d'indicateur visible, pas de compteur, pas de dots. La seule façon de découvrir les autres photos, c'est de survoler la card avec la souris. Des boutons précédent/suivant apparaissent alors. Ils ne sont pas cachés en CSS, ils n'existent pas dans le DOM. Le JavaScript les crée au hover, et les détruit quand la souris s'en va. Pas de souris, pas de boutons, pas de navigation, pas même l'information qu'il y a quelque chose à naviguer.
Pour un lecteur d'écran, c'est le même silence. Le carousel n'a ni aria-roledescription, ni aria-live, ni aria-label. L'utilisateur entend une image. Une seule. Il ne sait pas qu'il y en a quatre autres.
Le double lien
La card a deux liens vers la fiche produit : un sur l'image, un sur le titre. Le deuxième a tabindex="-1", il est hors du parcours clavier. Mais un lecteur d'écran en mode lecture le rencontre quand même. Deux liens identiques, même destination, même texte implicite. Du bruit.
Et le deuxième lien enveloppe un <header>, un landmark. Un point de repère de navigation à l'intérieur d'un élément interactif. Sémantiquement, c'est comme mettre un panneau de signalisation dans le coffre d'une voiture.
Les prix fantômes
Les deux lignes de prix ont un attribut aria-label="" vide sur des <p>. Selon la spécification ARIA, le rôle paragraph est "Name From: prohibited", aria-label ne devrait avoir aucun effet. JAWS et NVDA l'ignorent. Mais VoiceOver sur macOS le lit, et TalkBack sur Android va plus loin : il écrase le contenu textuel par la valeur du label. Un aria-label="" vide sur TalkBack, c'est un prix qui disparaît.
Et le prix barré ? "114,95 €" est visuellement barré avec text-decoration: line-through en CSS. Le texte environnant ("Prix de référence") donne un indice, mais dans le HTML, le barré lui-même est un <span> stylé, il n'a pas d'équivalent sémantique. Un lecteur d'écran lit le texte sans annoncer qu'il est barré. Le sens visuel du barré (ce prix n'est plus valable) ne repose que sur le CSS.
La structure fantaisiste
Les dots du carousel utilisent <i class="dot-indicator">, un élément de voix alternative détourné en décoration visuelle. Et la zone du carousel est une <section> sans titre. La spec HTML exige un heading dans une <section>. Sans titre, elle crée un landmark anonyme, un point de repère sans nom, du bruit pour le lecteur d'écran.
La reconstruction
La structure
La card originale empile <article>, <section>, <footer>, <header>, et 27 <div>. Chaque wrapper ajoute un nœud dans le DOM sans ajouter de sens. La version reconstruite aplatit tout :
<article class="card">
<header class="card-media" role="group"
aria-roledescription="carrousel" aria-label="Images du produit">
<!-- slides, boutons prev/next, badge -->
</header>
<button type="button" aria-label="Ajouter aux préférés"><!-- favoris --></button>
<button type="button" aria-haspopup="dialog"><!-- sponsorisé --></button>
<h3><a href="/produit/exemple"><!-- titre --></a></h3>
<p><!-- prix soldé --></p>
<p><!-- prix de référence --></p>
</article><article> : La card est un contenu autonome. Elle a du sens toute seule, dans une grille, dans un flux, dans un résultat de recherche. C'est la définition de <article>.
<header> : Aucun élément HTML ne correspond parfaitement à "conteneur de carousel". <figure> serait tentante, pas de landmark, pas de titre requis, mais la spec la définit comme "un contenu autonome, référencé depuis le flux principal". Les images produit ne sont pas une illustration annexe, elles sont le produit. <section> crée un landmark parasite. <aside> aussi, et les images ne sont pas "tangentiellement liées". <header> est le plus défendable : la spec le définit comme "un groupe d'aides introductives ou de navigation". Le carousel avec ses boutons prev/next est une aide à la navigation dans les images du produit. Dans un <article>, <header> n'a pas le rôle banner. Le HTML AAM lui attribue sectionheader, un rôle récent (ARIA 1.3) qui ne crée pas de landmark parasite. Le role="group" explicite satisfait l'exigence du APG du W3C. Le conteneur carousel doit englober les slides et les contrôles, et aria-roledescription écrase de toute façon le rôle annoncé.
Plus de <footer> : Le titre et les prix ne sont pas des informations de pied de page. Ce sont le contenu principal de la card. Ils vivent directement dans l'<article>.
Plus de <div> : Chaque élément a un rôle. Le CSS s'applique directement sur les éléments sémantiques, pas besoin de wrappers.
Le carousel qui parle
Le carousel original n'a pas de contrôles. Celui que je reconstruis en a, et ils sont accessibles au clavier, à la souris, et au lecteur d'écran.
<header class="card-media" role="group"
aria-roledescription="carrousel" aria-label="Images du produit">
<ol aria-live="polite">
<li role="group" aria-roledescription="diapositive" aria-label="1 sur 5">
<img alt="Chaussures blanches avec détails texturés et semelle plate."
width="300" height="433" loading="eager"
src="...">
</li>
</ol>
<button type="button" aria-label="Image précédente">‹</button>
<button type="button" aria-label="Image suivante">›</button>
</header>aria-roledescription="carrousel" : Le lecteur d'écran annonce "carrousel, Images du produit". L'utilisateur sait immédiatement à quoi il a affaire. La valeur est en français pour correspondre au lang="fr" de la page, comme le recommande la spec ARIA. L'attribut écrase le rôle annoncé, que l'élément soit un <header>, une <div> ou autre, l'utilisateur entend "carrousel".
aria-roledescription="diapositive" sur chaque <li> : Chaque image est annoncée comme "diapositive, 1 sur 5". L'utilisateur sait combien d'images il reste, l'information qui manquait complètement dans l'original.
aria-live="polite" : Quand le carousel change de slide, le lecteur d'écran annonce la nouvelle image sans interrompre ce qu'il était en train de lire. Sans cet attribut, le changement est silencieux. L'APG recommande polite précisément pour les carousels pilotés par l'utilisateur. Il a cliqué, il a besoin d'un retour. Pour un carousel en rotation automatique, c'est l'inverse : aria-live="off" pour ne pas interrompre la lecture à chaque changement non sollicité.
Les boutons prev/next : Ils existent dans le HTML. Toujours. Pas injectés en JavaScript, pas créés au survol. Ils sont masqués visuellement par défaut (opacity: 0) et apparaissent au hover et au focus-visible. Les aria-label rendent les boutons compréhensibles pour un lecteur d'écran, et les critères RGAA/WCAG sont satisfaits. Mais pour un utilisateur de commande vocale (Dragon, Voice Control), un bouton sans texte visible reste un obstacle : il faut deviner quoi dire. Dans le contexte compact d'une card produit, c'est un compromis accepté, pas une solution complète :
.carousel-prev,
.carousel-next {
opacity: 0;
transition: opacity var(--duration);
}
.card-media:hover .carousel-prev,
.card-media:hover .carousel-next,
.carousel-prev:focus-visible,
.carousel-next:focus-visible {
opacity: 1;
}Un utilisateur clavier qui tabule dans la card atteint les boutons, ils deviennent visibles au focus. La différence avec l'original : les boutons sont toujours dans le DOM, toujours atteignables. Le CSS contrôle leur visibilité, pas le JavaScript leur existence.
Les dots : Les indicateurs de position sont purement décoratifs, pas besoin d'éléments HTML. Un pseudo-élément ::after sur le conteneur fait le travail :
.card-media::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 0.25rem;
z-index: 5;
background: var(--color-dot-inactive);
background-image: linear-gradient(var(--color-dot-active), var(--color-dot-active));
background-size: calc(100% / var(--total, 5)) 100%;
background-position: calc(var(--current, 0) * 100% / var(--total, 5)) 0;
background-repeat: no-repeat;
}Le JavaScript pose deux custom properties sur le conteneur : --total (nombre de slides) et --current (index actif). Les fallbacks (5 et 0) garantissent un rendu correct avant que le JavaScript ait initialisé les variables — contrairement aux tokens de design, ces valeurs sont dynamiques. Le CSS calcule la taille et la position du segment actif. Une seule opération JS au changement de slide, zéro élément HTML pour de la décoration. Le W3C APG recommande les boutons prev/next comme contrôle suffisant. Les dots interactifs sont un ajout possible, pas une obligation.
Le lien unique
La card originale a deux liens vers la fiche produit, un sur l'image, un sur le titre. Le deuxième a tabindex="-1" pour le sortir du parcours clavier, mais un lecteur d'écran en mode lecture le rencontre quand même. Deux liens identiques, même destination : du bruit.
La version reconstruite n'a qu'un seul lien, dans le <h3> :
<h3 class="card-title">
<a class="info-link" href="/produit/exemple">
<span class="brand-name">Marque</span>
<span class="product-name">Modèle XYZ</span>
</a>
</h3><h3><a> : Un lien dans un heading, c'est du HTML valide. Les headings acceptent du contenu phrasé, et <a> est du contenu phrasé. C'est aussi le pattern recommandé par Adrian Roselli pour les cards cliquables. Le lien est dans le titre, pas autour de toute la card.
Pourquoi pas un <a> autour de tout l'<article> ? Parce qu'un lecteur d'écran lirait d'un bloc tout le contenu du lien. Image, badge, titre, prix, texte sponsorisé en une seule annonce de 25 secondes. L'utilisateur ne peut ni naviguer à l'intérieur, ni atteindre les boutons wishlist ou sponsorisé.
La surface de clic étendue : Le lien ne couvre que le titre, mais tu veux que toute la card soit cliquable. Un pseudo-élément fait le travail :
.info-link::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
}Le ::after couvre toute la card. Les boutons interactifs (wishlist, sponsorisé, carousel) restent au-dessus avec un z-index supérieur, ils sont cliquables par-dessus le lien étendu. Le résultat : un clic n'importe où sur la card mène à la fiche produit, sauf sur les boutons qui ont leur propre action.
La sélection de texte : Le ::after en z-index: 1 intercepte les événements souris sur toute la card. Sans précaution, l'utilisateur ne peut plus sélectionner les prix pour les copier. La solution : position: relative; z-index: 2 sur les éléments textuels qui doivent rester sélectionnables. Ils "percent" la couche du lien étendu, redeviennent accessibles à la souris, et un clic dessus ne déclenche plus la navigation.
Les prix lisibles
La card originale a deux problèmes de prix. Le premier est un attribut fantôme : les deux <p> portent un aria-label="". Selon la spécification ARIA, le rôle paragraph est "Name From: prohibited", aria-label ne devrait avoir aucun effet. JAWS et NVDA l'ignorent effectivement. Mais TalkBack écrase le contenu textuel par la valeur du label, et un label vide fait disparaître les prix. VoiceOver sur macOS le lit aussi. Un attribut invalide qui ne devrait rien faire, mais qui casse l'expérience sur mobile Android.
La solution : supprimer l'attribut.
Le deuxième problème est subtil. Le prix barré, "114,95 €", est visuellement barré avec text-decoration: line-through en CSS. Le texte environnant ("Prix de référence") donne du contexte, mais un lecteur d'écran ne voit pas le barré. Il lit le texte sans annoncer qu'il est barré. Le sens visuel du barré ne repose que sur le CSS.
La solution : <s>. L'élément HTML <s> signifie "ce contenu n'est plus exact". Le navigateur le barre visuellement, et le texte "Prix de référence" donne le contexte nécessaire pour tous les utilisateurs. Pourquoi <s> et pas <del> ? Parce que le prix de référence n'a pas été "supprimé du document", il est affiché volontairement pour la comparaison. Il n'est plus le prix courant, c'est exactement ce que <s> exprime.
<p class="price-sale">à partir de 85,95 €</p>
<p class="price-ref">Prix de référence : <s>114,95 €</s> Jusqu'à -25%</p>Deux <p>, un <s>, zéro <span>. Mais le design demande deux couleurs. Gris pour le prix de référence, rouge pour le rabais. Sans <span>, comment cibler chaque partie ?
Ce qui suit est la partie la plus technique de l'article. Si tu n'es pas à l'aise avec les gradients CSS, retiens l'essentiel : on peut coloriser du texte sans <span> grâce à background-clip: text. Le détail du conic-gradient est un bonus.
Avec un conic-gradient.
.price-ref {
background: conic-gradient(from 0deg at var(--gradient-center) var(--gradient-top), var(--color-sale) 270deg, var(--color-muted) 270deg);
background-clip: text;
color: transparent;
}
.price-ref s {
text-decoration-color: var(--color-muted);
}Le centre du gradient est placé à 23ch, la largeur de 23 caractères dans la police courante, là où le texte gris se termine. Le quadrant haut-gauche du gradient est gris, tout le reste est rouge. Pourquoi un conic-gradient et pas un linear-gradient ? Parce qu'un gradient linéaire s'applique à la boîte de l'élément. Si le texte passe à la ligne, la deuxième ligne recommence à gauche du gradient. Elle serait grise au lieu de rouge. Le conic-gradient rayonne depuis un point : tout ce qui est en dessous et à droite du centre est rouge, y compris les lignes suivantes.
Le text-decoration-color sur le <s> force la couleur du barré, sans ça, le color: transparent rendrait la ligne de barré invisible.
En forced-colors : Le gradient ne fonctionne pas en mode haute contraste Windows, utilisé pour la malvoyance, mais aussi pour la fatigue visuelle ou la sensibilité à la lumière. Le texte serait invisible. Un fallback est obligatoire :
@media (forced-colors: active) {
.price-ref { background: none; color: ActiveText; }
.price-ref s { color: GrayText; text-decoration-color: GrayText; }
}ActiveText et GrayText sont des mots-clés de couleurs système, ils s'adaptent au thème choisi par l'utilisateur. On perd la bicolorie gris/rouge, mais le texte reste lisible et le <s> reste visuellement distinct grâce à sa couleur atténuée. Pour aller plus loin sur forced-colors et les modes haute contraste, voir Les couleurs mentent.
Le résultat
| Avant | Après | |
|---|---|---|
| Éléments | 87 + 2 en JS | 38 |
<div> | 27 | 0 |
| Liens vers la fiche produit | 2 | 1 |
| Contrôles carousel au clavier | 0 | 2 |
| Prix annoncés par le lecteur d'écran | potentiellement 0 | 2 |
| Diapositives annoncées | 0 / 5 | 5 / 5 |
Pour voir la différence en action, ouvre la démonstration côte à côte.
Même card. Même design. Même fonctionnalité. Moins de la moitié des éléments, 0 div, et un utilisateur clavier qui peut enfin voir les 5 images.
Les critères d'ensemble
Chaque correction de cette autopsie répond à un ou plusieurs critères du RGAA :
- Critère 1.1 — Chaque image a-t-elle une alternative textuelle ? L'original avait un bon
alt— on le conserve. - Critère 7.1 — Chaque script est-il compatible avec les technologies d'assistance ? Les boutons du carousel créés en JavaScript au survol ne le sont pas. Ceux de la version reconstruite sont dans le HTML.
- Critère 8.2 — Le code source est-il valide ? Plus de
<section>sans titre, plus de<i>détourné, plus de<header>dans un lien. - Critère 9.1 — L'information est-elle structurée par l'utilisation appropriée de titres ? La hiérarchie
<article>→<h3>remplace le<section>sans titre et le<header>détourné. - Critère 10.7 — Le focus est-il visible ? Les boutons carousel, wishlist et sponsorisé ont tous un
focus-visibleavec un outline de contraste suffisant.
Cinq critères, huit défauts corrigés, zéro compromis visuel. Chaque correction rend la card conforme au RGAA sans changer ce que l'utilisateur voit à l'écran.