CSS moderne en 2026 : les fonctionnalités qui ont changé ma façon d'écrire des styles
Container queries, CSS layers, :has(), color-mix(), nesting, animations liées au scroll et anchor positioning. Les fonctionnalités CSS qui m'ont fait arrêter de recourir au JavaScript.
J'ai supprimé mon dernier fichier Sass il y a six mois. Pas parce que je voulais faire une déclaration. Parce que je n'en avais sincèrement plus besoin.
Pendant plus d'une décennie, CSS était le langage dont on s'excusait. On avait besoin de préprocesseurs pour l'imbrication et les variables. On avait besoin de JavaScript pour le dimensionnement basé sur les conteneurs, les animations liées au scroll, la sélection par le parent, et la moitié des patterns de mise en page que les designers nous donnaient. On a construit des systèmes d'exécution entiers — bibliothèques CSS-in-JS, frameworks utilitaires, chaînes de plugins PostCSS — pour compenser ce que le langage ne pouvait pas faire nativement.
Cette ère est terminée. Pas « presque terminée ». Pas « en bonne voie ». Terminée.
Les fonctionnalités qui ont atterri dans les navigateurs entre 2024 et 2026 n'ont pas juste ajouté du confort. Elles ont changé le modèle mental. CSS n'est plus un langage de style contre lequel on se bat. C'est un langage de style qui pense véritablement aux composants, à la gestion de la spécificité, au responsive design au niveau de l'élément, et aux animations sans runtime.
Voici ce qui a changé, pourquoi c'est important, et ce que tu peux arrêter de faire grâce à ça.
Container Queries : la fin de « Quelle est la largeur du viewport ? »#
C'est celle qui a fondamentalement changé ma façon de penser le responsive design. Pendant vingt ans, on avait les media queries. Les media queries demandent : « quelle est la largeur du viewport ? » Les container queries demandent : « quelle est la largeur du conteneur dans lequel ce composant vit ? »
Cette distinction semble subtile. Elle ne l'est pas. C'est la différence entre des composants qui ne marchent que dans un seul contexte de mise en page et des composants qui marchent partout.
Le problème qui existait depuis deux décennies#
Considère un composant carte. Dans un sidebar, il devrait s'empiler verticalement avec une petite image. Dans une zone de contenu principal, il devrait passer en horizontal avec une image plus grande. Dans une section pleine largeur hero, il devrait être encore autre chose.
Avec les media queries, on écrivait quelque chose comme ça :
/* L'ancienne façon : coupler les styles du composant à la mise en page */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.sidebar .card {
/* Toujours vertical dans le sidebar */
}
.main-content .card {
flex-direction: row;
/* Horizontal dans la zone principale */
}
}
@media (min-width: 1200px) {
.hero .card {
/* Encore une autre mise en page */
}
}La carte connaît .sidebar, .main-content, et .hero. Elle connaît la page. Ce n'est plus un composant — c'est un fragment conscient de la page. Déplace-le sur une autre page et tout casse.
Les container queries corrigent ça complètement#
/* La façon container query : le composant ne connaît que lui-même */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 200px 1fr;
}
}
@container card (min-width: 700px) {
.card {
grid-template-columns: 300px 1fr;
gap: 2rem;
font-size: 1.125rem;
}
}La carte ne sait pas où elle vit. Elle s'en fiche. Mets-la dans un sidebar de 300px et elle est verticale. Mets-la dans une zone principale de 700px et elle est horizontale. Pose-la dans une section pleine largeur et elle s'adapte. Zéro connaissance de la mise en page requise.
inline-size vs size#
Tu voudras presque toujours container-type: inline-size. Ça active les requêtes sur l'axe inline (la largeur dans les modes d'écriture horizontaux). Utiliser container-type: size active les requêtes sur les deux axes inline et block, mais ça nécessite que le conteneur ait un dimensionnement explicite dans les deux dimensions, ce qui casse le flux normal du document dans la plupart des cas.
/* C'est ce que tu veux 99% du temps */
.wrapper {
container-type: inline-size;
}
/* Ceci nécessite une hauteur explicite — rarement ce que tu veux */
.wrapper-both {
container-type: size;
height: 500px; /* obligatoire, sinon ça s'effondre */
}Conteneurs nommés pour les contextes imbriqués#
Quand tu imbriques des conteneurs, le nommage devient essentiel :
.page-layout {
container-type: inline-size;
container-name: layout;
}
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* Cibler le sidebar spécifiquement, pas l'ancêtre le plus proche */
@container sidebar (max-width: 250px) {
.nav-item {
font-size: 0.875rem;
padding: 0.25rem;
}
}
/* Cibler la mise en page de la page */
@container layout (min-width: 1200px) {
.page-header {
font-size: 2.5rem;
}
}Sans noms, @container interroge l'ancêtre le plus proche avec du confinement. Avec l'imbrication, ce n'est souvent pas le conteneur que tu veux. Nomme-les. Toujours.
Unités de container query#
Celle-ci est sous-estimée. Les unités de container query (cqi, cqb, cqw, cqh) te permettent de dimensionner les choses relativement au conteneur, pas au viewport :
@container (min-width: 400px) {
.card-title {
font-size: clamp(1rem, 4cqi, 2rem);
}
}4cqi représente 4% de la taille inline du conteneur. Le titre s'adapte au conteneur, pas à la fenêtre. C'est ce que la typographie fluide aurait dû être depuis le début.
CSS Layers : la guerre de la spécificité est finie#
Si les container queries ont changé ma façon de penser le responsive design, @layer a changé ma façon de penser l'architecture CSS. Pour la première fois, on a un moyen sain et déclaratif de gérer la spécificité à travers un projet entier.
Le problème#
La spécificité CSS est un système de points qui se fiche de tes intentions. Une classe utilitaire avec .text-red perd contre .card .title parce que ce dernier a une spécificité plus élevée. Le correctif était toujours le même : rendre tes sélecteurs plus spécifiques, ajouter !important, ou restructurer tout.
On a construit des méthodologies entières (BEM, SMACSS, ITCSS) et des chaînes d'outils juste pour éviter les conflits de spécificité. Tout ça était un contournement pour une fonctionnalité manquante du langage.
Ordre des layers#
@layer te permet de déclarer l'ordre dans lequel les groupes de styles sont considérés, indépendamment de la spécificité au sein de ces groupes :
/* Déclarer l'ordre des layers — cette seule ligne contrôle tout */
@layer reset, base, components, utilities, overrides;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer base {
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
color: oklch(20% 0 0);
}
h1, h2, h3 {
line-height: 1.2;
text-wrap: balance;
}
}
@layer components {
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
}
.card .title {
font-size: 1.25rem;
font-weight: 600;
color: oklch(25% 0.05 260);
}
}
@layer utilities {
.text-red {
color: oklch(55% 0.25 25);
}
.hidden {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
}Même si .card .title a une spécificité plus élevée que .text-red, l'utilitaire gagne parce que le layer utilities est déclaré après components. Pas de !important. Pas de hacks de spécificité. L'ordre des layers a le dernier mot.
Comment Tailwind CSS v4 utilise les layers#
Tailwind v4 s'appuie fortement sur @layer. Quand tu écris @import "tailwindcss", tu obtiens :
@layer theme, base, components, utilities;Chaque utilitaire Tailwind vit dans le layer utilities. Tes styles de composants personnalisés vont dans components. C'est pourquoi une classe text-red-500 peut écraser la couleur d'un composant sans avoir besoin de !important — elle est dans un layer ultérieur.
Si tu construis ton propre design system sans Tailwind, vole cette architecture. C'est la bonne :
@layer reset, tokens, base, layouts, components, utilities, overrides;Sept layers suffisent. Je n'ai jamais eu besoin de plus.
Les styles hors layer l'emportent sur tout#
Un piège : les styles qui ne sont dans aucun layer ont la priorité la plus élevée. C'est en fait utile — ça signifie que tes surcharges ponctuelles spécifiques à une page gagnent automatiquement :
@layer components {
.modal {
background: white;
}
}
/* Pas dans un layer — l'emporte sur tout ce qui est dans les layers */
.special-page .modal {
background: oklch(95% 0.02 260);
}Mais ça signifie aussi que du CSS tiers qui n'est pas conscient des layers peut écraser tout ton système. Enveloppe les styles tiers dans un layer pour les contrôler :
@layer third-party {
@import url("some-library.css");
}Le sélecteur :has() : le sélecteur parent qu'on a toujours voulu#
Pendant littéralement des décennies, les développeurs demandaient un sélecteur parent. « Je veux styler un parent en fonction de ses enfants. » La réponse était toujours « CSS ne peut pas faire ça » suivie d'un contournement JavaScript. :has() change ça complètement, et il s'avère qu'il est encore plus puissant que ce qu'on demandait.
Sélection parent basique#
/* Styler un groupe de formulaire quand son input a le focus */
.form-group:has(input:focus) {
border-color: oklch(55% 0.2 260);
box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
/* Styler une carte différemment quand elle contient une image */
.card:has(img) {
padding-top: 0;
}
.card:has(img) .card-content {
padding: 1.5rem;
}
/* Une carte sans image reçoit un traitement différent */
.card:not(:has(img)) {
border-left: 4px solid oklch(55% 0.2 260);
}États de validation de formulaire sans JavaScript#
C'est là que :has() devient véritablement excitant. Combiné avec les pseudo-classes de validation HTML, tu peux construire des interfaces de formulaire qui réagissent à l'état de validité avec zéro JavaScript :
/* Le wrapper du champ réagit à la validité de son input */
.field:has(input:invalid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.25 25);
}
.field:has(input:valid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.2 145);
}
.field {
--field-color: oklch(70% 0 0);
border: 2px solid var(--field-color);
border-radius: 0.5rem;
padding: 0.75rem;
transition: border-color 0.2s;
}
.field label {
color: var(--field-color);
font-size: 0.875rem;
font-weight: 500;
}
.field .error-message {
display: none;
color: oklch(55% 0.25 25);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
display: block;
}La partie :not(:placeholder-shown) est cruciale — elle empêche les styles de validation d'apparaître sur les champs vides qui n'ont pas encore été touchés.
Mise en page basée sur le nombre d'enfants#
Ce pattern est absurdement utile et était véritablement impossible avant :has() :
/* Ajuster les colonnes de la grille en fonction du nombre d'éléments */
.grid-auto:has(> :nth-child(4)) {
grid-template-columns: repeat(2, 1fr);
}
.grid-auto:has(> :nth-child(7)) {
grid-template-columns: repeat(3, 1fr);
}
.grid-auto:has(> :nth-child(13)) {
grid-template-columns: repeat(4, 1fr);
}La grille change son nombre de colonnes en fonction du nombre d'enfants qu'elle a. Pas de JavaScript. Pas de ResizeObserver. Pas de basculement de classe.
Détection de sidebar#
Un de mes patterns préférés — changer la mise en page selon qu'un sidebar existe ou non :
.page-layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
/* Si la mise en page contient un sidebar, passer en deux colonnes */
.page-layout:has(.sidebar) {
grid-template-columns: 1fr 300px;
}
/* Ajuster la largeur du contenu principal quand le sidebar est présent */
.page-layout:has(.sidebar) .main-content {
max-width: 65ch;
}Ajoute un composant sidebar au DOM, la mise en page s'ajuste. Supprime-le, elle se réajuste. Le CSS est la source de vérité, pas une variable d'état.
Combiner :has() avec d'autres sélecteurs#
:has() se compose magnifiquement avec tout le reste :
/* Styler un article uniquement quand il contient une classe spécifique de figure */
article:has(figure.full-bleed) {
overflow: visible;
}
/* Une navigation qui change quand elle contient un input de recherche */
nav:has(input[type="search"]:focus) {
background: oklch(98% 0 0);
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
/* Activer le mode sombre au niveau du composant */
.theme-switch:has(input:checked) ~ main {
color-scheme: dark;
background: oklch(15% 0 0);
color: oklch(90% 0 0);
}Le support navigateur est excellent maintenant. Chaque navigateur moderne supporte :has() depuis début 2024. Il n'y a aucune raison de ne pas l'utiliser.
CSS Nesting : natif, enfin stable#
Je ne vais pas survendre celui-ci. L'imbrication CSS, c'est agréable. Ce n'est pas révolutionnaire comme les container queries ou :has(). Mais ça supprime l'une des dernières raisons d'utiliser Sass, et c'est important.
La syntaxe#
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
.title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.description {
color: oklch(40% 0 0);
line-height: 1.7;
}
&:hover {
box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
}
&.featured {
border: 2px solid oklch(60% 0.2 260);
}
}C'est du CSS valide. Pas de step de build. Pas de préprocesseur. Pas de plugin PostCSS. Le navigateur le gère nativement.
Différences avec Sass#
Il y a quelques différences de syntaxe à connaître :
/* CSS Nesting — fonctionne maintenant dans tous les navigateurs */
.parent {
/* L'imbrication directe de classes/éléments fonctionne sans & */
.child {
color: red;
}
/* & est requis pour les pseudo-classes et les sélecteurs composés */
&:hover {
background: blue;
}
&.active {
font-weight: bold;
}
/* Media queries imbriquées — c'est super */
@media (width >= 768px) {
flex-direction: row;
}
}Dans les premières implémentations, on avait besoin de & avant les sélecteurs d'éléments (comme & p au lieu de juste p). Cette restriction a été levée. Depuis 2025, tous les navigateurs majeurs supportent l'imbrication d'éléments sans & : .parent { p { ... } } fonctionne très bien.
Media queries imbriquées#
C'est la fonctionnalité phare de l'imbrication CSS, à mon avis. Pas l'imbrication de sélecteurs — la capacité de mettre des media queries à l'intérieur d'un bloc de règles :
.hero {
padding: 2rem;
font-size: 1rem;
@media (width >= 768px) {
padding: 4rem;
font-size: 1.25rem;
}
@media (width >= 1200px) {
padding: 6rem;
font-size: 1.5rem;
}
}Compare ça avec l'ancienne façon où tes styles .hero étaient dispersés dans trois blocs @media différents, potentiellement à des centaines de lignes d'écart. L'imbrication garde le comportement responsive co-localisé avec le composant. La lisibilité s'améliore dramatiquement.
Ne pas trop imbriquer#
Un avertissement : ce n'est pas parce que tu peux imbriquer sur six niveaux que tu dois le faire. Le même conseil de Sass s'applique ici. Si ton imbrication crée des sélecteurs comme .page .section .card .content .text .highlight, tu as créé un monstre de spécificité et un cauchemar de maintenance. Deux ou trois niveaux, c'est le sweet spot.
/* Bien — deux niveaux */
.nav {
.link {
color: inherit;
&:hover {
color: oklch(55% 0.2 260);
}
}
}
/* Mal — cauchemar de spécificité */
.page {
.layout {
.sidebar {
.nav {
.list {
.item {
.link {
color: red; /* bonne chance pour écraser ça */
}
}
}
}
}
}
}Fonctions de couleur : oklch() et color-mix() changent tout#
hsl() a eu son moment de gloire. C'était plus intuitif que rgb(). Mais il a un défaut fondamental : il n'est pas perceptuellement uniforme. Un hsl(60, 100%, 50%) (jaune) paraît dramatiquement plus clair à l'œil humain qu'un hsl(240, 100%, 50%) (bleu), même s'ils ont la même valeur de luminosité.
Pourquoi oklch() gagne#
oklch() est perceptuellement uniforme. Des valeurs de luminosité égales paraissent également lumineuses. Ça compte énormément quand on génère des palettes de couleurs, crée des thèmes, et assure un contraste accessible :
:root {
/* oklch(luminosité chroma teinte) */
--color-primary: oklch(55% 0.2 260); /* bleu */
--color-secondary: oklch(55% 0.2 330); /* violet */
--color-success: oklch(55% 0.2 145); /* vert */
--color-danger: oklch(55% 0.25 25); /* rouge */
--color-warning: oklch(75% 0.18 85); /* jaune — L plus élevé pour une perception égale */
/* Ceux-ci paraissent tous également « moyens » à l'œil humain */
/* Avec HSL, il faudrait des valeurs de luminosité différentes pour chaque teinte */
}Les trois valeurs sont intuitives une fois comprises :
- Luminosité (0% à 100%) : clair ou sombre. 0% est noir, 100% est blanc.
- Chroma (0 à ~0.37) : l'intensité. 0 est gris, plus élevé est plus saturé.
- Teinte (0 à 360) : l'angle de couleur. 0/360 est rouge rosé, 145 est vert, 260 est bleu.
color-mix() pour les couleurs dérivées#
color-mix() te permet de créer des couleurs à partir d'autres couleurs à l'exécution. Pas de fonction darken() de Sass. Pas de JavaScript. Juste du CSS :
:root {
--brand: oklch(55% 0.2 260);
/* Éclaircir en mélangeant avec du blanc */
--brand-light: color-mix(in oklch, var(--brand) 30%, white);
/* Assombrir en mélangeant avec du noir */
--brand-dark: color-mix(in oklch, var(--brand) 70%, black);
/* Créer un fond subtil */
--brand-bg: color-mix(in oklch, var(--brand) 8%, white);
/* Version semi-transparente */
--brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
.button {
background: var(--brand);
color: white;
&:hover {
background: var(--brand-dark);
}
&:active {
background: color-mix(in oklch, var(--brand) 60%, black);
}
}
.button.secondary {
background: var(--brand-bg);
color: var(--brand-dark);
&:hover {
background: color-mix(in oklch, var(--brand) 15%, white);
}
}La partie in oklch est importante. Mélanger en srgb donne des couleurs intermédiaires ternes. Mélanger en oklch donne des résultats perceptuellement uniformes. Mélange toujours en oklch.
Construire une palette complète avec oklch()#
Voici comment je génère une palette complète de nuances à partir d'une seule teinte :
:root {
--hue: 260;
--chroma: 0.2;
--color-50: oklch(97% calc(var(--chroma) * 0.1) var(--hue));
--color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
--color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
--color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
--color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
--color-500: oklch(55% var(--chroma) var(--hue));
--color-600: oklch(48% var(--chroma) var(--hue));
--color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
--color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
--color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
--color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}Change --hue à 145 et tu as une palette verte. Change-la à 25 et tu as du rouge. Les paliers de luminosité sont perceptuellement uniformes. Le chroma diminue aux extrêmes pour que les nuances les plus claires et les plus sombres ne soient pas sursaturées. C'est le genre de chose qui nécessitait un outil de design ou une fonction Sass. Maintenant c'est huit lignes de CSS.
Animations liées au scroll : pas de JavaScript requis#
C'est la fonctionnalité qui m'a fait supprimer le plus de JavaScript. Les animations liées au scroll — barres de progression, effets parallaxe, animations de révélation, en-têtes sticky avec transitions — nécessitaient IntersectionObserver, des écouteurs d'événements scroll, ou une bibliothèque comme GSAP. Maintenant c'est du CSS.
Indicateur de progression de lecture#
La classique « barre de progression de lecture » en haut d'une page d'article :
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: oklch(55% 0.2 260);
transform-origin: left;
z-index: 1000;
animation: grow-progress linear both;
animation-timeline: scroll();
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}C'est tout. L'intégralité de l'indicateur de progression de lecture. Pas de JavaScript. Pas d'écouteur d'événement scroll. Pas de requestAnimationFrame. Pas de calcul de « pourcentage scrollé ». Le binding animation-timeline: scroll() fait tout.
Révélation au scroll#
Des éléments qui apparaissent en fondu quand ils entrent dans le viewport :
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}animation-timeline: view() lie l'animation à la visibilité de l'élément dans le viewport. animation-range: entry 0% entry 100% signifie que l'animation s'exécute du moment où l'élément commence à entrer dans le viewport jusqu'à ce qu'il soit complètement visible.
Parallaxe sans bibliothèque#
.parallax-bg {
animation: parallax linear both;
animation-timeline: scroll();
}
@keyframes parallax {
from {
transform: translateY(-20%);
}
to {
transform: translateY(20%);
}
}
.parallax-section {
overflow: hidden;
position: relative;
}L'image de fond se déplace à un rythme différent du scroll, créant un effet parallaxe. Fluide, performant (le navigateur peut composer sur le GPU), et zéro JavaScript.
Timelines de scroll nommées#
Pour plus de contrôle, tu peux nommer les timelines de scroll et les référencer depuis d'autres éléments :
.scroller {
overflow-y: auto;
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;
}
/* Un élément n'importe où dans le DOM peut référencer cette timeline */
.indicator {
animation: progress linear both;
animation-timeline: --my-scroller;
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}Ça fonctionne même quand l'indicateur et le scroller ne sont pas parent/enfant. N'importe quel élément peut se lier à n'importe quelle timeline de scroll nommée. C'est puissant pour les interfaces de tableau de bord où un panneau scrollable pilote un indicateur dans un en-tête fixe.
Anchor Positioning : les tooltips et popovers bien faits#
Avant l'anchor positioning, connecter un tooltip à son élément déclencheur nécessitait du JavaScript. On calculait les positions avec getBoundingClientRect(), on gérait les décalages de scroll, les collisions avec le viewport, et on recalculait au redimensionnement. Des bibliothèques comme Popper.js (maintenant Floating UI) existaient spécifiquement parce que c'était si difficile à faire correctement.
L'anchor positioning CSS rend ça déclaratif :
.trigger {
anchor-name: --my-trigger;
}
.tooltip {
position: fixed;
position-anchor: --my-trigger;
/* Positionner le centre-haut du tooltip au centre-bas du déclencheur */
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
/* Positionnement de fallback si ça déborde du viewport */
position-try-fallbacks: flip-block, flip-inline;
}Gestion automatique des collisions avec le viewport#
La propriété position-try-fallbacks est la partie qui aurait pris 200 lignes de JavaScript. Elle dit au navigateur : « si le tooltip déborde du viewport en bas, retourne-le en haut. S'il déborde à droite, retourne-le à gauche. »
.dropdown-menu {
position: fixed;
position-anchor: --menu-button;
/* Par défaut : sous le bouton, aligné au bord gauche */
top: anchor(bottom);
left: anchor(left);
/* Si ça ne rentre pas en bas, essayer en haut. Si ça ne rentre pas aligné à gauche, essayer à droite */
position-try-fallbacks: flip-block, flip-inline;
/* Ajouter un espace entre l'ancre et le dropdown */
margin-top: 4px;
}Fallbacks de position nommés#
Pour plus de contrôle sur les positions de fallback, tu peux définir des options try personnalisées :
@position-try --above-right {
bottom: anchor(top);
right: anchor(right);
top: auto;
left: auto;
margin-top: 0;
margin-bottom: 4px;
}
@position-try --left-center {
right: anchor(left);
top: anchor(center);
left: auto;
bottom: auto;
translate: 0 -50%;
margin-top: 0;
margin-right: 4px;
}
.tooltip {
position: fixed;
position-anchor: --trigger;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 4px;
position-try-fallbacks: --above-right, --left-center;
}Le navigateur essaie chaque fallback dans l'ordre jusqu'à en trouver un qui garde l'élément dans le viewport. C'est le genre de raisonnement spatial qui était véritablement pénible en JavaScript.
Ancrage avec l'API Popover#
L'anchor positioning s'associe parfaitement avec la nouvelle API Popover :
<button popovertarget="my-popover" style="anchor-name: --btn">Settings</button>
<div popover id="my-popover" style="
position-anchor: --btn;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 8px;
">
Contenu du popover ici
</div>Pas de JavaScript pour afficher/masquer (l'API Popover gère ça). Pas de JavaScript pour le positionnement (l'anchor positioning gère ça). Pas de JavaScript pour le comportement de fermeture par clic extérieur (l'API Popover gère ça aussi). Tout le pattern tooltip/popover/dropdown — un pattern qui alimentait des packages npm entiers — est maintenant du HTML et du CSS.
CSS Grid Subgrid : l'alignement imbriqué qui fonctionne vraiment#
Grid est puissant, mais il avait une limitation frustrante : une grille enfant ne pouvait pas aligner ses éléments sur la grille parent. Si tu avais une rangée de cartes et que tu voulais que le titre, le contenu et le pied de page de chaque carte s'alignent entre les cartes, tu n'avais pas de chance. La grille interne de chaque carte était indépendante.
Subgrid corrige ça.
Le problème d'alignement des cartes#
/* Grille parent */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
/* Chaque carte devient un subgrid, alignant les lignes sur le parent */
.card {
display: grid;
grid-row: span 3; /* La carte s'étend sur 3 lignes dans le parent */
grid-template-rows: subgrid;
gap: 0; /* La carte contrôle son propre espacement interne */
}
.card-title {
font-weight: 600;
padding: 1rem 1rem 0.5rem;
}
.card-body {
padding: 0 1rem;
color: oklch(40% 0 0);
}
.card-footer {
padding: 0.5rem 1rem 1rem;
margin-top: auto;
border-top: 1px solid oklch(90% 0 0);
}Maintenant les titres de toutes les trois cartes s'alignent sur la même ligne. Les corps s'alignent. Les pieds de page s'alignent. Même quand une carte a un titre sur deux lignes et une autre un titre sur une ligne, l'alignement est maintenu par la grille parent.
Avant Subgrid : les bidouilles#
Sans subgrid, obtenir cet alignement nécessitait soit :
- Des hauteurs fixes (fragile, casse avec du contenu dynamique)
- Des mesures JavaScript (lent, flash de désalignement)
- Abandonner et accepter le désalignement (courant, laid)
/* L'ancienne bidouille — fragile et casse avec du contenu dynamique */
.card-title {
min-height: 3rem; /* prier qu'aucun titre ne dépasse ça */
}
.card-body {
min-height: 8rem; /* encore plus de prières */
}Subgrid rend la bidouille inutile. La grille parent distribue les hauteurs de ligne en fonction du contenu le plus haut dans chaque ligne à travers toutes les cartes.
Subgrid en colonnes pour les mises en page de formulaires#
Subgrid fonctionne aussi sur les colonnes, ce qui est parfait pour les mises en page de formulaires :
.form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem 1rem;
}
.form-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.form-row label {
grid-column: 1;
text-align: right;
font-weight: 500;
}
.form-row input {
grid-column: 2;
padding: 0.5rem;
border: 1px solid oklch(80% 0 0);
border-radius: 0.375rem;
}Tous les labels s'alignent. Tous les inputs s'alignent. La colonne des labels s'auto-dimensionne au label le plus large. Pas de largeurs codées en dur.
API View Transitions : une navigation style SPA sans framework#
Celle-ci est la fonctionnalité la plus ambitieuse de cette liste. L'API View Transitions te permet d'animer les transitions entre navigations de pages — y compris les navigations cross-documents (clics de liens réguliers dans une application multi-pages). Ton site HTML statique peut maintenant avoir des transitions de page fluides et animées.
Transitions cross-documents#
Pour activer les transitions de vue cross-documents, tu ajoutes une seule règle CSS sur les deux pages, l'ancienne et la nouvelle :
@view-transition {
navigation: auto;
}C'est tout. Le navigateur va maintenant faire un fondu enchaîné entre les pages lors de la navigation. La transition par défaut est un fondu d'opacité fluide. Pas de JavaScript. Pas de framework. Juste deux lignes de CSS sur chaque page.
Personnaliser la transition#
Tu peux personnaliser les transitions en nommant des éléments spécifiques :
/* Sur les deux pages */
.page-header {
view-transition-name: header;
}
.main-content {
view-transition-name: content;
}
.hero-image {
view-transition-name: hero;
}
/* Animations de transition */
::view-transition-old(content) {
animation: slide-out 0.3s ease-in both;
}
::view-transition-new(content) {
animation: slide-in 0.3s ease-out both;
}
::view-transition-old(hero) {
animation: fade-out 0.2s ease-in both;
}
::view-transition-new(hero) {
animation: none; /* Le nouveau hero apparaît simplement */
}
@keyframes slide-out {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}Quand tu navigues entre deux pages qui ont toutes deux un .hero-image avec view-transition-name: hero, le navigateur anime automatiquement l'image depuis sa position sur l'ancienne page vers sa position sur la nouvelle page. C'est le pattern « shared element transition » du développement mobile, maintenant dans le navigateur.
View Transitions SPA#
Pour les applications single-page (React, Vue, Svelte, etc.), l'API JavaScript est directe :
/* Côté CSS — définir tes animations de transition */
::view-transition-old(root) {
animation: fade-out 0.2s ease-in;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-out;
}// Côté JS — envelopper ta mise à jour du DOM dans startViewTransition
document.startViewTransition(() => {
// Mettre à jour le DOM ici — rendu React, swap innerHTML, etc.
updateContent(newPageData);
});Le navigateur capture l'état ancien, exécute ta mise à jour, capture le nouvel état, et anime entre les deux. Les éléments nommés reçoivent des transitions individuelles ; tout le reste reçoit un fondu enchaîné par défaut.
Respecter les préférences utilisateur#
Toujours respecter prefers-reduced-motion :
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Ce que tu peux arrêter d'utiliser#
Ces fonctionnalités éliminent collectivement beaucoup d'outils, de bibliothèques et de patterns dont on dépendait depuis des années. Voici ce que j'ai supprimé ou arrêté d'utiliser :
Sass/SCSS pour l'imbrication et les variables#
CSS a l'imbrication native. CSS a les propriétés personnalisées (et les a depuis des années). Les deux principales raisons pour lesquelles on utilisait Sass sont maintenant dans le langage. Si tu utilises encore Sass uniquement pour les $variables et l'imbrication, tu peux arrêter.
/* Avant : Sass */
$primary: #3b82f6;
.card {
background: white;
border: 1px solid lighten($primary, 40%);
&:hover {
border-color: $primary;
}
.title {
color: darken($primary, 15%);
}
}
/* Après : CSS natif */
.card {
--primary: oklch(55% 0.2 260);
background: white;
border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
&:hover {
border-color: var(--primary);
}
.title {
color: color-mix(in oklch, var(--primary) 70%, black);
}
}La version CSS est plus puissante — oklch donne une manipulation de couleur perceptuellement uniforme, color-mix fonctionne à l'exécution (le lighten de Sass est compile-time uniquement), et les propriétés personnalisées peuvent être changées dynamiquement depuis JavaScript ou les media queries.
JavaScript pour les animations au scroll#
Supprime ton code IntersectionObserver de révélation au scroll. Supprime ton JavaScript de barre de progression de scroll. Supprime ton gestionnaire de scroll parallaxe. animation-timeline: scroll() et animation-timeline: view() gèrent tout ça avec de meilleures performances (thread de composition, pas thread principal).
// Avant : JavaScript que tu peux supprimer
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));/* Après : CSS qui remplace tout ce qui précède */
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}Popper.js / Floating UI pour le positionnement#
Si tu utilises Floating UI (anciennement Popper.js) uniquement pour le positionnement basique de tooltips/popovers, l'anchor positioning CSS le remplace. Tu perds certaines fonctionnalités avancées (éléments virtuels, middleware personnalisé), mais pour le cas d'usage à 90% — « mettre ce popover près de ce bouton et le garder dans le viewport » — CSS le fait nativement maintenant.
Les hacks de centrage margin: auto#
Ce n'est pas nouveau, mais je vois encore margin: 0 auto partout. Les outils de mise en page modernes le rendent inutile dans la plupart des cas :
/* Ancienne façon */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Mieux : CSS Grid ou Flexbox */
.page {
display: grid;
place-items: center;
}
/* Ou : les container queries rendent le conteneur lui-même responsive */
.content {
container-type: inline-size;
width: min(100% - 2rem, 1200px);
margin-inline: auto; /* OK, toujours auto, mais margin-inline est plus clair */
}margin: 0 auto fonctionne toujours très bien. Mais si tu te retrouves à l'utiliser pour le centrage vertical ou l'alignement complexe, utilise plutôt flexbox ou grid.
Media queries pour le responsive au niveau des composants#
C'est la grosse révolution. Si tu écris des @media queries pour rendre un composant responsive, tu fais probablement fausse route maintenant. Les container queries devraient être ton choix par défaut pour le responsive au niveau des composants. Réserve les @media queries pour les décisions au niveau de la page : changements de mise en page, patterns de navigation, styles d'impression.
/* Avant : Media query pour le style de composant (mauvais périmètre) */
@media (min-width: 768px) {
.product-card {
flex-direction: row;
}
}
/* Après : Container query (bon périmètre) */
.product-card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.product-card {
flex-direction: row;
}
}La vue d'ensemble#
Ces fonctionnalités n'existent pas de manière isolée. Elles se composent. Container queries + :has() + nesting + oklch() + layers — utilisés ensemble — te donnent une expérience d'écriture CSS qui aurait été méconnaissable il y a cinq ans :
@layer components {
.card-wrapper {
container-type: inline-size;
}
.card {
--accent: oklch(55% 0.2 260);
display: grid;
gap: 1rem;
padding: 1.5rem;
border-radius: 0.5rem;
background: white;
border: 1px solid oklch(90% 0 0);
&:has(img) {
padding-top: 0;
img {
border-radius: 0.5rem 0.5rem 0 0;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
&:has(.badge.new) {
border-color: var(--accent);
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: oklch(20% 0 0);
text-wrap: balance;
}
.meta {
font-size: 0.875rem;
color: oklch(50% 0 0);
}
@container (min-width: 500px) {
grid-template-columns: 200px 1fr;
&:has(img) {
padding: 0;
img {
border-radius: 0.5rem 0 0 0.5rem;
height: 100%;
aspect-ratio: auto;
}
}
.content {
padding: 1.5rem;
}
}
}
}Ce seul bloc gère :
- La mise en page responsive au niveau du composant (container queries)
- Le style conditionnel basé sur le contenu (
:has()) - L'organisation propre des sélecteurs (nesting)
- Les couleurs perceptuellement uniformes (
oklch) - La gestion prévisible de la spécificité (
@layer)
Pas de préprocesseur. Pas de runtime CSS-in-JS. Pas de classes utilitaires pour ça. Juste du CSS, faisant ce que CSS aurait dû faire depuis le début.
La plateforme a rattrapé son retard. Enfin. Et ça valait la peine d'attendre.