Toggle-Switch und Custom-Checkboxen in HTML
Einen barrierefreien Toggle-Switch baust du aus einem echten <input type="checkbox"> mit role="switch", das du per appearance: none direkt zum Schalter gestaltest. Ein Toggle ist ein Bedienelement mit genau zwei Zuständen, „an“ und „aus“. Das native Feld bringt Fokus, Tastatur, Formularwert und Ansage mit. Verstecken und der Nachbau mit einem <div> sind deshalb unnötig. Wirkt der Schalter sofort und speichert keinen Formularwert, passt stattdessen ein <button> mit aria-pressed.
Gestaltete Bedienelemente gehören zu den häufigsten Barrierefallen, und der Grund ist fast immer derselbe: Das native Element wird ersetzt statt gestaltet. Der Umweg ist seit Jahren unnötig. appearance: none gibt dir volle Kontrolle über das Aussehen, ohne dass du Fokus, Tastatur, Formularverhalten und Ansage neu erfinden musst. Die Zustände selbst gestaltest du über Pseudoklassen wie :checked und :focus-visible, erklärt unter CSS-Selektoren und Spezifität. Die erste Regel von ARIA zeigt die Richtung, diese Seite zeigt die Umsetzung im Detail.
Das Wichtigste in Kürze
- Ein Toggle-Switch ist eine Checkbox mit der Bedeutung „an/aus“. In HTML entsteht er als
<input type="checkbox" role="switch">. Der Screenreader sagt dann „Schalter, ein“ statt „Kontrollkästchen, aktiviert“. -
appearance: nonestatt Verstecken. Das native<input>bleibt sichtbar an Ort und Stelle und wird direkt gestaltet.display: noneam Feld entfernt es für Tastatur und Screenreader. - Schalter, zwei Wege:
role="switch"auf einem Formularfeld,aria-pressedauf einem Button mit Sofortwirkung. Beides an einem Element widerspricht sich. - Der Zustand braucht mehr als Farbe. Position, Text oder Icon müssen ihn ebenfalls zeigen (1.4.1).
- Kontrastmodus mitdenken: Im erzwungenen Farbmodus verschwinden Hintergründe. Ein Schalterknopf aus
backgroundist dort unsichtbar, solange@media (forced-colors: active)fehlt. - 24 × 24 CSS-Pixel Zielgröße als Untergrenze (2.5.8). Ein verknüpftes Label zählt zur Klickfläche.
- Das Label benennt die Funktion. „Benachrichtigungen“ passt zur Ansage „ein/aus“, „Benachrichtigungen aktivieren“ kollidiert mit ihr.
Toggle-Switch in HTML und CSS: der komplette Code
Das Muster unten ist ein vollständiger Schalter mit Schiene und Knopf. Es braucht kein JavaScript, schickt seinen Wert mit dem Formular ab und funktioniert im Kontrastmodus.
<label class="switch">
<span class="switch-text">Wöchentliche Zusammenfassung</span>
<input type="checkbox" role="switch" name="zusammenfassung" checked />
<span class="switch-zustand" aria-hidden="true"></span>
</label>
.switch {
display: inline-flex;
align-items: center;
gap: 0.75em;
min-block-size: 2.75rem; /* Klickfläche samt Label, weit über 24 × 24 px */
cursor: pointer;
}
.switch input[role="switch"] {
appearance: none;
position: relative;
flex-shrink: 0;
inline-size: 2.75em;
block-size: 1.5em;
margin: 0;
border: 2px solid currentColor; /* 3:1 gegen den Hintergrund prüfen */
border-radius: 999px;
background: transparent;
cursor: pointer;
}
/* Der Knopf: seine Position zeigt den Zustand, nicht nur die Farbe */
.switch input[role="switch"]::before {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-start: 0.2em;
inline-size: 1em;
block-size: 1em;
border-radius: 50%;
background: currentColor;
transform: translateY(-50%);
transition: transform 150ms ease-out;
}
.switch input[role="switch"]:checked {
background: var(--color-primary);
border-color: var(--color-primary);
}
.switch input[role="switch"]:checked::before {
background: #fff;
transform: translate(1.1em, -50%);
}
.switch input[role="switch"]:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
/* Zustand zusätzlich als sichtbarer Text */
.switch-zustand::after { content: "Aus"; }
.switch input:checked + .switch-zustand::after { content: "An"; }
@media (prefers-reduced-motion: reduce) {
.switch input[role="switch"]::before { transition: none; }
}
@media (forced-colors: active) {
.switch input[role="switch"] {
forced-color-adjust: none;
background: Canvas;
border-color: ButtonText;
}
.switch input[role="switch"]::before { background: ButtonText; }
.switch input[role="switch"]:checked {
background: Highlight;
border-color: Highlight;
}
.switch input[role="switch"]:checked::before { background: HighlightText; }
}
Fünf Entscheidungen stecken in diesem Code. Das <label> umschließt Text und Feld, also schaltet ein Klick auf den Text mit und die Klickfläche wächst. Der Knopf wandert beim Umschalten nach rechts, damit der Zustand auch ohne Farbe erkennbar ist. Der Text „An“ oder „Aus“ steht mit aria-hidden="true" daneben, weil der Screenreader den Zustand über role="switch" schon ansagt. forced-color-adjust: none schaltet für dieses eine Element die Farbumwandlung ab und setzt dafür Systemfarben ein. Ohne diesen Block verschwindet der Knopf im Kontrastmodus, weil der Browser Hintergrundfarben ersetzt. Und prefers-reduced-motion nimmt die Animation für alle heraus, die Bewegung reduziert haben (mehr dazu unter CSS-Animationen).
Das verbreitete Muster aus Tutorials setzt das Feld auf opacity: 0; width: 0; height: 0 und zeichnet den Schalter in ein benachbartes <span>. Tastatur und Screenreader erreichen das Feld dann noch, aber der Fokusring muss umgeleitet werden, und role="switch" fehlt meist. Warum das die schwächere Technik ist, zeigt der nächste Abschnitt am Beispiel der Checkbox.
Warum der <div>-Nachbau scheitert
Ein <div> mit Klick-Handler sieht identisch aus und verliert dabei fünf Dinge auf einmal, die ein <input> gratis mitbringt:
| Was verloren geht | Folge |
|---|---|
| Fokussierbarkeit | Per Tastatur nicht erreichbar (2.1.1) |
| Rolle & Zustand | Screenreader sagt „Klickbar“, nicht „Kontrollkästchen, angehakt“ |
| Leertasten-Verhalten | Muss selbst nachgebaut werden, inklusive Scroll-Unterdrückung |
| Formularwert | Wird beim Absenden nicht mitgeschickt |
| Label-Verknüpfung | for/id greift nicht, der Klick aufs Label tut nichts |
Jedes dieser fünf Dinge lässt sich nachrüsten. Jedes ist aber eine eigene Fehlerquelle, und zusammen sind sie ein <input>, das schlechter funktioniert als das echte. Wer role="checkbox", tabindex="0", aria-checked und einen Keydown-Handler schreibt, hat viel Arbeit investiert, um am Ende auf demselben Stand zu sein.
Custom-Checkboxen: drei Techniken, eine Empfehlung
Es gibt drei verbreitete Wege, eine Checkbox anders aussehen zu lassen. Sie sind nicht gleichwertig.
Vergrößern: Dieselbe Optik, drei Techniken. Nur eine davon kommt aber ohne Nachrüsten aus.
Weg 1: display: none am Input. Der Klassiker aus alten Tutorials ist schlicht falsch. Ein per display: none oder visibility: hidden ausgeblendetes Feld ist aus der Tab-Reihenfolge und aus dem Accessibility-Tree verschwunden. Es existiert für Tastatur und Screenreader nicht mehr.
Weg 2: Input visuell verstecken, Ersatz danebenstellen. Das Input bekommt eine .visually-hidden-Klasse (absolut positioniert, 1 × 1 px, clip-path), und ein ::before am Label übernimmt die Optik. Das funktioniert: Fokus und Tastatur bleiben erhalten. Zwei Haken gibt es aber. Der Fokusring muss per input:focus-visible + label::before umgeleitet werden, sonst ist der Fokus unsichtbar. Und Sara Soueidan weist auf ein Problem hin, das man am Schreibtisch nie bemerkt: Wer mit einem Touch-Screenreader tastend über den Bildschirm fährt, sucht das Bedienelement dort, wo es aussieht. An dieser Stelle findet er nichts, weil das echte Feld ein Pixel groß in einer Ecke sitzt. Ihre Fassung dieses Musters legt das transparente Input deshalb über die Grafik.
Weg 3: appearance: none direkt am Input. Das ist der heute empfohlene Weg. Das Feld bleibt genau dort, wo es hingehört, in Originalgröße, und wird selbst gestaltet:
input[type="checkbox"] {
appearance: none;
/* Schriftgröße erben, damit alles in em skaliert */
font: inherit;
color: currentColor;
width: 1.15em;
height: 1.15em;
border: 0.15em solid currentColor;
border-radius: 0.15em;
display: grid;
place-content: center;
}
/* Der Haken: als Form, nicht als Zeichen */
input[type="checkbox"]::before {
content: "";
width: 0.65em;
height: 0.65em;
transform: scale(0);
transition: transform 120ms ease-in-out;
background-color: CanvasText;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
input[type="checkbox"]:checked::before { transform: scale(1); }
input[type="checkbox"]:focus-visible {
outline: max(2px, 0.15em) solid var(--color-focus);
outline-offset: max(2px, 0.15em);
}
Drei Details lohnen den zweiten Blick. currentColor erbt die Textfarbe, sodass die Checkbox in jedem Kontext mitzieht (auch im Kontrastmodus). Das clip-path zeichnet den Haken als Form. Damit ist er unabhängig von der Schriftart und skaliert sauber. Und max(2px, 0.15em) sorgt dafür, dass der Fokusring auch bei sehr kleiner Schrift nicht unter 2 px fällt.
Für Radiobuttons gilt dasselbe, nur mit border-radius: 50% und einem gefüllten Kreis statt Haken. Die Gruppierung übernehmen weiterhin fieldset und legend.
Der schnellste Weg: accent-color
Wenn es nur um die Markenfarbe geht, brauchst du gar kein Custom-Styling:
:root { accent-color: var(--color-primary); }
Mit einer Zeile nehmen Checkboxen, Radios, Fortschrittsbalken und Range-Slider deine Farbe an. Der Browser wählt die Farbe des Hakens automatisch so, dass der Kontrast stimmt. Das ist ein seltener Fall, in dem Nichtstun die zugänglichere Lösung ist. Der Preis: Die Form bleibt die des Betriebssystems und sieht auf macOS anders aus als auf Windows.
Der blinde Fleck: erzwungener Farbmodus
Windows-Kontrastmodus (und forced-colors allgemein) ersetzt Farben durch eine kleine Systempalette und entfernt Hintergrundbilder, Schatten und oft auch Hintergrundfarben. Ein Schalter, dessen „an“-Zustand nur aus background: green besteht, sieht dort identisch aus wie „aus“. Das ist der häufigste Bug in ansonsten sauberen Komponenten.
@media (forced-colors: active) {
input[type="checkbox"] {
/* Rahmen und Haken folgen der Systemfarbe */
border-color: ButtonText;
}
input[type="checkbox"]::before {
background-color: Highlight;
}
input[type="checkbox"]:disabled {
border-color: GrayText;
}
}
CanvasText, ButtonText, Highlight und GrayText sind Systemfarben, die der Kontrastmodus nicht wegwirft. Wer currentColor und diese Schlüsselwörter nutzt, bekommt den Kontrastmodus fast geschenkt. Testen lässt sich das in den Chrome-DevTools unter Rendering → Emulate CSS media feature forced-colors.
Toggle-Switch: drei Wege, einer davon neu
Ein Schalter („an/aus“) war lange kein eigenes HTML-Element. Heute gibt es drei legitime Umsetzungen.
Vergrößern: Drei Auszeichnungen, drei Ansagen. Den Ausschlag gibt, ob der Schalter einen…
Der native Weg (noch nicht überall). Safari kennt seit Version 17.4 ein switch-Attribut an der Checkbox:
<input type="checkbox" switch id="darkmode" name="darkmode" />
<label for="darkmode">Dunkles Design</label>
Browser, die es nicht kennen, ignorieren das Attribut und zeigen eine ganz normale Checkbox. Die Seite bleibt also funktionsfähig, das ist Progressive Enhancement im Lehrbuchsinn. Stand Oktober 2026 ist Safari laut Web Features Explorer aber weiterhin der einzige Browser mit Unterstützung. Ein Schalter, der auf drei Vierteln der Geräte wie eine Checkbox aussieht, ist ein Designproblem. Für Steuerungen im eigenen Admin-Bereich ist das Attribut brauchbar, für die öffentliche Seite noch nicht.
Checkbox mit role="switch": wenn ein Wert gespeichert wird. Das ist der Weg für Formularfelder, die abgeschickt werden, und die Grundlage des Codes oben:
<input type="checkbox" role="switch" id="mails" name="mails" checked />
<label for="mails">Wöchentliche Zusammenfassung</label>
Wichtig: aria-checked schreibst du hier nicht selbst. Der Browser leitet es aus checked ab. Ein handgesetztes aria-checked würde irgendwann auseinanderlaufen.
Button mit aria-pressed: wenn sofort etwas passiert. Hier gibt es keinen Formularwert und kein Absenden, die Wirkung tritt beim Klick ein:
<button type="button" aria-pressed="false" id="ton">Ton stumm schalten</button>
const btn = document.getElementById('ton');
btn.addEventListener('click', () => {
const an = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!an));
});
Hier musst du den Zustand selbst pflegen, und zwar immer am selben Element, das auch den Fokus hat.
Welcher Weg wann?
| Situation | Auszeichnung | Zustand kommt von |
|---|---|---|
| Einstellung im Formular, wird abgeschickt | <input type="checkbox" role="switch"> |
checked (automatisch) |
| Einstellung, die sofort wirkt, JS vorausgesetzt | <button role="switch"> |
aria-checked (selbst gesetzt) |
| Aktion mit Zwei-Zustands-Charakter („stumm“, „merken“) | <button aria-pressed> |
aria-pressed (selbst gesetzt) |
| Auswahl aus mehreren Optionen | normale Checkbox, kein Schalter | checked |
Die häufigste Verwechslung: aria-pressed an einem Element mit role="switch". Beides zusammen ist widersprüchlich. Ein Switch nutzt aria-checked, ein Toggle-Button aria-pressed. Mehr zur Systematik dahinter auf der Seite Rollen, States & Properties.
Was Screenreader daraus machen
role="switch" ist gut spezifiziert, aber nicht überall gleich gut umgesetzt. Adrian Roselli hat die Kombinationen systematisch durchgetestet, und das Ergebnis ist ernüchternd genug, um die eigene Umsetzung danach auszurichten:
- VoiceOver auf macOS sagt sauber „ein“ / „aus“.
- NVDA mit Firefox meldete Schalter lange schlicht als Kontrollkästchen („angehakt“ statt „ein“).
- VoiceOver auf iOS ignorierte die Switch-Rolle über Jahre komplett.
-
aria-checked="mixed"ist an einem Switch ungültig und wird von Browsern unterschiedlich falsch behandelt. Nicht verwenden.
Daraus folgt eine Regel: Verlass dich nie allein auf die Rolle. Wenn der Unterschied zwischen „angehakt“ und „ein“ für das Verständnis zählt, gehört er zusätzlich in sichtbaren Text. Das kann ein „An“/„Aus“ neben dem Schalter sein wie im Code oben, oder ein Label, das ohne Zustandswissen funktioniert. Wie man so etwas selbst nachprüft, steht unter Mit NVDA testen und Mit VoiceOver testen.
Randnotiz: Zustand ansagen und zeigen. Ein Schalter, der seinen Zustand nur über Farbe zeigt, ist für Farbfehlsichtige mehrdeutig.
role="switch"undaria-pressedliefern die Ansage „ein/aus“. Ein zusätzliches Textlabel oder Icon macht den Zustand unabhängig von der Farbe sichtbar. Und der Schalter selbst braucht 3 : 1 Kontrast gegen seinen Hintergrund (1.4.11). Der beliebte hellgraue „Aus“-Zustand reißt diese Grenze regelmäßig.
Größe, Label und Tastatur
Zielgröße. Ein 20 × 20 px großes Kästchen unterschreitet die 24 × 24 CSS-Pixel aus 2.5.8. Die Ausnahme greift, wenn ein gleichwertiges Ziel danebenliegt, und genau das ist ein korrekt verknüpftes Label: Es ist Teil der Klickfläche. Ein Label mit padding und min-height: 24px löst das Problem elegant.
Label-Text. Das Label benennt, was geschaltet wird. „Dunkles Design“ plus Ansage „ein“ ergibt einen klaren Satz. „Dunkles Design aktivieren“ plus Ansage „aus“ ergibt Ratespiele. Details unter Labels & Beschriftungen.
Tastatur. Checkbox und Switch-Checkbox schalten mit der Leertaste. Ein <button> reagiert zusätzlich auf Enter. Baust du einen Schalter aus einem <div> nach, musst du beides selbst abfangen und event.preventDefault() bei der Leertaste setzen, sonst scrollt die Seite.
Häufige Fehler
-
<div>statt<input>/<button>: Fokus, Tastatur und Formularwert fehlen. -
Natives Input mit
display: noneversteckt: Es ist gar nicht mehr fokussierbar. -
Input mit
opacity: 0; width: 0wie im verbreiteten Tutorial-Muster: Der Fokusring muss umgeleitet werden, und die Klickfläche liegt woanders als die Grafik. -
.visually-hiddenohne umgeleiteten Fokusring: Der Fokus ist unsichtbar. - Zustand nur per Farbe ohne Text, Icon oder Position.
-
Kein
@media (forced-colors: active): Im Kontrastmodus sehen an und aus gleich aus. -
aria-pressedzusammen mitrole="switch": Die Auszeichnung widerspricht sich. -
aria-checkedvon Hand an einer echten Checkbox: Es läuft mitcheckedauseinander. - Schalter kleiner als 24 × 24 px ohne klickbares Label als Ausgleich.
-
aria-checked="mixed"an einem Switch ist ungültig.
Checkbox mit role="switch" oder Button mit aria-pressed? Der Komponenten-Baukasten zeigt beide Varianten samt der erwarteten Screenreader-Ausgabe. Daran wird der Unterschied am schnellsten klar.
Häufige Fragen
Was ist ein Toggle?
Ein Toggle ist ein Umschalter mit genau zwei Zuständen, meist „an“ und „aus“. Im Web kommt er in zwei Formen vor: als Schalter mit Schiene und Knopf (Toggle-Switch) und als Button, der gedrückt bleibt (Toggle-Button). Der Switch speichert eine Einstellung, der Toggle-Button löst eine Aktion wie „stumm schalten“ aus. Technisch ist der Switch eine Checkbox mit role="switch", der Toggle-Button ein <button> mit aria-pressed.
Wann nehme ich eine Checkbox, wann einen Switch?
Ein Switch ist ein Sonderfall der Checkbox für „an/aus“, der meist sofort wirkt (z. B. Dunkelmodus). Geht es um eine Auswahl, die erst beim Absenden zählt (etwa „AGB akzeptieren“ oder eine Mehrfachauswahl), ist die normale Checkbox richtig. Als Faustregel: Klingt „ein/aus“ vorgelesen sinnvoller als „angehakt/nicht angehakt“, ist es ein Switch.
role="switch" oder aria-pressed?
role="switch" gehört auf ein Element, das einen Zustand speichert. Der Zustand kommt dann über aria-checked bzw. bei einer Checkbox automatisch über checked. aria-pressed gehört auf einen Button, der eine Aktion umschaltet. Beides ist korrekt, gehört aber nie gleichzeitig an dasselbe Element.
Ist appearance: none sicher einsetzbar?
Ja. appearance: none ist seit März 2022 in allen großen Browsern Baseline, die Kombination mit gestalteten Pseudo-Elementen an Checkboxen funktioniert dort ebenfalls. Wichtig ist nur, den Fokusring selbst zu setzen. appearance: none entfernt auch den vom Browser mitgelieferten.
Wie teste ich, ob mein Schalter im Kontrastmodus funktioniert?
In Chrome oder Edge über die DevTools: ⌘/Strg + Shift + P → „Show Rendering“ → Emulate CSS media feature forced-colors: active. Unter Windows geht es echt über Einstellungen → Barrierefreiheit → Kontrastdesigns. Der Knopf muss in beiden Zuständen sichtbar bleiben und seine Position wechseln.
Quellen
- Switch Pattern (W3C WAI-ARIA Authoring Practices: Rolle, Zustand und Tastaturverhalten)
- Switch Role Support (Adrian Roselli: systematische Tests, welche Screenreader-Browser-Kombination `role="switch"` korrekt ansagt)
- Inclusively Hiding & Styling Checkboxes and Radio Buttons (Sara Soueidan: warum `.sr-only` für Bedienelemente die falsche Technik ist)