css et mises en page

Container Queries : le composant qui connaît son conteneur, pas le viewport

Container Queries : le composant qui connaît son conteneur, pas le viewport

Vous avez une carte produit. Une image au-dessus du titre, un prix, un bouton « Ajouter au panier ». Dans la grille principale de la boutique, à partir de 768px de largeur d'écran, une media query bascule son mise en page en horizontal – l'image à gauche, le contenu à droite, parce que cela exploite mieux l'espace disponible. Ça fonctionne très bien. Puis l'équipe produit voit cette même carte et veut la placer dans une sidebar étroite « produits recommandés », à côté du contenu principal d'un article. Vous injectez le composant sans rien changer – et il se casse. La mise en page horizontale, pensée pour 700 pixels et plus, tente de tenir dans une colonne de 260 pixels. L'image est écrasée en une bande, le texte se replie mot par mot sur des lignes séparées.

Vous n'avez pas commis d'erreur en CSS. Votre media query fonctionne exactement comme prévu – sauf qu'elle répond à la mauvaise question. @media (min-width: 768px) demande : « quelle est la largeur du viewport ? ». Or, pour bien s'afficher, une carte produit ne devrait jamais dépendre de la largeur du viewport – elle devrait dépendre de l'espace que son parent lui a accordé. Ce sont deux questions différentes, que le CSS a traitées comme une seule pendant deux décennies, faute d'avoir un moyen de poser la seconde.

Pourquoi la media query ne suffit pas

Une media query est une requête globale. Peu importe que votre composant se trouve dans une colonne de 1200 pixels ou dans une carte latérale étroite de 260 – @media (min-width: 768px) renverra le même true dans les deux cas, car elle interroge la fenêtre du navigateur, pas le parent de l'élément. Un composant bâti uniquement sur des media queries n'est donc correct que dans le contexte pour lequel il a été conçu à l'origine – et cesse de l'être dès que ce même code atterrit ailleurs. Cela brise fondamentalement la promesse que les composants sont censés tenir : qu'on puisse les réutiliser en toute sécurité, n'importe où dans une mise en page.

Avant 2023, le seul contournement réel était le ResizeObserver en JavaScript – observer un élément, mesurer sa largeur à chaque changement, injecter le résultat dans un état, ajouter conditionnellement une classe CSS. Ça fonctionne, mais ça coûte : un état supplémentaire dans le composant, un re-rendu à chaque redimensionnement, du code à écrire à la main pour chaque composant séparément, et qui n'existe pas tant que le JavaScript ne s'est pas exécuté – donc en rendu côté serveur, la première image affichera de toute façon une mise en page « à l'aveugle », avant que ResizeObserver ait eu le temps de mesurer quoi que ce soit.

jsx

