Modales uniquement en CSS qui ne vous trahiront pas : :target, arrière-plans et modèles de fermeture

Cet article vous a aidé ?

Quelque part en production, une modale bloque actuellement un bouton de paiement, enferme un utilisateur clavier ou transforme le bouton « précédent » en roulette russe. Et le pire : « ça marchait sur ma machine », parce que votre machine n’avait pas le même niveau de zoom, le même réglage de police, les bizarreries iOS, le routage par fragment, ou un contexte d’empilement supplémentaire créé par un transform qui avait l’air inoffensif.

Les modales uniquement en CSS sont tout à fait viables dans des cas précis : pages marketing, sites de documentation, interfaces peu interactives, ou cas étranges du type « j’ai besoin d’un dialogue mais le service juridique refuse le JS sur ce chemin ». Mais si vous les traitez comme un repas gratuit, elles vous factureront plus tard — généralement sous forme de dette d’accessibilité et de cas limites de navigation.

Quand utiliser des modales uniquement en CSS (et quand cesser d’être courageux)

Les modales uniquement en CSS conviennent le mieux quand le contenu de la modale est peu profond, que le modèle d’état est simple et que la page n’a pas de routeur client qui vous cause des problèmes. Pensez « détails de politique de cookies », « lightbox d’image », « inscription à la newsletter », « extrait de conditions », « détails d’une ligne de tableau » sur une page majoritairement statique.

Ce sont de mauvais candidats quand l’une des situations suivantes est vraie :

  • Vous avez besoin d’une gestion réelle du focus (piéger le focus, restaurer le focus) et vous vous souciez des utilisateurs clavier au-delà d’une case cochée dans un tableau de conformité.
  • Vous avez des modales imbriquées, des flux à étapes ou plusieurs superpositions.
  • Vous devez fermer avec la touche Échap de façon fiable.
  • Vous êtes dans un routeur SPA qui contrôle le hash, l’état d’historique et la restauration du scroll.
  • Vous devez empêcher l’interaction avec l’arrière-plan de façon robuste, y compris pour les lecteurs d’écran.

Si vous construisez une modale d’application qui modifie les données utilisateur, je serai catégorique : livrez une modale JS avec une sémantique dialog correcte et une gestion du focus. Les patterns uniquement CSS ci‑dessous servent quand les contraintes sont réelles, pas quand vous tentez de gagner un concours de pureté.

Une citation qui tient la route en exploitation et fiabilité UI : paraphrase d’une idée de John Allspaw : rendez le système facile pour faire la bonne chose et difficile pour faire la mauvaise. Votre modale doit être difficile à mal utiliser — surtout pour les utilisateurs qui n’ont pas demandé votre ingéniosité.

Faits et contexte : pourquoi ces patterns existent

  • Le sélecteur :target remonte au comportement des fragments d’URL de l’ère CSS2 : il a été conçu pour la navigation intra-page, pas pour des machines d’état d’UI. On l’a réutilisé parce qu’il était déjà présent partout.
  • Les overlays uniquement CSS sont devenus populaires durant les vagues « no-JS » et d’« amélioration progressive » quand la bande passante et les bloqueurs de scripts étaient plus courants. Les patterns ont perduré.
  • L’élément HTML <dialog> est relativement récent dans l’usage grand public et n’a pas été supporté de façon uniforme pendant des années, alors les équipes ont construit leurs propres modales (en JS ou CSS).
  • backdrop-filter (flous d’arrière-plan) est arrivé tard et reste coûteux, particulièrement sur les GPUs mobiles et sur des pages complexes. Ce n’est pas une « jolie option gratuite ».
  • Les bugs de contextes d’empilement ont explosé quand position: sticky, transform et filter sont devenus courants. Chacun peut créer un nouveau contexte d’empilement qui invalide votre stratégie « z-index: 9999 ».
  • Les fragments de hash affectent l’historique du navigateur. Chaque ouverture/fermeture via :target peut créer une entrée d’historique, ce qui transforme le bouton Retour en partie intégrante de votre UI, que vous le vouliez ou non.
  • Les premiers scripts lightbox ont inspiré des clones CSS : le langage visuel (backdrop assombri + boîte centrée) est devenu « standard » avant que les pratiques d’accessibilité ne suivent.
  • Mobile Safari a été une source récurrente de problèmes d’overlay à cause du redimensionnement de la fenêtre, du comportement de la barre d’adresse et du chaînage de défilement. Si votre modale « échoue seulement sur iPhones », ce n’est pas une coïncidence ; c’est une tradition.

Pattern 1 : la modale :target (pilotée par le fragment)

Comment ça marche

Le sélecteur :target applique des styles à un élément dont l’id correspond au fragment URL. Cliquer sur un lien vers #modal « active » la modale en changeant le fragment ; cliquer sur un lien vers # ou un autre fragment la « désactive ».

Cela paraît trop simple parce que ça l’est. Mais c’est aussi robuste d’un point important : ça ne dépend pas d’inputs cachés ni d’associations label/for. Ça repose sur la navigation, chose que les navigateurs maîtrisent.

Implémentation minimale et convenable

cr0x@server:~$ cat modal-target.html
<!-- Trigger -->
<a href="#modal-about" class="btn">About pricing</a>

