H1 display:none en mobile-first : −34 % de trafic organique

H1 en display:none sur mobile : comment un breakpoint CSS a effacé 1 200 headings de l'index Google

Mercredi 14h. L'équipe design d'un site éditorial français — 8 000 pages, 1,4 million de visites organiques par mois — déploie une refonte du hero banner. Le nouveau composant affiche un titre visuel stylisé sur desktop et un titre condensé sur mobile. Le code passe la QA. Le staging est validé. Lighthouse donne 94 en performance. Le vendredi soir, tout est en production. Le lundi suivant, personne ne remarque rien. Trois semaines plus tard, le trafic organique sur les pages catégories a fondu de 34 %. Le H1 n'existe plus pour Google.

Lundi, J+17 — Le dashboard vire au rouge

Le responsable acquisition ouvre Looker Studio le lundi matin. Le rapport hebdomadaire montre un décrochage progressif sur les pages catégories. Pas un crash brutal — une érosion. −8 % la première semaine. −14 % la deuxième. La troisième semaine affiche −34 % cumulé sur 1 200 pages de catégories et sous-catégories.

Premier réflexe : vérifier Search Console. L'onglet Performances filtre sur les pages concernées. Les impressions chutent. Les positions moyennes glissent de 6,2 à 14,7 sur les requêtes cibles. Pas de message d'erreur dans la couverture. Pas de pénalité manuelle. Pas de core update récent — le dernier a été déployé bien avant.

L'équipe SEO lance un crawl Screaming Frog sur le périmètre touché. 1 247 pages catégories. Le rapport "H1" remonte une anomalie : 1 203 pages ont un H1 présent dans le DOM mais marqué comme display:none dans le CSS calculé. Screaming Frog le signale en warning, pas en erreur. L'outil détecte le H1 dans le HTML brut. Mais il flag le display:none comme un risque.

Le lead SEO ouvre une page catégorie dans Chrome, redimensionne la fenêtre en desktop : le titre s'affiche. Il passe en mode responsive 375px : le titre disparaît. Un autre élément visuel le remplace — un <span> stylisé en gros, sans balise heading.

L'hypothèse initiale est rapide : "C'est cosmétique, le H1 est quand même dans le DOM, Google le voit." L'équipe croit que le contenu masqué par CSS est simplement dépriorisé, pas ignoré.

C'est faux. Et c'est exactement là que le piège se referme.

Google utilise l'indexation mobile-first depuis 2021. Le crawler rend la page avec un viewport mobile. Le CSS est interprété. display:none sur un breakpoint mobile signifie que le contenu n'existe pas dans le rendu crawlé. Ce n'est pas un contenu dépriorisé. C'est un contenu absent.

Le moment de bascule arrive quand un dev senior utilise l'outil d'inspection d'URL de Search Console et clique sur "Afficher la page explorée". Le screenshot montre le rendu mobile. Pas de H1. Juste le <span> décoratif. Google ne voit littéralement pas le titre principal de 1 200 pages.

Le bug : un breakpoint qui efface le heading sémantique

Le composant hero a été restructuré pendant la refonte. L'ancienne version était simple :

<!-- Ancien composant hero — avant refonte -->
<section class="hero">
  <h1 class="hero__title">Chaussures de running homme</h1>
  <p class="hero__subtitle">Découvrez notre sélection</p>
</section>

Le nouveau design introduit deux éléments distincts : un titre "riche" pour desktop avec un SVG décoratif, et un titre "compact" pour mobile. Le problème : le titre desktop porte le <h1>, et le titre mobile est un <span>.

<!-- Nouveau composant hero — après refonte -->
<section class="hero">
  <!-- Titre desktop : contient le H1 -->
  <div class="hero__title-desktop">
    <h1 class="hero__heading">Chaussures de running homme</h1>
    <svg class="hero__decoration" aria-hidden="true"><!-- flourish --></svg>
  </div>

  <!-- Titre mobile : pas de heading sémantique -->
  <div class="hero__title-mobile">
    <span class="hero__heading-visual">Running homme</span>
  </div>
</section>

