Combinez Angular Standalone et CSS Container Queries pour des composants vraiment responsives : product-card, dashboard, units cqi et patterns Signals.
Le problème des composants vraiment responsives
Vous développez un composant Angular <app-product-card>. Le designer le veut large dans la page /products (zone main pleine largeur), compact dans une sidebar de recommandations, et ultra-compact dans une modale latérale. Trois contextes, trois layouts internes, mais un seul composant réutilisable.
Avec les media queries, c'est mission impossible. @media (max-width: 768px) teste la largeur du viewport, pas celle de la card. Si le viewport est en 1440px et que la card est dans une sidebar de 280px, la media query ne se déclenche pas. La card reste « grande » même dans un emplacement étroit.
Solutions historiques : ajouter un input variant: 'large' | 'compact' au composant, multiplier les classes [class.is-narrow]="isNarrow()" avec un ResizeObserver dans ngAfterViewInit, ou créer trois composants distincts. CSS Container Queries éliminent tout ce code.
Avant — ResizeObserver dans Angular
// product-card.component.ts (avant)
import { Component, ElementRef, signal, afterNextRender, inject } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="card" [class.is-narrow]="isNarrow()" [class.is-medium]="isMedium()">
<!-- ... -->
</div>
`,
})
export class ProductCardComponent {
private host = inject(ElementRef);
isNarrow = signal(false);
isMedium = signal(false);
constructor() {
afterNextRender(() => {
const observer = new ResizeObserver(([entry]) => {
const w = entry.contentRect.width;
this.isNarrow.set(w < 320);
this.isMedium.set(w >= 320 && w < 600);
});
observer.observe(this.host.nativeElement);
});
}
}
Après — Container Queries pures
// product-card.component.ts (après)
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="card">
<!-- Aucune classe conditionnelle -->
</div>
`,
styles: [`
:host { container-type: inline-size; }
.card { display: flex; flex-direction: column; }
@container (min-width: 320px) {
.card { flex-direction: row; }
}
@container (min-width: 600px) {
.card { gap: 2rem; padding: 1.5rem; }
}
`],
})
export class ProductCardComponent {}
Container Queries en 5 minutes
Trois étapes : déclarer un container, écrire des règles @container, utiliser les unités relatives au container (optionnel).
1. Déclarer un container avec container-type
.parent {
container-type: inline-size; /* observe la largeur uniquement */
/* container-type: size; observe largeur ET hauteur */
/* container-type: normal; ne pas être un container */
}
2. Nommer le container (optionnel mais recommandé)
.parent {
container-type: inline-size;
container-name: product-card;
}
/* Ou en shorthand */
.parent {
container: product-card / inline-size;
}
3. Interroger avec @container
/* Sans nom — interroge le container ancêtre le plus proche */
@container (min-width: 400px) {
.title { font-size: 1.25rem; }
}
/* Avec nom — cible un container spécifique */
@container product-card (min-width: 400px) {
.title { font-size: 1.25rem; }
}
/* Combinaisons logiques */
@container (min-width: 400px) and (max-width: 800px) {
.title { font-size: 1.5rem; }
}
@container (min-width: 600px) or (orientation: landscape) {
.title { display: block; }
}
@container not (min-width: 400px) {
.title { font-size: 1rem; }
}
Tableau des container-type disponibles
| Valeur | Observe | Coût performance |
|---|---|---|
normal (défaut) |
Pas un container | Aucun |
inline-size |
Largeur uniquement | Faible — recommandé |
size |
Largeur ET hauteur | Plus élevé — bloque le layout |
inline-size par défaut. size empêche les enfants de déterminer la hauteur du container (puisque la hauteur est observée), ce qui peut causer des layouts cassés sans flex/grid.
Configuration dans un projet Angular
Compatibilité Angular et CSS
Container Queries fonctionnent avec toutes les versions d'Angular dès lors que le navigateur cible les supporte (Chrome 105+, Safari 16+, Firefox 110+ — soit ~93% en 2026). Aucune configuration spéciale n'est requise dans angular.json.
Avec ViewEncapsulation par défaut
// product-card.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="card">
<img class="card-img" [src]="image" alt="">
<div class="card-body">
<h3 class="card-title">{{ title }}</h3>
<p class="card-price">{{ price | currency:'EUR' }}</p>
</div>
</div>
`,
styleUrl: './product-card.component.scss',
})
export class ProductCardComponent {
title = 'Sneakers Trail';
price = 89;
image = 'sneakers.webp';
}
/* product-card.component.scss */
:host {
display: block;
container-type: inline-size;
container-name: product-card;
}
.card {
display: flex;
flex-direction: column;
border-radius: 0.5rem;
overflow: hidden;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card-img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.card-body { padding: 1rem; }
.card-title { font-size: 1rem; margin: 0 0 0.25rem; }
.card-price { font-weight: 700; color: #0d6efd; margin: 0; }
/* Layout horizontal dès 360px de large */
@container product-card (min-width: 360px) {
.card { flex-direction: row; }
.card-img { width: 40%; aspect-ratio: 1 / 1; }
.card-body { flex: 1; padding: 1rem 1.25rem; }
}
/* Variante large : enrichir le layout */
@container product-card (min-width: 600px) {
.card-img { width: 50%; }
.card-title { font-size: 1.25rem; }
.card-body { padding: 1.5rem 2rem; }
}
Avec ViewEncapsulation.None (déconseillé pour containers)
Si vous utilisez ViewEncapsulation.None, déclarez tout de même container-name pour éviter qu'un container imbriqué d'un autre composant interfère.
Compatibilité avec les Standalone Components
Les Standalone Components (Angular 14+) n'imposent rien de particulier. Le pattern :host { container-type: inline-size } reste identique. Tirez parti de styleUrl ou styles: [] selon votre préférence.
Cas pratique : ProductCard adaptative
Reprenons le scénario initial : la même ProductCardComponent dans trois emplacements aux dimensions très différentes.
Page produits — pleine largeur
<!-- products.component.html -->
<div class="row g-3">
<div class="col-12 col-md-6 col-lg-4" *ngFor="let p of products()">
<app-product-card [product]="p" />
</div>
</div>
Sur desktop, chaque card mesure ~380px → layout horizontal complet. Sur mobile (col-12), elle mesure ~95% du viewport → layout horizontal aussi. Sur tablette (col-6), elle mesure ~360px → layout horizontal compact.
Sidebar de recommandations — étroit
<!-- product-detail.component.html -->
<aside class="recommendations" style="width: 280px">
<h3>Articles similaires</h3>
<app-product-card *ngFor="let p of similar()" [product]="p" />
</aside>
Width = 280px → en dessous du seuil 320px → layout vertical compact, image au-dessus du texte. Aucune classe conditionnelle, aucune logique TypeScript.
Modal lateral — moyen
<!-- cart-drawer.component.html -->
<div class="cart-drawer" style="width: 480px">
<app-product-card *ngFor="let item of cartItems()" [product]="item" />
</div>
Width entre 320 et 600 → layout horizontal moyen. Toujours le même composant. Idéal aussi pour Storybook : un seul composant, plusieurs stories qui changent juste la largeur du wrapper.
variant: 'large' | 'medium' | 'compact' en TypeScript.
Dashboard avec widgets contextuels
Cas d'usage le plus impactant : un dashboard où chaque widget peut être placé dans des grids de tailles variables (1×1, 2×1, 2×2, 3×2). Container Queries permettent à chaque widget de s'auto-adapter.
Widget StatsCard adaptatif
// stats-card.component.ts
import { Component, input } from '@angular/core';
@Component({
selector: 'app-stats-card',
standalone: true,
template: `
<div class="stats">
<span class="stats-icon" [class]="iconClass()">{{ icon() }}</span>
<div class="stats-content">
<p class="stats-label">{{ label() }}</p>
<p class="stats-value">{{ value() }}</p>
<p class="stats-trend">{{ trend() }}</p>
<canvas class="stats-chart" #chart></canvas>
</div>
</div>
`,
styleUrl: './stats-card.component.scss',
})
export class StatsCardComponent {
label = input.required<string>();
value = input.required<string>();
trend = input<string>('+0%');
icon = input<string>('📊');
iconClass = input<string>('text-primary');
}
/* stats-card.component.scss */
:host {
display: block;
container-type: inline-size;
container-name: stats;
}
.stats {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
padding: 1rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
.stats-icon { font-size: 1.5rem; }
.stats-label { color: #6c757d; margin: 0; font-size: 0.85rem; }
.stats-value { font-size: 1.5rem; font-weight: 700; margin: 0.25rem 0 0; }
.stats-trend { display: none; }
.stats-chart { display: none; }
/* Variante moyenne : afficher le trend */
@container stats (min-width: 240px) {
.stats-trend {
display: block;
margin: 0.25rem 0 0;
color: #198754;
font-size: 0.85rem;
}
}
/* Variante large : ajouter le mini-graph */
@container stats (min-width: 360px) {
.stats { padding: 1.5rem; }
.stats-value { font-size: 2rem; }
.stats-chart {
display: block;
grid-column: 1 / -1;
height: 60px;
margin-top: 0.5rem;
}
}
/* Variante extra-large : layout horizontal complet */
@container stats (min-width: 520px) {
.stats {
grid-template-columns: 80px 1fr 200px;
align-items: center;
}
.stats-icon { font-size: 3rem; }
.stats-chart {
grid-column: 3;
grid-row: 1 / 3;
height: auto;
margin-top: 0;
}
}
Utilisation dans un dashboard avec CSS Grid
<!-- dashboard.component.html -->
<div class="dashboard-grid">
<app-stats-card class="span-1" label="Visiteurs" value="12 480" />
<app-stats-card class="span-2" label="Revenus" value="48 300 €" />
<app-stats-card class="span-1" label="Conversion" value="3,2%" />
<app-stats-card class="span-3" label="Engagement total" value="28 min" />
</div>
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.span-1 { grid-column: span 1; }
.span-2 { grid-column: span 2; }
.span-3 { grid-column: span 3; }
@media (max-width: 768px) {
.dashboard-grid { grid-template-columns: 1fr; }
.span-1, .span-2, .span-3 { grid-column: span 1; }
}
Chaque app-stats-card s'adapte à sa cellule grid grâce aux container queries internes. Le designer compose librement le dashboard sans soucis de variantes.
Unités cqw, cqh, cqi : typo qui suit le conteneur
Container queries introduisent de nouvelles unités relatives au container : cqw (1% de la largeur), cqh (1% de la hauteur), cqi (inline), cqb (block), cqmin et cqmax.
Typo fluide qui suit le conteneur
:host {
container-type: inline-size;
}
.title {
/* Min 1.25rem, max 2.5rem, sinon 5% de la largeur du container */
font-size: clamp(1.25rem, 5cqi, 2.5rem);
}
.description {
font-size: clamp(0.9rem, 2.8cqi, 1.1rem);
line-height: 1.5;
}
Si la card mesure 400px, 5cqi = 20px. Si elle mesure 800px, 5cqi = 40px (clampé à 2.5rem = 40px). Le clamp évite les extrêmes.
Padding dynamique
.card-body {
padding: clamp(1rem, 4cqi, 2.5rem);
}
Comparaison avec vw
vw dépend du viewport (l'écran entier), cqi dépend du container. Dans une sidebar de 280px sur un écran de 1440px, 5vw = 72px (énorme dans la sidebar !) tandis que 5cqi = 14px (proportionnel à la sidebar).
Combiner avec Angular Signals
Container Queries gèrent l'apparence visuelle. Si vous avez besoin de logique conditionnelle TypeScript (afficher/cacher un slot, charger une donnée seulement si compact), combinez avec Signals + ResizeObserver, mais uniquement quand nécessaire.
Pattern hybride : CSS pour le visuel, Signal pour la logique
// adaptive-card.component.ts
import { Component, ElementRef, signal, afterNextRender, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-adaptive-card',
standalone: true,
template: `
<div class="card">
<h3>{{ title }}</h3>
<!-- Slot lourd : on évite de le rendre quand inutile -->
@if (showRichContent()) {
<app-chart [data]="chartData" />
}
</div>
`,
styleUrl: './adaptive-card.component.scss',
})
export class AdaptiveCardComponent {
private host = inject(ElementRef);
private destroyRef = inject(DestroyRef);
title = 'Statistiques';
showRichContent = signal(false);
constructor() {
afterNextRender(() => {
const observer = new ResizeObserver(([entry]) => {
// Active le rendu lourd uniquement si la card est large
this.showRichContent.set(entry.contentRect.width >= 360);
});
observer.observe(this.host.nativeElement);
this.destroyRef.onDestroy(() => observer.disconnect());
});
}
}
Règle d'arbitrage
- ✅ Container Queries CSS pour : layout, typo, padding, visibilité d'éléments simples (
display: none) - ✅ Signal + ResizeObserver pour : éviter le rendu de composants enfants coûteux, déclencher des fetches conditionnels, charger lazily des modules
- ❌ Ne pas mélanger : si CSS suffit, n'ajoutez pas de logique TS — KISS
Tester un composant avec Container Queries
Tests visuels en Storybook
Storybook est l'outil idéal pour tester un composant à différentes tailles de container. Créez une story par variante de largeur.
// product-card.stories.ts
import { Meta, StoryObj } from '@storybook/angular';
import { ProductCardComponent } from './product-card.component';
const meta: Meta<ProductCardComponent> = {
component: ProductCardComponent,
decorators: [
(story) => ({
template: `<div [style.width]="width">${story().template}</div>`,
props: { ...story().props },
}),
],
};
export default meta;
export const Compact: StoryObj<ProductCardComponent> = {
args: { width: '280px' },
};
export const Medium: StoryObj<ProductCardComponent> = {
args: { width: '420px' },
};
export const Large: StoryObj<ProductCardComponent> = {
args: { width: '720px' },
};
Tests unitaires Karma/Jest
// product-card.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { ProductCardComponent } from './product-card.component';
describe('ProductCardComponent', () => {
it('rend le composant sans erreur', () => {
TestBed.configureTestingModule({
imports: [ProductCardComponent],
});
const fixture = TestBed.createComponent(ProductCardComponent);
fixture.detectChanges();
expect(fixture.nativeElement).toBeTruthy();
});
// Note : les Container Queries ne sont pas testables en unit
// (le moteur de test n'a pas de viewport réel).
// Préférez les tests visuels (Storybook + Chromatic, Playwright).
});
Tests E2E Playwright avec différentes largeurs
// e2e/product-card.spec.ts
import { test, expect } from '@playwright/test';
for (const width of [280, 420, 720]) {
test(`ProductCard ${width}px - layout correct`, async ({ page }) => {
await page.goto('/storybook?path=/story/productcard--' + width + 'px');
// Vérifier que la direction du flex est attendue
const card = page.locator('app-product-card .card');
const direction = await card.evaluate(el =>
window.getComputedStyle(el).flexDirection
);
if (width < 320) {
expect(direction).toBe('column');
} else {
expect(direction).toBe('row');
}
});
}
Conclusion
CSS Container Queries résolvent enfin le problème historique des composants Angular réutilisables : un composant doit s'adapter à son contexte d'utilisation, pas au viewport global. Avec container-type: inline-size et @container, vous éliminez les ResizeObserver, les Signals de tracking, les inputs variant et les classes conditionnelles. Votre composant devient véritablement portable — sidebar, main, modale, dashboard — sans une ligne de logique TypeScript dédiée.
Pour un design system Angular moderne en 2026, c'est l'approche par défaut. Combinez :host { container-type: inline-size } dans chaque composant standalone, écrivez vos breakpoints en @container, utilisez cqi pour la typographie fluide. Réservez Signals + ResizeObserver aux cas où vous devez vraiment changer la logique du composant (chargement conditionnel, rendu de slots lourds), pas son apparence.
:host { container-type: inline-size }dans chaque composant standalone@container (min-width: 320px) { ... }remplace les media queries- Nommer les containers :
container-nameévite les conflits - Unités
cqi,cqw: typographie fluide proportionnelle - Compatible avec ViewEncapsulation par défaut
- Combiner avec Signals + ResizeObserver uniquement pour la logique métier
- Stories Storybook avec différentes largeurs pour tester
- Tests E2E Playwright pour valider les breakpoints critiques