~10 min de lecture

::ng-deep, le style global qui s'ignore : 4 façons propres de styler un composant enfant

Tu as un composant Card réutilisable. Sur la page pricing, tu veux juste que son titre passe en ambre. Tu écris le sélecteur dans les styles de la page, tu le vérifies trois fois... et rien. Le titre reste de la couleur par défaut. Dans les devtools, des attributs bizarres genre _ngcontent-ng-c2065204252 partout. Alors tu fais comme tout le monde : une recherche, ::ng-deep, ça marche, tu passes à autre chose.

Trois sprints plus tard, la carte "Support" du panneau latéral passe en ambre elle aussi, sans rien avoir demandé. Le bug est vicieux : le style coupable vit dans un autre composant affiché au même moment, et il disparaît avec lui dès que tu quittes la page pour investiguer. Bienvenue dans la vraie nature de ::ng-deep : pas un outil de précision, un style global déguisé en style scopé.


TL;DR

L'encapsulation émulée d'Angular réécrit tes sélecteurs avec des attributs uniques par composant : les styles du parent ne peuvent physiquement pas matcher le DOM interne de l'enfant. ::ng-deep "résout" le problème en désactivant cette réécriture : placé en tête de sélecteur, il rend la règle littéralement globale tant qu'une instance du composant est affichée ; et même scopé derrière un :host, il couple ton parent à la structure interne d'un enfant qui ne t'a rien promis. La doc Angular le conserve pour compatibilité et déconseille fortement tout nouvel usage. Les alternatives, par ordre de préférence : une API de variantes sur l'enfant (signal input + classe host), des custom properties CSS, la projection de contenu, et le style global assumé dans styles.css pour le vraiment transversal.

Exemples vérifiés en Angular 22. Le mécanisme d'encapsulation est le même depuis bien plus longtemps ; les quatre alternatives valent telles quelles depuis Angular 19, standalone par défaut ; dès la 17.1, qui a introduit input(), avec standalone: true explicite.


Pourquoi ton sélecteur ne matche pas

Le setup minimal. Un enfant :