<!-- Modal container -->
<div id="modal-about" class="modal" aria-hidden="true">
  <a href="#close" class="modal__backdrop" aria-label="Close"></a>

  <div class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="modal-about-title">
    <header class="modal__header">
      <h2 id="modal-about-title">Pricing details</h2>
      <a class="modal__close" href="#close" aria-label="Close dialog">×</a>
    </header>

    <div class="modal__body">
      <p>Short copy. No form submission. No wizard.</p>
    </div>
  </div>
</div>

<style>
.modal {
  position: fixed;
  inset: 0;
  display: none;
  z-index: 1000;
}
.modal:target {
  display: block;
}
.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.55);
}
.modal__dialog {
  position: relative;
  max-width: min(680px, calc(100vw - 2rem));
  margin: 10vh auto;
  background: white;
  color: black;
  border-radius: 12px;
  padding: 1rem 1.25rem;
  box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.modal__close {
  float: right;
  text-decoration: none;
  font-size: 1.5rem;
  line-height: 1;
}
</style>

Points forts de :target

  • Pas d’inputs cachés. Moins de bidouilles DOM.
  • État linkable. Vous pouvez partager une URL qui ouvre la modale. Parfois c’est une fonctionnalité, parfois un souci légal.
  • Fonctionne sans script. Évident, mais toujours utile sur des pages à forte restriction.

Ce qui peut vous mordre

  • Pollution de l’historique. Chaque ouverture/fermeture peut créer des entrées d’historique. Les utilisateurs appuient sur Retour et la modale se rouvre ; ils appuient encore et la page défile ; maintenant ils vous détestent.
  • Collisions avec le routeur hash. Si votre application utilise le routage par hash, #modal-about pourrait être une route, pas un état de fragment. Bon amusement.
  • Comportement de scroll vers la cible. Certains navigateurs peuvent défiler jusqu’à l’élément ciblé. Avec position: fixed et inset: 0, cela n’est généralement pas visible, mais ne misez pas votre disponibilité sur le « généralement ».

Choix de conception : utilisez :target pour des modales de contenu sur des sites multi-pages. Évitez-le dans les SPA sauf si vous maîtrisez le routeur et l’intégrez explicitement.

Pattern 2 : la modale checkbox (:checked)

Comment ça marche

Vous créez une checkbox cachée. Un label l’active ; un autre label (ou le backdrop) la désactive. Le CSS surveille input:checked et révèle la modale. C’est une machine d’état déguisée en contrôle de formulaire.

Implémentation avec moins de piéges

cr0x@server:~$ cat modal-checkbox.html
<input id="m1" class="modal-toggle" type="checkbox" />

<label for="m1" class="btn">Open details</label>

<div class="modal" role="dialog" aria-modal="true" aria-labelledby="m1-title">
  <label class="modal__backdrop" for="m1" aria-label="Close"></label>

  <div class="modal__dialog">
    <header class="modal__header">
      <h2 id="m1-title">Details</h2>
      <label class="modal__close" for="m1" aria-label="Close dialog">×</label>
    </header>
    <div class="modal__body">
      <p>Checkbox pattern: no hash, no history entries.</p>
    </div>
  </div>
</div>

<style>
.modal-toggle {
  position: absolute;
  left: -9999px;
}
.modal {
  position: fixed;
  inset: 0;
  display: none;
  z-index: 1000;
}
.modal-toggle:checked ~ .modal {
  display: block;
}
.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.55);
  cursor: pointer;
}
.modal__dialog {
  position: relative;
  max-width: min(680px, calc(100vw - 2rem));
  margin: 10vh auto;
  background: white;
  border-radius: 12px;
  padding: 1rem 1.25rem;
}
.modal__close {
  cursor: pointer;
  float: right;
  font-size: 1.5rem;
  line-height: 1;
}
</style>

Points forts

  • Pas de changement de hash. Votre bouton Retour reste un bouton Retour.
  • Se compose bien avec les routeurs. Ce n’est que de l’état DOM.
  • Possibilité de multiples modales si vous gardez des IDs uniques et une structure propre.

Ce qui est mauvais (et c’est mauvais)

  • C’est un mensonge sémantique. Une checkbox n’est pas un dialogue. Les lecteurs d’écran peuvent l’annoncer de façon étrange ; vous corrigez la sémantique avec de l’ARIA.
  • Contraintes de structure DOM. Le sélecteur sibling général (~) signifie que votre modale doit suivre la checkbox dans l’ordre DOM. Félicitations, votre architecture markup dépend maintenant d’un tour CSS.
  • Le focus est toujours non géré. Sans JS, vous ne pouvez pas piéger le focus à l’intérieur ni restaurer le focus au close de façon fiable.

Appel d’opinion : si vous devez absolument faire du CSS-only dans un environnement où les hashes sont toxiques (routage SPA), la technique de la checkbox est généralement le moindre mal. Mais documentez la contrainte structurelle comme si c’était une API.

Style d’arrière-plan qui se comporte

L’arrière-plan n’est pas une décoration ; c’est une surface de contrôle

Un backdrop de modale a trois rôles :

  1. Signaler la modalité. « Vous êtes dans un dialogue maintenant. »
  2. Empêcher l’interaction. Bloquer les clics vers l’UI sous-jacente.
  3. Offrir une issue de secours. Cliquer en dehors pour fermer (quand c’est approprié).

Dans les modales uniquement CSS, le backdrop est votre frontière d’interaction principale. S’il est trop petit, mal positionné ou n’intercepte pas réellement les événements pointeur, les utilisateurs cliqueront à travers et déclencheront la page en dessous. Ce n’est pas un « bug mineur ». C’est un plan de contrôle cassé.