Le CSS associé utilise une approche mobile-first classique. Les breakpoints sont écrits avec min-width, ce qui signifie que les styles par défaut s'appliquent au mobile :

/* Base = mobile */
.hero__title-desktop {
  display: none; /* masqué par défaut (mobile) */
}

.hero__title-mobile {
  display: block; /* visible par défaut (mobile) */
}

/* Desktop : à partir de 1024px */
@media (min-width: 1024px) {
  .hero__title-desktop {
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .hero__title-mobile {
    display: none;
  }
}

La logique CSS est techniquement correcte pour le design. Sur un écran mobile, hero__title-desktop est masqué, hero__title-mobile est visible. Sur desktop, c'est l'inverse. Le problème : le H1 sémantique vit uniquement dans le bloc desktop.

Googlebot rend avec un viewport de 412×823 pixels (Nexus 5X). À cette largeur, min-width: 1024px ne s'applique pas. Le CSS par défaut s'applique. hero__title-desktop est en display:none. Le <h1> disparaît du rendu.

Ce qui reste visible pour Google : un <span> sans valeur sémantique. Pas de <h1>. Pas de <h2> de remplacement. La page devient headingless dans l'index.

Pourquoi les tests n'ont rien détecté

Trois raisons convergentes :

1. La QA teste en desktop. L'équipe QA vérifie le rendu sur Chrome 1440px. Le H1 est visible. Le test passe. Personne n'inspecte le DOM rendu en viewport mobile pour vérifier la présence sémantique d'un heading.

2. Screaming Frog crawle le HTML brut par défaut. En mode statique (sans rendu JavaScript), Screaming Frog voit le H1 dans le source HTML. Il est là, dans le DOM. Le warning display:none existe, mais il est souvent ignoré parmi des dizaines d'autres flags. L'équipe n'avait pas configuré de rendu JavaScript dans Screaming Frog, et même avec le rendu activé, le viewport par défaut de Screaming Frog est desktop.

3. Lighthouse ne vérifie pas la présence de H1. Lighthouse vérifie l'accessibilité des headings (ordre logique, hiérarchie), mais ne lève pas d'erreur si un H1 est en display:none. L'audit "Heading elements are not in a sequentially-descending order" reste vert tant que les headings visibles sont ordonnés — et ici, il n'y en a simplement aucun.

Pour reproduire exactement ce que Google voit, il faut utiliser l'outil d'inspection d'URL de Search Console, ou exécuter un fetch avec un user-agent mobile et un viewport contraint :

# Simuler le crawl mobile de Googlebot avec curl + Puppeteer headless
npx puppeteer-cli screenshot \
  --url "https://example.com/chaussures-running-homme" \
  --viewport "412x823" \
  --user-agent "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.126 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
  --output rendered-mobile.png

Le screenshot obtenu confirme : pas de H1 visible. Seul le <span> "Running homme" apparaît.

On peut aussi inspecter le DOM rendu programmatiquement pour détecter les headings masqués :

// Script Puppeteer pour auditer les H1 masqués en viewport mobile
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setViewport({ width: 412, height: 823 });
  await page.setUserAgent(
    'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) ' +
    'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.126 ' +
    'Mobile Safari/537.36 (compatible; Googlebot/2.1)'
  );

  await page.goto('https://example.com/chaussures-running-homme', {
    waitUntil: 'networkidle0',
  });

  const h1Status = await page.evaluate(() => {
    const h1 = document.querySelector('h1');
    if (!h1) return { exists: false };
    const style = window.getComputedStyle(h1);
    return {
      exists: true,
      text: h1.textContent.trim(),
      display: style.display,
      visibility: style.visibility,
      opacity: style.opacity,
      hidden: style.display === 'none' || style.visibility === 'hidden',
    };
  });

  console.log('H1 audit result:', h1Status);
  // Résultat attendu : { exists: true, text: "Chaussures de running homme",
  //                       display: "none", hidden: true }

  await browser.close();
})();

Ce script retourne hidden: true. Le H1 existe dans le DOM, mais son display calculé est none en viewport mobile. Google le traite comme inexistant.

