Intégration web angularforall.com

- Toggle switch CSS pur : sans JavaScript

Css Toggle-Switch Accessibilite Checkbox Css-Moderne
Toggle switch CSS pur : sans JavaScript

Créez des toggle switches CSS purs sans JavaScript : checkbox + label, :has(), animations et accessibilité ARIA.

Introduction — pourquoi éviter JavaScript

Le toggle switch est l'un des composants UI les plus répandus du web moderne : activation de notifications, basculement de thème sombre, opt-in dans les formulaires de paramètres, consentement RGPD... Partout où un utilisateur doit choisir entre deux états (actif/inactif, on/off), le toggle switch s'impose comme le composant le plus lisible et le plus intuitif.

Pourtant, la première réaction de beaucoup de développeurs est d'atteindre pour JavaScript : un addEventListener('click'), un classList.toggle(), quelques lignes pour gérer l'état. Fonctionnel, certes. Mais inutilement lourd, fragile et souvent non accessible.

La bonne nouvelle : HTML et CSS disposent depuis longtemps de tout ce qu'il faut pour créer des toggle switches parfaitement fonctionnels, animés et accessibles — sans une seule ligne de JavaScript. La clé se trouve dans une combinaison élégante : une <input type="checkbox"> masquée visuellement, un <label> stylisé comme un toggle, et le sélecteur CSS :checked pour piloter l'état visuel.

Ce que vous maîtriserez à la fin de cet article :
  • La technique fondamentale checkbox + label pour un toggle CSS pur
  • Les animations de transition fluides entre les états on/off
  • Les variantes de design : tailles, couleurs, icônes
  • La technique moderne avec :has() (CSS Level 4)
  • L'accessibilité ARIA complète (role, aria-checked, focus visible)
  • Le toggle dark mode en CSS pur appliqué à une page entière
  • Les pièges courants (display:none, spécificité, iOS)

Support navigateurs en 2026

Technique Chrome Firefox Safari Edge Couverture
checkbox :checked 1+ 1+ 3.1+ 12+ ~100%
combinateur + (fratrie) 1+ 1+ 3.1+ 7+ ~100%
CSS :has() 105+ 121+ 15.4+ 105+ ~93%
CSS custom properties 49+ 31+ 9.1+ 16+ ~98%

Anatomie d'un toggle switch

Avant d'écrire le moindre CSS, il faut comprendre les trois éléments qui composent un toggle switch CSS pur et le rôle précis de chacun.

Les trois composants HTML

<!-- Anatomie complète d'un toggle switch CSS pur -->

<!-- 1. Le conteneur — regroupe la checkbox et le label -->
<div class="toggle-wrapper">

    <!-- 2. La checkbox — le cerveau du système.
         Elle gère l'état (coché/non coché) et l'accessibilité clavier.
         Elle sera masquée visuellement (mais PAS avec display:none) -->
    <input
        type="checkbox"
        id="toggle-notifications"
        class="toggle-input"
        role="switch"
        aria-checked="false"
    >

    <!-- 3. Le label — la partie visible.
         L'attribut "for" associe le label à la checkbox via l'id.
         Cliquer sur le label active/désactive la checkbox. -->
    <label for="toggle-notifications" class="toggle-label">
        <span class="toggle-track">
            <span class="toggle-thumb"></span>
        </span>
        <span class="toggle-text">Notifications</span>
    </label>
</div>
Règle fondamentale : L'association input[id]label[for] est ce qui rend le composant fonctionnel sans JavaScript. Quand l'utilisateur clique sur le label, c'est la checkbox qui change d'état. CSS réagit à cet état via :checked.

Le sélecteur :checked — le cœur du mécanisme

/* :checked cible une checkbox ou radio lorsqu'elle est cochée.
   C'est le déclencheur de tout le système. */

/* État par défaut (checkbox non cochée = toggle OFF) */
.toggle-thumb {
    transform: translateX(0);
    background-color: #6c757d; /* gris Bootstrap — état inactif */
}

/* État coché (toggle ON) — sélecteur clé du mécanisme */
.toggle-input:checked + .toggle-label .toggle-thumb {
    transform: translateX(24px);    /* déplace le thumb vers la droite */
    background-color: #0d6efd;      /* bleu Bootstrap — état actif */
}

/* Décomposition du sélecteur :
   .toggle-input:checked   → la checkbox cochée
   + .toggle-label         → le label IMMÉDIATEMENT suivant (fratrie adjacente)
   .toggle-thumb           → l'élément à l'intérieur du label */

Structure recommandée dans le DOM

<!-- ✅ Structure correcte : checkbox AVANT le label dans le HTML.
     Le combinateur + (fratrie adjacente) exige que le label
     suive IMMÉDIATEMENT la checkbox dans le DOM. -->

<input type="checkbox" id="mon-toggle" class="toggle-input">
<label for="mon-toggle" class="toggle-label">...</label>

<!-- ❌ Structure incorrecte : checkbox DANS le label.
     Le sélecteur .toggle-input:checked + .toggle-label ne fonctionne plus
     car le label n'est plus un frère mais un parent de la checkbox. -->

