De 76 à 100 : obtenir un score Lighthouse parfait
En bref : Un site portfolio personnel est passé d’un score de performance mobile Lighthouse de 76 avec un CLS de 0,493 à un parfait 100/100/100/100 dans toutes les catégories. Ce parcours a révélé des problèmes subtils de chargement CSS, des bugs de regex et une substitution de variable CSS particulièrement sournoise qui provoquait des décalages de mise en page sur mobile.
Le point de départ
Le site était un portfolio FastAPI + Jinja2 avec HTMX et Alpine.js pour l’interactivité. Audit initial Lighthouse sur mobile :
| Métrique | Score |
|---|---|
| Performance | 76 |
| Accessibilité | 91 |
| Bonnes pratiques | 100 |
| SEO | 100 |
| CLS | 0,493 |
Ce chiffre de CLS est brutal. Google considère tout ce qui dépasse 0,1 comme « médiocre ». À 0,493, la page sautait visiblement dans tous les sens pendant le chargement.
Phase 1 : gains rapides en accessibilité
Avant de s’attaquer à la performance, j’ai corrigé les problèmes d’accessibilité pour établir une base de référence :
Labels de formulaire
Remplacement des spans décoratifs par de véritables éléments <label> :
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">
Ratios de contraste
Les titres du pied de page et les labels de formulaire utilisaient --color-text-tertiary (blanc à 40 % d’opacité). Passage à --color-text-secondary (65 % d’opacité) pour respecter les exigences de contraste WCAG AA de 4,5:1.
Texte alternatif redondant
Les icônes de réseaux sociaux avaient un texte alternatif comme « LinkedIn icon » alors qu’elles se trouvaient dans des liens possédant déjà des aria-labels. Remplacement par alt="" avec aria-hidden="true" pour éviter la répétition par les lecteurs d’écran.
Textes de liens identiques
Plusieurs liens « View Case Study » étaient indiscernables pour les lecteurs d’écran. Ajout d’attributs aria-label avec les noms des projets :
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
Résultat : l’accessibilité est passée de 91 à 100.
Phase 2 : le problème du CSS bloquant le rendu
Lighthouse indiquait un « Element render delay » de 2 460 ms dans le détail du LCP. Le coupable : un fichier CSS synchrone bloquant le premier affichage.
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
Le navigateur devait télécharger et analyser l’intégralité de la feuille de style de 25 Ko avant de pouvoir afficher quoi que ce soit.
La solution : CSS critique + chargement asynchrone
Étape 1 : Extraire le CSS critique (au-dessus de la ligne de flottaison) dans un fichier séparé qui est intégré directement dans le <head>.
Étape 2 : Charger la feuille de style complète de manière asynchrone grâce à l’astuce media="print" :
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>
L’attribut media="print" indique au navigateur « cette feuille de style n’est pas nécessaire pour l’affichage à l’écran », ce qui évite le blocage. Une fois chargée, le gestionnaire onload la bascule sur media="all".
Le script d’extraction du CSS critique
J’ai écrit un script Python pour extraire automatiquement le CSS critique des composants au-dessus de la ligne de flottaison :
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
Résultat : le délai de rendu de l’élément LCP est passé de 2 460 ms à environ 300 ms.
Phase 3 : le cauchemar du CLS commence
Après l’implémentation du CSS asynchrone, le CLS a en réalité empiré — passant à 0,119. Lighthouse indiquait <main> comme élément décalé.
Bug n° 1 : les commentaires CSS polluant la regex
Le script d’extraction utilisait une regex pour identifier les sélecteurs :
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
Problème : les commentaires CSS n’étaient pas supprimés avant l’extraction. Un commentaire comme :
/* Hero Section - Editorial */
.hero { ... }
Provoquait une correspondance de la regex depuis « Hero Section » jusqu’à l’accolade ouvrante, créant des sélecteurs malformés qui échouaient à la vérification des préfixes. Les styles critiques de .hero étaient silencieusement ignorés.
Correction : supprimer les commentaires avant toute opération regex :
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Bug n° 2 : les règles des media queries extraites comme règles autonomes
La regex identifiait les règles à l’intérieur des media queries et les extrayait comme des règles autonomes :
/* Full CSS structure */
.hero__title { font-size: 5rem; } /* Desktop */
@media (max-width: 768px) {
.hero__title { font-size: 1.875rem; } /* Mobile */
}
Le script extrayait les deux règles au niveau racine :
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
Sur mobile, le titre s’affichait à la taille mobile grâce au CSS critique, puis… y restait parce que le CSS complet contenait les mêmes règles. Mais sur desktop, la surcharge mobile s’appliquait incorrectement.
Correction : suivre les positions des media queries et ignorer les règles qu’elles contiennent :
# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
media_ranges.append((match.start(), match.end()))
def is_inside_media_query(pos: int) -> bool:
return any(start <= pos < end for start, end in media_ranges)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
Phase 4 : l’hypothèse du 100vh
Le CLS était toujours à 0,116. Théorie : si rien sous la ligne de flottaison n’est visible au premier affichage, cela ne peut pas contribuer au CLS.
Passage du hero de 85vh à 100vh :
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
Résultat : aucun changement. CLS toujours à 0,116. Le décalage se produisait à l’intérieur de la zone visible.
Phase 5 : la preuve irréfutable
Le filmstrip de Lighthouse montrait le texte du hero se décalant visiblement entre les images. Pas un fondu — un déplacement horizontal.
Analyse approfondie des styles affectant le positionnement horizontal :
- .hero__content utilise padding: 0 var(--gutter)
- --gutter est défini à 48px dans :root
Et puis j’ai trouvé :
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
La séquence sur mobile :
- Le CSS critique se charge :
--gutter: 48px - Le hero s’affiche avec 48px de padding latéral
- Le CSS complet se charge de manière asynchrone
- La media query définit
--gutter: 24px - Le padding du hero passe de 48px à 24px
- Le texte se redistribue et se décale = CLS 0,116
La correction
Extraire les media queries de :root dans le CSS critique :
# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
critical_rules.append(match.group())
Le CSS critique inclut désormais :
:root { --gutter: 48px; /* ... */ }
@media (max-width: 768px) {
:root { --gutter: var(--spacing-md); }
}
Le mobile s’affiche avec la gouttière correcte de 24px dès le départ. Quand le CSS complet se charge, --gutter est déjà à 24px — aucun changement, aucun décalage.
Phase 6 : CSP et Alpine.js
Un dernier problème : Alpine.js générait des erreurs dans la console concernant des violations de la Content Security Policy. Alpine utilise en interne l’évaluation dynamique d’expressions.
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
La directive 'unsafe-eval' est nécessaire pour l’analyse d’expressions d’Alpine.js. L’alternative consiste à utiliser le build compatible CSP d’Alpine, qui présente certaines limitations.
Résultats finaux
| Métrique | Avant | Après |
|---|---|---|
| Performance | 76 | 100 |
| Accessibilité | 91 | 100 |
| Bonnes pratiques | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0,493 | 0,033 |
| LCP | 2,6 s | 0,8 s |
La preuve
Audit desktop affichant des scores parfaits dans les quatre catégories :

