Pourquoi s’intéresser aux Custom Elements aujourd’hui ?
Les frameworks frontend ont beaucoup apporté à l’écosystème web : composition d’interface, état réactif, rendu déclaratif, outillage moderne. Mais ils ont aussi installé une habitude parfois problématique : considérer qu’un composant réutilisable doit forcément être lié à React, Vue, Svelte ou Angular.
Les Custom Elements, au cœur du standard Web Components, proposent une autre approche : créer ses propres balises HTML, directement reconnues par le navigateur.
<user-card name="Ada Lovelace" role="Développeuse"></user-card>
Cette balise n’est pas fournie par HTML. Elle est définie par votre JavaScript, mais utilisée comme n’importe quel autre élément du DOM. C’est particulièrement intéressant pour construire des composants indépendants d’un framework, intégrer une interface dans plusieurs environnements, ou encapsuler des widgets réutilisables dans un design system.
Si vous travaillez déjà sur des architectures frontend structurées, les Custom Elements complètent bien des sujets comme les design tokens en CSS et TypeScript, les container queries CSS ou encore les cascade layers CSS.
Créer un premier Custom Element
Un Custom Element est une classe JavaScript qui étend HTMLElement. Elle est ensuite enregistrée avec customElements.define().
class UserCard extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') ?? 'Utilisateur inconnu'
const role = this.getAttribute('role') ?? 'Rôle non défini'
this.innerHTML = `
<article class="user-card">
<strong>${name}</strong>
<span>${role}</span>
</article>
`
}
}
customElements.define('user-card', UserCard)
On peut ensuite utiliser le composant dans une page HTML :
<user-card name="Grace Hopper" role="Informaticienne"></user-card>
Le nom d’un Custom Element doit toujours contenir un tiret. C’est une règle du standard, conçue pour éviter les collisions avec de futures balises HTML natives. user-card est valide, usercard ne l’est pas.
Ce premier exemple fonctionne, mais il reste volontairement simple. Il injecte du HTML dans l’élément lui-même, sans isolation de style, sans typage précis des propriétés et sans gestion fine des changements d’attributs.
Comprendre le cycle de vie
Un Custom Element dispose de plusieurs méthodes de cycle de vie. Les plus importantes sont :
connectedCallback(): appelée quand l’élément est ajouté au DOM ;disconnectedCallback(): appelée quand il est retiré du DOM ;attributeChangedCallback(): appelée quand un attribut observé change ;adoptedCallback(): appelée quand l’élément change de document.
Voici un exemple plus structuré :
class CounterButton extends HTMLElement {
private count = 0
private button: HTMLButtonElement | null = null
connectedCallback() {
this.render()
this.button = this.querySelector('button')
this.button?.addEventListener('click', this.increment)
}
disconnectedCallback() {
this.button?.removeEventListener('click', this.increment)
}
private increment = () => {
this.count++
this.render()
}
private render() {
this.innerHTML = `
<button type="button">
Compteur : ${this.count}
</button>
`
}
}
customElements.define('counter-button', CounterButton)
Ce code illustre une règle importante : si votre composant attache des écouteurs d’événements, des timers, des observers ou des connexions à des APIs externes, vous devez prévoir le nettoyage dans disconnectedCallback(). C’est le même réflexe que dans un useEffect React avec fonction de nettoyage, sujet également lié aux problématiques traitées dans l’article sur AbortController en JavaScript.
Réagir aux changements d’attributs
Un composant web doit souvent réagir à ses attributs. Pour cela, on déclare une liste d’attributs observés avec observedAttributes.
class StatusBadge extends HTMLElement {
static get observedAttributes() {
return ['status']
}
connectedCallback() {
this.render()
}
attributeChangedCallback() {
this.render()
}
private render() {
const status = this.getAttribute('status') ?? 'neutral'
this.innerHTML = `
<span class="badge badge--${status}">
${this.getLabel(status)}
</span>
`
}
private getLabel(status: string) {
if (status === 'success') return 'Succès'
if (status === 'warning') return 'Attention'
if (status === 'error') return 'Erreur'
return 'Neutre'
}
}
customElements.define('status-badge', StatusBadge)
Utilisation :
<status-badge status="success"></status-badge>
Puis en JavaScript :
document
.querySelector('status-badge')
?.setAttribute('status', 'warning')
À chaque modification de l’attribut status, le composant se met à jour. Cette approche est simple, mais elle demande de rester vigilant : les attributs HTML sont toujours des chaînes de caractères. Pour manipuler des objets, des tableaux ou des valeurs complexes, il vaut mieux exposer des propriétés JavaScript.
Attributs ou propriétés : faire la différence
En HTML, les attributs servent à configurer l’élément depuis le markup. En JavaScript, les propriétés permettent de lui transmettre des valeurs plus riches.
type Product = {
id: string
name: string
price: number
}
class ProductPreview extends HTMLElement {
private productValue: Product | null = null
set product(value: Product) {
this.productValue = value
this.render()
}
get product() {
return this.productValue
}
connectedCallback() {
this.render()
}
private render() {
if (!this.productValue) {
this.innerHTML = `<p>Aucun produit sélectionné.</p>`
return
}
this.innerHTML = `
<article>
<h2>${this.productValue.name}</h2>
<p>${this.productValue.price.toFixed(2)} €</p>
</article>
`
}
}
customElements.define('product-preview', ProductPreview)
Utilisation côté JavaScript :
const preview = document.querySelector('product-preview') as ProductPreview
preview.product = {
id: 'p-001',
name: 'Clavier mécanique',
price: 129
}
Cette distinction est essentielle. Utilisez les attributs pour les valeurs simples, sérialisables et utiles dans le HTML. Utilisez les propriétés pour les données complexes, typées ou calculées.
Isoler le style avec Shadow DOM
L’un des grands intérêts des Web Components est la possibilité d’utiliser le Shadow DOM. Il permet d’attacher à un composant un DOM interne isolé du reste de la page.
class AppButton extends HTMLElement {
private root: ShadowRoot
constructor() {
super()
this.root = this.attachShadow({ mode: 'open' })
}
connectedCallback() {
this.root.innerHTML = `
<style>
button {
border: 0;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font: inherit;
cursor: pointer;
}
</style>
<button type="button">
<slot></slot>
</button>
`
}
}
customElements.define('app-button', AppButton)
Utilisation :
<app-button>Enregistrer</app-button>
Le <slot> indique où le contenu placé entre les balises du composant doit être injecté. Le style défini dans le Shadow DOM ne fuit pas vers le document parent, et les styles globaux de la page ne s’appliquent pas automatiquement à l’intérieur du composant.
Cette isolation est utile, mais elle doit être utilisée avec discernement. Dans un design system, vous aurez souvent besoin d’exposer certains points de personnalisation. Les variables CSS sont très pratiques pour cela :
class ThemedButton extends HTMLElement {
constructor() {
super()
const root = this.attachShadow({ mode: 'open' })
root.innerHTML = `
<style>
button {
background: var(--button-background, black);
color: var(--button-color, white);
border-radius: var(--button-radius, 0.5rem);
padding: 0.75rem 1rem;
}
</style>
<button type="button">
<slot></slot>
</button>
`
}
}
customElements.define('themed-button', ThemedButton)
Puis dans la page :
themed-button {
--button-background: rebeccapurple;
--button-color: white;
--button-radius: 999px;
}
Cette stratégie s’intègre très bien avec des tokens de design, notamment si vous avez déjà centralisé vos couleurs, espacements et rayons dans des variables CSS.
Accessibilité : ne pas casser le HTML natif
Créer ses propres éléments ne doit pas signifier réinventer tous les comportements natifs du navigateur. Si votre composant agit comme un bouton, il doit contenir un vrai <button> ou implémenter correctement les rôles ARIA, le focus clavier, les événements et les états accessibles.
La bonne approche est souvent de composer avec HTML plutôt que de le remplacer :
class ConfirmButton extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<button type="button" aria-describedby="hint">
<slot>Confirmer</slot>
</button>
<span id="hint">Cette action peut modifier vos données.</span>
`
}
}
customElements.define('confirm-button', ConfirmButton)
Le navigateur sait déjà gérer le focus, l’activation au clavier et les interactions de base d’un bouton. Vous bénéficiez donc d’un comportement robuste, plutôt que de reconstruire une pseudo-interaction fragile avec une div cliquable.
Cette logique rejoint les principes présentés dans l’article sur les formulaires accessibles : privilégier le HTML sémantique, puis enrichir progressivement.
Quand utiliser les Custom Elements ?
Les Custom Elements ne remplacent pas nécessairement React, Vue ou Next.js. Ils répondent plutôt à un besoin différent : créer des composants portables, standards et indépendants d’un runtime applicatif spécifique.
Ils sont pertinents pour :
- des widgets intégrables dans plusieurs sites ;
- des composants de design system utilisés par plusieurs équipes ;
- des interfaces embarquées dans un CMS ;
- des composants isolés dans une application existante ;
- des expérimentations où l’on veut rester proche des standards du navigateur.
Ils sont moins adaptés si vous avez besoin d’un système complet de rendu réactif, de routing, de gestion d’état applicatif complexe ou de rendu serveur avancé. Dans ce cas, un framework reste souvent plus productif. Par exemple, les problématiques de rendu serveur et de réduction du JavaScript navigateur seront mieux abordées avec des outils comme les React Server Components.
Bonnes pratiques d’architecture
Pour éviter que vos Custom Elements deviennent difficiles à maintenir, appliquez quelques règles simples.
Séparez d’abord la logique de rendu, la logique métier et les effets de bord. Un composant qui lit une API, transforme des données, génère du HTML et gère ses événements dans une seule méthode devient rapidement fragile.
Ensuite, typez les données qui entrent dans vos composants. TypeScript est particulièrement utile pour les propriétés publiques, les événements personnalisés et les contrats entre composants.
Enfin, documentez l’interface du composant : attributs disponibles, propriétés JavaScript, événements émis, slots supportés, variables CSS personnalisables. Un Custom Element est une API frontend. Il mérite donc la même rigueur qu’une fonction ou qu’un endpoint HTTP.
Exemple d’événement personnalisé :
class QuantitySelector extends HTMLElement {
private value = 1
connectedCallback() {
this.render()
}
private updateValue(nextValue: number) {
this.value = Math.max(1, nextValue)
this.dispatchEvent(
new CustomEvent('quantity-change', {
detail: { value: this.value },
bubbles: true
})
)
this.render()
}
private render() {
this.innerHTML = `
<button type="button" data-action="decrement">-</button>
<span>${this.value}</span>
<button type="button" data-action="increment">+</button>
`
this.querySelector('[data-action="decrement"]')
?.addEventListener('click', () => this.updateValue(this.value - 1))
this.querySelector('[data-action="increment"]')
?.addEventListener('click', () => this.updateValue(this.value + 1))
}
}
customElements.define('quantity-selector', QuantitySelector)
L’événement quantity-change peut ensuite être écouté depuis le document, depuis un framework ou depuis un autre composant.
Conclusion
Les Custom Elements sont une brique puissante du web moderne. Ils permettent de créer des composants natifs, portables et utilisables dans des contextes très différents. Leur intérêt n’est pas de reproduire entièrement l’expérience d’un framework, mais de fournir un contrat stable entre HTML, JavaScript et CSS.
Bien utilisés, ils offrent une excellente base pour des widgets indépendants, des composants de design system ou des intégrations progressives. Comme toujours en frontend, la qualité vient moins de l’outil que de la façon dont vous définissez ses limites : API claire, accessibilité, typage, nettoyage des effets de bord et stratégie CSS cohérente.