Ce type de divergence entre ce que voit un développeur sur son écran 27 pouces et ce que voit le crawler mobile est un classique des régressions silencieuses. L'incident rappelle directement les problèmes rencontrés lors de refontes de header où le H1 est remplacé par un div — la cause diffère, mais le résultat pour l'index est identique.

L'impact documenté par Google

La documentation officielle de Google est explicite. Le contenu en display:none est considéré comme du contenu caché. Google ne l'ignore pas toujours — le contexte compte. Mais pour un élément structurant comme le H1, l'absence de visibilité dans le rendu mobile signifie qu'il ne contribue plus au signal de pertinence de la page.

Plus problématique : sur certaines pages, Google a commencé à réécrire le title snippet dans les SERP en utilisant le <span> visible ("Running homme") au lieu du H1 complet ("Chaussures de running homme"). Les CTR ont chuté parce que le snippet raccourci est moins informatif.

Le fix : un seul H1, visible partout, stylisé par viewport

Le correctif repose sur un principe simple : un seul <h1> dans le DOM, toujours visible, avec un style adaptatif par media query. Pas de duplication. Pas de masquage conditionnel de heading.

<!-- Composant hero corrigé -->
<section class="hero">
  <div class="hero__title-wrapper">
    <h1 class="hero__heading">Chaussures de running homme</h1>
    <svg class="hero__decoration" aria-hidden="true"><!-- flourish, desktop only --></svg>
  </div>
</section>
/* Base = mobile : titre compact */
.hero__heading {
  font-size: 1.5rem;
  line-height: 1.2;
  /* Le texte complet est toujours visible, seul le style change */
}

.hero__decoration {
  display: none; /* SVG décoratif masqué en mobile */
}

