Verschachtelte Akkordeons: mehrere Ebenen mit Alpine.js korrekt verwalten
AI generated
x-data
Alpine
Alpine.js / Praxis-Fallbeispiel
Verschachtelte Akkordeons: mehrere Ebenen korrekt verwalten
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.

10 Min. Lesezeit Eltern- und Kind-Scope trennen Event-Propagation kontrollieren FAQ-Kategorien mit Unterfragen

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.

11. FAQ: Verschachtelte Akkordeons mit Alpine.js: Das Wichtigste auf einen Blick

1Warum schließt sich beim naiven Ansatz die Elternkategorie mit, wenn eine Unterfrage geklickt wird?
Wenn beide Ebenen dieselbe Variable wie open im selben Scope verwenden, löst ein Klick auf die Unterfrage fälschlich auch die Elternebene mit aus.
2Wie werden Eltern- und Kind-Zustand sauber getrennt?
Jede Verschachtelungsebene erhält ihren eigenen, unabhängigen x-data-Scope mit eigenen Variablennamen, sodass gleichnamige Variablen nicht kollidieren.
3Wann ist der .stop-Modifier bei verschachtelten Akkordeons nötig?
Wenn der Klick-Handler der Elternebene auf einem Element liegt, das auch die Kind-Ebene umschließt, verhindert .stop das ungewollte Bubbling des Klick-Ereignisses.
4Wie bleibt der Zustand einzelner Unterfragen erhalten, wenn die Kategorie geschlossen wird?
Da jede Unterfrage ihren eigenen, unabhängigen open-Wert im eigenen Scope besitzt, bleibt dieser Wert unverändert, auch wenn die Elternkategorie zwischenzeitlich geschlossen wird.
5Wie wird erreicht, dass nur eine Unterfrage pro Kategorie gleichzeitig offen ist?
Ein geteilter Index-Wert im Scope der jeweiligen Kategorie wird von allen zugehörigen Unterfragen gemeinsam gelesen und beim Toggle aktualisiert.
6Wie funktioniert ein globaler Alles-schließen-Button ohne die Kapselung zu brechen?
Über ein Custom Event per $dispatch, das jede Kategorie-Komponente per @close-all.window abhört und ihren eigenen, lokalen Zustand daraufhin zurücksetzt.
7Welche ARIA-Attribute braucht jede Ebene eines verschachtelten Akkordeons?
Jeder Toggle-Button braucht dynamisches aria-expanded sowie eine eindeutige id, auf die das zugehörige Inhaltselement per aria-controls verweist.
8Warum ist die Überschriftenhierarchie bei verschachtelten Akkordeons wichtig?
Eine Kategorie-Überschrift auf h3-Ebene gefolgt von Unterfragen auf h4-Ebene erlaubt Screenreader-Nutzern, die inhaltliche Hierarchie über die Überschriften-Navigation nachzuvollziehen.
9Wie lässt sich die Performance bei vielen Unterfragen verbessern?
Mit x-if statt x-show wird der Inhalt geschlossener Unterfragen erst beim ersten Öffnen tatsächlich ins DOM gerendert, was den initialen Render-Aufwand reduziert.
10Wann lohnt sich ein zentraler Alpine.store statt getrennter Scopes?
Bei sehr tiefer Verschachtelung mit vier oder mehr Ebenen und komplexen Abhängigkeiten zwischen den Ebenen, während zwei bis drei Ebenen meist mit getrennten Scopes auskommen.