Patterns de backdrop qui n’autorisent pas le click-through

  • Utilisez un élément positionné plein écran : position: absolute; inset: 0; à l’intérieur d’un conteneur modal fixe.
  • Assurez-vous qu’il soit sous le dialogue mais au-dessus de la page : backdrop et dialogue doivent être dans le même contexte d’empilement ; ne vous battez pas avec des guerres globales de z-index.
  • Privilégiez un élément réel plutôt qu’un pseudo-élément quand il doit être cliquable (fermeture au clic). Les pseudo-éléments peuvent être cliquables, mais il est bien plus simple de raisonner sur un élément réel.

Arrière-plans flous : backdrop-filter est coûteux

Si vous appliquez backdrop-filter: blur(10px), vous demandez au navigateur d’échantillonner et de flouter tout ce qui se trouve derrière l’overlay. Sur une page complexe, cela peut plomber les fréquences d’images. Testez sur un téléphone bas de gamme, pas sur votre portable 32 cœurs.

cr0x@server:~$ cat backdrop.css
.modal__backdrop {
  background: rgba(0,0,0,0.45);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

Utilisez le flou avec parcimonie. Si la performance est un souci, un simple backdrop semi‑transparent est la réponse ennuyeuse mais correcte. L’ennuyeux marche. L’ennuyeux est livrable.

Moyens de fermeture : rendez-les évidents et sûrs

La fermeture doit être redondante

Les utilisateurs doivent pouvoir fermer une modale via :

  • Un bouton de fermeture visible en haut à droite (ou en haut à gauche dans certains contextes RTL).
  • Cliquer sur le backdrop (pour les dialogues non destructifs, non critiques).
  • La navigation clavier vers un contrôle de fermeture (tabulation vers lui, activation).

Dans les patterns uniquement CSS, vous n’obtenez pas de façon fiable la fermeture par Échap. Ne faites pas semblant. Si la modale est suffisamment critique pour que les utilisateurs appuient instinctivement sur Échap, elle est suffisamment critique pour justifier du JS.

Notes de conception pour le bouton de fermeture

  • La zone cible compte. Rendez-la au moins de 40×40 pixels CSS. Mettre un « × » en 12px, c’est un défi, pas un contrôle.
  • Utilisez un label explicite. aria-label="Close dialog" n’est pas optionnel.
  • Placez-le tôt dans le DOM à l’intérieur du dialogue pour que les utilisateurs clavier l’atteignent rapidement.

Blague #1 : la seule chose plus persistante qu’une modale qui ne se ferme pas est la personne qui insiste que « c’est juste un petit tweak CSS ».

Fermeture via le backdrop : quand la désactiver

La fermeture au clic sur le backdrop est pratique pour les lightboxes et panneaux d’info. Elle est risquée pour les formulaires, confirmations ou tout ce qui peut perdre une saisie par erreur. Pour ceux‑ci, gardez le backdrop inerte (pas de fermeture au clic) et rendez la fermeture explicite.

Dans le monde CSS-only, désactiver la fermeture via le backdrop signifie généralement : le backdrop est un <div> au lieu d’un lien/label ; il bloque les clics mais ne bascule pas l’état.

Contrôle de réalité accessibilité (sans langue de bois)

Vous ne pouvez pas implémenter complètement une modale accessible en pur CSS

Soyons directs. Une vraie modale nécessite :

  • Que le focus soit déplacé dans le dialogue à l’ouverture.
  • Que le focus soit piégé à l’intérieur du dialogue tant qu’il est ouvert.
  • Que le focus soit restauré à l’élément déclencheur à la fermeture.
  • Que les lecteurs d’écran soient empêchés de naviguer dans le contenu sous-jacent.
  • Que la touche Échap ferme le dialogue (comportement attendu).

Le CSS seul ne peut pas gérer les transitions d’état du focus. Vous pouvez approcher avec autofocus dans des contextes limités, mais vous ne pouvez pas piéger le focus de façon fiable sans JS. Donc : si votre modale est un élément UI central, arrêtez d’essayer de la faire uniquement en CSS.

Ce que vous pouvez quand même faire (et devriez)

  • Utilisez une structure sémantique : role="dialog", aria-modal="true", et aria-labelledby.
  • Cachez la modale quand elle est fermée : display: none la retire de l’arbre d’accessibilité. Bien.
  • Assurez-vous que les contrôles de fermeture sont activables au clavier : liens (<a>) ou boutons (<button>). Les labels peuvent être activés mais sont moins évidents pour les aides techniques.
  • Ne pas utiliser aria-hidden="true" comme bascule dans les patterns uniquement CSS. Vous ne pouvez pas le basculer sans JS ; le laisser traîner est pire que de l’omettre.

Alternative meilleure si vous pouvez utiliser un JS minimal : <dialog>

Si du JS est autorisé, utilisez <dialog> et appelez showModal(). Il vous donne un vrai backdrop et une meilleure sémantique. Vous aurez toujours besoin d’une politique de focus et de fermeture, mais vous partez d’un primitive navigateur, pas d’un tour de fête.

Z-index et contextes d’empilement : le tueur silencieux des modales

Pourquoi « z-index: 9999 » ne marche pas

Z-index ne compare que des éléments dans le même contexte d’empilement. Et les contextes d’empilement sont créés par des choses que vous ajoutez pour des raisons non liées :

  • transform (même translateZ(0))
  • filter, backdrop-filter
  • opacity < 1
  • position + z-index dans certaines combinaisons
  • isolation: isolate
  • contain: paint et apparentés

Ainsi votre modale peut avoir z-index 1000000 et quand même s’afficher sous un en-tête sticky qui vit dans un autre contexte d’empilement avec un z-index local plus bas. C’est la partie où les gens commencent à ajouter des valeurs z-index aléatoires jusqu’à ce que le CSS ressemble à un BIOS overclocké.

Comment éviter la bagarre

  • Montez la modale près de la fin du <body>. Gardez-la hors des composants imbriqués qui appliquent des transforms.
  • Gardez le conteneur modal lui‑même comme racine d’empilement : position: fixed plus un z-index clair.
  • Évitez de mettre transform sur le body ou de larges wrappers de mise en page si vous comptez aussi sur des overlays fixes. C’est une source connue de problèmes.

Verrouillage du défilement et pièges de la fenêtre mobile

Le verrouillage du scroll en CSS-only est au mieux partiel

Dans une modale JS, vous mettriez typiquement body { overflow: hidden; } pendant l’ouverture. Les patterns uniquement CSS ne peuvent pas basculer cela globalement sans sélecteurs avancés et une structure soigneuse.

Vous pouvez parfois utiliser :has() (là où supporté) pour verrouiller le défilement quand une modale est ciblée/checked. Mais s’appuyer sur :has() pour un comportement central comporte encore un risque de compatibilité dans certains environnements d’entreprise.

cr0x@server:~$ cat scroll-lock.css
/* Only if you can rely on :has() support */
html:has(.modal:target),
html:has(.modal-toggle:checked) {
  overflow: hidden;
}

Sans :has(), le compromis pratique est :

  • Faire du conteneur modal un position: fixed; inset: 0;
  • Rendre le corps du dialogue défilable avec max-height et overflow: auto
  • Accepter que la page en arrière-plan puisse encore défiler dans certaines circonstances (surtout le rubber-banding iOS)

Empêcher le chaînage de scroll et la lueur d’overscroll

Quand le corps du dialogue atteint les limites de défilement, les navigateurs peuvent « enchaîner » le défilement vers la page en dessous. Vous pouvez réduire cela avec :

cr0x@server:~$ cat overscroll.css
.modal__dialog {
  max-height: 80vh;
  overflow: auto;
  overscroll-behavior: contain;
}

Ça ne résoudra pas tout sur tous les navigateurs mobiles, mais ça réduit la pire sensation de « faire défiler la page derrière la modale ».

Playbook de diagnostic rapide

Vous êtes d’astreinte pour un incident UI. Une modale est coincée ouverte, ne s’ouvre pas, ou bloque les clics. Voici ce que vous vérifiez en premier, deuxième, troisième — parce que flâner dans DevTools n’est pas une stratégie.

1) Premier contrôle : la modale est-elle réellement activée ?

  • Pour :target : le fragment URL correspond-il à l’id de la modale ? Sinon le sélecteur ne s’appliquera jamais.
  • Pour checkbox : la checkbox est‑elle checked dans le DOM ? Sinon le CSS ne peut rien révéler.

