Design · Bausteine-Dossier

Kategorie

Barrierefreiheit-Grundlagen

Das Pflicht-Fundament für jede Seite: Barrierefreiheit nach BFSG und rücksichtsvolle Bewegung. Diese Bausteine gehören in jedes Projekt, bevor Optik draufkommt — sichtbarer Fokus, Skip-Link, Screenreader-Texte, prefers-reduced-motion und Touch-Komfort. Akzentfarbe über --akzent in einer Zeile anpassbar.

Skip-Link „Zum Inhalt springen"

Pol 2 · Klarheit Anlass: Tastatur-Navigation, jede Seite

Erster fokussierbarer Link der Seite — off-screen geparkt, springt bei :focus sichtbar herein und lässt Tastatur-Nutzer die Navigation überspringen (BFSG/WCAG 2.4.1). Vorbild: Bernstein-Montageservice. Demo: in die Box klicken, dann Tab drücken.

Zum Inhalt springen

Box anklicken, dann Tab → der Link erscheint oben links.

<a href="#inhalt" class="skip-link">Zum Inhalt springen</a>
<!-- ... Header/Navigation ... -->
<main id="inhalt"> ... </main>

<style>
.skip-link{position:absolute;left:-9999px;top:0;z-index:300;
  padding:.7rem 1.2rem;background:var(--akzent, #7c87ff);color:#fff;
  font-weight:600;border-radius:0 0 10px 0;text-decoration:none}
.skip-link:focus{left:0}
</style>

Globaler Fokus-Ring (:focus-visible)

Pol 2 · Klarheit Anlass: alle interaktiven Elemente

Sichtbarer Tastatur-Fokus für Links, Buttons und Felder — Pflicht nach BFSG/WCAG 2.4.7. :focus-visible zeigt den Ring nur bei Tastatur, nicht bei Maus-Klick. Wichtig: auf dunklen oder farbigen Flächen einen hellen Ring setzen, sonst ist er unsichtbar (Demo: Tab durch die zwei Elemente). Vorbild: NDS.

Mehr erfahren
<a href="#">Mehr erfahren</a>
<button type="button">Angebot anfordern</button>

<style>
/* Ein Ring für ALLE interaktiven Elemente */
:where(a,button,input,select,textarea,[tabindex]):focus-visible{
  outline:3px solid var(--akzent, #7c87ff);
  outline-offset:2px;
  border-radius:8px}
/* Auf dunklen/farbigen Flaechen heller Ring, sonst unsichtbar */
.btn-primary:focus-visible,
.site-header a:focus-visible{outline-color:#f4f4f5}
</style>

Screenreader-Text (.sr-only)

Pol 2 · Klarheit Anlass: Icon-Buttons, versteckte Labels

Text nur für Screenreader, optisch unsichtbar — für Icon-Links ohne sichtbare Beschriftung oder erklärende Zusätze. Nicht display:none (das blendet auch für Screenreader aus). Vorbild: NDS. Demo: das Icon trägt das versteckte Label „Anrufen".

Anrufen ← Icon-Link mit verstecktem Label „Anrufen"

<a href="tel:+49...">
  <span aria-hidden="true">☎</span>
  <span class="sr-only">Anrufen</span>
</a>

<style>
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
</style>

reduced-motion-Grundregel

Pol 2 · Klarheit Anlass: jede Animation/Transition

Wer im System „Bewegung reduzieren" aktiviert hat, soll keine Animationen sehen (BFSG/WCAG 2.3.3, gegen Schwindel/Übelkeit). Statt Bewegung wegzulassen, einen ruhigen Ersatz-Zustand zeigen — hier endet das Quadrat statisch gedreht statt zu rotieren. Vorbild: Bernstein. Demo: aktiviere im System „Bewegung reduzieren", dann steht das Quadrat still.

rotiert — bei „reduce" steht es still

<div class="motion-demo"></div>

<style>
.motion-demo{width:64px;height:64px;border-radius:14px;
  background:var(--akzent, #7c87ff);
  animation:spin 2.4s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}

/* Pflicht: jede Animation/Transition hier neutralisieren */
@media (prefers-reduced-motion:reduce){
  .motion-demo{animation:none;transform:rotate(45deg)} /* ruhiger Ersatz-Zustand statt Bewegung */
}
</style>

Touch-Komfort (Tap & Hover)

Pol 2 · Klarheit Anlass: Buttons/Links auf Mobil

Drei Mobil-Korrekturen: -webkit-tap-highlight-color entfernt den blauen Tap-Blitz, touch-action:manipulation killt die 300-ms-Doppeltipp-Verzögerung, und @media (hover:none) schaltet auf Touch-Geräten klebrige Hover-Zustände ab (sonst „hängt" der Hover nach dem Tippen fest). Vorbild: Silver-Mountain.

<a href="#" class="touch-btn">Termin anfragen</a>

<style>
.touch-btn{color:#fff;background:var(--akzent, #7c87ff);
  padding:.7rem 1.1rem;border-radius:10px;text-decoration:none;
  -webkit-tap-highlight-color:transparent; /* kein blauer Tap-Flash */
  touch-action:manipulation;               /* keine 300ms Tap-Verzoegerung */
  transition:transform .25s ease,filter .25s ease}
.touch-btn:hover,.touch-btn:focus-visible{transform:translateY(-3px);filter:brightness(1.1)}
.touch-btn:focus-visible{outline:3px solid #f4f4f5;outline-offset:2px}

/* Touch ohne echtes Hover: klebrige Hover-States abschalten */
@media (hover:none){.touch-btn:hover{transform:none;filter:none}}
@media (prefers-reduced-motion:reduce){
  .touch-btn{transition:none}
  .touch-btn:hover,.touch-btn:focus-visible{transform:none}
}
</style>

Theme-Toggle Dark/Light (flicker-frei)

Pol 2 · Klarheit Anlass: Header-Umschalter

Hell/Dunkel-Umschalter ohne Flackern: ein winziges Inline-Skript im <head> setzt data-theme vor dem ersten Paint. Die Wahl wird doppelt gespeichert (localStorage und Cookie) — falls eines blockiert ist, greift das andere. Button mit aria-pressed und Sonne/Mond-Tausch. Vorbild: NDS. Hinweis: Diese Demo schaltet nur die abgegrenzte Box um (data-fxtheme statt data-theme), damit sie nicht mit der Dossier-Seite kollidiert; der echte Code unten setzt data-theme auf <html>.

Vorschau
Küchenmontage

Klick auf das Symbol schaltet diese Box um.

<!-- 1) Inline-Bootstrap GANZ OBEN im <head>, vor dem CSS:
        setzt data-theme vor dem ersten Paint = kein Flackern -->
<script>(function(){var t="dark";try{
  var ls=localStorage.getItem("theme");
  if(ls==="light"||ls==="dark"){t=ls;}
  else{var m=document.cookie.match(/(?:^|;\s*)theme=(light|dark)/);if(m){t=m[1];}}
}catch(e){}document.documentElement.setAttribute("data-theme",t);})();</script>

<!-- 2) Toggle-Button im Header -->
<button type="button" class="theme-toggle" aria-pressed="false"
        aria-label="Hellmodus aktivieren">
  <svg class="icon-sun" ...></svg>
  <svg class="icon-moon" ...></svg>
</button>

<style>
.theme-toggle .icon-moon{display:none}
[data-theme="light"] .theme-toggle .icon-sun{display:none}
[data-theme="light"] .theme-toggle .icon-moon{display:block}
[data-theme="light"]{ /* helle Variablen-Overrides */ }
</style>

<script>
(function(){
  var btn=document.querySelector(".theme-toggle");
  if(!btn) return;
  function setLabel(t){
    btn.setAttribute("aria-label", t==="light" ? "Dunkelmodus aktivieren" : "Hellmodus aktivieren");
    btn.setAttribute("aria-pressed", t==="light" ? "true" : "false");
  }
  setLabel(document.documentElement.getAttribute("data-theme")||"dark");
  btn.addEventListener("click",function(){
    var next=document.documentElement.getAttribute("data-theme")==="light" ? "dark" : "light";
    document.documentElement.setAttribute("data-theme",next);
    /* doppelte Persistenz: localStorage UND Cookie */
    try{localStorage.setItem("theme",next);}catch(e){}
    try{document.cookie="theme="+next+";path=/;max-age=31536000;samesite=lax";}catch(e){}
    setLabel(next);
  });
})();
</script>

Event-Delegation data-action="fn:arg;fn2"

Pol 2 · Klarheit Anlass: CSP-Härtung, viele Klick-Ziele

Ein einziger Klick-Listener am document liest data-action aus und ruft die passende Funktion — ersetzt alle onclick-Inline-Handler (sauberer für Content-Security-Policy). Mehrere Aktionen per ;, Argument per :. Ein keydown-Trigger macht auch Nicht-Buttons (z. B. eine FAQ-Zeile) per Enter/Space bedienbar. Vorbild: Nördlicht (kompakte Demo).

Ja — der keydown-Trigger ruft click() auf.

Ausgabe erscheint hier.

<button data-action="ping:Hallo">ping:Hallo</button>
<button data-action="ping:Welt;mark">ping:Welt;mark</button>
<div class="faq" role="button" tabindex="0" data-action="toggleFaq">Frage ▾</div>

<script>
/* EIN globaler Click-Listener parst data-action="fn:arg;fn2" */
document.addEventListener("click",function(evt){
  var trigger=evt.target.closest("[data-action]");
  if(!trigger) return;
  if(trigger.tagName==="A") evt.preventDefault();
  trigger.dataset.action.split(";").forEach(function(str){
    var parts=str.split(":"), fn=parts[0], arg=parts[1];
    switch(fn){
      case "ping":     ping(arg);          break;
      case "mark":     mark(trigger);      break;
      case "toggleFaq":toggleFaq(trigger); break;
      default: console.warn("Unbekannte data-action:",str);
    }
  });
});

/* Tastatur: Enter/Space auf Nicht-Button/Link-Elementen */
document.addEventListener("keydown",function(evt){
  if(evt.key!=="Enter" && evt.key!==" ") return;
  var trigger=evt.target.closest("[data-action]");
  if(!trigger) return;
  if(trigger.tagName==="A" || trigger.tagName==="BUTTON") return;
  evt.preventDefault();
  trigger.click();
});
</script>

aria-live Statusmeldung (Live-Region)

Pol 2 · Klarheit Anlass: „Gespeichert", Filter-Treffer, Formular-Feedback

Eine aria-live-Region liest Statusänderungen automatisch vor, ohne den Fokus zu verschieben — Pflicht für alles, was sich ohne Seiten-Neuladen ändert (BFSG/WCAG 4.1.3). polite wartet auf eine Sprechpause, assertive unterbricht sofort (nur für Fehler). Wichtig: die Region muss schon im HTML stehen, bevor Text hineinkommt. Demo: Buttons setzen die Meldung.

Noch keine Meldung.

Screenreader liest die neue Meldung vor — der Fokus bleibt beim Button.

<!-- Region steht LEER schon im HTML, bevor Text kommt -->
<p class="status" role="status" aria-live="polite"></p>

<button type="button" onclick="melde('Änderungen gespeichert.')">Speichern</button>

<script>
function melde(text){
  var region=document.querySelector(".status");
  if(!region) return;
  region.textContent=text;   // Textwechsel loest die Vorlesung aus
}
</script>

<style>
/* polite = wartet auf Sprechpause · assertive = unterbricht (nur Fehler) */
/* role="status" impliziert aria-live="polite" – beides schadet nicht */
.status{margin:0;font-size:.9rem;color:#e9e9ee}
</style>

Barrierefreie Feld-Fehlermeldung (aria-invalid + aria-describedby)

Pol 2 · Klarheit Anlass: Kontaktformular, Pflichtfeld-Prüfung

Ein fehlerhaftes Feld darf nicht nur rot werden — Screenreader-Nutzer sehen die Farbe nicht. aria-invalid="true" meldet den Fehlerzustand, aria-describedby verknüpft das Feld mit dem Fehlertext (wird beim Fokus mit vorgelesen), und die Meldung selbst liegt in einer aria-live-Region (BFSG/WCAG 3.3.1). Demo: Feld leer lassen und „Prüfen" klicken.

<form novalidate>
  <label for="mail">E-Mail-Adresse</label>
  <input type="email" id="mail" name="mail" autocomplete="email"
         aria-describedby="mail-msg">
  <p class="err-msg" id="mail-msg" role="alert"></p>
  <button type="submit">Prüfen</button>
</form>

<script>
document.querySelector("form").addEventListener("submit",function(e){
  e.preventDefault();
  var feld=this.querySelector("#mail"), msg=this.querySelector("#mail-msg");
  var leer=!feld.value.trim();
  feld.setAttribute("aria-invalid", leer ? "true" : "false");
  msg.textContent = leer ? "Bitte eine E-Mail-Adresse eingeben." : "";
  if(leer) feld.focus();   // Fokus zurueck aufs Feld = sofort korrigierbar
});
</script>

<style>
.err-msg:empty{display:none}          /* leer = kein Platzhalter-Rand */
input[aria-invalid="true"]{border-color:#f4a3a3}
input:focus-visible{outline:3px solid var(--akzent, #7c87ff);outline-offset:2px}
</style>

Auto-Theme nach prefers-color-scheme

Pol 2 · Klarheit Anlass: Seite folgt der OS-Vorgabe

Ohne jeden Umschalter: Die Seite übernimmt automatisch, ob das Betriebssystem auf Hell oder Dunkel steht (@media (prefers-color-scheme)). Reines CSS, kein JS, keine Speicherung. Unterschied zum manuellen Toggle (Baustein 6): hier entscheidet das System. Hinweis: Da die echte OS-Vorgabe hier nicht umschaltbar ist, ahmt die Demo beide Zustände per Knopf nach (data-fxscheme); der echte Code unten nutzt die @media-Abfrage.

OS-Vorgabe
Küchenmontage

Farben folgen automatisch der System-Einstellung.

aktiv: Dunkel
<body>
  <h1>Küchenmontage</h1>
</body>

<style>
/* Standard = Dunkel */
:root{--bg:#16161a;--text:#e9e9ee;--linie:#26262c}

/* Steht das OS auf Hell, kippen die Farben automatisch */
@media (prefers-color-scheme: light){
  :root{--bg:#f4f4f5;--text:#17171b;--linie:#d8d8df}
}

body{background:var(--bg);color:var(--text)}
</style>

Fokusfalle im Dialog (Focus-Trap)

Pol 1 · Craft Anlass: Modal, Cookie-Banner, Overlay-Menü

Ein offener Dialog muss den Tastatur-Fokus einfangen: Tab springt nicht hinter den Dialog auf die Seite, sondern zykelt zwischen den Dialog-Elementen; Esc schließt, und der Fokus kehrt danach auf den auslösenden Button zurück (BFSG/WCAG 2.1.2 „keine Tastaturfalle" im positiven Sinn). Demo: öffnen, dann mit Tab durchsteppen — der Fokus bleibt im Dialog.

<button type="button" class="modal-open">Dialog öffnen</button>

<div class="modal-wrap" role="presentation">
  <div class="veil" data-close="1"></div>
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="t">
    <button class="close" data-close="1" aria-label="Dialog schließen">✕</button>
    <h2 id="t">Termin bestätigen</h2>
    <button class="ok"     data-close="1">Bestätigen</button>
    <button class="cancel" data-close="1">Abbrechen</button>
  </div>
</div>

<script>
(function(){
  var host   = document.querySelector(".modal-open").parentNode;
  var opener = host.querySelector(".modal-open");
  var wrap   = host.querySelector(".modal-wrap");
  var dialog = host.querySelector(".modal");
  var vorher = null;                       // Fokus vor dem Öffnen merken
  var SEL="a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex='-1'])";

  function fokusierbare(){ return Array.prototype.slice.call(dialog.querySelectorAll(SEL)); }

  function open(){
    vorher=document.activeElement;
    wrap.classList.add("is-open");
    var f=fokusierbare(); if(f.length) f[0].focus();   // Fokus in den Dialog
    document.addEventListener("keydown", onKey);
  }
  function close(){
    wrap.classList.remove("is-open");
    document.removeEventListener("keydown", onKey);
    if(vorher) vorher.focus();                         // Fokus zurück zum Auslöser
  }
  function onKey(e){
    if(e.key==="Escape"){ close(); return; }
    if(e.key!=="Tab") return;
    var f=fokusierbare(); if(!f.length) return;
    var erste=f[0], letzte=f[f.length-1];
    // Tab-Zyklus einfangen: vom letzten zum ersten, mit Shift umgekehrt
    if(e.shiftKey && document.activeElement===erste){ e.preventDefault(); letzte.focus(); }
    else if(!e.shiftKey && document.activeElement===letzte){ e.preventDefault(); erste.focus(); }
  }
  opener.addEventListener("click", open);
  wrap.addEventListener("click", function(e){
    if(e.target.closest("[data-close]")) close();      // Veil / Buttons schließen
  });
})();
</script>

<style>
.modal-wrap{position:fixed;inset:0;display:none;place-items:center;z-index:1000}
.modal-wrap.is-open{display:grid}
.veil{position:absolute;inset:0;background:rgba(0,0,0,.55)}
.modal{position:relative}
.modal :where(button):focus-visible{outline:3px solid var(--akzent, #7c87ff);outline-offset:2px}
</style>