// Contournement d'avant les Container Queries – ça marche, mais ça coûte cher
import { useEffect, useRef, useState } from "react";
const ProductCard = ({ product }) => {
const cardRef = useRef(null);
const [isWide, setIsWide] = useState(false);
useEffect(() => {
const el = cardRef.current;
const observer = new ResizeObserver((entries) => {
setIsWide(entries[0].contentRect.width > 320);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<article ref={cardRef} className={`card ${isWide ? "card--wide" : ""}`}>
{/* ... */}
</article>
);
};

Les Container Queries résolvent ce même problème sans une seule ligne de JavaScript, sans état et sans re-rendu – car la question « combien d'espace ai-je à disposition » revient là où le CSS l'a toujours posée : dans la feuille de style.

Comment ça fonctionne vraiment sous le capot : le containment, pas seulement une nouvelle syntaxe

La syntaxe @container ressemble à @media avec un autre nom – mais la différence se situe ailleurs, dans ce qui doit se produire avant même que le navigateur accepte de répondre à une telle requête. Pour qu'un élément puisse être un « conteneur de requête », vous devez le déclarer explicitement :

css

.card-slot {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 40% 1fr;
}
}

container-type: inline-size n'est pas qu'un simple interrupteur activant le mode requête – c'est une déclaration de CSS Containment, une spécification distincte qui dit au navigateur : « le contenu de cet élément n'influence pas sa taille sur l'axe inline, tu peux donc le traiter en toute sécurité comme une île indépendante lors de la mise en page ». Ce n'est pas un détail d'implémentation sans importance – c'est une condition nécessaire pour que les requêtes de conteneur puissent exister sans se boucler sur elles-mêmes. Si le navigateur permettait à un élément de changer de taille en réaction à sa propre largeur tout en influençant cette largeur par son contenu, une dépendance circulaire apparaîtrait : le contenu modifie la taille du conteneur → le changement de taille déclenche @container → les nouvelles règles modifient le contenu → le contenu modifie à nouveau la taille du conteneur. Le containment coupe cette boucle à la racine, en imposant que la taille du conteneur sur un axe donné soit fixée indépendamment de ce qui se trouve à l'intérieur.

Cela a une conséquence pratique concrète dans le choix de la valeur de container-type :

  • inline-size – containment uniquement sur l'axe inline (généralement horizontal). La hauteur de l'élément découle toujours librement de son contenu. C'est la valeur que vous utiliserez dans 95 % des cas – exactement comme dans l'exemple de la carte produit.
  • size – containment sur les deux axes à la fois. L'élément cesse totalement de dépendre de son contenu pour déterminer sa taille – ce qui signifie que vous devez lui donner une hauteur explicite (par exemple via height ou aspect-ratio), sinon le containment le coupe de la hauteur naturelle de son contenu et sa hauteur retombe effectivement à zéro.
  • normal – valeur par défaut, aucun containment, l'élément n'est pas un conteneur de requête.

container-name est optionnel, mais c'est une bonne habitude à prendre – sans lui, @container (min-width: 400px) interroge l'ancêtre le plus proche qui soit un conteneur, quel qu'il soit. Dans un composant plat, c'est inoffensif, mais dans une mise en page imbriquée (une carte dans un panneau, le panneau dans une colonne – le panneau et la colonne étant tous deux des conteneurs), un conteneur nommé indique sans ambiguïté quel ancêtre vous interrogez précisément, au lieu de dépendre d'une proximité accidentelle dans l'arbre DOM.

Exemple pratique : une carte, deux contextes, zéro JavaScript

Retour à la carte produit de l'introduction – cette fois construite pour que le même composant s'affiche correctement à la fois dans une grille large et dans une sidebar étroite, sans avoir la moindre connaissance de l'endroit où il a été placé.

jsx

const ProductCard = ({ product }) => (
<div className="product-card-slot">
<article className="product-card">
<img
src={product.image}
alt={product.name}
className="product-card__image"
/>
<div className="product-card__body">
<h3 className="product-card__title">{product.name}</h3>
<p className="product-card__price">{product.price}</p>
<button type="button" className="product-card__cta">
Ajouter au panier
</button>
</div>
</article>
</div>
);
export default ProductCard;

css

/* Le parent du composant se déclare comme conteneur de requête */
.product-card-slot {
container-type: inline-size;
container-name: product-card;
}
/* Mise en page par défaut : image au-dessus du contenu – sûre dans un espace étroit */
.product-card {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.product-card__image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 8px;
}
/* Au-delà de 360px de largeur du CONTENEUR, pas du viewport – basculer en horizontal */
@container product-card (min-width: 360px) {
.product-card {
grid-template-columns: 40% 1fr;
align-items: center;
}
.product-card__image {
aspect-ratio: 1 / 1;
height: 100%;
}
}

La décision clé se cache ici dans la structure, pas dans le CSS : container-type est posé sur .product-card-slot, c'est-à-dire le parent de la carte, et non sur .product-card elle-même. Ce n'est ni un hasard ni un style superflu – la spécification exclut explicitement la possibilité qu'un élément interroge sa propre taille via @container (encore le même problème de dépendance circulaire : un élément ne peut pas à la fois définir un conteneur et être stylé en réaction à sa propre taille). D'où ce motif typique et répétitif : un fin wrapper-conteneur à l'extérieur, et le vrai composant à l'intérieur, stylé dans un bloc @container. Ce même fichier CSS, injecté une fois dans la grille de produits (où le slot fait 480px) et une fois dans la sidebar (où le slot fait 240px), produira deux mises en page différentes et correctes – sans prop, sans classe modificatrice, sans une seule ligne de JavaScript.

Les unités de conteneur : de la fluidité sans le viewport

Les Container Queries ont apporté avec elles un second élément, moins connu, de cette même spécification : des unités relatives au conteneur, et non au viewport – cqw (1 % de la largeur du conteneur), cqh (1 % de la hauteur), cqi et cqb (1 % sur l'axe inline/block, donc valables aussi dans les langues à écriture verticale), ainsi que cqmin/cqmax, analogues à vmin/vmax, mais calculées à partir de la plus petite ou de la plus grande dimension du conteneur.

Leur usage naturel est la typographie fluide à l'intérieur d'un composant, indépendamment de la largeur actuelle de l'écran de l'utilisateur :

css

.product-card__title {
/* S'adapte à la largeur de la carte, pas à celle de l'écran */
font-size: clamp(1rem, 0.85rem + 2cqi, 1.375rem);
}

La différence par rapport à l'astuce déjà connue de clamp() avec l'unité vw est subtile, mais fondamentale en pratique : un titre mis à l'échelle en vw change de taille avec la page entière, donc deux copies de la même carte – l'une dans une grille pleine largeur, l'autre dans une sidebar étroite – auront une taille de police identique, car les deux voient le même viewport. Un titre mis à l'échelle en cqi réagit à l'espace réellement accordé à cette carte précise – plus petit dans la sidebar, plus grand dans la grille, alors même que le viewport garde exactement la même largeur en permanence.

Pièges et bonnes pratiques

  • container-type: size sans hauteur explicite dévore le contenu. Le containment sur l'axe block coupe l'élément de la hauteur naturelle de ses descendants – si vous ne précisez ni height ni aspect-ratio, le conteneur se réduira effectivement à une hauteur nulle, et ses enfants disparaîtront visuellement, bien qu'ils restent présents dans le DOM. Dans la grande majorité des cas, inline-size suffit et ce problème ne se pose pas.
  • Un conteneur ne peut pas s'interroger lui-même. Si vous luttez contre un @container qui « ne fonctionne pas », le premier suspect est précisément cela : container-type et le sélecteur stylé par @container se trouvent sur le même élément. Séparez-les – container-type sur le wrapper, les styles ciblés sur l'enfant, exactement comme dans l'exemple de la carte produit ci-dessus.
  • Les container queries ne remplacent pas les media queries – elles les complètent. La media query reste l'outil approprié pour les décisions au niveau de la page (par exemple, faut-il afficher une sidebar du tout, ou basculer la navigation en menu burger). La container query gère les décisions au niveau du composant, où qu'il atterrisse. Une bonne mise en page en 2026 utilise généralement les deux à la fois, chacun là où la question qu'il pose est réellement pertinente.
  • Ne faites pas de chaque div un conteneur « au cas où ». Le containment a un coût réel pour le moteur de rendu – c'est un signal « traite-moi comme une unité de mise en page isolée », utile là où vous avez réellement un composant qui réagit à sa propre taille, pas un réglage par défaut pour tout l'arbre DOM.
  • La compatibilité des navigateurs n'est plus un problème. container-type et @container ont atteint le statut Baseline (largement disponible) début 2023 – ils fonctionnent nativement dans Chrome, Safari et Firefox, sans polyfill ni @supports. Les unités cqw/cqi et le reste de la famille bénéficient exactement de la même couverture.

Conclusion

Un composant réutilisable n'est pas celui qui dispose d'assez de props et de classes modificatrices pour gérer manuellement chaque endroit où il pourrait atterrir – c'est celui qui sait par lui-même combien d'espace il a, et qui y réagit sans l'aide de personne. Pendant deux décennies, la media query a été le seul outil de responsive du CSS, et nous l'avons naturellement surutilisée là où il s'agissait en réalité du parent, pas du viewport. Les Container Queries ne sont pas une nouvelle variante syntaxique du même mécanisme – c'est une question différente, posée au bon endroit de l'arbre DOM, appuyée par un containment qui garantit que la réponse ne bouclera jamais sur elle-même. Le code de cet article – un composant, un fichier CSS, zéro JavaScript – fonctionne correctement dans la grille, dans la sidebar, et dans tout autre endroit où vous ne savez pas encore que vous l'insérerez un jour.