2) Deuxième contrôle : est‑elle visible mais derrière quelque chose ?

  • Inspectez le z-index calculé du conteneur modal et s’il est dans le contexte d’empilement attendu.
  • Recherchez des transforms/filters sur les parents qui créent des contextes d’empilement.

3) Troisième contrôle : est‑elle visible mais non interactive ?

  • Vérifiez si le backdrop intercepte les clics ou les laisse passer (pointer-events, taille, position).
  • Vérifiez si le dialogue est hors écran à cause des marges et des changements de viewport (barre d’adresse mobile, zoom).

4) Quatrième contrôle : comportement de navigation et d’historique

  • Si les utilisateurs rapportent « Retour rouvre la modale », vous avez affaire à des entrées d’historique créées par :target.
  • Si la route SPA change de façon inattendue lors de l’ouverture, le hash est contrôlé par le routeur.

Erreurs courantes : symptômes → cause racine → correctif

Les clics traversent le backdrop et déclenchent des boutons dessous

Symptômes : l’utilisateur clique en dehors du dialogue ; des éléments de la page sous-jacente s’activent. Parfois la modale se ferme aussi, parfois non.

Cause racine : le backdrop ne couvre pas la viewport (inset manquant), ou le backdrop a pointer-events: none, ou il est derrière du contenu à cause du contexte d’empilement.

Correctif : assurez-vous que le backdrop est positionné et dimensionné correctement, et assurez-vous que le conteneur modal crée un contexte d’empilement prévisible.

La modale s’ouvre mais est partiellement hors écran sur mobile

Symptômes : le haut du dialogue est caché sous la barre d’adresse ; le bouton de fermeture inaccessible ; le contenu défile de façon étrange.

Cause racine : margin: 10vh auto plus des unités viewport dynamiques qui se comportent différemment ; fréquent aussi quand la taille de police est augmentée.

Correctif : utilisez max-height et un défilement interne ; envisagez margin: 2rem auto et le centrage avec flex via align-items.

La modale refuse d’apparaître en production mais fonctionne localement

Symptômes : le lien « Ouvrir » change l’URL ou la checkbox bascule, mais rien n’apparaît.

Cause racine : le bundling CSS a changé la spécificité/l’ordre des sélecteurs ; une règle ultérieure écrase display: block ; ou le conteneur modal est absent à cause de différences de templating.

