Le CSS est l'un des facteurs les plus critiques pour la performance web. Des sélecteurs aux animations, du chargement des polices à la stabilité du layout, optimisez chaque pixel pour une expérience fluide.
Chaque propriété CSS déclenche une chaîne de traitement dans le navigateur : Recalculation → Layout → Paint → Composite. Comprendre cette chaîne permet d'écrire du CSS qui ne ralentit jamais l'interface.
PIPELINE DE RENDU CSS
═════════════════════
DOM + CSSOM
│
▼
Style Calculation ← Résolution de la cascade, héritage
│
▼
Layout (Reflow) ← Calcul de la taille et position de TOUS les éléments
│
▼
Paint ← Dessin des pixels (texte, couleurs, images, ombres)
│
▼
Composite ← Assemblage des couches (GPU) et affichage final
⚠️ Layout + Paint = COÛTEUX (eviter d'y toucher souvent)
✅ Composite seul = PERFORMANT (transform, opacity)
Objectif : 60fps = 16ms par frame
Budget par frame :
├── JavaScript: ~8ms
├── Style: ~2ms
├── Layout: ~2ms
├── Paint: ~2ms
└── Composite: ~2ms
Pipeline de rendu du navigateur et budget par frame
/* HIÉRARCHIE DE PERFORMANCE DES SÉLECTEURS */
/* ✅ RAPIDE — sélecteur de classe simple */
.mon-bouton { }
/* ✅ RAPIDE — ID */
#header { }
/* ✅ RAPIDE — sélecteur d'élément direct */
.nav > ul { }
/* ⚠️ MOYEN — descendant */
.nav ul li a { }
/* ⚠️ MOYEN — sélecteur d'attribut */
[data-role="button"] { }
/* ❌ LENT — * sur un gros DOM */
* { }
/* ❌ LENT — deeply nested descendant */
body > div > main > section > article > p > a { }
/* ❌ LENT — sélecteur universel avec descendant */
* > * { }
/* ❌ LENT — pseudo-classe complexe */
:nth-last-child(odd):not(:first-child) { }
/* Règle d'or : garder les sélecteurs courts et ciblés */
/* Le premier élément du sélecteur doit être le plus spécifique */
.bouton-primaire { } /* ✅ 1 classe = rapide */
nav .bouton-primaire { } /* ⚠️ descendant */
nav > ul > li > .bouton { } /* ⚠️ 4 niveaux */
.classe { }
#id { }
.el > .enfant { }
.el .enfant { }
[attribut] { }
* { }
.el > * > * > * { }
.el .a .b .c .d { }
*:not(.spécial) { }
.a:nth-child(2n+1) { }
/* Propriétés par catégorie de coût */
/* ✅ COMPOSITE UNIQUEMENT (GPU) — le moins coûteux */
/* Ne déclenchent ni layout ni paint */
transform
opacity
will-change (utilisé correctement)
/* ⚠️ PAINT — coûteux mais pas de layout */
background (couleur, image, gradient)
box-shadow
text-shadow
border-radius
outline
visibility
/* ❌ LAYOUT (reflow) — le plus coûteux */
/* Force le recalcul de la taille/position de TOUS les éléments */
width, height
padding, margin
border-width
top, right, bottom, left
position
display
float
clear
flex-* (flex-direction, etc.)
grid-* (grid-template-columns, etc.)
font-size (change le flux !)
overflow
z-index
transform, opacity
Rapide — ne déclenche pas de reflow
box-shadow, border-radius, background
Moyen — redessine les pixels
width, height, margin, padding, position
Lent — recalcule tout le layout
transform et opacity. Ces propriétés ne déclenchent que le compositeur (GPU), sans reflow ni paint.
/* ❌ MAUVAIS — animation de layout (coûteux) */
@keyframes bad-anim {
from { width: 100px; margin-left: 0; top: 0; }
to { width: 200px; margin-left: 50px; top: 100px; }
}
/* Déclenche : Layout + Paint à chaque frame = lent */
/* ✅ BON — animation composite (rapide) */
@keyframes good-anim {
from { transform: translateX(0) scale(1); opacity: 1; }
to { transform: translateX(100px) scale(1.2); opacity: 0.5; }
}
/* Déclenche : Composite uniquement = 60fps */
/* ✅ will-change — prévenir le navigateur */
.animé {
will-change: transform, opacity;
/* Crée un nouveau calque GPU (layer promotion) */
/* À UTILISER avec parcimonie */
}
/* ⚠️ Attention au sur-usage de will-change */
/* Chaque will-change = un calque GPU = mémoire */
Animation de width + left
Animation de transform
<head> pour éviter un flash de contenu non stylé (FOUC).
<!-- CSS critique : inline dans le head -->
<head>
<style>
/* CSS critique (above-the-fold) */
body { margin: 0; font-family: system-ui; }
.header { background: #1e293b; color: white; padding: 1rem; }
.hero { min-height: 60vh; display: grid; place-items: center; }
</style>
<!-- CSS non critique : chargé en async -->
<link rel="preload" href="styles-full.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles-full.css"></noscript>
</head>
Styles nécessaires au rendu initial : layout, header, hero, premiers éléments visibles.
<style>
body { font-family: system-ui; }
.header { padding: 1rem; }
.hero { min-height: 60vh; }
</style>
Styles différés : footer, animations, composants secondaires, mode sombre.
<link rel="preload"
href="full.css" as="style"
onload="this.rel='stylesheet'">
/* === MINIFICATION === */
/* Avant : */
.mon-bouton {
background-color: #2563eb;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
/* Après (minifié) : */
.mon-bouton{background-color:#2563eb;padding:.5rem}
/* === COMPRESSION === */
/* gzip : réduction de 70-90% en moyenne */
/* Brotli : encore mieux que gzip (15-25% de plus) */
/* Configurer via serveur web ou CDN */
/* === STRATÉGIES DE CHARGEMENT === */
/* 1. CSS critique inline */
<style>/* critical CSS */</style>
/* 2. CSS non critique en async */
<link rel="preload" href="full.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
/* 3. HTTP/2 Server Push (si le serveur le supporte) */
/* Push le CSS critique avec la réponse HTML */
/* 4. Preconnect pour les polices externes */
<link rel="preconnect" href="https://fonts.googleapis.com">
/* 5. Preload pour les ressources critiques */
<link rel="preload" href="critical-font.woff2"
as="font" type="font/woff2" crossorigin>
Charge la ressource immédiatement, en haute priorité. Pour les ressources critiques.
Charge la ressource en arrière-plan, basse priorité. Pour les prochaines pages.
Établit la connexion TCP + TLS en avance. Pour les domaines tiers.
Résout le DNS en avance. Plus léger que preconnect.
/* === font-display === */
@font-face {
font-family: 'MaPolice';
src: url('font.woff2') format('woff2');
font-display: swap; /* Affiche immédiatement, swap si nécessaire */
/* swap: FOIT → FOUT (flash) mais texte visible rapidement */
}
/* Valeurs de font-display */
/* auto — comportement par défaut du navigateur */
/* block — masque le texte 3s max, puis swap */
/* swap — texte visible immédiatement, swap quand prêt */
/* fallback — masque 100ms, swap 3s max */
/* optional — aucun flash, mais peut ne pas afficher */
/* === Préchargement des polices === */
<link rel="preload" href="font.woff2" as="font"
type="font/woff2" crossorigin>
/* === Font subsetting (réduire la taille) === */
/* Ne garder que les caractères nécessaires */
/* Utiliser tools como Glyphhanger ou FontSquirrel */
/* === Images sans dimensions → CLS === */
/* ❌ MAUVAIS — le layout saute quand l'image charge */
img { width: 100%; }
/* ✅ BON — dimensions explicites */
img { width: 100%; aspect-ratio: 16/9; object-fit: cover; }
/* Ou en HTML : */
/* <img width="800" height="450" ...> */
/* === Polices web → CLS === */
/* Le texte peut changer de taille quand la police se charge */
/* Solution : font-display: optional ou swap + size-adjust */
/* === Contenu injecté → CLS === */
/* ❌ Banners, notifications qui poussent le contenu */
/* ✅ Réserver l'espace avec min-height ou space toggles */
/* === Espace réservé pour vidéo/iframe === */
.video-wrapper {
aspect-ratio: 16 / 9;
width: 100%;
}
/* === font-display: optional === */
/* Pas de changement de taille = pas de CLS */
@font-face {
font-family: 'MaPolice';
src: url('font.woff2') format('woff2');
font-display: optional;
}
Le layout saute quand l'image charge
L'espace est réservé, pas de CLS
/* === content-visibility: auto === */
/* Skip le rendu des éléments hors viewport */
.section-lointaine {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* taille estimée */
}
/* Bénéfice : le navigateur saute le layout/paint des sections hors écran */
/* Réduit le temps de rendu initial de 50%+ sur les longues pages */
/* === contain (CSS Containment) === */
.conteneur-card {
contain: layout style; /* Isole le layout de cet élément */
/* Le navigateur sait que les changements internes
n'affectent pas le reste de la page */
}
/* Valeurs de contain */
/* layout — le layout de l'élément n'affecte pas les autres */
/* style — les compteurs et les quotes sont isolés */
/* paint — le contenu est peint dans la boîte de l'élément */
/* size — la taille est indépendante des enfants */
/* content — layout + style + paint (combinaison complète) */
/* strict — layout + style + paint + size */
/* paint — le contenu est clippé à la boîte de bordure */
Isole le layout — les changements internes ne reflow pas le reste.
Isole les compteurs CSS et les quotes.
Clip le contenu, crée un nouveau stacking context.
COUT PAR PROPRIÉTÉ CSS
═══════════════════════
COMPOSITE (GPU) PAINT LAYOUT (Reflow)
────────────── ───── ───────────────
transform background-* width / height
opacity box-shadow padding / margin
will-change text-shadow border-width
filter (parfois) border-radius position / display
outline top / right / bottom / left
visibility overflow / float / clear
color flex-* / grid-*
background-image font-size (impacte le flux)
line-height (impacte le flux)
💚 RAPIDE 🟡 MOYEN 🔴 LENT
GPU uniquement CPU (repaint) CPU (reflow global)
RÈGLE : Pour les animations, utilisez uniquement
transform et opacity = 60fps garanti
Classification des propriétés CSS par coût de rendu
/* Sélecteurs lourds */
body > div > main > section > article > p {
font-size: 16px;
}
/* Animation coûteuse */
@keyframes move {
from { left: 0; width: 100px; }
to { left: 500px; width: 200px; }
}
/* CSS non critique inline */
<link rel="stylesheet" href="huge.css">
/* Image sans dimensions */
img { width: 100%; }
/* Font sans font-display */
@font-face {
font-family: 'Web';
src: url('web.woff2');
}
/* Sélecteur ciblé */
.article-text {
font-size: 1rem;
}
/* Animation performante */
@keyframes move {
from { transform: translateX(0); }
to { transform: translateX(500px); }
}
/* CSS critique inline + async */
<style> /* critical */ </style>
<link rel="preload" href="full.css" as="style">
/* Image avec dimensions */
img { width: 100%; aspect-ratio: 16/9; }
/* Font avec font-display */
@font-face {
font-family: 'Web';
src: url('web.woff2');
font-display: swap;
}
/* ❌ AVANT — animation de layout (reflow à chaque frame) */
@keyframes slide-bad {
from { left: 0; width: 100px; }
to { left: 500px; width: 200px; }
}
.element-animé {
position: absolute;
animation: slide-bad 2s linear infinite;
}
/* ✅ APRÈS — animation composite (60fps) */
@keyframes slide-good {
from { transform: translateX(0) scaleX(1); }
to { transform: translateX(500px) scaleX(2); }
}
.element-animé {
will-change: transform;
animation: slide-good 2s linear infinite;
}
<!-- Étape 1 : Identifier le CSS critique -->
<!-- Utiliser Chrome DevTools → Coverage -->
<!-- Étape 2 : Inline le CSS critique -->
<head>
<style>
/* CSS critique = above-the-fold */
*, *::before, *::after { box-sizing: border-box; margin: 0; }
body { font-family: system-ui, sans-serif; line-height: 1.6; }
.header { position: sticky; top: 0; z-index: 100; background: white; }
.hero { min-height: 60vh; display: grid; place-items: center; }
.nav { display: flex; gap: 1rem; padding: 1rem; }
</style>
<!-- Étape 3 : Charge le CSS non critique en async -->
<link rel="preload" href="styles-full.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="styles-full.css">
</noscript>
</head>
/* Template d'animation performante */
.animé {
/* 1. Prévenir le navigateur */
will-change: transform, opacity;
}
.animé.actif {
/* 2. Animation sur transform/opacity uniquement */
animation: fade-slide-in 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 3. Respecter prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.animé.actif {
animation: none;
opacity: 1;
transform: none;
}
}
/* 4. Nettoyer après l'animation */
.animé.finalisé {
will-change: auto; /* Libérer le calque GPU */
}
/* Images avec dimensions explicites */
.article img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: #f1f5f9; /* Placeholder pendant le chargement */
}
/* Polices avec optional (pas de CLS) */
@font-face {
font-family: 'SiteFont';
src: url('font.woff2') format('woff2');
font-display: optional;
size-adjust: 102%; /* Ajuster pour matcher la fallback */
ascent-override: 90%;
descent-override: 20%;
line-gap-override: 0%;
}
/* Espacement réservé pour contenus dynamiques */
.notification-zone {
min-height: 60px; /* Espace toujours réservé */
}
/* Aspect ratio pour vidéo/iframe */
.video-responsive {
aspect-ratio: 16 / 9;
width: 100%;
}
/* Contenu invisible jusqu'au chargement */
.contenu {
visibility: hidden;
}
.contenu.charge {
visibility: visible;
}
/* Conteneur de carte — isolé du layout global */
.card {
contain: layout style;
/* Le layout interne ne reflow pas les autres cartes */
}
/* Liste longue — optimisation progressive */
.liste-longue > .item:nth-child(n+20) {
content-visibility: auto;
contain-intrinsic-size: 0 80px; /* hauteur estimée */
}
/* Sidebar — isolée */
.sidebar {
contain: layout;
/* Les changements de la sidebar ne reflow pas le contenu */
}
/* Section hors écran */
.section-footer {
content-visibility: auto;
contain-intrinsic-size: auto 400px;
}
/* ❌ AVANT — sélecteurs complexes */
body > div.container > main#content > section.articles > article > h2.title { }
nav ul li a.active { }
*:not(.special):not(.hidden) { }
/* ✅ APRÈS — sélecteurs ciblés */
.article-title { }
.nav-link.active { }
.interactive { }
/* Règles d'optimisation des sélecteurs :
1. Utiliser des classes plutôt que des éléments
2. Éviter les sélecteurs universels (*)
3. Limiter la profondeur à 3 niveaux max
4. Le sélecteur le plus spécifique en premier
5. Éviter :nth-child sur de gros DOMs */
/* FAUX — évalué pour CHAQUE élément du DOM */
* {
box-sizing: border-box;
margin: 0;
}
/* CORRECT — utiliser le sélecteur universel une seule fois */
*, *::before, *::after {
box-sizing: border-box;
}
body, h1, h2, h3, p {
margin: 0;
}
/* FAUX — layout reflow à chaque frame */
@keyframes move {
from { left: 0; width: 100px; }
to { left: 500px; width: 200px; }
}
/* CORRECT — transform uniquement */
@keyframes move {
from { transform: translateX(0) scaleX(1); }
to { transform: translateX(500px) scaleX(2); }
}
/* FAUX — trop de calques GPU = mémoire gaspillée */
* { will-change: transform; }
.card { will-change: opacity; }
.text { will-change: color; }
/* CORRECT — uniquement sur les éléments animés */
.animé { will-change: transform; }
/* Et retirer après l'animation */
.animé.finalisé { will-change: auto; }
/* FAUX — le layout saute quand l'image charge */
img { width: 100%; }
iframe { width: 100%; }
/* CORRECT — dimensions explicites */
img { width: 100%; aspect-ratio: 16/9; }
iframe { width: 100%; aspect-ratio: 16/9; }
/* Ou en HTML : width="800" height="450" */
/* FAUX — bloque le rendu */
<link rel="stylesheet" href="huge.css">
/* CORRECT — CSS critique inline + async */
<style> /* critical CSS */ </style>
<link rel="preload" href="full.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
/* FAUX — FOIT (Flash of Invisible Text) */
@font-face {
font-family: 'Web';
src: url('web.woff2');
/* Pas de font-display = behavior par défaut (block) */
}
/* CORRECT — swap pour un texte visible immédiatement */
@font-face {
font-family: 'Web';
src: url('web.woff2') format('woff2');
font-display: swap;
}
/* FAUX — le navigateur doit vérifier chaque niveau */
body > div.container > main > section > article > p > strong { }
/* CORRECT — cibler directement */
.article-highlight strong { }
/* AVANT — rendu de tout le contenu même hors viewport */
.longue-page section {
/* Pas d'optimisation */
}
/* APRÈS — rendu sélectif */
.longue-page section:nth-child(n+5) {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
/* Réduit le rendu initial de 50%+ */
<link rel="preload">./* 1. Sélecteur ciblé */
.mon-élément {
/* 2. will-change pour prévenir */
will-change: transform, opacity;
}
/* 3. Animation composite uniquement */
.mon-élément.actif {
animation: my-anim 0.3s ease forwards;
}
@keyframes my-anim {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* 4. Respecter les préférences */
@media (prefers-reduced-motion: reduce) {
.mon-élément.actif { animation: none; }
}
/* 5. Nettoyer après */
.mon-élément.finalisé { will-change: auto; }
Défi 1 : Optimiser une animation
Transformez cette animation coûteuse en une version performante utilisant uniquement transform et opacity.
/* ❌ AVANT — coûteux */
@keyframes slide {
from { left: 0; width: 100px; margin: 0; }
to { left: 500px; width: 200px; margin: 20px; }
}
/* ✅ APRÈS — performant */
@keyframes slide-performant {
from { transform: translateX(0) scaleX(1); }
to { transform: translateX(500px) scaleX(2); }
}
.bloc {
position: relative;
will-change: transform;
animation: slide-performant 2s ease-in-out infinite alternate;
}
Défi 2 : Zéro CLS
Corrigez le code suivant pour éliminer tout Cumulative Layout Shift.
/* ❌ AVANT */
img { width: 100%; }
.video { width: 100%; }
/* ✅ APRÈS */
img {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
background: #f1f5f9;
}
.video {
width: 100%;
aspect-ratio: 16/9;
}
/* Espacement réservé */
.dynamic-zone {
min-height: 100px;
}
Défi 3 : CSS critique
Séparez le CSS en critique (inline) et non critique (async).
<head>
<style>
/* CSS critique = ce qui est visible au chargement */
body { margin: 0; font-family: system-ui; }
.header { padding: 1rem; background: white; position: sticky; top: 0; }
.hero { min-height: 60vh; display: grid; place-items: center; }
</style>
<link rel="preload" href="full.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="full.css"></noscript>
</head>
Défi 4 : Sélecteurs optimisés
Refactorisez ces sélecteurs pour améliorer la performance.
/* ❌ AVANT */
body > div.container > main > section > article > h2.title { }
nav ul li a.active { }
*:not(.special):not(.hidden):not(.disabled) { }
/* ✅ APRÈS */
.article-title { }
.nav-link.active { }
.interactive { }
Défi 5 : Conteneur optimisé
Appliquez CSS containment à une liste de cartes pour isoler chaque carte du layout global.
.card {
contain: layout style;
/* Le layout interne de chaque carte est isolé */
}
/* Contenu hors écran */
.card:nth-child(n+20) {
content-visibility: auto;
contain-intrinsic-size: 0 200px;
}
/* Résultat : le reflow d'une carte n'affecte pas les autres
et le rendu initial est beaucoup plus rapide */
Projet 1 : Page optimisée
Optimisez une page de 50+ sections avec :
Projet 2 : Système d'animations performant
Créez un système d'animations avec :
Projet 3 : Audit de performance CSS
Audittez un site existant avec :
| Concept | Propriété / Méthode | Impact |
|---|---|---|
| Animation performante | transform + opacity |
Composite GPU uniquement = 60fps |
| Prévenir le navigateur | will-change |
Layer promotion (utiliser avec parcimonie) |
| Sélecteur rapide | .classe |
O(1) lookup par le navigateur |
| Sélecteur lent | * > * > * |
Vérification de chaque candidat |
| CSS critique | <style> inline |
Élimine le FOUC |
| CSS async | rel="preload" |
Ne bloque pas le rendu |
| Font perf | font-display: swap |
Texte visible immédiatement |
| Stabilité layout | aspect-ratio |
Zéro CLS pour images/vidéos |
| Containment | contain |
Isole le layout d'un élément |
| Rendu différé | content-visibility: auto |
Saute le rendu hors viewport |
| Compression | brotli / gzip | Réduction de 70-90% de la taille |
| Préchargement | <link rel="preload"> |
Charge en haute priorité |
transform et opacity — elles n'activent que le compositeur GPU.<link rel="preload" href="..." as="style" onload="this.rel='stylesheet'">font-display: swap — affiche le texte avec la fallback immédiatement, swap quand la police est prête.will-change crée un calque GPU dédié. En abuser = trop de calques = consommation mémoire excessive.contain: layout style ou contain: strict.content-visibility: auto sur les sections hors viewport.preload = haute priorité, pour les ressources critiques. prefetch = basse priorité, pour les prochaines navigations./* === ANIMATION PERFORMANTE === */
.el { will-change: transform, opacity; }
.el.animé { animation: slide 0.3s ease forwards; }
@keyframes slide { from { transform: translateY(20px); opacity: 0; } to { transform: none; opacity: 1; } }
/* === SÉLECTEURS RAPIDES === */
.mon-el { } /* ✅ classe simple */
#mon-id { } /* ✅ ID */
.el > .enfant { } /* ✅ enfant direct */
.el .petit { } /* ⚠️ descendant (acceptable) */
/* === CSS CRITIQUE INLINE === */
<style>/* above-the-fold CSS */</style>
<link rel="preload" href="full.css" as="style" onload="this.rel='stylesheet'">
/* === FONTS === */
@font-face { font-display: swap; }
/* === CLS === */
img, video, iframe { width: 100%; aspect-ratio: 16/9; }
/* === CONTAINMENT === */
.card { contain: layout style; }
.section:nth-child(n+10) { content-visibility: auto; contain-intrinsic-size: auto 500px; }
/* === PREFERS === */
@media (prefers-reduced-motion: reduce) { * { animation: none !important; } }