unabhängige, aber sauber gekapselte Zustände für Eltern- und Kind-Akkordeons
Ein einzelnes Akkordeon ist mit Alpine.js in wenigen Zeilen erledigt, doch sobald ein Akkordeon-Eintrag selbst wieder ein Akkordeon enthält, etwa bei FAQ-Kategorien mit mehreren Unterfragen, treten schnell Überraschungen auf: Ein Klick auf eine Unterfrage schließt fälschlich die gesamte Elternkategorie, weil sich Klick-Ereignisse und geteilter Zustand ungewollt vermischen. Wer verschachtelte Akkordeons robust bauen will, muss verstehen, wie Alpine.js-Scopes ineinander verschachtelt sind, wie Klick-Events sich durch den DOM-Baum nach oben propagieren und wie sich beide Ebenen sauber voneinander trennen lassen, ohne auf zwei komplett unabhängige, unverbundene Komponenten zurückzugreifen.
Inhaltsverzeichnis
- 1. Das Grundproblem: geteilter Zustand bei naiv verschachtelten Komponenten
- 2. Saubere Scope-Trennung zwischen Eltern- und Kind-Ebene
- 3. Klick-Events und ihre Propagation durch den DOM-Baum verstehen
- 4. Praxisbeispiel: FAQ-Kategorien mit Unterfragen
- 5. Nur ein geöffnetes Element pro Ebene mit einem geteilten Index steuern
- 6. Praktische Steuerelemente: Alle Kategorien auf einmal schließen
- 7. Barrierefreiheits-Aspekte bei tiefer Verschachtelung
- 8. Performance bei vielen Kategorien mit jeweils vielen Unterfragen
- 9. Grenzen des Ansatzes und wann sich eine zentrale Zustandsverwaltung lohnt
- 10. Zusammenfassung
- 11. FAQ
1. Das Grundproblem: geteilter Zustand bei naiv verschachtelten Komponenten
Der naheliegendste, aber fehleranfälligste Ansatz für ein verschachteltes Akkordeon ist, sowohl das Eltern- als auch das Kind-Akkordeon mit derselben, generischen Variable wie open zu steuern und beide Ebenen im selben x-data-Scope zu belassen. Sobald jedoch eine Unterfrage per Klick ihren eigenen Zustand ändern soll, greift dieselbe Variable fälschlich auch auf die Elternebene zu, weil Alpine.js Variablen im nächstgelegenen x-data-Scope auflöst und bei fehlender eigener Deklaration automatisch beim nächsten umgebenden Scope nachsieht.
Das eigentliche Problem ist also nicht Alpine.js selbst, sondern eine unklare Scope-Struktur: Jede Verschachtelungsebene braucht ihren eigenen, klar abgegrenzten x-data-Scope mit eigenen Variablennamen, damit ein Klick innerhalb einer Unterfrage niemals versehentlich Variablen der Elternebene mitverändert. Diese Trennung ist der wichtigste Baustein für robuste, verschachtelte Akkordeons.
2. Saubere Scope-Trennung zwischen Eltern- und Kind-Ebene
Die robuste Lösung gibt jeder Ebene eine eigene, in sich geschlossene Komponente mit eigenem, unabhängigem Zustand. Die Elternkategorie verwaltet lediglich, ob die Kategorie selbst geöffnet ist, während jede Unterfrage innerhalb dieser Kategorie ihr eigenes, unabhängiges x-data mit einer eigenen open-Variable erhält. Weil Alpine.js bei jedem x-data-Attribut einen neuen, in sich geschlossenen Scope aufspannt, können gleichnamige Variablen auf unterschiedlichen Ebenen koexistieren, ohne sich gegenseitig zu überschreiben.
Wichtig dabei ist, dass die Kind-Komponente NICHT versucht, per $parent auf den Zustand der Elternkomponente zuzugreifen, außer dies ist explizit gewünscht, etwa um beim Öffnen einer Unterfrage automatisch auch die Elternkategorie aufzuklappen. Ein versehentlicher $parent-Zugriff aus Bequemlichkeit führt schnell wieder zu genau der Kopplung, die die saubere Scope-Trennung eigentlich vermeiden soll.
function accordionCategory() {
return {
open: false,
toggle() {
this.open = !this.open;
},
};
}
function accordionSubQuestion() {
return {
open: false,
toggle() {
this.open = !this.open;
},
};
}
3. Klick-Events und ihre Propagation durch den DOM-Baum verstehen
Selbst mit sauber getrennten Scopes kann ein weiteres Problem auftreten, wenn der Klick-Handler der Unterfrage nicht sauber vom Klick-Handler der Elternkategorie isoliert wird: Ein Klick-Ereignis propagiert in JavaScript standardmäßig vom angeklickten Element nach oben durch alle umschließenden Elemente, sogenanntes Event-Bubbling. Liegt der Klick-Handler der Elternkategorie auf einem Element, das auch die Unterfrage umschließt, würde ein Klick auf die Unterfrage zusätzlich den Handler der Elternkategorie auslösen, sofern dieser nicht explizit gestoppt wird.
Die Lösung ist der Alpine.js-Modifier .stop, der intern event.stopPropagation() aufruft und verhindert, dass das Klick-Ereignis über das Element hinaus nach oben weitergereicht wird. Bei sauber getrennten Toggle-Buttons pro Ebene, wie im nächsten Codebeispiel gezeigt, ist .stop in den meisten Fällen zwar nicht zwingend nötig, weil die Buttons ohnehin nicht ineinander verschachtelt liegen, schadet als defensive Absicherung aber nicht.
4. Praxisbeispiel: FAQ-Kategorien mit Unterfragen
Im konkreten Markup erhält jede Kategorie einen eigenen x-data="accordionCategory()"-Scope, und innerhalb der Kategorie bekommt jede Unterfrage wiederum ihren eigenen x-data="accordionSubQuestion()"-Scope. Der Toggle-Button der Kategorie steuert ausschließlich open im Kategorie-Scope, während jede Unterfrage ihren eigenen, unabhängigen open-Wert im eigenen Scope besitzt. Dadurch bleibt der Zustand jeder Unterfrage erhalten, auch wenn die Kategorie zwischenzeitlich geschlossen und wieder geöffnet wird.
Diese Struktur erlaubt es Nutzern, mehrere Unterfragen gleichzeitig geöffnet zu lassen, während sie durch verschiedene Kategorien navigieren, was in der Praxis dem erwarteten Verhalten eines FAQ-Bereichs mit Unterfragen entspricht: Das Schließen der Elternkategorie blendet zwar den gesamten Inhaltsbereich aus, verändert aber nicht den individuellen Öffnungszustand der einzelnen Unterfragen, sodass beim erneuten Öffnen der Kategorie der zuletzt gesehene Zustand wiederhergestellt wird.
<div class="border rounded-lg">
<template x-for="category in faqCategories" :key="category.id">
<div x-data="accordionCategory()" class="border-b">
<button
@click="toggle()"
:aria-expanded="open"
class="w-full text-left font-semibold p-4"
>
<span x-text="category.title"></span>
</button>
<div x-show="open" x-collapse>
<template x-for="question in category.questions" :key="question.id">
<div x-data="accordionSubQuestion()" class="pl-6 border-t">
<button
@click="toggle()"
:aria-expanded="open"
class="w-full text-left p-3"
>
<span x-text="question.q"></span>
</button>
<div x-show="open" x-collapse class="pb-3 text-gray-600">
<span x-text="question.a"></span>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
5. Nur ein geöffnetes Element pro Ebene mit einem geteilten Index steuern
Manche FAQ-Bereiche sollen so funktionieren, dass innerhalb einer Kategorie immer nur eine Unterfrage gleichzeitig geöffnet ist, während andere Kategorien davon unberührt bleiben. Dafür reicht das eigenständige open pro Unterfrage nicht mehr aus, stattdessen braucht jede Kategorie einen eigenen, geteilten Index-Wert, der von allen Unterfragen derselben Kategorie gemeinsam gelesen wird.
Wichtig ist, dass dieser Index-Wert im Scope der jeweiligen Kategorie liegt, nicht global für die gesamte Seite, da sonst das Öffnen einer Unterfrage in einer Kategorie fälschlich auch Unterfragen in einer komplett anderen, unabhängigen Kategorie schließen würde. Die Kapselung pro Kategorie bleibt also auch bei dieser Variante entscheidend.
function accordionCategory() {
return {
open: false,
openQuestionIndex: null,
toggleQuestion(index) {
this.openQuestionIndex = this.openQuestionIndex === index ? null : index;
},
};
}
6. Praktische Steuerelemente: Alle Kategorien auf einmal schließen
Ein häufig gewünschtes Feature ist ein globaler Alles-schließen-Button oberhalb aller FAQ-Kategorien. Da jede Kategorie ihren eigenen, isolierten Zustand führt, kann die übergeordnete Seite diesen Zustand nicht direkt manipulieren, ohne dass die Kategorien selbst auf ein entsprechendes Signal reagieren. Die sauberste Lösung sind Custom Events: Der Alles-schließen-Button feuert per $dispatch ein Event, das jede Kategorie-Komponente per @close-all.window abhört und daraufhin ihren eigenen, lokalen Zustand zurücksetzt.
Dieser Ansatz respektiert weiterhin die Kapselung jeder einzelnen Komponente, weil keine Komponente direkt in den internen Zustand einer anderen eingreift, sondern lediglich auf ein öffentlich sichtbares, lose gekoppeltes Ereignis reagiert. Das ist derselbe Grundgedanke wie bei serverseitigen Observer-Pattern und lässt sich beliebig auf weitere geteilte Steuerelemente wie Alle-öffnen übertragen.
<button @click="$dispatch('close-all')" class="mb-4">
Alle Kategorien schließen
</button>
<div x-data="accordionCategory()" @close-all.window="open = false">
<!-- Kategorie-Inhalt -->
</div>
7. Barrierefreiheits-Aspekte bei tiefer Verschachtelung
Jede Ebene eines verschachtelten Akkordeons braucht ihre eigenen, korrekten ARIA-Attribute: Jeder Toggle-Button, egal ob auf Eltern- oder Kind-Ebene, erhält aria-expanded, das dynamisch an den jeweiligen open-Wert der eigenen Ebene gebunden wird, sowie eine eindeutige id, auf die das zugehörige, aufklappbare Inhaltselement per aria-controls verweist. Bei tiefer Verschachtelung ist besonders wichtig, dass diese IDs über alle Ebenen hinweg wirklich eindeutig sind, etwa durch eine Kombination aus Kategorie- und Fragen-Index, da doppelte IDs von Screenreadern inkonsistent behandelt werden.
Zusätzlich sollte die semantische Überschriftenhierarchie die visuelle Verschachtelung widerspiegeln: Eine Kategorie-Überschrift auf h3-Ebene sollte von Unterfragen auf h4-Ebene gefolgt werden, damit Screenreader-Nutzer per Überschriften-Navigation die tatsächliche inhaltliche Hierarchie nachvollziehen können, statt nur eine flache Liste optisch ähnlicher Elemente zu sehen. Für Tastaturnutzer ist zudem eine logische Tab-Reihenfolge entscheidend: Wird eine Kategorie geschlossen, sollten die darin liegenden, jetzt unsichtbaren Unterfragen-Buttons per x-collapse automatisch aus der Tab-Reihenfolge entfernt werden, was x-collapse in Kombination mit x-show bereits automatisch übernimmt.
8. Performance bei vielen Kategorien mit jeweils vielen Unterfragen
Bei FAQ-Bereichen mit zwanzig oder mehr Kategorien und jeweils mehreren Unterfragen erzeugt Alpine.js für jede einzelne Unterfrage einen eigenen, reaktiven Scope, was bei sehr großen Gesamtmengen an Elementen spürbaren Initialisierungsaufwand beim ersten Rendern verursachen kann. Eine wirksame Optimierung ist, den Inhalt geschlossener Unterfragen zunächst nicht vollständig ins DOM zu rendern, sondern per x-if statt x-show erst beim ersten Öffnen tatsächlich zu erzeugen.
Dieser Ansatz eignet sich besonders für Unterfragen mit umfangreichem Antworttext oder eingebetteten Bildern, bei denen sich der zusätzliche Aufwand für wiederholtes Erzeugen und Entfernen des DOM-Knotens gegenüber der eingesparten initialen Render-Zeit klar lohnt. Für kurze, einfache Textantworten ist der Unterschied dagegen meist vernachlässigbar, und die einfachere x-show-Variante bleibt vorzuziehen, weil sie den zuletzt gesehenen Scroll-Zustand innerhalb der Antwort erhält.
9. Grenzen des Ansatzes und wann sich eine zentrale Zustandsverwaltung lohnt
Der hier gezeigte Ansatz mit unabhängigen, pro Ebene gekapselten Scopes funktioniert zuverlässig bis zu einer überschaubaren Verschachtelungstiefe von zwei bis drei Ebenen, wie sie bei FAQ-Kategorien mit Unterfragen typisch ist. Bei noch tieferer Verschachtelung, etwa einer vierten oder fünften Ebene mit komplexen Abhängigkeiten zwischen den Ebenen, etwa wenn das Öffnen eines Kind-Elements automatisch mehrere Elternebenen gleichzeitig aufklappen soll, wird die reine Custom-Event-Kommunikation zunehmend unübersichtlich.
In solchen Fällen lohnt sich der Wechsel zu einem zentralen Alpine.store, der den gesamten Baum-Zustand als verschachtelte Datenstruktur hält und von allen Ebenen gleichermaßen gelesen und geschrieben wird. Für die typische FAQ-Kategorien-Anwendung mit maximal zwei Verschachtelungsebenen ist dieser zusätzliche Aufwand jedoch selten gerechtfertigt, und die hier gezeigte, einfachere Scope-Trennung bleibt die pragmatischere Wahl.
| Aspekt | Naiver, geteilter Zustand | Sauber getrennte Scopes | Praxisrelevanz |
|---|---|---|---|
| Variablen-Kollision | open auf Eltern- und Kind-Ebene identisch | Eigener x-data-Scope pro Ebene | Kein versehentliches Mitschließen |
| Event-Propagation | Klick bubbelt zum Eltern-Handler | Getrennte Toggle-Buttons, optional .stop | Kein ungewolltes Auslösen |
| Nur eine Unterfrage offen | Nicht ohne Zusatzlogik möglich | Geteilter Index-Wert im Kategorie-Scope | Klar definiertes UI-Verhalten |
| Alle schließen | Direkter Zustandszugriff nötig | Custom Event per $dispatch | Kapselung bleibt erhalten |
| Sehr tiefe Verschachtelung | Schnell unübersichtlich | Ab vier Ebenen Alpine.store sinnvoll | Zwei bis drei Ebenen reichen meist aus |
Mironsoft
Alpine.js-Interaktivität für Hyvä-Frontends
Hyvä-Frontend, das mehr Interaktivität braucht, aber ohne React-Overhead?
Wir bauen interaktive Frontend-Komponenten für Hyvä-Themes mit Alpine.js, leichtgewichtig und ohne Build-Step-Komplexität, von einfachen Toggles bis zu komplexen Formular-Flows.
Custom-Komponenten
Interaktive Alpine.js-Komponenten für spezifische Shop-Anforderungen entwickeln.
Performance-Review
Bestehende Alpine.js-Implementierungen auf Reaktivitäts-Fallen und Performance prüfen.
Team-Schulung
Entwickler in Alpine.js-Patterns für Hyvä-Themes praxisnah einarbeiten.
10. Zusammenfassung
Verschachtelte Akkordeons mit Alpine.js: Das Wichtigste auf einen Blick
Grundproblem
Geteilte Variablennamen über Verschachtelungsebenen hinweg führen dazu, dass das Öffnen einer Unterfrage fälschlich die Elternkategorie mit beeinflusst.
Lösung
Jede Verschachtelungsebene erhält ihren eigenen, unabhängigen x-data-Scope mit eigenen Variablennamen, ohne unnötigen $parent-Zugriff.
Geteiltes Verhalten
Ein Alles-schließen-Button kommuniziert per Custom Event mit $dispatch, statt direkt in den internen Zustand anderer Komponenten einzugreifen.
Barrierefreiheit
Eindeutige IDs pro Ebene für aria-controls, dynamisches aria-expanded und eine der Verschachtelung entsprechende Überschriftenhierarchie sind Pflicht.