/* Desktop */
@media (min-width: 1024px) {
  .hero__heading {
    font-size: 2.75rem;
    line-height: 1.1;
  }

  .hero__title-wrapper {
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .hero__decoration {
    display: block;
  }
}

Le H1 reste dans le flux quel que soit le viewport. Seule la typographie change. Le SVG décoratif est masqué sur mobile — c'est un élément non sémantique marqué aria-hidden="true", sa disparition n'a aucun impact SEO.

Si le design mobile exige vraiment un texte plus court que le desktop, la technique recommandée est d'utiliser le CSS pour tronquer visuellement tout en conservant le texte complet dans le DOM accessible :

/* Alternative : texte complet dans le DOM, tronqué visuellement en mobile */
.hero__heading {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}

@media (min-width: 1024px) {
  .hero__heading {
    white-space: normal;
    overflow: visible;
    text-overflow: unset;
  }
}

Dans ce cas, Googlebot voit le texte complet dans le DOM rendu — overflow:hidden ne supprime pas le contenu, il masque seulement le dépassement visuel. La différence avec display:none est fondamentale : le contenu existe dans le layout, il participe au rendu.

Le déploiement et la récupération

Le patch a été poussé le mercredi J+19. L'équipe a pris deux précautions supplémentaires :

  1. Invalidation du cache CDN sur toutes les pages catégories via un purge ciblé Cloudflare (cf-cache-purge par prefix /categorie/). Le HTML en cache avec l'ancien composant devait être évacué immédiatement.

  2. Demande d'indexation manuelle via Search Console sur les 15 pages catégories les plus stratégiques. Pour les 1 200 autres, l'équipe a soumis le sitemap mis à jour avec des <lastmod> rafraîchis pour signaler le changement.

Le suivi quotidien dans Search Console a montré la chronologie de récupération :

  • J+19 à J+22 : Google re-crawle les pages prioritaires. L'inspection d'URL confirme que le H1 est visible dans le rendu mobile.
  • J+25 : Les positions commencent à remonter sur les 200 premières pages re-crawlées.
  • J+33 : 80 % du trafic perdu est récupéré.
  • J+45 : Retour au niveau pré-incident. Les positions moyennes reviennent à 6,8 (contre 6,2 avant — un léger delta résiduel qui s'est résorbé sur les deux semaines suivantes).

Au total, l'incident a coûté 26 jours de visibilité dégradée et environ 210 000 clics organiques perdus sur le périmètre impacté.

Les gardes-fous ajoutés

L'équipe a mis en place trois contrôles post-incident :

Un test automatisé dans la CI/CD qui exécute Puppeteer en viewport 412×823 sur un échantillon de pages et vérifie qu'un <h1> visible existe :

// test/seo/h1-mobile-visible.test.js — exécuté en CI
describe('H1 visibility on mobile viewport', () => {
  it('should have a visible H1 on category pages', async () => {
    await page.setViewport({ width: 412, height: 823 });
    await page.goto(TEST_URL, { waitUntil: 'networkidle0' });

    const h1 = await page.evaluate(() => {
      const el = document.querySelector('h1');
      if (!el) return null;
      const s = window.getComputedStyle(el);
      return {
        text: el.textContent.trim(),
        visible: s.display !== 'none' && s.visibility !== 'hidden'
                 && parseFloat(s.opacity) > 0,
      };
    });

    expect(h1).not.toBeNull();
    expect(h1.visible).toBe(true);
    expect(h1.text.length).toBeGreaterThan(5);
  });
});

Une règle Screaming Frog custom configurée pour crawler en user-agent mobile avec un viewport de 412px, et alerter sur tout H1 en display:none.

Un monitoring Search Console avec une alerte sur la position moyenne des pages catégories — seuil à +3 positions de dégradation sur 7 jours glissants.

Ce type de contrôle rejoint les bonnes pratiques décrites dans d'autres incidents de refonte, comme celui où un A/B test servait un noindex à 50 % du trafic — dans les deux cas, le problème est invisible dans un navigateur classique et ne se manifeste que via le comportement réel du crawler.

Ce qu'on en retient

L'indexation mobile-first n'est pas une option de configuration. C'est le mode par défaut de Google depuis des années. Pourtant, la majorité des équipes continuent à valider le SEO technique sur un écran desktop.

Un H1 en display:none sur un breakpoint mobile n'est pas "dépriorisé" — il est absent. La page perd son heading principal dans l'index. Les positions glissent. Les snippets se dégradent. Et personne ne reçoit d'alerte.

Le fix est simple : un seul H1, toujours dans le flux, stylisé par CSS. Jamais masqué. Si le design impose deux variantes visuelles, le texte sémantique reste unique et visible — seul l'habillage change.

Un monitoring continu comme Seogard détecte cette divergence entre le DOM desktop et le rendu mobile crawlé en quelques minutes, avant que 26 jours de trafic ne disparaissent dans un breakpoint CSS.

Articles connexes

Refonte1 juin 2026

Design system : un div remplace le H1 sur 800 pages

Un composant générique remplace silencieusement le H1 par un div sur 800 pages. Récit du bug, diagnostic technique et fix complet.

Actualités SEO2 juin 2026

EntityMap : le standard ouvert qui structure votre marque pour l'IA

Analyse technique d'EntityMap, le fichier JSON-LD qui expose vos entités aux LLM. Implémentation, déploiement, limites et monitoring.

A/B test2 juin 2026

A/B test header : noindex servi à 50% du trafic pendant 9 jours

Un snippet d'expérimentation injecte un meta noindex sur la variante B. 50% du crawl touché pendant 9 jours. Récit, diagnostic logs, fix.

Migration1 juin 2026

Migration Vercel → Railway : TTFB ×4, 2000 pages sans Edge ISR

Récit d'une migration Next.js de Vercel vers Railway. Perte de l'Edge ISR, TTFB multiplié par 4, Core Web Vitals en chute. Diagnostic et fix complet.

Migration31 mai 2026

Migration Cloudflare → Bunny CDN : 302 au lieu de 301 pendant 2 mois

Récit d'une migration CDN où les Page Rules Cloudflare n'ont pas été portées vers Bunny. 302 silencieux, jus SEO perdu, et fix complet.

Actualités SEO31 mai 2026

Google I/O 2026 : le problème de visibilité business que les démos révèlent

Les démos Google I/O finalisent des transactions sans jamais montrer de site. Analyse technique du nouveau problème de visibilité business et comment s'y préparer.