Nested Accordions: Correctly Managing Multiple Levels with Alpine.js
AI generated
x-data
Alpine
Alpine.js / Practical Case Study
Nested Accordions: Correctly Managing Multiple Levels
independent yet cleanly encapsulated state for parent and child accordions

A single accordion is done in a few lines with Alpine.js, but as soon as one accordion entry itself contains another accordion, for instance FAQ categories with several sub-questions, surprises quickly show up: clicking a sub-question mistakenly closes the entire parent category, because click events and shared state get mixed up unintentionally. Building robust nested accordions requires understanding how Alpine.js scopes nest inside each other, how click events propagate upward through the DOM tree, and how both levels can be cleanly separated without resorting to two entirely independent, unconnected components.

10 min read Separating parent and child scope Controlling event propagation FAQ categories with sub-questions

1. The core problem: shared state in naively nested components

The most obvious, but also most error-prone approach to a nested accordion is controlling both the parent and the child accordion with the same, generic variable such as open and leaving both levels in the same x-data scope. But as soon as a sub-question is supposed to change its own state on click, the same variable mistakenly also reaches into the parent level, because Alpine.js resolves variables in the nearest enclosing x-data scope and automatically falls back to the next surrounding scope when there is no own declaration.

The real problem, then, is not Alpine.js itself, but an unclear scope structure: every nesting level needs its own, clearly bounded x-data scope with its own variable names, so a click within a sub-question never accidentally alters variables of the parent level too. This separation is the single most important building block for robust nested accordions.

2. Clean scope separation between parent and child level

The robust solution gives each level its own, self-contained component with its own, independent state. The parent category only manages whether the category itself is open, while every sub-question inside that category gets its own, independent x-data with its own open variable. Because Alpine.js opens a new, self-contained scope for every x-data attribute, identically named variables can coexist on different levels without overwriting each other.

It matters that the child component does NOT try to reach into the parent component's state via $parent unless that is explicitly intended, for instance to automatically expand the parent category too when a sub-question opens. An accidental $parent access out of convenience quickly reintroduces exactly the coupling that clean scope separation is meant to avoid.


function accordionCategory() {
    return {
        open: false,
        toggle() {
            this.open = !this.open;
        },
    };
}

function accordionSubQuestion() {
    return {
        open: false,
        toggle() {
            this.open = !this.open;
        },
    };
}

3. Understanding click events and their propagation through the DOM tree

Even with cleanly separated scopes, another problem can arise if the sub-question's click handler is not cleanly isolated from the parent category's click handler: a click event, by default in JavaScript, propagates upward from the clicked element through all enclosing elements, known as event bubbling. If the parent category's click handler sits on an element that also encloses the sub-question, a click on the sub-question would additionally trigger the parent category's handler, unless that is explicitly stopped.

The solution is the Alpine.js .stop modifier, which internally calls event.stopPropagation() and prevents the click event from being passed further upward beyond the element. With cleanly separated toggle buttons per level, as shown in the next code example, .stop is in most cases not strictly required, since the buttons are not nested inside each other anyway, but it doesn't hurt as a defensive safeguard.

4. Practical example: FAQ categories with sub-questions

In the concrete markup, every category gets its own x-data="accordionCategory()" scope, and within the category, every sub-question in turn gets its own x-data="accordionSubQuestion()" scope. The category's toggle button exclusively controls open within the category scope, while every sub-question holds its own, independent open value in its own scope. This preserves each sub-question's state even when the category gets closed and reopened in between.

This structure lets users keep several sub-questions open at the same time while navigating through different categories, which in practice matches the expected behavior of an FAQ section with sub-questions: closing the parent category hides the entire content area, but does not change the individual open state of the sub-questions, so reopening the category restores the last-seen state.


<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. Controlling only one open item per level with a shared index

Some FAQ sections are meant to work so that only one sub-question can be open at a time within a category, while other categories remain unaffected. For that, the independent open per sub-question is no longer enough, instead every category needs its own, shared index value that all sub-questions within the same category read together.

It matters that this index value lives in the scope of the respective category, not globally across the whole page, since otherwise opening a sub-question in one category would mistakenly also close sub-questions in a completely different, unrelated category. Per-category encapsulation therefore remains decisive even in this variant.


function accordionCategory() {
    return {
        open: false,
        openQuestionIndex: null,
        toggleQuestion(index) {
            this.openQuestionIndex = this.openQuestionIndex === index ? null : index;
        },
    };
}

6. Practical controls: closing all categories at once

A frequently requested feature is a global close-all button above all FAQ categories. Since every category holds its own, isolated state, the parent page cannot directly manipulate that state without the categories themselves reacting to a corresponding signal. The cleanest solution is custom events: the close-all button fires an event via $dispatch that every category component listens for via @close-all.window and resets its own, local state in response.

This approach still respects the encapsulation of every individual component, since no component reaches directly into another's internal state, but merely reacts to a publicly visible, loosely coupled event. That's the same underlying idea as server-side observer patterns and can be extended to further shared controls such as an open-all button.


<button @click="$dispatch('close-all')" class="mb-4">
    Close all categories
