Chapitre 40 : Personnalisation des éléments natifs : checkbox et radio
Technique de masquage et utilisation de l'élément label avec le sélecteur adjacent
Les éléments de formulaire natifs, en particulier les checkbox et les radio buttons, sont parmi les éléments les plus frustrants à styliser en CSS. Pourquoi ? Parce qu'ils sont rendus par le système d'exploitation (OS) et non par le navigateur seul, ce qui rend leurs propriétés visuelles quasi-immuables via les propriétés CSS classiques comme background-color ou border.
Pour obtenir un design moderne et cohérent avec une charte graphique, nous devons contourner le rendu natif tout en préservant l'accessibilité.
La stratégie du "Proxy Visuel"
1. Quoi
La personnalisation des inputs natifs repose sur un concept appelé Proxy Visuel. Puisqu'on ne peut pas styliser l'élément <input> lui-même de manière flexible, on le masque visuellement et on utilise l'élément <label> associé comme substitut graphique.
L'astuce technique repose sur le sélecteur adjacent (+) ou le sélecteur de frère général (~), combiné à la pseudo-classe :checked. Cela permet de modifier le style du label (ou d'un élément à l'intérieur du label) en fonction de l'état de l'input, même si cet input est invisible.
2. Pourquoi
Dans un projet professionnel, l'utilisation des styles natifs est rarement acceptable pour plusieurs raisons :
- Cohérence visuelle : Un bouton radio bleu sur Chrome sera différent sur Safari ou Firefox, et encore plus différent sur Android ou iOS.
- Expérience Utilisateur (UX) : Les cases à cocher natives sont souvent trop petites pour les interfaces tactiles modernes.
- Branding : Les couleurs de marque doivent s'appliquer à tous les composants, y compris les indicateurs de sélection.
3. Comment
A. La structure HTML fondamentale
Pour que cette technique fonctionne, l'input et le label doivent être frères (au même niveau hiérarchique) et liés par l'attribut id et for.
<div class="custom-control">
<input type="checkbox" id="terms" class="hidden-input" />
<label for="terms" class="custom-label">
<span class="checkmark"></span>
J'accepte les conditions générales
</label>
</div>
B. Le masquage accessible
Il est tentant d'utiliser display: none ou visibility: hidden. C'est une erreur grave. Ces propriétés retirent l'élément du flux et, surtout, du focus clavier. Un utilisateur naviguant avec la touche Tab ne pourrait jamais atteindre votre case à cocher.
La bonne pratique consiste à utiliser une technique de masquage "visuel uniquement" :
.hidden-input {
/* Masque l'élément visuellement mais le laisse accessible aux lecteurs d'écran */
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
C. La stylisation via le sélecteur adjacent
C'est ici que la magie opère. On utilise le sélecteur input:checked + label pour dire : "Quand l'input est coché, modifie le style du label qui le suit immédiatement".
/* Style de base du label (état non coché) */
.custom-label {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
user-select: none;
}
/* Le carré personnalisé (via un pseudo-élément ou un span) */
.checkmark {
width: 20px;
height: 20px;
border: 2px solid #ccc;
border-radius: 4px;
transition: all 0.2s ease;
position: relative;
}
/* État COCHÉ : On cible le .checkmark à l'intérieur du label */
.hidden-input:checked + .custom-label .checkmark {
background-color: #007bff;
border-color: #007bff;
}
/* Création de l'icône "Check" avec un pseudo-élément */
.hidden-input:checked + .custom-label .checkmark::after {
content: '';
position: absolute;
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
::before et ::after::before et ::after sont des pseudo-éléments CSS qui permettent d'insérer du contenu graphique avant ou après le contenu réel d'un élément, directement depuis le CSS, sans ajouter de balise HTML.
::before→ inséré juste avant le contenu de l'élément.::after→ inséré juste après le contenu de l'élément.
La propriété content est obligatoire (même vide : content: ''). Sans elle, le pseudo-élément n'est pas rendu.
/* Équivalent visuel de : <div>▶ Texte</div> */
.element::before {
content: '▶ ';
color: #007bff;
}
Ces pseudo-éléments se comportent comme des éléments inline par défaut. On leur ajoute souvent display: block ou position: absolute pour les positionner librement. C'est la technique utilisée ici pour dessiner la coche blanche à l'intérieur du .checkmark.
D. Cas particulier : Les boutons Radio
Pour les boutons radio, la logique est identique, mais on joue sur le border-radius: 50% pour créer le cercle et un pseudo-élément central pour le point de sélection.
.radio-checkmark {
width: 20px;
height: 20px;
border: 2px solid #ccc;
border-radius: 50%; /* Cercle parfait */
}
.hidden-input:checked + .custom-label .radio-checkmark::after {
content: '';
position: absolute;
left: 5px;
top: 5px;
width: 6px;
height: 6px;
border-radius: 50%;
background: white;
}
4. Limitations et pièges
- L'ordre du DOM : Le sélecteur
+(adjacent) et~(frère) ne fonctionnent que vers le bas du document. Vous ne pouvez pas styliser un élément qui se trouve avant l'input dans le HTML. - L'accessibilité du Focus : Puisque l'input est masqué, l'utilisateur ne voit plus le contour bleu (outline) natif lors de la navigation au clavier. Il faut impérativement recréer cet état :
/* Recréer l'indicateur de focus pour l'accessibilité */
.hidden-input:focus-visible + .custom-label .checkmark {
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.4);
border-color: #007bff;
}
5. Zone de Danger
❌ Erreur commune : Utiliser display: none sur l'input.
- Conséquence : L'élément est ignoré par les lecteurs d'écran et impossible à sélectionner via le clavier.
✅ Bonne pratique : Utiliser opacity: 0 et position: absolute ou la propriété moderne appearance: none (bien que appearance: none demande encore beaucoup de travail de reset selon les navigateurs).
❌ Erreur commune : Oublier l'attribut for sur le <label>.
- Conséquence : Cliquer sur le texte du label ne déclenchera pas le changement d'état de la checkbox.
✅ Bonne pratique : Toujours lier l'ID de l'input au for du label.
Flux de fonctionnement de la personnalisation des inputs
Questions clés
1. Pourquoi ne peut-on pas simplement changer la couleur d'une checkbox avec background-color ?
Découvrir la réponse
Parce que les checkboxes et radio buttons sont des "éléments remplacés" dont le rendu est géré par l'OS. Le navigateur applique un style par défaut très restrictif. Pour modifier l'apparence, il faut soit utiliser appearance: none (pour supprimer le style OS), soit masquer l'élément et créer un substitut visuel.
2. Quelle est la différence entre le sélecteur + et le sélecteur ~ dans ce contexte ?
Découvrir la réponse
Le sélecteur + cible l'élément immédiatement suivant. Si vous avez un élément entre l'input et le label, + ne fonctionnera pas. Le sélecteur ~ cible n'importe quel frère suivant, peu importe le nombre d'éléments entre les deux.
3. Comment garantir que mon composant personnalisé reste accessible aux malvoyants ?
Découvrir la réponse
En gardant l'input natif dans le DOM (ne pas utiliser display: none), en utilisant des labels correctement liés via for/id, et en stylisant l'état :focus-visible pour que les utilisateurs du clavier sachent où ils se trouvent.
4. Peut-on utiliser cette technique pour créer des "Toggle Switches" (interrupteurs) ?
Découvrir la réponse
Oui, absolument. Un Toggle Switch est simplement une checkbox stylisée où le label ressemble à une goulotte et le pseudo-élément ::after ressemble à un bouton coulissant qui se déplace avec transform: translateX() lorsque l'input est :checked.
Mise en pratique
Exercice 1 : Reproduction guidée
Objectif : Créer une checkbox personnalisée simple.
- Créez un input de type checkbox et un label associé.
- Masquez l'input visuellement (sans
display: none). - Stylisez le label pour qu'il affiche un carré gris de 20px.
- Lorsque la case est cochée, le carré doit devenir vert.
Découvrir la solution commentée
<input type="checkbox" id="ex1" class="hidden-input" />
<label for="ex1" class="custom-label">Accepter</label>
/* CSS */
.hidden-input {
position: absolute;
opacity: 0;
}
.custom-label {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
/* On crée le carré via un pseudo-élément pour ne pas polluer le HTML */
.custom-label::before {
content: '';
width: 20px;
height: 20px;
border: 2px solid #ccc;
display: inline-block;
transition: background-color 0.3s;
}
/* Changement de couleur quand l'input est coché */
.hidden-input:checked + .custom-label::before {
background-color: green;
border-color: green;
}
Exercice 2 : Adaptation
Objectif : Transformer la checkbox en un "Toggle Switch" moderne.
- Reprenez la structure précédente.
- Le label doit ressembler à une pilule (arrondie, fond gris).
- Un petit cercle blanc doit se trouver à l'intérieur.
- Au clic, le fond devient bleu et le cercle glisse vers la droite.
Découvrir la solution commentée
<input type="checkbox" id="switch" class="hidden-input" />
<label for="switch" class="toggle-pill"></label>
.hidden-input {
/* Masquage visuel tout en conservant l'accessibilité clavier */
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-pill {
/* IMPORTANT : <label> est display: inline par défaut.
Sans display: inline-block (ou block), width et height sont ignorés
et la pilule n'a aucune dimension visible. */
display: inline-block;
width: 50px;
height: 26px;
background-color: #ccc;
border-radius: 13px;
position: relative;
cursor: pointer;
/* background-color (et non background) garantit la transition sur tous les navigateurs */
transition: background-color 0.3s ease;
}
/* Le bouton coulissant */
.toggle-pill::after {
content: '';
position: absolute;
width: 22px;
height: 22px;
background-color: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: transform 0.3s ease;
}
/* État actif */
.hidden-input:checked + .toggle-pill {
background-color: #2196F3;
}
.hidden-input:checked + .toggle-pill::after {
/* Déplace le cercle vers la droite : 50px (pill) - 22px (cercle) - 4px (marges) = 24px */
transform: translateX(24px);
}
/* Accessibilité : indicateur de focus clavier */
.hidden-input:focus-visible + .toggle-pill {
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.4);
}
Exercice 3 : Conception
Objectif : Créer un groupe de "Boutons Radio-Tags" pour un sélecteur de taille (S, M, L).
HTML de départ :
<fieldset class="tag-group">
<legend>Choisissez une taille</legend>
<input type="radio" name="size" id="size-s" value="s" class="hidden-input" />
<label for="size-s" class="tag-label">S</label>
<input type="radio" name="size" id="size-m" value="m" class="hidden-input" checked />
<label for="size-m" class="tag-label">M</label>
<input type="radio" name="size" id="size-l" value="l" class="hidden-input" />
<label for="size-l" class="tag-label">L</label>
</fieldset>
Spécifications visuelles attendues :
| Propriété | État par défaut | État sélectionné (:checked) |
|---|---|---|
| Fond | transparent | #6200ee |
| Bordure | 2px solid #ddd | 2px solid #6200ee |
| Couleur du texte | #333 | white |
| Coins arrondis | 20px (pilule) | — |
| Padding | 0.5rem 1.25rem | — |
| Police | 0.875rem, font-weight: 600 | — |
Au survol (:hover, état non sélectionné) : bordure #aaa.
Au focus clavier (:focus-visible) : outline: 2px solid #6200ee avec outline-offset: 3px.
Consignes :
- Masquez les inputs natifs (
position: absolute,opacity: 0,width: 0,height: 0). - Stylez
.tag-labelselon le tableau ci-dessus. - Utilisez
input:checked + .tag-labelpour appliquer les styles de l'état sélectionné. - Ajoutez
:hoveret:focus-visiblepour l'accessibilité.
name regroupe les radiosTous les <input type="radio"> qui partagent le même attribut name="size" forment un groupe exclusif : sélectionner l'un désélectionne automatiquement les autres.
Découvrir la solution commentée
<div class="tag-group">
<input type="radio" name="size" id="s" value="s" class="hidden-input" />
<label for="s" class="tag-label">S</label>
<input type="radio" name="size" id="m" value="m" class="hidden-input" />
<label for="m" class="tag-label">M</label>
<input type="radio" name="size" id="l" value="l" class="hidden-input" />
<label for="l" class="tag-label">L</label>
</div>
.tag-group {
display: flex;
gap: 10px;
}
.hidden-input {
position: absolute;
opacity: 0;
}
.tag-label {
padding: 8px 16px;
border: 2px solid #ddd;
border-radius: 20px;
cursor: pointer;
font-family: sans-serif;
transition: all 0.2s;
}
/* État coché : on cible le label juste après l'input coché */
.hidden-input:checked + .tag-label {
border-color: #6200ee;
background-color: #6200ee;
color: white;
}
/* Accessibilité : focus visible */
.hidden-input:focus-visible + .tag-label {
outline: 2px solid #6200ee;
outline-offset: 3px;
}
.tag-label:hover {
border-color: #bbb;
}