Correctif : vérifiez les styles calculés dans la build de production ; réduisez la dépendance aux jeux de spécificité ; placez les styles de modale près du scope du composant avec des sélecteurs explicites.

Le bouton Retour se comporte mal

Symptômes : Retour ferme la modale, puis Retour la rouvre ; ou Retour saute à des positions de défilement étranges.

Cause racine : :target ajoute des entrées d’historique et déclenche des comportements de scroll vers un fragment.

Correctif : utilisez le pattern checkbox ou gérez l’historique avec JS ; si vous devez impérativement utiliser :target, acceptez que Retour fasse partie de l’UX et concevez en conséquence.

L’en-tête sticky recouvre la modale

Symptômes : l’en-tête est visible au‑dessus de la modale/backdrop ; les utilisateurs peuvent encore cliquer les éléments de l’en-tête.

Cause racine : l’en-tête est dans un contexte d’empilement supérieur ; la modale est piégée dans un contexte inférieur à cause d’un ancêtre transformé.

Correctif : déplacez la modale à la fin du body ; retirez les transforms des ancêtres ; créez explicitement un contexte d’empilement sur la racine de la modale.

Les utilisateurs clavier se perdent ou se coincent

Symptômes : la tabulation atteint la page derrière la modale ; le focus commence derrière la modale ; la fermeture laisse le focus en début de page.

Cause racine : absence de gestion du focus (limitation du CSS-only).

Correctif : si l’accessibilité compte (elle compte), utilisez JS ou <dialog>. Si vous êtes forcé au CSS-only, gardez le contenu minimal et assurez-vous que le contrôle de fermeture est tôt et visible.

Tâches pratiques avec commandes, sorties et décisions

Voici le type de vérifications « faites-le maintenant » que j’exécute quand une modale CSS-only se comporte mal selon les environnements. Elles sont volontairement concrètes. Chaque tâche inclut une commande, une sortie d’exemple, ce que cela signifie et la décision à prendre.

Tâche 1 : Confirmer que :target correspond réellement

cr0x@server:~$ python3 - <<'PY'
from urllib.parse import urlparse
u="https://example.test/page.html#modal-about"
p=urlparse(u)
print("fragment:", p.fragment)
PY
fragment: modal-about

Ce que cela signifie : le navigateur ciblera id="modal-about".

Décision : si le fragment ne correspond pas exactement à l’id de la modale (sensible à la casse), arrêtez de déboguer le CSS et corrigez le balisage/liens.

Tâche 2 : Vérifier que l’id de la modale existe dans le HTML généré

cr0x@server:~$ grep -R --line-number 'id="modal-about"' dist/
dist/page.html:214:<div id="modal-about" class="modal">

Ce que cela signifie : l’élément existe dans la sortie de production.

Décision : si grep ne trouve rien, votre pipeline de build l’a supprimé ou renommé (templating, blocs CMS, partials). Corrigez la build, pas le CSS.

Tâche 3 : Vérifier que le CSS de la modale a survécu à la minification/le bundling

cr0x@server:~$ grep -R --line-number '\.modal:target' dist/assets/
dist/assets/app.min.css:1:.modal:target{display:block}

Ce que cela signifie : le sélecteur critique existe.

Décision : si absent, votre outil d’arbre CSS l’a purgé (typique avec des setups utility-first). Ajoutez des règles safelist ou restructurez le CSS.

Tâche 4 : Détecter le purge/outillage qui supprime des sélecteurs « inutilisés »

cr0x@server:~$ rg "content:" tailwind.config.js
18:  content: ["./src/**/*.html","./src/**/*.js"],

Ce que cela signifie : le purge scanne uniquement ces fichiers.

Décision : si votre HTML de modale est injecté depuis un CMS ou du markdown non couvert, purge peut enlever les styles de modale. Ajoutez des chemins ou safelistez les sélecteurs modal.

Tâche 5 : Confirmer l’ordre DOM de la checkbox et de la modale pour le sélecteur ~

cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("modal-checkbox.html").read()
s=BeautifulSoup(html,"html.parser")
toggle=s.select_one("input.modal-toggle")
modal=s.select_one("div.modal")
print("toggle before modal:", toggle.sourceline < modal.sourceline)
PY
toggle before modal: True

Ce que cela signifie : le sélecteur sibling général peut s’appliquer.

Décision : si le toggle vient après la modale, le CSS ne matchera jamais. Déplacez le toggle plus tôt ou changez de pattern.

Tâche 6 : Repérer les triggers de contexte d’empilement dans le CSS

cr0x@server:~$ rg -n "transform:|filter:|backdrop-filter:|opacity:" src/styles/
src/styles/layout.css:44:transform: translateZ(0);
src/styles/header.css:12:position: sticky;
src/styles/header.css:13:z-index: 2000;

Ce que cela signifie : des transforms et filters sont présents ; les headers sticky créent probablement des couches d’empilement.

Décision : si la modale est nidifiée sous un conteneur transformé, déplacez le point de montage de la modale ou retirez le hack de transform.

Tâche 7 : Valider que le backdrop couvre la viewport

cr0x@server:~$ node - <<'NODE'
const css = `
.modal__backdrop { position:absolute; inset:0; }
`;
console.log(css.includes("inset:0") ? "OK: inset present" : "BAD: missing inset");
NODE
OK: inset present

Ce que cela signifie : la dimension la plus simple et la plus robuste est utilisée.

Décision : si inset manque, ajoutez-le ; ne tentez pas de faire « width:100%; height:100% » dans des contextes imbriqués puis vous étonner qu’il échoue.