</button>

<div x-data="accordionCategory()" @close-all.window="open = false">
    <!-- category content -->
</div>

7. Accessibility considerations for deep nesting

Every level of a nested accordion needs its own, correct ARIA attributes: every toggle button, whether on the parent or child level, gets aria-expanded dynamically bound to that level's own open value, plus a unique id that the associated, expandable content element references via aria-controls. With deep nesting, it is especially important that these IDs are genuinely unique across all levels, for instance through a combination of category and question index, since duplicate IDs get handled inconsistently by screen readers.

Additionally, the semantic heading hierarchy should mirror the visual nesting: a category heading at the h3 level should be followed by sub-questions at the h4 level, so screen reader users can follow the actual content hierarchy through heading navigation instead of just seeing a flat list of visually similar items. For keyboard users, a logical tab order is also decisive: when a category is closed, the now-invisible sub-question buttons inside it should automatically get removed from the tab order via x-collapse, which x-collapse combined with x-show already handles automatically.

8. Performance with many categories that each have many sub-questions

With FAQ sections containing twenty or more categories, each with several sub-questions, Alpine.js creates its own reactive scope for every single sub-question, which can cause noticeable initialization overhead on the first render for very large total element counts. An effective optimization is not fully rendering the content of closed sub-questions into the DOM at first, but using x-if instead of x-show to actually create it only on first opening.

This approach is particularly well suited for sub-questions with extensive answer text or embedded images, where the extra cost of repeatedly creating and removing the DOM node clearly pays off against the saved initial render time. For short, simple text answers, the difference is usually negligible, and the simpler x-show variant remains preferable, since it preserves the last-seen scroll state within the answer.

9. Limits of this approach and when centralized state management pays off

The approach shown here, with independent, per-level encapsulated scopes, works reliably up to a manageable nesting depth of two to three levels, typical for FAQ categories with sub-questions. With even deeper nesting, for instance a fourth or fifth level with complex dependencies between levels, such as when opening a child item should automatically expand several parent levels at once, pure custom-event communication increasingly becomes hard to follow.

In such cases, switching to a centralized Alpine.store that holds the entire tree state as a nested data structure and gets read and written equally by all levels pays off. For the typical FAQ-categories application with at most two nesting levels, however, this extra effort is rarely justified, and the simpler scope separation shown here remains the more pragmatic choice.

Aspect Naive shared state Cleanly separated scopes Practical relevance
Variable collision open identical on parent and child level Own x-data scope per level No accidental co-closing
Event propagation Click bubbles to the parent handler Separate toggle buttons, optional .stop No unintended triggering
Only one sub-question open Not possible without extra logic Shared index value in the category scope Clearly defined UI behavior
Close all Direct state access required Custom event via $dispatch Encapsulation is preserved
Very deep nesting Quickly becomes hard to follow Alpine.store worth it from four levels up Two to three levels are usually enough

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Nested Accordions with Alpine.js: The Essentials at a Glance

Core problem

Shared variable names across nesting levels cause opening a sub-question to mistakenly also affect the parent category.

Solution

Every nesting level gets its own, independent x-data scope with its own variable names, without unnecessary $parent access.

Shared behavior

A close-all button communicates via a custom event with $dispatch instead of directly reaching into other components' internal state.

Accessibility

Unique IDs per level for aria-controls, dynamic aria-expanded, and a heading hierarchy matching the nesting are mandatory.

11. FAQ: Nested Accordions with Alpine.js: The Essentials at a Glance

1Why does the parent category close along with a sub-question in the naive approach?
If both levels use the same variable such as open in the same scope, clicking the sub-question mistakenly also triggers the parent level.
2How are parent and child state cleanly separated?
Every nesting level gets its own, independent x-data scope with its own variable names, so identically named variables never collide.
3When is the .stop modifier needed for nested accordions?
When the parent level's click handler sits on an element that also encloses the child level, .stop prevents the click event from unintentionally bubbling up.
4How does the state of individual sub-questions stay preserved when the category closes?
Since every sub-question holds its own, independent open value in its own scope, that value stays unchanged even when the parent category gets closed in between.
5How is it achieved that only one sub-question per category is open at a time?
A shared index value in the scope of the respective category is read together by all associated sub-questions and updated on toggle.
6How does a global close-all button work without breaking encapsulation?
Via a custom event through $dispatch that every category component listens for via @close-all.window and resets its own, local state in response.
7Which ARIA attributes does every level of a nested accordion need?
Every toggle button needs dynamic aria-expanded plus a unique id that the associated content element references via aria-controls.
8Why does the heading hierarchy matter for nested accordions?
A category heading at the h3 level followed by sub-questions at the h4 level lets screen reader users follow the content hierarchy through heading navigation.
9How can performance be improved with many sub-questions?
Using x-if instead of x-show renders closed sub-questions' content into the DOM only on first opening, reducing the initial render cost.
10When does a centralized Alpine.store pay off over separate scopes?
With very deep nesting of four or more levels and complex dependencies between levels, while two to three levels usually work fine with separate scopes.