<label for="mon-toggle" class="toggle-label">
    <input type="checkbox" id="mon-toggle" class="toggle-input"> <!-- ❌ imbriqué -->
</label>
Exception : Si vous imbriqquez la checkbox dans le label, vous pouvez omettre les attributs id/for (association implicite). Mais vous devrez utiliser le sélecteur :has() au lieu du combinateur +. La technique avec :has() est couverte en section 6.

Technique 1 — checkbox + label

C'est la technique universelle, compatible avec tous les navigateurs. Voici l'implémentation complète étape par étape, du plus simple au plus raffiné.

Étape 1 — Masquer la checkbox correctement

/* ❌ Ne JAMAIS utiliser display:none ou visibility:hidden
   Cela retire la checkbox du flux d'accessibilité :
   - Les lecteurs d'écran ne la voient plus
   - Le focus clavier est perdu
   - Le toggle devient inaccessible aux utilisateurs non-visuels */

.toggle-input {
    display: none; /* ❌ interdit */
}

/* ✅ Technique "visually-hidden" — masquage visuel sans perte d'accessibilité.
   La checkbox reste dans le DOM et dans le flux d'accessibilité,
   mais elle est invisible et ne prend pas de place visuellement. */

.toggle-input {
    position: absolute;      /* sort du flux normal */
    width: 1px;              /* taille minimale (pas 0 — certains screen readers ignorent les éléments 0x0) */
    height: 1px;
    padding: 0;
    margin: -1px;            /* compense le 1px de largeur */
    overflow: hidden;
    clip: rect(0, 0, 0, 0); /* coupe le contenu visible */
    white-space: nowrap;
    border: 0;
}

Étape 2 — Construire la "piste" (track) et le "bouton" (thumb)

/* ================================================
   TOGGLE SWITCH — CSS COMPLET (technique checkbox)
   ================================================ */

/* --- La piste (track) : le fond allongé du toggle --- */
.toggle-track {
    display: inline-flex;
    align-items: center;
    width: 52px;            /* largeur de la piste */
    height: 28px;           /* hauteur de la piste */
    border-radius: 14px;    /* pill shape — moitié de la hauteur */
    background-color: #adb5bd;  /* gris Bootstrap 5 — état OFF */
    padding: 3px;           /* espace intérieur autour du thumb */
    cursor: pointer;
    transition: background-color 0.25s ease;
    position: relative;
}

/* --- Le bouton (thumb) : le cercle qui glisse --- */
.toggle-thumb {
    width: 22px;            /* diamètre du cercle */
    height: 22px;
    border-radius: 50%;     /* cercle parfait */
    background-color: #fff; /* blanc */
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); /* ombre subtile */
    transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); /* animation Material Design */
    transform: translateX(0); /* position gauche — état OFF */
    flex-shrink: 0;
}

/* --- État ON : checkbox cochée --- */
.toggle-input:checked + .toggle-label .toggle-track {
    background-color: #0d6efd; /* bleu Bootstrap — état ON */
}

.toggle-input:checked + .toggle-label .toggle-thumb {
    /* Déplacement = largeur piste - 2×padding - diamètre thumb
       = 52 - 2×3 - 22 = 24px */
    transform: translateX(24px);
}

Étape 3 — Le label complet avec texte

<!-- HTML final avec structure complète -->
<div class="d-flex align-items-center gap-3 mb-3">
    <input
        type="checkbox"
        id="toggle-wifi"
        class="toggle-input"
        role="switch"
        aria-checked="false"
    >
    <label for="toggle-wifi" class="toggle-label d-flex align-items-center gap-2">
        <span class="toggle-track">
            <span class="toggle-thumb"></span>
        </span>
        <span class="fs-6 fw-semibold">Wi-Fi</span>
    </label>
</div>

<div class="d-flex align-items-center gap-3 mb-3">
    <input
        type="checkbox"
        id="toggle-bluetooth"
        class="toggle-input"
        checked <!-- pré-coché = état ON par défaut -->
        role="switch"
        aria-checked="true"
    >
    <label for="toggle-bluetooth" class="toggle-label d-flex align-items-center gap-2">
        <span class="toggle-track">
            <span class="toggle-thumb"></span>
        </span>
        <span class="fs-6 fw-semibold">Bluetooth</span>
    </label>
</div>

Étape 4 — Indicateur de focus pour la navigation clavier

/* Le focus visuel est CRITIQUE pour l'accessibilité clavier.
   Sans lui, les utilisateurs naviguant au clavier ne savent pas
   où se trouve leur focus. */

/* ❌ Ne jamais supprimer outline sans le remplacer */
.toggle-input:focus + .toggle-label .toggle-track {
    outline: none; /* ❌ supprime le focus — illégal WCAG */
}

/* ✅ Remplacer par un outline visible sur le track */
.toggle-input:focus-visible + .toggle-label .toggle-track {
    /* :focus-visible s'active uniquement pour la navigation clavier
       (pas pour les clics souris — expérience propre) */
    outline: 3px solid #0d6efd;
    outline-offset: 3px;
    border-radius: 14px; /* respecte la forme de la piste */
}

