Wir bauen ein barrierefreies Menü mit modernen Web-Features

Wir alle kennen Menüelemente auf Websites. Ein Menü ist ein Widget, das den Nutzer:innen eine Liste von Optionen bietet, z.B. eine Reihe von Aktionen. Solche Menüs verhalten sich wie native Betriebssystem-Menüs und sollten nicht mit Navigationsmenüs verwechselt werden, die üblicherweise im Header der Seite platziert werden.

Leider verhält sich das vorhandene <menu>-Element nicht wie erwartet. Es gibt Pläne, das native Element anzupassen, aber in der Zwischenzeit müssen wir unser eigenes Menü erstellen. Wir könnten eine Menükomponente aus einer UI-Bibliothek verwenden. Das würde eine weitere Abhängigkeit im Projekt und möglicherweise Breaking Changes in der Zukunft bedeuten. Lieber nicht.

Aber es gibt gute Nachrichten! Dank neuer Web-Features wie der Popover API und CSS Anchor Positioning ist es viel einfacher geworden, ein benutzerdefiniertes Menüelement zu erstellen. Lasst uns gemeinsam ein barrierefreies Menü erstellen!

Ein Skateboarder mitten im Sprung, wie ein Menüfeld das plötzlich auftaucht. Foto: © Zachary DeBottis / pexels.com

Was wir erreichen wollen

Unser selbstgebautes Menü sollte die folgenden Anforderungen erfüllen:

  1. Das Menü ist komplett barrierefrei, sprich:
    • Es vermittelt seine Rolle und Status an assistive Technologien wie Bildschirmlesegeräte.
    • Es unterstützt die Tastaturbedienung und hat einen sichtbaren Fokusindikator.
    • Der Text und die grafischen Elemente haben einen ausreichenden Farbkontrast.
  2. Innerhalb des Menüfelds können Nutzer:innen einen von mehreren Menüpunkten aktivieren. Der Einfachheit halber unterstützen wir keine verschachtelten Menüs (vielleicht probiere ich das später mal aus).
  3. Das Menüfeld öffnet sich neben der Auslösetaste und bleibt beim Scrollen daran haften.
  4. Das Öffnen und Schließen des Menüfelds ist flüssig animiert.

Ich zeige euch, wie ihr dies Schritt für Schritt erreichen könnt. Aber werft zuerst einmal einen Blick auf das Ergebnis.

Demo: Barrierefreies Menü

Ich habe eine CodePen-Demo erstellt, die viermal das gleiche Menüelement enthält. Die Menübuttons sind grob bei den vier Ecken des Bildschirms platziert, damit ihr leicht testen könnt, wie die Platzierung des Menüfelds den verfügbaren Platz berücksichtigt:

Aufbau des Menüelements Schritt für Schritt

Schritt 1: Die grundlegende HTML-Struktur

Wir definieren eine Icon-Schaltfläche mit einem aria-label, um ihren Zweck für Screenreader-User:innen zu kommunizieren. Daneben platzieren wir einen div-Container mit mehreren Schaltflächen darin, was unser Menüfeld darstellt. Die Attribute role="menu" für den Container und role="menuitem" für die einzelnen Schaltflächen vermitteln die korrekten Rollen:

<button id="menu-btn-1" class="menu-btn" aria-label="More options" type="button"> <span>/* icon */</span> </button> <div role="menu" aria-labelledby="menu-btn-1"> <button role="menuitem" type="button">More information</button> <button role="menuitem" type="button">Share</button> <button role="menuitem" type="button">Download the file</button> </div>

Mit CSS gestalten wir unseren Menü-Button so, dass er eine ausreichende Zielgröße und Farbkontrast hat. Wir verwandeln das Menüfeld in einen Flex-Container, der die Menüoptionen in einer Spalte anordnet. Hier ist ein Auszug:

button.menu-btn { color: white; background-color: #00514c; /* other styling */ } div[role="menu"] { display: flex; flex-direction: column; margin: 0.25rem 0; /* other styling */ }

Was haben wir bisher? Einen Icon-Button mit einer stets sichtbaren Liste von Schaltflächen daneben. Nun bauen wir auf dieser Grundlage auf, um unser barrierefreies Menü zu erstellen.

Schritt 2: Die Popover API für das Menüfeld nutzen

Als Nächstes fügen wir das popover-Attribut dem Menüfeld hinzu. Dadurch ist das Menüfeld beim Laden der Seite zunächst ausgeblendet. Wenn das Popover angezeigt wird, wird es im Top-Layer eingefügt, sodass es über allen anderen Seiteninhalten angezeigt wird.

Wir wollen, dass unser Menü-Button steuert, wann das Menüfeld ein- oder ausgeblendet wird. Um diese Verbindung herzustellen, weisen wir dem Popover-Element eine id zu und setzen das Attribut popovertarget mit diesem ID-Wert auf dem Menü-Button. Hier ist das Endergebnis:

<button popovertarget="menu-content-1" id="menu-btn-1" class="menu-btn" aria-label="More options" type="button"> <span>/* icon */</span> </button> <div popover id="menu-content-1" role="menu" aria-labelledby="menu-btn-1"> /* menu items */ </div>

Diese einfachen Schritte bringen uns bereits eine Menge Features: Der Menü-Button öffnet und schließt das Panel, er übermittelt seinen Status an assistive Technologien (wie es aria-expanded tun würde), wir erhalten Light-Dismiss und eine gewisse Tastaturbedienbarkeit. Weitere Infos enthält der MDN-Artikel Using the Popover API.

Ein wichtiger Hinweis zur Gestaltung von Popover-Inhalten: Wenn ihr ein Flexbox- oder Grid-Layout verwenden möchten, solltet ihr den Wert der display-Eigenschaft des Popover nur ändern, wenn das Popover geöffnet ist. Andernfalls würdet ihr den Wert display: none überschreiben, wenn das Popover geschlossen ist, so dass es immer sichtbar ist. Hier ist ein Code-Beispiel:

div[role="menu"] { flex-direction: column; margin: 0.25rem 0; /* other styling */ &:popover-open { display: flex; } }

Schritt 3: Menü-Platzierung mit CSS Anchor Positioning

Um eine visuelle Verbindung zwischen Menü-Button und dem zugehörigen Menüfeld herzustellen, müssen wir sie nebeneinander platzieren. Und wir wollen sicherstellen, dass das Menüfeld beim Button haften bleibt, z.B. beim Scrollen. Historisch gesehen würde dies die Verwendung einer JavaScript-Overlay-Bibliothek erfordern. Wie nervig!

Zum Glück erledigt das neue Feature CSS Anchor Positioning diese Aufgabe für uns. Sie ist derzeit Teil der Interop 2025 und sollte bis Ende des Jahres in allen Browsern unterstützt werden.

Generell müsst ihr ein Ankerelement mit der Eigenschaft anchor-name definieren. Dann bindet ihr ein anderes Element mit der Eigenschaft position-anchor an diesen Anker an. Im Falle von Popover-Elementen müsst ihr jedoch nichts von alledem tun. Ein Blick in die Spezifikation zeigt:

Some specifications can define that, in certain circumstances, a particular element is an implicit anchor element for another element. Implicit anchor elements can be referenced with the auto keyword in position-anchor.

Genau das gilt für eine Schaltfläche, die als Popover-Steuerelement dient, wie unser Menü-Button. Das bedeutet: Wir sind startklar! Unser Menü-Button ist bereits implizit als Ankerelement des Menüfelds definiert.

Wo wollen wir das Menüfeld platzieren? Wie wäre es unterhalb und rechts von der Schaltfläche? Wir könnten dies mit der CSS-Funktion anchor() erreichen. Ich bevorzuge aber die elegantere position-area-Eigenschaft. Werft einen Blick darauf:

div[role="menu"] { position: absolute; position-area: end span-end; }

Stellt euch das Ankerelement und die sie umgebende Fläche als ein 3x3-Raster vor. Der erste Wert, den wir definieren, bezieht sich auf die Blockachse, der zweite auf die Inline-Achse. So bedeutet position-area: end span-end, dass wir unser Menüfeld am Ende der Blockachse (unterhalb des Buttons) platzieren und es von der Mitte bis zum Ende der Inline-Achse aufspannen.

Aber was passiert, wenn der Menü-Button am rechten Bildschirmrand platziert ist? Oder wenn die Scroll-Position der Seite die Schaltfläche am unteren Rand des Bildschirms platziert? In beiden Fällen reicht der Platz nicht aus, um das Menüfeld mit allen Optionen unterhalb und rechts vom Menü-Button anzuzeigen.

Mit Hilfe der Eigenschaft position-try-fallbacks können wir dieses Problem leicht lösen. Ihr definiert einfach eine Liste mit einer oder mehreren alternativen Positionen. Wenn das Element andernfalls seinen Inhaltsblock überfüllen würde, versucht der Browser, das positionierte Element in den verschiedenen Fallback-Positionen zu platzieren.

position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;

Im Grunde sagen wir dem Browser: Wenn unter dem Menü-Button nicht genug Platz ist, dann platziere das Menüfeld oberhalb der Schaltfläche. Nicht genug Platz auf der rechten Seite? Ok, dann dreh die Inline-Achse um und lass das Menüfeld den Platz von links bis zur Mitte abdecken. Und wenn keines von beiden alleine funktioniert, dann mach beides (flip-block flip-inline).

Schritt 4: Tastaturbedienbarkeit mit JavaScript

Unser benutzerdefiniertes Menü sollte für Tastaturbenutzer:innen vollständig bedienbar sein. Das ARIA APG Menu Pattern definiert die folgenden Anforderungen:

  • Wenn ein Menü geöffnet wird, wird der Tastaturfokus auf das erste Element gesetzt.
  • Mit Tab und Shift + Tab kann der Fokus nicht zwischen den Menüoptionen verschoben werden.
  • Wenn sich der Fokus in einem Menü befindet, verschiebt Pfeil-nach-unten den Fokus zum nächsten Element, wobei man optional vom letzten zum ersten Element wechselt.
  • Wenn sich der Fokus in einem Menü befindet, verschiebt Pfeil-nach-oben den Fokus zum vorherigen Element, wobei man optional vom ersten zum letzten Element wechselt.
  • Wenn sich der Fokus auf einer Option im Menü befindet, bewegen Tab und Shift + Tab den Fokus aus dem Menü hinaus und alle Menüs und Untermenüs werden geschlossen.
  • Mit Enter wird die Menüoption aktiviert und das Menü geschlossen.
  • Escape schließt das Menü, das den Fokus enthält, und fokussiert den Menü-Button.

Für die letzte Anforderung ist dank der Popover API bereits gesorgt. Für den Rest müssen wir etwas JavaScript-Code implementieren und unsere HTML-Struktur anpassen. Zunächst setzen wir tabindex="-1" für alle Menüoptionen, um sie aus der Fokusreihenfolge herauszunehmen:

<div popover id="menu-content-1" role="menu" aria-labelledby="menu-btn-1"> <button role="menuitem" tabindex="-1" type="button">More information</button> <button role="menuitem" tabindex="-1" type="button">Share</button> <button role="menuitem" tabindex="-1" type="button">Download the file</button> </div>

Ich habe eine JavaScript-Klasse namens MenuNavigationHandler geschrieben, welche die erforderliche Interaktivität des benutzerdefinierten Menüelements kapselt. Einfach eine neue Instanz der Klasse erstellen, wobei ihr ein konkretes Menüelement als Parameter übergebt. Hier ist ein Auszug aus dem Code:

class MenuNavigationHandler { constructor(menuEl) { this.menuEl = menuEl; this.menuBtn = document.getElementById(this.menuEl.getAttribute("aria-labelledby") ); this.menuItems = Array.from(menuEl.children); this.selectedItem = null; this.selectedItemIndex = 0; // Handle interaction with menu this.menuEl.addEventListener("toggle", (event) => this.onMenuOpen(event)); this.menuEl.addEventListener("keydown", (event) => this.onMenuKeydown(event)); this.menuEl.addEventListener("click", (event) => this.onMenuClick(event)); } // Private methods } const menus = document.querySelectorAll('div[role="menu"]'); menus.forEach(item => new MenuNavigationHandler(item));

Der Konstruktor speichert Verweise auf den Menü-Button und das Menüfeld. Er fügt Event-Listener für das Popover-Toggle-Ereignis sowie für Keydown- und Click-Ereignisse auf dem Menüfeld hinzu. Ihr könnt euch den gesamten Code in meiner CodePen demo ansehen.

Schritt 5: Flüssige Animationen hinzufügen

Nicht zuletzt soll das benutzerdefinierte Menü gut aussehen und sich gut anfühlen. Wir verwenden CSS-Transitionen, um das Öffnen und Schließen des Menüfelds zu animieren.

Das Menüfeld sollte eingeblendet und vergrößert werden, wenn es geöffnet wird. Da wir die Transition auf ein Popover anwenden, das von display: none zu display: flex wechselt, müssen wir auch die Eigenschaften overlay und display berücksichtigen.

div[role="menu"] { transition: opacity, transform, overlay, display; transition-behavior: allow-discrete; }

Die Einblendung soll 120 Millisekunden dauern und eine weiche kubisch-beziersche Zeitfunktion verwenden. Am Ende des Übergangs ist das Popover vollständig sichtbar und auf 100 Prozent skaliert:

div[role="menu"]:popover-open { /* End of fade-in and start of fade-out */ transition-duration: 120ms; transition-timing-function: cubic-bezier(0, 0, 0.2, 1); opacity: 1; transform: scale(1); }

Da das Menüfeld von einem verborgenen in einen sichtbaren Zustand wechselt, gibt es keine berechneten Werte, die der Übergang als Ausgangspunkt verwenden kann. Daher verwenden nutzen wir @starting-style, um die initiale Deckkraft auf 0 und die anfängliche Skalierung auf 80 Prozent zu setzen:

@starting-style { /* Start of fade-in */ div[role="menu"]:popover-open { opacity: 0; transform: scale(0.8); } }

Um nun die Endwerte für die Ausblendung zu definieren, lassen wir einfach die Pseudoklasse :popover-open weg und definieren die Stile für das Menüelement direkt. Wir wollen eine zügigere, lineare Animation, die nur 100 Millisekunden dauert:

div[role="menu"] { /* End of fade-out */ transition-duration: 100ms; transition-timing-function: linear; opacity: 0; transform: scale(1); }

Na gut, die Animation sieht schon ziemlich cool aus. Wir könnten es dabei belassen. Aber ich möchte noch etwas erreichen: Das Menüfeld soll aus dem Menü-Button herauswachsen. Da wir die Eigenschaft transform für den Skalierungseffekt verwenden, müssen wir einfach den Ursprung wie folgt definieren:

transform-origin: top left;

Aber es gibt ein Problem! Der Ursprung sollte sich an die Platzierung des Menüfelds neben dem Menü-Button anpassen. Wenn sich das Menüfeld zum Beispiel oberhalb und links von der Schaltfläche öffnet, müssen wir als Ursprung bottom right festlegen.

Leider wirken sich die von uns definierten position-try-fallbacks nicht auf die Eigenschaft transform-origin aus. Ich habe auch versucht, benutzerdefinierte Positions-Optionen mit der @position-try-Regel zu definieren. Aber dann wurde mir klar, dass die Spezifikation (und die Browser-Implementierung) nur eine sehr begrenzte Anzahl von Eigenschaften zulässt. Es hat einfach nicht funktioniert.

In der Spezifikation gibt es aber einen Hinweis, der mich für die Zukunft hoffen lässt:

It is expected that a future extension to container queries will allow querying an element based on the position fallback it's using, enabling the sort of conditional styling not allowed by this restricted list.

In der Zwischenzeit müssen wir auf das gute alte JavaScript zurückgreifen. Wenn das Menüfeld geöffnet wird, rufen wir die folgende Methode der MenuNavigationHandler-Klasse auf, um den passenden transform-origin festzulegen:

setTransformOriginOnMenuPanel() { const menuPanelPos = this.menuEl.getBoundingClientRect(); const menuTriggerBtnPos = this.menuBtn.getBoundingClientRect(); let originY = menuTriggerBtnPos.y > menuPanelPos.y ? "bottom" : "top"; let originX = menuTriggerBtnPos.x > menuPanelPos.x ? "right" : "left"; this.menuEl.setAttribute("style", `transform-origin: ${originY} ${originX};`); }

Ich weiß, es ist ein bisschen holprig. Aber es funktioniert! Zumindest in Chrome und Edge. Ich werde das ganze benutzerdefinierte Menü in Firefox und Safari gründlich testen müssen, wenn sie endlich CSS Anchor Positioning unterstützen.

Bonus: Benutzerdefiniertes Menü in Angular

Wenn ihr das JavaScript-Framework Angular weder kennt noch euch dafür interessiert, dann überspringt bitte diesen Abschnitt.

Ihr seid immer noch hier? Gut, dann schaut euch mein Accessible Popover Menu project an. Es enthält die benutzerdefinierten Direktiven CustomMenuTrigger, CustomMenu und CustomMenuItem. Diese Direktiven kapseln alle Funktionen, die ich bisher in diesem Artikel beschrieben habe. Ihr könnt sie verwenden, um barrierefreie Menü-Komponenten in eurem Angular-Projekt zu erstellen.

Fazit

Die Popover-API und CSS Anchor Positioning sind einfach genial! Sie machen es so viel einfacher, komplexe Widgets wie ein benutzerdefiniertes Menü zu erstellen, wie ich euch gezeigt habe. Ich werde noch einen gründlichen Testlauf in Firefox und Safari durchführen, sobald diese ebenfalls die Ankerpositionierung unterstützen. Aber ich bin sehr optimistisch! 🤩

Ich hoffe, dieser Artikel inspiriert euch dazu, selbst mit den neuen Web-Features zu experimentieren. Ich bin gespannt, welche Art von Widgets und Komponenten ihr damit baut!

Erstellt am