// card.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article class="card">
      <h3 class="card-title"><ng-content select="[card-title]" /></h3>
      <div class="card-body"><ng-content /></div>
    </article>
  `,
  styles: `
    .card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; }
    .card-title { font-size: 1.125rem; margin: 0 0 8px; }
  `,
})
export class Card {}

Et un parent qui essaie de personnaliser le titre :

// pricing.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Card } from './card';

@Component({
  selector: 'app-pricing',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [Card],
  template: `
    <app-card class="highlight">
      <span card-title>Plan Pro</span>
      49 euros par mois, sans engagement.
    </app-card>
  `,
  styles: `
    .highlight .card-title { color: #b45309; } /* ne matche rien */
  `,
})
export default class Pricing {}

Le sélecteur est juste. Le problème : ce qu'Angular en fait. Par défaut (ViewEncapsulation.Emulated), Angular attribue un identifiant à chaque composant (calculé à l'exécution, le code compilé ne transporte qu'un marqueur), le pose en attribut sur les éléments du template, et réécrit chaque sélecteur pour l'exiger. Concrètement :

/* ta règle dans pricing.ts, telle que le navigateur la reçoit : */
.highlight[_ngcontent-ng-c111] .card-title[_ngcontent-ng-c111] { color: #b45309; }
<!-- le DOM réel : -->
<app-card _ngcontent-ng-c111 _nghost-ng-c222 class="highlight">
  <article _ngcontent-ng-c222 class="card">
    <h3 _ngcontent-ng-c222 class="card-title">...</h3>
  </article>
</app-card>

Le h3.card-title porte l'attribut de Card (c222), pas celui de Pricing (c111). Ta règle exige c111 sur les deux segments : elle ne peut pas matcher, par construction. Ce n'est pas un bug, c'est le contrat : les styles d'un composant s'arrêtent à sa frontière. L'élément host, lui, vit dans le template du parent, donc il porte les deux attributs : _ngcontent-ng-c111 (DOM du parent) et _nghost-ng-c222 (host de l'enfant). Retiens ce détail : les alternatives 1 et 2 reposent dessus.

Ce que ::ng-deep fait vraiment

::ng-deep dit à Angular : arrête la réécriture à partir d'ici. Tout ce qui précède ::ng-deep reste réécrit, donc scopé ; tout ce qui le suit ne l'est plus :

/* dans pricing.ts */
:host ::ng-deep .card-title { color: #b45309; }
/* devient : [_nghost-ng-c111] .card-title { ... } */

.wrapper ::ng-deep .card-title { color: #b45309; }
/* devient : .wrapper[_ngcontent-ng-c111] .card-title { ... } : encore scopé */

::ng-deep .card-title { color: #b45309; }
/* devient : .card-title { ... } -> global, plus aucun scope */

La troisième est la vraie forme toxique : ::ng-deep en tête de sélecteur, sans rien de scopé devant, et la règle sort du composant sans laisser d'adresse. Dès qu'une instance de Pricing est rendue, cette règle s'applique à tous les .card-title affichés en même temps qu'elle : le panneau latéral, le header, une modale. Et comme Angular retire par défaut les styles d'un composant quand sa dernière instance est détruite (token REMOVE_STYLES_ON_COMPONENT_DESTROY), la fuite apparaît et disparaît avec la page qui la porte. C'est le scénario de la carte "Support" : un bug intermittent que tu ne reproduis jamais au bon moment.

La forme préfixée a l'air plus propre, et c'est bien ça le piège. Elle reste scopée au sous-arbre du parent, mais elle couple ton parent au détail d'implémentation d'un autre composant : le jour où Card renomme .card-title, ta page casse en silence. Aucun import, aucun type, aucun test ne matérialise cette dépendance. Et quand deux règles de même spécificité, venues de deux composants à durées de vie indépendantes (une page et un widget du panneau latéral), visent le même élément, c'est l'ordre d'insertion des feuilles de style dans le <head> qui tranche. Cet ordre suit le premier rendu de chaque composant, et un composant détruit puis réaffiché revient en fin de <head> : le gagnant peut changer au fil des interactions. Bon courage pour reproduire le bug.

Côté officiel : la doc actuelle déconseille fortement ("strongly discourages") tout nouvel usage de ::ng-deep, conservé exclusivement pour compatibilité. Pas de date de suppression annoncée, ça fonctionne toujours en Angular 22 ; mais construire du neuf dessus, c'est s'endetter volontairement.

Voyons les quatre façons propres, par ordre de préférence.

Alternative 1 : l'enfant expose une API de variantes

Si un parent doit changer l'apparence d'un enfant, la question à poser n'est pas "comment je perce l'encapsulation" mais "pourquoi cette variante n'existe pas". La personnalisation devient un contrat explicite : un signal input, une classe sur le host, et les styles restent chez celui qui possède le DOM.

// card.ts
import {
  booleanAttribute,
  ChangeDetectionStrategy,
  Component,
  input,
} from '@angular/core';

@Component({
  selector: 'app-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { '[class.card-featured]': 'featured()' },
  template: `
    <article class="card">
      <h3 class="card-title"><ng-content select="[card-title]" /></h3>
      <div class="card-body"><ng-content /></div>
    </article>
  `,
  styles: `
    .card { /* styles de base inchangés */ }
    .card-title { font-size: 1.125rem; margin: 0 0 8px; }
    :host(.card-featured) .card-title { color: #b45309; }
  `,
})
export class Card {
  readonly featured = input(false, { transform: booleanAttribute });
}
<app-card featured>...</app-card>

booleanAttribute permet d'écrire l'attribut seul, sans ="true". Côté styles, :host(.card-featured) .card-title devient .card-featured[_nghost-ng-c222] .card-title[_ngcontent-ng-c222] : la règle matche quand la classe est posée sur le host, et tout reste dans le périmètre de Card. Le parent exprime une intention (featured), l'enfant décide ce que ça veut dire visuellement. Renommer .card-title demain ne casse personne.

C'est l'option par défaut quand tu possèdes l'enfant. Son seul vrai coût, devoir le toucher, est un coût de design d'API.

Alternative 2 : les custom properties traversent gratuitement

L'encapsulation émulée réécrit des sélecteurs, et au passage les noms de @keyframes. Elle ne réécrit pas tes custom properties (seul l'opt-in provideCssVarNamespacing(), indépendant du mode d'encapsulation, les préfixe), et celles-ci sont héritées à travers les frontières de composants, exactement comme color ou font-family. L'enfant déclare ses points d'extension, le parent les remplit :

/* dans card.ts : l'enfant consomme, avec une valeur par défaut */
.card-title {
  color: var(--card-title-color, inherit);
}
/* dans pricing.ts : le parent fournit */
app-card.highlight {
  --card-title-color: #b45309;
}

Pourquoi ça marche alors que .highlight .card-title échouait : la règle du parent cible le host, qui porte bien son attribut _ngcontent. La custom property posée là descend par héritage jusqu'au h3, et l'encapsulation n'a rien à y redire.

C'est le bon outil pour du theming fin : une poignée de variables documentées (--card-title-color, --card-padding) forme une API de style explicite, sans TypeScript. La limite est la même que celle de l'alternative 1 : une custom property que l'enfant ne consomme pas ne fait rien.

Alternative 3 : donne le DOM au parent

Relis le composant du début : le titre arrive par <ng-content select="[card-title]" />. Le <span card-title> est écrit dans le template de Pricing, donc il porte l'attribut d'encapsulation de Pricing, même affiché dans Card. Conséquence directe :

/* dans pricing.ts : matche, sans ::ng-deep */
span[card-title] { color: #b45309; }

Un nœud projeté reste stylable par le composant qui l'a écrit. Si un parent doit contrôler finement une zone d'un enfant, la projection est souvent la vraie réponse : l'enfant fournit la structure, le parent fournit et style le contenu qui lui appartient. Pas de frontière à percer : le DOM n'a pas changé de propriétaire.

Le piège symétrique existe : les styles de Card scopés sur .card-title stylent le h3 (son template), pas le span projeté dedans. Si l'enfant veut imposer une typo au contenu projeté, il passe par des propriétés héritées posées sur le conteneur (font-size et color sur .card-title se transmettent au span), pas par un sélecteur qui vise le nœud projeté. Le même mécanisme piège les règles :has() sur du contenu projeté : voir pourquoi un :has() mal placé ne matche rien.

Alternative 4 : le style global assumé

Il reste les cas où le style est réellement transversal : le design system (tout .card-title de l'app partage les mêmes espacements et hauteurs de ligne, ce qu'on appelle le rythme vertical), ou un composant tiers dont tu ne contrôles ni le template ni l'API CSS. Là, la réponse honnête n'est pas de déguiser du global en scopé avec ::ng-deep, c'est d'écrire du global qui se présente comme tel : dans styles.css, avec un commentaire qui dit sa cible et pourquoi.

D'abord, un style dans styles.css est trouvable : quand un rendu surprend, on sait où chercher. Un ::ng-deep de tête est le même global, caché dans un fichier de composant où personne n'ira. Ensuite, pour les composants tiers à overlay (dialogs, menus, tooltips montés sur un conteneur en bas de body), le scoping n'atteint pas le DOM que le composant tiers crée lui-même : le panneau et son fond ne sont pas des descendants de ton host, et même :host ::ng-deep .panel ne les matche pas. Seul du global les atteint : autant l'écrire là où il se voit. Le contenu que tu passes en ng-template à l'overlay, lui, garde ton attribut d'encapsulation : il se style en scopé, sans rien percer.

Les deux fausses bonnes idées

ViewEncapsulation.None sur l'enfant rend tous ses styles globaux d'un coup : même fuite qu'un ::ng-deep de tête, à l'échelle du composant entier. Et ViewEncapsulation.ShadowDom isole moins qu'il n'y paraît : il bloque les sélecteurs de styles.css et garde ses propres styles à l'intérieur, mais Angular recopie dans le shadow root (la racine isolée que le navigateur attache au host, où vivent le template et les styles du composant) les feuilles des autres composants qu'il gère, ::ng-deep de tête et styles en None compris. L'isolation complète des feuilles de style entrantes, c'est ExperimentalIsolatedShadowDom, encore expérimental (l'héritage CSS, custom properties comprises, traverse toujours). Aucun de ces modes d'encapsulation n'est "le fix de ::ng-deep".

Before / after

Before : sur une vraie base, le scénario du début se multiplie. Cinq ::ng-deep dispersés dans des pages, dont deux en tête de sélecteur : un titre ambre apparaît dans le panneau latéral quand la page pricing est affichée, et disparaît avec elle. Chaque refactor du template de Card est une roulette russe, et la dépendance n'apparaît dans aucun import.

After : Card expose featured et deux custom properties documentées. Le titre appartient au parent par projection. Le rythme vertical commun vit dans styles.css avec un commentaire. Zéro ::ng-deep, et chaque style a un propriétaire identifiable.

Récap actionnable

  1. Comprends ce que tu combats : l'encapsulation émulée réécrit tes sélecteurs avec des attributs par composant. Un sélecteur du parent ne peut pas matcher le DOM interne de l'enfant.
  2. Un ::ng-deep en tête de sélecteur est global tant que son composant est affiché. Audite ceux-là en premier : ce sont des fuites actives.
  3. Tu possèdes l'enfant ? Ajoute une variante : signal input + classe host + :host(.variant). C'est un contrat, pas un contournement.
  4. Theming fin ? Custom properties : l'enfant consomme var(--x, défaut), le parent pose --x sur le host. Traverse l'encapsulation nativement, sans TypeScript.
  5. Le parent veut contrôler une zone entière ? Projette-la : un nœud écrit dans le template du parent reste stylable par le parent, où qu'il s'affiche.
  6. Vraiment transversal ou composant tiers ? styles.css, assumé et commenté. Pour le panneau d'un overlay tiers monté sur body, ni un style scopé ni :host ::ng-deep ne l'atteignent : il faut du global, autant qu'il s'assume.

Chaque ::ng-deep que tu t'apprêtes à écrire est une question de design d'API à laquelle tu refuses de répondre. Réponds-y, et le sélecteur magique n'a plus de raison d'exister.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.