Audit mobile — celui qui compte le plus — atteignant également des notes parfaites :

Points clés à retenir
1. Les variables CSS peuvent provoquer du CLS
Les surcharges de propriétés CSS personnalisées par media query sont invisibles dans le panneau des styles calculés de DevTools — vous ne voyez que la valeur finale. Si votre CSS critique n’inclut pas les surcharges de variables, la mise en page se décalera au chargement de la feuille de style complète.
2. L’extraction du CSS critique est délicate
Les approches simples par regex échouent sur :
- Les commentaires CSS (peuvent polluer la correspondance des sélecteurs)
- Les règles à l’intérieur des media queries (doivent rester dans leurs blocs)
- Les media queries de :root (les changements de variables affectent tout)
3. L’astuce media="print" fonctionne
Charger le CSS non critique avec media="print" onload="this.media='all'" est un moyen légitime de différer le chargement des feuilles de style sans complexité JavaScript supplémentaire.
4. Le débogage du CLS nécessite le filmstrip
La vue filmstrip de Lighthouse montre exactement quand les décalages se produisent. Sans elle, vous tâtonnez. L’élément signalé comme « décalé » n’est peut-être qu’un symptôme — cherchez ce qui provoque le décalage.
5. Les sections hero en 100vh ont des avantages au-delà de l’esthétique
Si votre hero remplit la zone visible, le contenu sous la ligne de flottaison ne peut pas contribuer au CLS car il n’est mesuré qu’au défilement de l’utilisateur.
La stack technique
- Backend : FastAPI + Jinja2
- Frontend : HTMX + Alpine.js (auto-hébergé, sans CDN)
- CSS : CSS pur avec propriétés personnalisées, sans préprocesseurs
- Optimisation : Script Python personnalisé pour l’extraction du CSS critique
- Hébergement : Railway avec compression GZip et mise en cache immuable
Outils utilisés
- Lighthouse (Chrome DevTools)
- Vue filmstrip de Lighthouse pour le débogage du CLS
grepetregex101.compour l’analyse CSS- Python pour le script d’extraction du CSS critique
- iTerm 2
- Claude Code CLI avec Opus 4.5 et beaucoup d’ultrathink