/* Alternative : box-shadow pour un rendu plus doux */
.toggle-input:focus-visible + .toggle-label .toggle-track {
    box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.35);
}

Animations et transitions fluides

Un bon toggle switch ne se contente pas de fonctionner : il doit sentir réactif et naturel. Les animations CSS permettent d'obtenir une qualité professionnelle proche des composants natifs iOS et Android.

Transitions avancées avec cubic-bezier

/* === COURBES D'ANIMATION RECOMMANDÉES === */

/* Material Design standard — fluide et réactif */
.toggle-thumb {
    transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Ease-in-out plus marqué — rebond naturel */
.toggle-thumb {
    transition: transform 0.25s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}

/* iOS-like — accélération rapide, décélération douce */
.toggle-thumb {
    transition: transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

/* Changement de couleur de la piste — légèrement plus lent */
.toggle-track {
    transition:
        background-color 0.25s ease,
        box-shadow 0.2s ease;
}

/* Regroupement des transitions pour meilleures performances */
.toggle-track,
.toggle-thumb {
    /* will-change indique au navigateur de préparer la couche de composition */
    will-change: transform, background-color;
}

Animation d'écrasement du thumb (squish effect)

/* Effet "squish" : le thumb s'étire horizontalement pendant
   le déplacement, imitant les composants natifs Android/Material.
   Donne une sensation de physique réelle au toggle. */

/* État par défaut — thumb circulaire */
.toggle-thumb {
    width: 22px;
    height: 22px;
    border-radius: 50%;
    transition:
        transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
        width 0.15s ease,      /* étirement horizontal */
        border-radius 0.15s ease;
}

/* Pendant l'interaction — pseudo-classe :active sur le label */
.toggle-label:active .toggle-thumb {
    width: 30px;             /* s'étire de 22px à 30px */
    border-radius: 11px;     /* devient une pilule allongée */
}

/* État ON avec étirement */
.toggle-input:checked + .toggle-label:active .toggle-thumb {
    width: 30px;
    border-radius: 11px;
    /* Le transform déplace vers la droite — ajuster pour compenser l'étirement
       (52 - 2×3 - 30 = 16px au lieu de 24px) */
    transform: translateX(16px);
}

/* État OFF sans étirement (repositionné à gauche) */
.toggle-label:active .toggle-thumb {
    transform: translateX(0);
    /* Rester aligné à gauche — le padding gère l'espace */
}

Icônes animées dans le thumb

/* Ajouter des icônes SVG inline dans le thumb via content ou background-image */

/* Version texte/emoji — simple et léger */
.toggle-thumb::after {
    content: '';          /* vide par défaut (état OFF) */
    display: block;
    width: 100%;
    height: 100%;
    background-position: center;
    background-repeat: no-repeat;
    background-size: 14px 14px;
    opacity: 0;
    transition: opacity 0.15s ease;
}

/* Icône "check" SVG encodée en base64 — état ON */
.toggle-input:checked + .toggle-label .toggle-thumb::after {
    content: '';
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%230d6efd' d='M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z'/%3E%3C/svg%3E");
    opacity: 1;
}

Texte ON/OFF dans la piste

/* Afficher ON/OFF dans la piste — pattern courant sur iOS */

.toggle-track {
    position: relative;
    /* ... propriétés de base ... */
}

/* Texte "OFF" — visible par défaut */
.toggle-track::before {
    content: 'OFF';
    position: absolute;
    right: 6px;
    top: 50%;
    transform: translateY(-50%);
    font-size: 9px;
    font-weight: 700;
    color: rgba(255, 255, 255, 0.7);
    letter-spacing: 0.5px;
    transition: opacity 0.2s ease;
}

/* Texte "ON" — caché par défaut */
.toggle-track::after {
    content: 'ON';
    position: absolute;
    left: 6px;
    top: 50%;
    transform: translateY(-50%);
    font-size: 9px;
    font-weight: 700;
    color: rgba(255, 255, 255, 0.9);
    letter-spacing: 0.5px;
    opacity: 0;
    transition: opacity 0.2s ease;
}

/* État ON : inverser la visibilité */
.toggle-input:checked + .toggle-label .toggle-track::before {
    opacity: 0;  /* masquer "OFF" */
}

.toggle-input:checked + .toggle-label .toggle-track::after {
    opacity: 1;  /* afficher "ON" */
}

Variantes de design : couleurs et tailles

Un seul toggle ne suffit pas. La réalité d'une interface demande des variantes de couleur (succès, danger, avertissement) et des tailles adaptées aux contextes (mobile, tableau de bord, formulaire compact).

Système de variantes via CSS Custom Properties

/* === TOGGLE — BASE AVEC VARIABLES CSS === */
.toggle-track {
    /* Variables remplaçables par chaque variante */
    --toggle-color-off: #adb5bd;
    --toggle-color-on: #0d6efd;
    --toggle-width: 52px;
    --toggle-height: 28px;
    --toggle-thumb-size: 22px;
    --toggle-padding: 3px;

    display: inline-flex;
    align-items: center;
    width: var(--toggle-width);
    height: var(--toggle-height);
    border-radius: calc(var(--toggle-height) / 2);
    background-color: var(--toggle-color-off);
    padding: var(--toggle-padding);
    cursor: pointer;
    transition: background-color 0.25s ease;
}

.toggle-thumb {
    width: var(--toggle-thumb-size);
    height: var(--toggle-thumb-size);
    border-radius: 50%;
    background-color: #fff;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
    transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
    transform: translateX(0);
}

.toggle-input:checked + .toggle-label .toggle-track {
    background-color: var(--toggle-color-on);
}

.toggle-input:checked + .toggle-label .toggle-thumb {
    /* calc automatique — pas de valeur en dur */
    transform: translateX(calc(
        var(--toggle-width)
        - 2 * var(--toggle-padding)
        - var(--toggle-thumb-size)
    ));
}

/* === VARIANTES COULEURS === */

/* Vert succès */
.toggle-success .toggle-track { --toggle-color-on: #198754; }

/* Rouge danger */
.toggle-danger .toggle-track { --toggle-color-on: #dc3545; }

/* Orange avertissement */
.toggle-warning .toggle-track { --toggle-color-on: #fd7e14; }

/* Violet info */
.toggle-info .toggle-track { --toggle-color-on: #6610f2; }

/* === VARIANTES TAILLES === */

/* Petit — sidebar, tableaux */
.toggle-sm .toggle-track {
    --toggle-width: 36px;
    --toggle-height: 20px;
    --toggle-thumb-size: 14px;
    --toggle-padding: 3px;
}

/* Normal — défaut */
/* .toggle-md — valeurs ci-dessus */

/* Grand — mobile, CTA */
.toggle-lg .toggle-track {
    --toggle-width: 68px;
    --toggle-height: 36px;
    --toggle-thumb-size: 28px;
    --toggle-padding: 4px;
}

Rendu HTML avec variantes

<!-- Variante succès -->
<div class="toggle-success">
    <input type="checkbox" id="t-success" class="toggle-input" checked>
    <label for="t-success" class="toggle-label">
        <span class="toggle-track"><span class="toggle-thumb"></span></span>
        <span class="ms-2">Sauvegarde automatique</span>
    </label>
</div>

<!-- Variante danger -->
<div class="toggle-danger">
    <input type="checkbox" id="t-danger" class="toggle-input">
    <label for="t-danger" class="toggle-label">
        <span class="toggle-track"><span class="toggle-thumb"></span></span>
        <span class="ms-2">Mode maintenance</span>
    </label>
</div>

<!-- Variante petite taille -->
<div class="toggle-sm">
    <input type="checkbox" id="t-sm" class="toggle-input">
    <label for="t-sm" class="toggle-label">
        <span class="toggle-track"><span class="toggle-thumb"></span></span>
        <span class="ms-2 small">Compact</span>
    </label>
</div>

Toggle désactivé (disabled)

<!-- En HTML : attribut disabled sur la checkbox -->
<input type="checkbox" id="t-disabled" class="toggle-input" disabled>
<label for="t-disabled" class="toggle-label">
    <span class="toggle-track"><span class="toggle-thumb"></span></span>
    <span class="ms-2 text-muted">Option indisponible</span>
</label>
/* CSS — état désactivé */
.toggle-input:disabled + .toggle-label {
    opacity: 0.5;        /* réduit l'opacité pour signaler l'indisponibilité */
    cursor: not-allowed; /* curseur interdit */
    pointer-events: none; /* bloque les clics */
}

/* Optionnel : teinte grise plus prononcée */
.toggle-input:disabled + .toggle-label .toggle-track {
    background-color: #e9ecef;
    filter: grayscale(100%);
}

.toggle-input:disabled + .toggle-label .toggle-thumb {
    background-color: #dee2e6;
    box-shadow: none;
}

Technique 2 — CSS :has() moderne

La pseudo-classe :has() (CSS Level 4, aussi appelée "sélecteur de parent") permet de styler un élément en fonction de ses descendants. Elle offre une alternative élégante à la technique checkbox+fratrie, avec un HTML plus naturel.

Principe de :has() appliqué aux toggles

/* Sans :has() — la checkbox DOIT être AVANT le label (fratrie adjacente) */
.toggle-input:checked + .toggle-label .toggle-track {
    background-color: #0d6efd;
}

/* Avec :has() — la checkbox PEUT être n'importe où dans le label */
.toggle-label:has(.toggle-input:checked) .toggle-track {
    background-color: #0d6efd;
}

/* :has() lit comme :
   "Cible .toggle-label s'il CONTIENT (.toggle-input:checked)"
   On remonte dans le DOM — le sélecteur de parent qu'on attendait depuis 20 ans ! */

HTML naturel avec :has()

<!-- Avec :has(), la checkbox peut être DANS le label
     (pas besoin des attributs id/for) -->

<label class="toggle-label">
    <!-- La checkbox est imbriquée — association implicite -->
    <input type="checkbox" class="toggle-input" role="switch">
    <span class="toggle-track">
        <span class="toggle-thumb"></span>
    </span>
    <span class="ms-2">Notifications push</span>
</label>

<!-- Ou dans un composant Bootstrap 5 d'un formulaire -->
<div class="form-check form-switch">
    <input class="form-check-input" type="checkbox" role="switch" id="flexSwitchCheck">
    <label class="form-check-label" for="flexSwitchCheck">Activer les emails</label>
</div>

Toggle page entière avec :has() sur <html>

/* Exemple avancé : appliquer un thème à TOUTE la page
   selon l'état d'un toggle spécifique.
   :has() peut cibler l'élément racine <html> ! */

/* Le toggle se trouve quelque part dans la page */
#toggle-theme {
    /* checkbox masquée visuellement */
    position: absolute;
    width: 1px; height: 1px;
    clip: rect(0,0,0,0);
    margin: -1px;
    overflow: hidden;
}

/* Thème clair — par défaut */
:root {
    --bg-color: #ffffff;
    --text-color: #212529;
    --card-bg: #f8f9fa;
    --border-color: #dee2e6;
}

/* Thème sombre — activé quand le toggle est coché */
html:has(#toggle-theme:checked) {
    --bg-color: #1a1a2e;
    --text-color: #e2e8f0;
    --card-bg: #16213e;
    --border-color: #2d3748;
}

/* Toute la page utilise les variables → changement automatique */
body {
    background-color: var(--bg-color);
    color: var(--text-color);
    transition: background-color 0.3s ease, color 0.3s ease;
}

.card {
    background-color: var(--card-bg);
    border-color: var(--border-color);
}

/* L'icône du toggle change aussi */
html:has(#toggle-theme:checked) .theme-icon::before {
    content: '🌙';
}
html:not(:has(#toggle-theme:checked)) .theme-icon::before {
    content: '☀️';
}

Compatibilité :has() et fallback

/* :has() n'est pas supporté dans Firefox < 121 (décembre 2023).
   Utiliser @supports pour fournir un fallback. */

/* Fallback — technique checkbox classique (100% compatible) */
.toggle-input:checked + .toggle-label .toggle-track {
    background-color: #0d6efd;
}

/* Amélioration progressive — :has() pour les navigateurs modernes */
@supports selector(:has(a, b)) {
    /* Remplace les sélecteurs fratrie uniquement si :has() est supporté */
    .toggle-input:checked + .toggle-label .toggle-track {
        /* Remis à zéro — :has() prend le relais */
        background-color: initial;
    }

    .toggle-label:has(.toggle-input:checked) .toggle-track {
        background-color: #0d6efd;
    }
}

Accessibilité : ARIA et clavier

Un toggle switch est un composant interactif à deux états. Pour qu'il soit accessible aux technologies d'assistance (lecteurs d'écran, navigation clavier), il doit respecter les patterns ARIA définis par le W3C.

Attributs ARIA obligatoires

<!-- Toggle switch accessible complet -->
<div class="d-flex align-items-center gap-2">
    <!--
        role="switch" : indique que c'est un interrupteur on/off
        (et non une simple checkbox).
        Les lecteurs d'écran annoncent "activé" ou "désactivé"
        au lieu de "coché" ou "décoché".

        aria-checked="false/true" : reflète l'état actuel.
        IMPORTANT : synchroniser avec JavaScript si l'état change
        programmatiquement. Pour les toggles CSS purs (sans JS),
        l'état est géré par la checkbox native — pas besoin de JS.

        aria-label ou aria-labelledby si le texte du label est
        visuellement séparé.
    -->
    <input
        type="checkbox"
        id="toggle-accessible"
        class="toggle-input"
        role="switch"
        aria-checked="false"
    >
    <label for="toggle-accessible" class="toggle-label">
        <span class="toggle-track" aria-hidden="true">
            <!-- aria-hidden sur le visuel — le label textuel suffit -->
            <span class="toggle-thumb"></span>
        </span>
        <span>Recevoir la newsletter</span>
    </label>
</div>

Annonces textuelles pour les lecteurs d'écran

<!-- Texte d'état visible pour les lecteurs d'écran -->
<div class="d-flex align-items-center gap-2">
    <input
        type="checkbox"
        id="toggle-sr"
        class="toggle-input"
        role="switch"
        aria-checked="false"
        aria-describedby="toggle-sr-desc"
    >
    <label for="toggle-sr" class="toggle-label d-flex align-items-center gap-2">
        <span class="toggle-track" aria-hidden="true">
            <span class="toggle-thumb"></span>
        </span>
        <span>Mode avion</span>
    </label>
    <!-- Description complémentaire lue par les screen readers -->
    <span id="toggle-sr-desc" class="visually-hidden">
        Désactive toutes les connexions radio (Wi-Fi, Bluetooth, Données)
    </span>
</div>
/* .visually-hidden — classe Bootstrap 5 intégrée
   Masque visuellement mais garde accessible aux screen readers */
.visually-hidden {
    position: absolute !important;
    width: 1px !important;
    height: 1px !important;
    padding: 0 !important;
    margin: -1px !important;
    overflow: hidden !important;
    clip: rect(0, 0, 0, 0) !important;
    white-space: nowrap !important;
    border: 0 !important;
}

Compatibilité prefers-reduced-motion

/* Les animations CSS peuvent causer des problèmes pour les utilisateurs
   souffrant d'épilepsie photosensible ou de troubles vestibulaires.
   Toujours respecter prefers-reduced-motion. */

@media (prefers-reduced-motion: reduce) {
    .toggle-thumb,
    .toggle-track {
        transition: none !important; /* supprime toutes les animations */
    }

    /* Le toggle reste fonctionnel — seule l'animation est supprimée */
}

Checklist accessibilité

Validation WCAG 2.1 niveau AA :
  • role="switch" sur la checkbox
  • Checkbox masquée avec visually-hidden (jamais display:none)
  • Association id/for valide
  • Focus visible sur :focus-visible (ring 3px minimum)
  • Contraste couleur track ON vs fond ≥ 3:1
  • Contraste texte label vs fond ≥ 4.5:1
  • aria-hidden="true" sur les éléments décoratifs
  • Touche Espace active/désactive (natif via checkbox)
  • prefers-reduced-motion respecté
  • Testé avec VoiceOver (macOS), NVDA (Windows), TalkBack (Android)

Cas d'usage réels

Les toggle switches CSS purs s'intègrent naturellement dans des contextes variés. Voici les implémentations les plus courantes.

Cas 1 — Page de paramètres (Settings)

<!-- Section paramètres avec groupe de toggles -->
<section class="card p-4 mb-4">
    <h3 class="fs-5 fw-bold mb-3">Notifications</h3>

    <!-- Toggle dans une liste -->
    <ul class="list-unstyled mb-0">
        <li class="d-flex justify-content-between align-items-center py-3 border-bottom">
            <div>
                <strong class="d-block">Emails promotionnels</strong>
                <small class="text-muted">Offres, nouveautés et mises à jour produit</small>
            </div>
            <div>
                <input type="checkbox" id="notif-promo" class="toggle-input" role="switch" checked>
                <label for="notif-promo" class="toggle-label">
                    <span class="toggle-track"><span class="toggle-thumb"></span></span>
                </label>
            </div>
        </li>
        <li class="d-flex justify-content-between align-items-center py-3 border-bottom">
            <div>
                <strong class="d-block">Alertes de sécurité</strong>
                <small class="text-muted">Connexions depuis de nouveaux appareils</small>
            </div>
            <div>
                <input type="checkbox" id="notif-security" class="toggle-input" role="switch" checked disabled>
                <label for="notif-security" class="toggle-label">
                    <span class="toggle-track"><span class="toggle-thumb"></span></span>
                </label>
            </div>
        </li>
    </ul>
</section>

Cas 2 — Banner de consentement RGPD

<!-- Toggles de consentement cookies -->
<div class="card p-4">
    <h4 class="fw-bold mb-3">Gestion des cookies</h4>

    <div class="d-flex justify-content-between align-items-center mb-3">
        <div>
            <strong>Cookies essentiels</strong>
            <p class="text-muted small mb-0">Nécessaires au fonctionnement du site</p>
        </div>
        <!-- Toujours actif — disabled + checked -->
        <input type="checkbox" id="cookie-essential" class="toggle-input toggle-success" role="switch" checked disabled>
        <label for="cookie-essential" class="toggle-label">
            <span class="toggle-track"><span class="toggle-thumb"></span></span>
        </label>
    </div>

    <div class="d-flex justify-content-between align-items-center mb-3">
        <div>
            <strong>Cookies analytiques</strong>
            <p class="text-muted small mb-0">Mesure d'audience anonymisée</p>
        </div>
        <input type="checkbox" id="cookie-analytics" class="toggle-input toggle-success" role="switch">
        <label for="cookie-analytics" class="toggle-label">
            <span class="toggle-track"><span class="toggle-thumb"></span></span>
        </label>
    </div>
</div>

Cas 3 — Tableau de bord avec statistiques conditionnelles

/* Afficher/masquer des sections entières selon un toggle —
   sans JavaScript, via le sélecteur :checked + ~ (fratrie générale) */

/* Structure HTML */
<input type="checkbox" id="show-advanced" class="toggle-input">
<label for="show-advanced" class="toggle-label btn btn-outline-secondary d-flex align-items-center gap-2">
    <span class="toggle-track toggle-sm"><span class="toggle-thumb"></span></span>
    Statistiques avancées
</label>

<!-- Section cachée par défaut -->
<div class="advanced-stats row g-3 mt-2">
    <div class="col-md-4"><div class="card p-3">Taux de rebond : 42%</div></div>
    <div class="col-md-4"><div class="card p-3">Durée session : 3m 24s</div></div>
    <div class="col-md-4"><div class="card p-3">Pages/session : 2.8</div></div>
</div>
/* CSS — cacher/afficher la section selon le toggle */

/* ❌ APPROCHE 1 — display:none/block (ne pas utiliser avec une transition)
   La transition opacity ne fonctionnera PAS car display:none
   retire l'élément du flux de rendu avant que la transition ait le temps de s'activer. */
.advanced-stats {
    display: none;
    opacity: 0;
    /* transition: opacity 0.3s ease; → IGNORÉE avec display:none */
}

/* Le sélecteur ~ cible la FRATRIE GÉNÉRALE (pas juste le suivant direct) */
#show-advanced:checked ~ .advanced-stats {
    display: flex; /* ou display: grid selon la structure */
    opacity: 1;    /* mais le changement de display annule toute animation */
}

/* ✅ APPROCHE 2 — max-height (animable, recommandée)
   max-height: 0 collapse l'élément sans le retirer du flux,
   ce qui permet une vraie transition fluide de reveal. */
.advanced-stats {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.4s ease;
}

#show-advanced:checked ~ .advanced-stats {
    max-height: 500px; /* valeur supérieure au contenu réel */
}

Toggle dark mode CSS pur

L'implémentation d'un toggle de thème sombre (dark mode) en CSS pur est le cas d'usage le plus spectaculaire de cette technique. Avec :has() et les CSS Custom Properties, on peut basculer toute une page sans JavaScript.

Architecture du système de thème

/* ===================================================
   DARK MODE CSS PUR — Architecture complète
   =================================================== */

/* 1. Définir les variables de thème sur :root (thème clair par défaut) */
:root {
    /* Couleurs de surface */
    --surface-bg:       #ffffff;
    --surface-card:     #f8f9fa;
    --surface-input:    #ffffff;
    --surface-border:   #dee2e6;

    /* Couleurs texte */
    --text-primary:     #212529;
    --text-secondary:   #6c757d;
    --text-muted:       #adb5bd;

    /* Couleurs d'accent (identiques dans les deux thèmes) */
    --accent-primary:   #0d6efd;
    --accent-success:   #198754;

    /* Ombres */
    --shadow-sm:        0 1px 3px rgba(0, 0, 0, 0.08);
    --shadow-md:        0 4px 12px rgba(0, 0, 0, 0.12);

    /* Transitions globales */
    --theme-transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

/* 2. Thème sombre — activé par :has() */
html:has(#toggle-dark-mode:checked) {
    --surface-bg:       #0f172a;
    --surface-card:     #1e293b;
    --surface-input:    #334155;
    --surface-border:   #334155;

    --text-primary:     #f1f5f9;
    --text-secondary:   #94a3b8;
    --text-muted:       #475569;

    --accent-primary:   #60a5fa;
    --accent-success:   #4ade80;

    --shadow-sm:        0 1px 3px rgba(0, 0, 0, 0.4);
    --shadow-md:        0 4px 12px rgba(0, 0, 0, 0.5);
}

/* 3. Appliquer les variables à tous les éléments */
body {
    background-color: var(--surface-bg);
    color: var(--text-primary);
    transition: var(--theme-transition);
}

.card, .modal-content, .dropdown-menu {
    background-color: var(--surface-card);
    border-color: var(--surface-border);
    box-shadow: var(--shadow-sm);
    transition: var(--theme-transition);
}

input, textarea, select {
    background-color: var(--surface-input);
    border-color: var(--surface-border);
    color: var(--text-primary);
}

.text-muted {
    color: var(--text-muted) !important;
}

a { color: var(--accent-primary); }
.btn-primary { background-color: var(--accent-primary); border-color: var(--accent-primary); }

HTML du toggle de thème

<!-- Placer dans le <body> (peut être n'importe où dans le DOM) -->
<input
    type="checkbox"
    id="toggle-dark-mode"
    class="toggle-input"
    aria-label="Basculer le thème sombre"
    role="switch"
    aria-checked="false"
>

<!-- Bouton toggle stylisé dans la navbar -->
<label for="toggle-dark-mode" class="theme-toggle-btn btn btn-outline-secondary d-flex align-items-center gap-2">
    <!-- Icône soleil (thème clair) -->
    <svg class="icon-sun" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
        <path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8z"/>
    </svg>
    <!-- Icône lune (thème sombre) -->
    <svg class="icon-moon" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
        <path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
    </svg>
    <span class="toggle-track toggle-sm"><span class="toggle-thumb"></span></span>
</label>
/* Affichage conditionnel des icônes soleil/lune */

/* Par défaut : soleil visible, lune cachée */
.icon-sun  { display: block; }
.icon-moon { display: none; }

/* Dark mode actif : inverser */
html:has(#toggle-dark-mode:checked) .icon-sun  { display: none; }
html:has(#toggle-dark-mode:checked) .icon-moon { display: block; }

Bonnes pratiques et pièges à éviter

1. Jamais display:none sur la checkbox

/* ❌ Ces trois approches cassent l'accessibilité */
.toggle-input { display: none; }           /* retire du flux d'accessibilité */
.toggle-input { visibility: hidden; }      /* inaccessible aux screen readers */
.toggle-input { opacity: 0; }              /* perd le focus clavier visible */

/* ✅ La seule approche correcte : visually-hidden */
.toggle-input {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
    /* La checkbox reste dans le DOM, accessible et focusable */
}

2. Le combinateur + exige la fratrie directe

/* ❌ Ne fonctionne PAS — label n'est pas le frère DIRECT de la checkbox */
<div>
    <input type="checkbox" id="t1" class="toggle-input">
    <div>  <!-- élément intermédiaire → casse le sélecteur + -->
        <label for="t1" class="toggle-label">...</label>
    </div>
</div>

/* ❌ CSS qui échoue */
.toggle-input:checked + .toggle-label { ... } /* pas de fratrie directe */

/* ✅ Structure correcte — checkbox et label au même niveau */
<div>
    <input type="checkbox" id="t1" class="toggle-input">
    <label for="t1" class="toggle-label">...</label> <!-- frère direct ✅ -->
</div>

3. Spécificité CSS — éviter les conflits Bootstrap

/* Bootstrap 5 a ses propres styles sur input[type="checkbox"].
   Pour éviter les conflits, augmenter la spécificité de votre toggle. */

/* ❌ Peut être écrasé par Bootstrap */
.toggle-input { ... }

/* ✅ Spécificité renforcée */
input.toggle-input[type="checkbox"] {
    position: absolute;
    width: 1px;
    /* ... */
}

/* Ou utiliser une classe parente unique */
.toggle-wrapper input.toggle-input {
    /* ... */
}

4. Taille minimale de la zone cliquable (mobile)

/* Sur mobile, une zone cliquable trop petite cause des erreurs.
   WCAG 2.5.8 (Level AA) recommande une cible tactile minimale de 24×24px.
   Apple recommande 44×44px pour iOS. */

/* ✅ Garantir une zone cliquable suffisante sur le label */
.toggle-label {
    min-height: 44px;    /* recommandation Apple */
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    cursor: pointer;
    /* La zone cliquable s'étend à tout le label */
}

/* Ou agrandir uniquement la piste */
.toggle-track {
    min-width: 44px;
    min-height: 28px;
}

5. Persistance d'état sans JavaScript

<!-- Le CSS pur ne peut pas persister l'état du toggle entre les pages.
     Pour les préférences utilisateur (dark mode, paramètres),
     utiliser l'une de ces approches : -->

<!-- Option A : PHP/serveur — injecter l'attribut checked côté serveur -->
<input
    type="checkbox"
    id="toggle-dark"
    class="toggle-input"
    <?php echo (isset($_COOKIE['dark_mode']) && $_COOKIE['dark_mode'] === '1') ? 'checked' : ''; ?>
>

<!-- Option B : JavaScript minimal pour la persistence uniquement -->
<script>
    // Lire la préférence sauvegardée
    if (localStorage.getItem('darkMode') === 'true') {
        document.getElementById('toggle-dark').checked = true;
    }

    // Sauvegarder quand l'état change
    document.getElementById('toggle-dark').addEventListener('change', function() {
        localStorage.setItem('darkMode', this.checked);
    });
</script>
<!-- Ce JS ne gère QUE la persistance — l'animation et le rendu restent en CSS pur -->
Checklist avant déploiement :
  • Checkbox masquée avec visually-hidden (jamais display:none)
  • Association id/for unique par toggle
  • Checkbox avant le label dans le DOM (pour le combinateur +)
  • Focus visible sur :focus-visible (outline ≥ 3px)
  • Zone cliquable ≥ 44×44px sur mobile
  • role="switch" sur la checkbox
  • Contraste piste ON vs fond ≥ 3:1
  • État disabled visuellement clair (opacity + cursor:not-allowed)
  • prefers-reduced-motion respecté (transition: none)
  • Testé sur iOS Safari (comportement natif des checkboxes)
  • Testé avec VoiceOver et NVDA

Conclusion et ressources

Le toggle switch CSS pur illustre parfaitement la philosophie du web moderne : utiliser les primitives natives du navigateur avant d'ajouter des couches d'abstraction JavaScript. Une checkbox, un label, le sélecteur :checked et quelques transitions CSS — c'est tout ce qu'il faut pour un composant UI professionnel, performant et accessible.

La technique checkbox + label est un classique intemporel, compatible avec 100% des navigateurs. La technique :has() offre des possibilités plus puissantes (styliser des ancêtres, thème global) avec un support moderne qui couvre désormais plus de 93% des utilisateurs. Dans la majorité des cas, combiner les deux — :checked + comme base solide, :has() pour les fonctionnalités avancées avec @supports — est la stratégie optimale.

Points clés à retenir

  • Checkbox masquée avec visually-hidden, jamais display:none
  • :checked + label : le mécanisme universel — checkbox avant le label dans le DOM
  • CSS Custom Properties pour les variantes de couleur et de taille
  • :has() pour styler les ancêtres (dark mode, sections conditionnelles)
  • role="switch" sur la checkbox pour les lecteurs d'écran
  • :focus-visible pour un outline visible sans polluer les clics souris
  • prefers-reduced-motion : toujours respecter les préférences d'animation
  • JavaScript optionnel uniquement pour la persistance de l'état

Ressources recommandées

Partager