Tâche 8 : Vérifier l’absence accidentelle de pointer-events: none sur l’overlay/backdrop

cr0x@server:~$ rg -n "pointer-events:\s*none" src/styles/
src/styles/utilities.css:88:.no-pointer{pointer-events:none}

Ce que cela signifie : il existe une utilité qui pourrait être appliquée par accident.

Décision : si votre backdrop hérite d’une classe « no-pointer » via la composition de composants, corrigez la composition des classes ; ne réparez pas ça en ajoutant du z-index.

Tâche 9 : Reproduire le problème d’historique avec :target

cr0x@server:~$ cat <<'TXT'
Repro steps:
1) Load page.html
2) Click "About pricing" (URL becomes #modal-about)
3) Click close (URL becomes #close)
4) Press Back
Expected: return to previous page state
Actual: modal reopens (#modal-about)
TXT
Repro steps:
1) Load page.html
2) Click "About pricing" (URL becomes #modal-about)
3) Click close (URL becomes #close)
4) Press Back
Expected: return to previous page state
Actual: modal reopens (#modal-about)

Ce que cela signifie : ce n’est pas un bug ; c’est la conception de la navigation par fragments.

Décision : si cette UX est inacceptable, cessez d’utiliser :target ici.

Tâche 10 : Confirmer que la CSP de production ne bloque pas les styles inline (si vous les avez utilisés)

cr0x@server:~$ curl -I https://example.test/page.html | sed -n '1,20p'
HTTP/2 200
content-type: text/html; charset=utf-8
content-security-policy: default-src 'self'; style-src 'self'

Ce que cela signifie : les blocs <style> inline peuvent être bloqués si 'unsafe-inline' n’est pas autorisé.

Décision : déplacez le CSS de la modale dans une feuille servie ou mettez à jour la CSP. Ne livrez pas du CSS inline qui « marche en dev » dans un environnement CSP strict.

Tâche 11 : Détecter des décalages de layout causés par le redimensionnement de police

cr0x@server:~$ python3 - <<'PY'
base=16
scaled=20
close_btn=24
print("close button px at base:", close_btn)
print("close button px relative to font scaling:", close_btn*(scaled/base))
PY
close button px at base: 24
close button px relative to font scaling: 30.0

Ce que cela signifie : le redimensionnement des polices affecte les cibles et le layout. Si vous avez dimensionné avec des marges en vh, le dialogue peut dériver.

Décision : dimensionnez le dialogue avec max-width/max-height et un défilement interne, pas des marges viewport fragiles.

Tâche 12 : Vérifier les IDs dupliqués (un tueur silencieux de :target)

cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("dist/page.html").read()
s=BeautifulSoup(html,"html.parser")
ids={}
dups=[]
for el in s.select("[id]"):
  i=el["id"]
  ids[i]=ids.get(i,0)+1
for i,c in ids.items():
  if c>1:
    dups.append((i,c))
print("duplicates:", dups[:10])
PY
duplicates: []

Ce que cela signifie : aucun ID dupliqué détecté.

Décision : si des duplicatas existent, :target peut cibler le mauvais élément ou se comporter de façon incohérente. Corrigez d’abord les IDs ; ne touchez pas au CSS jusqu’à ce qu’ils soient uniques.

Tâche 13 : S’assurer que la modale est montée à la fin du body (pour éviter des ancêtres transformés)

cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("dist/page.html").read()
s=BeautifulSoup(html,"html.parser")
body=s.body
last_tags=[t.name for t in body.find_all(recursive=False)][-5:]
print("last top-level body children:", last_tags)
PY
last top-level body children: ['footer', 'div', 'script', 'script', 'script']

Ce que cela signifie : il y a un div près de la fin du body qui pourrait être la racine de votre overlay.

Décision : montez les modales comme siblings au niveau du body, pas profondément à l’intérieur de wrappers de layout transformés.

Tâche 14 : Mesurer l’impact sur la taille du fichier CSS des backdrops sophistiqués

cr0x@server:~$ ls -lh dist/assets/app.min.css
-rw-r--r-- 1 www-data www-data 182K Dec 12 09:14 dist/assets/app.min.css

Ce que cela signifie : la feuille de style est de taille modérée ; mais cela ne mesure pas le coût de rendu à l’exécution.

Décision : si vous ajoutez plusieurs variantes de flou et d’animations, envisagez un seul style de backdrop. Ne payez pas la taxe de performance pour de la décoration sur des flux critiques.

Tâche 15 : Vérification rapide de la spécificité des sélecteurs d’ouverture/fermeture

cr0x@server:~$ rg -n "\.modal\s*\{|\:target|\:checked" dist/assets/app.min.css | sed -n '1,20p'
1:.modal{position:fixed;inset:0;display:none;z-index:1000}
1:.modal:target{display:block}
1:.modal-toggle:checked~.modal{display:block}

Ce que cela signifie : les règles d’ouverture sont présentes et simples.

Décision : si vous voyez aussi des règles ultérieures comme .modal{display:none!important}, retirez-les ou scopez-les. Ne vous battez pas avec !important sauf si vous aimez déboguer dans le noir.

Trois mini-récits d’entreprise issus des mines à modales

Incident : la mauvaise hypothèse sur le hash

Une équipe a livré une modale CSS-only :target pour « détails rapides produit » sur une vitrine. Aucun JS autorisé sur ce chemin à cause d’une initiative performance et d’un backlog de revue sécurité. La modale était propre et a passé la QA basique.

Deux semaines plus tard, les tickets support ont commencé à affluer : les utilisateurs se retrouvaient dans des « états vides » étranges et le bouton Retour semblait cassé. Sur mobile, c’était pire. L’incident n’était pas « site down », mais c’était une fuite de conversion, ce qui, en termes corporate, est le type d’incident que remarquent des gens qui ne savent pas ce qu’est le CSS.

La mauvaise hypothèse : ils pensaient que les fragments hash étaient un « état UI local » qui n’affectait pas la navigation. En réalité, chaque ouverture et fermeture a muté le fragment URL, créant des entrées d’historique. Les utilisateurs qui ouvraient les détails, les fermaient puis appuyaient sur Retour ne revenaient pas à la page catégorie. Ils rouvraient la modale. Appuyez encore sur Retour, et parfois le navigateur défilait vers une ancre près du haut où le conteneur modal vivait dans le DOM. Maintenant l’utilisateur est perdu et agacé.

La correction n’a pas été astucieuse. Ils ont remplacé :target par le pattern checkbox pour cette page, et ont supprimé les liens « #close ». Ça a éliminé le bruit dans l’historique. Ils ont aussi raccourci le contenu de la modale dans la conception, réduisant l’envie de naviguer d’avant en arrière.

Conclusion post-mortem : si votre état UI change l’URL, c’est de la navigation. Traitez‑le comme de la navigation. Revoyez‑le comme de la navigation.

Optimisation qui s’est retournee contre eux : le flou qui a fait fondre les téléphones

Une autre organisation voulait le « feeling premium » d’une modale verre givré. Le design a livré une spec avec un fort flou sur le backdrop et une animation subtile à l’ouverture. Ça avait l’air fantastique sur le navigateur desktop utilisé pour les validations. Et dans la présentation où la décision a été prise.

L’équipe a implémenté backdrop-filter: blur(14px) avec une overlay d’opacité et une transition. Aux premiers tests tout allait bien. Puis ça a atterri sur une vraie page : beaucoup d’images, un header sticky et un carousel qui faisait ses propres choses. Sur des Android milieu de gamme et des iPhones plus anciens, l’ouverture de la modale provoquait du jank. Parfois la page se figeait un instant. Parfois les tap ne s’enregistraient pas. Le support a appelé cela « intermittent unresponsiveness », la catégorie la plus agaçante de bugs parce qu’on ne peut pas toujours les reproduire à la demande.

L’« optimisation » avait consisté à pousser plus de travail sur le GPU : ajouter des transforms pour promouvoir des couches, forcer le compositing. Cela a créé des contextes d’empilement supplémentaires et parfois la modale s’est retrouvée rendue sous le header sticky. Maintenant le bouton de fermeture était partiellement caché sur certaines mises en page. Super.

Ils ont retiré le flou et utilisé une overlay rgba simple. Ils ont conservé une très subtile transition de fondu sur l’opacité seulement. Les performances se sont stabilisées. Le feeling premium a été remplacé par « ça marche », qui est en réalité la caractéristique la plus premium qu’une modale puisse avoir.

Blague #2 : Chaque fois que vous ajoutez un filtre de flou à une modale, un GPU mobile dépose une plainte officielle.

Pratique ennuyeuse mais correcte qui a sauvé la mise : une racine d’overlay, un contrat

Une troisième équipe gérait un site riche en contenu avec des dizaines de composants fournis par plusieurs squads. Ils avaient déjà été brûlés par des guerres de z-index et des contextes d’empilement, alors ils ont établi une règle ennuyeuse : tout overlay doit se monter dans une unique « overlay root » de haut niveau située à la fin du <body>.

Ça sonnait bureaucratique. Les gens râlaient parce que cela signifiait qu’on ne pouvait pas juste déposer un composant modal n’importe où et considérer le travail terminé. Mais il y avait un retour sur investissement : empilement prévisible, positionnement prévisible et moins de surprises du type « pourquoi c’est sous le header ».

Quand ils ont eu besoin d’une modale CSS-only pour un microsite de docs (aucun JS autorisé pour des contraintes de plateforme), ils ont utilisé la même racine d’overlay. Cela a évité des ancêtres transformés parce que la racine d’overlay était en dehors des wrappers de layout. Leur backdrop couvrant la viewport tenait toujours. Leur z-index était stable car défini une fois.

Plus tard, quand ils ont introduit une bannière promo sticky avec une animation basée sur transform (un générateur classique de contexts d’empilement), cela n’a pas cassé la modale. La racine d’overlay restait au-dessus. Pas de patch d’urgence, pas de déploiement le vendredi, pas de ping‑pong de blâme.

La pratique n’était pas astucieuse. C’était un contrat. Les contrats empêchent les incidents.

Checklists / plan étape par étape

Checklist : choisir le bon pattern CSS-only

  1. Si le routage par hash existe (SPA, analytics, ancres intra-page très utilisées) : préférez la checkbox (:checked).
  2. Si la modale doit être deep-linkable et que le comportement d’historique est acceptable : :target convient.
  3. Si la modale contient un formulaire ou une action utilisateur critique : n’utilisez pas uniquement le CSS. Utilisez JS ou <dialog>.
  4. Si vous avez besoin d’Échap pour fermer : n’utilisez pas uniquement le CSS.

Checklist : une modale CSS-only qui ne vous embarrassera pas

  1. La racine modal utilise position: fixed; inset: 0;.
  2. Le backdrop est un élément plein écran et intercepte les événements pointeur.
  3. Le dialogue utilise max-width et max-height ; le contenu défile en interne.
  4. Le bouton de fermeture a une large zone cible et un label ARIA.
  5. La fermeture au clic sur le backdrop n’est activée que pour du contenu non destructif.
  6. La modale est montée à la fin du body ou en dehors d’ancêtres transformés.
  7. Pas de course aux z-index. Un contexte d’empilement, un numéro.
  8. Testez à 200% de zoom et avec une police agrandie.

Étape par étape : construire une modale :target avec une navigation raisonnée

  1. Créez un lien déclencheur pointant vers #modal-id.
  2. Créez un conteneur de modale <div id="modal-id" class="modal"> près de la fin du body.
  3. Ajoutez un lien backdrop pointant vers un fragment neutre (communément #close), plus un lien de fermeture à l’intérieur du dialogue.
  4. Stylez .modal comme caché par défaut ; révélez-le avec .modal:target.
  5. Décidez explicitement si le comportement d’historique est acceptable. Sinon, arrêtez-vous et choisissez checkbox ou JS.

Étape par étape : construire une modale checkbox qui survit aux refactors

  1. Placez <input type="checkbox" class="modal-toggle"> directement avant la modale dans le DOM.
  2. Le contrôle d’ouverture est un <label for="..."> ; le contrôle de fermeture est un autre label.
  3. Utilisez .modal-toggle:checked ~ .modal pour l’afficher.
  4. Documentez l’exigence d’ordre DOM dans le README du composant et dans les commentaires de code.
  5. Ajoutez un test unitaire ou une vérification statique qui échoue si la structure change (oui, même pour des composants CSS-only).

FAQ

Une modale uniquement en CSS peut-elle être entièrement accessible ?

Non. Sans JS, vous ne pouvez pas piéger fiablemenet le focus, restaurer le focus ou implémenter la fermeture par Échap. Vous pouvez toutefois la rendre moins nuisible avec des rôles, labels et contrôles visibles appropriés.

Dois-je utiliser :target ou :checked ?

Si vous voulez des liens profonds et pouvez tolérer les effets d’historique, :target. Si vous êtes dans une appli lourde en routeur ou tenez à l’usage du bouton retour, :checked.

Pourquoi ma modale apparaît-elle derrière un header sticky même avec un z-index énorme ?

Parce que le z-index ne traverse pas les frontières de contexte d’empilement. Un parent avec transform ou filter peut piéger votre modale dans un contexte inférieur.

Comment empêcher le défilement de l’arrière-plan sans JS ?

Vous pouvez parfois utiliser :has() pour basculer overflow: hidden sur html. Sans :has(), votre meilleure option est de rendre le dialogue défilable en interne et de réduire le chaînage de scroll.

Le click-outside-to-close est-il toujours une bonne idée ?

Non. C’est acceptable pour les modales d’information et les images. C’est risqué pour les formulaires ou confirmations car un mauvais clic peut faire perdre le travail utilisateur.

Pourquoi ouvrir une modale :target fait parfois défiler la page ?

La navigation par fragment est autorisée à défiler vers l’élément ciblé. Le positionnement fixe masque souvent cela, mais le placement DOM et le comportement du navigateur peuvent encore provoquer des sauts.

Puis-je empiler plusieurs modales uniquement CSS ?

Vous pouvez, mais c’est fragile. :target cible un seul fragment à la fois. Les modales checkbox peuvent empiler, mais la gestion du focus et des z-index devient rapidement difficile.

Qu’en est-il de l’utilisation de <dialog> à la place ?

Si du JS est autorisé, <dialog> est un meilleur primitive que les hacks CSS. Il requiert encore des décisions UX soignées, mais vous partez plus près de la sémantique correcte.

Ai-je besoin d’attributs ARIA si c’est « juste du CSS » ?

Si vous présentez quelque chose comme un dialogue, oui : role="dialog", aria-modal="true" et aria-labelledby sont la base. Ils ne résolvent pas le focus, mais réduisent la confusion.

Prochaines étapes que vous pouvez réellement faire

  • Décidez si vous construisez une vraie modale d’application ou un overlay de contenu. Si c’est une UI d’application réelle, stoppez et budgétez du JS minimal.
  • Choisissez un seul pattern CSS-only et standardisez-le. Les patterns mixtes à travers un site multiplient les modes de défaillance.
  • Établissez une racine d’overlay près de la fin du body. C’est ennuyeux. Ça évite les mélodrames de z-index.
  • Notez les contraintes. Si vous utilisez le pattern checkbox, documentez l’exigence d’ordre DOM comme un contrat.
  • Testez comme un pessimiste : 200% de zoom, polices agrandies, mobile Safari et une page avec headers sticky et transforms. Si ça survit à ça, ça survivra à vos utilisateurs.

Si vous retenez une chose : les modales uniquement en CSS sont acceptables quand elles sont petites, prévisibles et honnêtes sur leurs limites. Dès que vous avez besoin d’une correction orientée clavier, prenez le bon outil. Votre canal d’incidents futur vous en remerciera.

← Précédent
Debian 13 : port SSH modifié — corriger l’ordre pare-feu + sshd sans se verrouiller (cas #27)
Suivant →
ZFS copies=2/3 : Redondance supplémentaire sans nouveau VDEV — Astuce ou gâchis ?

Laisser un commentaire