Mid-level 6 min · March 29, 2026

Bootstrap Accordion Plus/Minus — Fix the Frozen Icon Bug

CSS showed '+' on all states because the .

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Bootstrap accordion uses data-bs-toggle="collapse" to toggle panels without custom JS.
  • Plus/minus icon is pure CSS: ::before pseudo-element with content swapping on .accordion-button:not(.collapsed).
  • data-bs-parent enforces mutual exclusivity — miss it on one panel and multiple panels open simultaneously.
  • ARIA attributes (aria-expanded, aria-controls, aria-labelledby) are required for screen readers and must match initial visual state.
  • Programmatic control via bootstrap.Collapse API requires { toggle: false } to avoid double-toggles on construction.
Plain-English First

Picture a stack of filing cabinet drawers. Each drawer has a label on the front. You pull one open to see its contents, and the others stay shut — the cabinet doesn't explode every drawer at once just because you touched one. A Bootstrap accordion is exactly that for a webpage: a stack of labelled panels where clicking one opens it and the rest stay collapsed. The plus/minus icon is just the handle on the drawer — it tells you at a glance whether a drawer is open or shut.

The most common Bootstrap accordion bug I've debugged across five different client projects wasn't a JavaScript error — it was a plus sign that never became a minus sign, because someone copy-pasted the HTML from the docs and didn't read the part about data attributes. The accordion opened and closed just fine, but the icon stayed frozen as a plus forever. Users assumed it was broken and called support. That was a $40/hour support ticket for six lines of CSS that should have been there from day one.

HTML pages with dense content — FAQs, product details, settings panels — have a real problem: they dump everything on screen at once and users scroll endlessly looking for the one thing they need. Before accordions existed as a proper pattern, developers were writing raw jQuery show/hide spaghetti, duplicating event listeners, and forgetting to handle keyboard navigation entirely. Accessibility auditors had a field day. The Bootstrap accordion component solves this with a consistent, accessible, keyboard-navigable collapsible panel system — and it does it without a single line of custom JavaScript if you wire it up correctly.

By the end of this article you'll be able to build a fully working Bootstrap 5 accordion with a real plus/minus icon that flips state on open and close, understand exactly which HTML attributes control the collapse behaviour, and know the three mistakes that make accordions silently break in production — before you ship them.

How Bootstrap's Collapse Engine Actually Works Under the Hood

Before you touch a single accordion, you need to understand what's actually driving it — because the moment you don't, you'll spend an afternoon wondering why your panel won't open and the console is completely silent.

Bootstrap's accordion is built on top of its Collapse plugin. That plugin watches for click events on any element that has a data-bs-toggle="collapse" attribute. When clicked, it finds the target element — identified by data-bs-target or href — and toggles the CSS classes show and collapsed on the relevant elements. That's it. There's no magic. It's a class toggler with some CSS transitions baked in.

The accordion layer adds one rule on top: data-bs-parent. Set this attribute to the ID of the accordion's wrapper element, and Bootstrap will automatically close any currently open panel when you open a new one. Without data-bs-parent, every panel opens independently — which is sometimes what you want, but not what most people picture when they say 'accordion'.

Why does this matter practically? Because if you forget data-bs-parent on even one panel, that panel will open alongside others instead of replacing them. Users see two panels open at once, assume the component is broken, and your UX scores take a hit. I've seen this exact bug ship to production because a developer added a fourth panel to an existing three-panel accordion and didn't copy the attribute.

AccordionStructure.htmlHTML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<!-- io.thecodeforge — JavaScript tutorial -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Bootstrap AccordionTheCodeForge</title>

  <!-- Bootstrap 5 CSS from CDN — no npm required for this demo -->
  <link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
  />
</head>
<body class="p-4">

  <!--
    WRAPPER: id="faqAccordion" is the anchor point for data-bs-parent.
    Every panel inside MUST reference this ID or the
    'only one open at a time' behaviour will silently break.
  -->
  <div class="accordion" id="faqAccordion">

    <!-- ── PANEL 1 ─────────────────────────────────────── -->
    <div class="accordion-item">

      <!--
        accordion-header wraps the button.
        The id here ("headingShipping") is referenced by
        aria-labelledby on the body panel below — required for screen readers.
      -->
      <h2 class="accordion-header" id="headingShipping">
        <button
          class="accordion-button"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#collapseShipping"
          aria-expanded="true"
          aria-controls="collapseShipping"
        >
          How long does shipping take?
        </button>
      </h2>

      <!--
        accordion-collapse is the panel body wrapper.
        'show' class = visible on page load (matches aria-expanded="true" above).
        data-bs-parent="#faqAccordion" is what enforces mutual exclusivity.
        NEVER omit this — see callout below.
      -->
      <div
        id="collapseShipping"
        class="accordion-collapse collapse show"
        aria-labelledby="headingShipping"
        data-bs-parent="#faqAccordion"
      >
        <div class="accordion-body">
          Standard shipping takes 35 business days.
          Express options are available at checkout.
        </div>
      </div>
    </div>
    <!-- ── END PANEL 1 ──────────────────────────────────── -->

    <!-- ── PANEL 2 ─────────────────────────────────────── -->
    <div class="accordion-item">
      <h2 class="accordion-header" id="headingReturns">
        <!--
          No 'show' class on this button's panel = starts collapsed.
          aria-expanded="false" must match — Bootstrap won't auto-correct this.
        -->
        <button
          class="accordion-button collapsed"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#collapseReturns"
          aria-expanded="false"
          aria-controls="collapseReturns"
        >
          What is your return policy?
        </button>
      </h2>
      <div
        id="collapseReturns"
        class="accordion-collapse collapse"
        aria-labelledby="headingReturns"
        data-bs-parent="#faqAccordion"
      >
        <div class="accordion-body">
          Returns are accepted within 30 days of delivery.
          Items must be unused and in original packaging.
        </div>
      </div>
    </div>
    <!-- ── END PANEL 2 ──────────────────────────────────── -->

  </div>
  <!-- ── END ACCORDION WRAPPER ───────────────────────────── -->

  <!-- Bootstrap 5 JS bundle (includes Popper) — must be AFTER the HTML -->
  <script
    src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js">
  </script>

</body>
</html>
Output
Page renders with two accordion panels.
Panel 1 ('How long does shipping take?') is open by default.
Panel 2 ('What is your return policy?') is collapsed.
Clicking Panel 2 opens it and simultaneously closes Panel 1.
Clicking Panel 1 again reopens it and closes Panel 2.
Keyboard navigation (Tab + Enter/Space) works without any custom JS.
Production Trap: Missing data-bs-parent Breaks Mutual Exclusivity Silently
If you omit data-bs-parent="#faqAccordion" from even one panel's accordion-collapse div, that panel will open alongside others instead of replacing them. The console shows zero errors. The only symptom is two panels open at once — which users report as 'the accordion is broken'. Fix: grep your accordion HTML for every accordion-collapse element and confirm every single one carries the matching data-bs-parent attribute.
Production Insight
I once saw a four-panel accordion where only the first panel had data-bs-parent. The developer added three more panels thinking the attribute was inherited. It's not.
The result: panels 2, 3, and 4 opened independently while panel 1 closed as expected. Users saw a mix of open panels and called support.
Rule: data-bs-parent is per-element, not inherited. Every single panel must have it.
Key Takeaway
The data-bs-parent attribute must be present on every accordion-collapse div.
Without it, mutual exclusivity breaks silently.
Audit new panels with a search: grep 'accordion-collapse' and verify the attribute exists on each.

The Plus/Minus Toggle: Why Bootstrap Doesn't Give You This for Free

Here's something the Bootstrap docs bury in a footnote: the default accordion uses a CSS chevron (›) that rotates on open/close — it does NOT give you a plus/minus toggle out of the box. If your design calls for a + when collapsed and a − when expanded, you're doing that yourself. That's not a criticism of Bootstrap; it's just how it is. Know it going in.

The mechanism Bootstrap does give you for free is the collapsed class. When a panel is closed, the trigger button carries the class collapsed. When it's open, that class is absent. You can hook into this with CSS to swap any icon you want — no JavaScript required.

The cleanest production approach is to use CSS content on a pseudo-element tied to the button's state. You put your plus character as the default content, and you override it to a minus when the collapsed class is absent (meaning the panel is open). This is a single CSS rule pair. It costs you nothing at runtime, it works with Bootstrap's existing class-toggling behaviour, and it survives framework upgrades because it relies on the documented collapsed class — not internal Bootstrap implementation details that change between minor versions.

AccordionPlusMinus.htmlHTML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
<!-- io.thecodeforge — JavaScript tutorial -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>FAQ AccordionPlus/Minus Toggle</title>

  <link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
  />

  <style>
    /*
     * Step 1: Kill Bootstrap's default rotating chevron.
     * Bootstrap injects it via .accordion-button::after using a background-image.
     * Setting background-image to none removes it entirely.
     * We also clear the transform so Bootstrap's rotate animation doesn't fire.
     */
    .accordion-button::after {
      background-image: none !important;
      transform: none !important;
      content: '';
    }

    /*
     * Step 2: Create our own icon element via ::before.
     * We use ::before (not ::after) so the icon sits on the LEFT
     * of the button text, matching the design spec for this example.
     * Swap to ::after and adjust margin if your design puts it on the right.
     */
    .accordion-button::before {
      /*
       * Default state = button is COLLAPSED = panel is CLOSED.
       * Show a plus sign.
       */
      content: '+';
      font-size: 1.25rem;
      font-weight: 700;
      line-height: 1;
      margin-right: 0.75rem;

      /*
       * Prevent the icon from shifting layout when it changes character.
       * A minus sign is narrower than a plus in most fonts — inline-block
       * with a fixed width keeps the text from jumping left on open.
       */
      display: inline-block;
      width: 1rem;
      text-align: center;

      /*
       * Smooth the character swap with opacity — purely cosmetic but
       * removes the jarring instant change that looks like a bug.
       */
      transition: opacity 0.15s ease-in-out;
    }

    /*
     * Step 3: When the button does NOT have the 'collapsed' class,
     * the panel is OPEN. Swap the plus for a minus.
     *
     * Key insight: Bootstrap ADDS 'collapsed' when closing and REMOVES it
     * when opening. So 'no collapsed class' = open state.
     * We target the ABSENCE of the class, not its presence.
     */
    .accordion-button:not(.collapsed)::before {
      content: '\2212'; /* Unicode minus sign — visually wider than a hyphen */
    }

    /* Optional: remove Bootstrap's default blue focus outline colour change */
    .accordion-button:not(.collapsed) {
      color: inherit;
      background-color: #f8f9fa;
      box-shadow: none;
    }
  </style>
</head>
<body class="p-4">

  <h1 class="mb-4">Frequently Asked Questions</h1>

  <div class="accordion" id="productFaqAccordion">

    <!-- ── PANEL 1: Starts OPEN ────────────────────────── -->
    <div class="accordion-item">
      <h2 class="accordion-header" id="headingPayment">
        <button
          class="accordion-button"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#collapsePayment"
          aria-expanded="true"
          aria-controls="collapsePayment"
        >
          What payment methods do you accept?
        </button>
      </h2>
      <div
        id="collapsePayment"
        class="accordion-collapse collapse show"
        aria-labelledby="headingPayment"
        data-bs-parent="#productFaqAccordion"
      >
        <div class="accordion-body">
          We accept Visa, Mastercard, PayPal, and bank transfer.
          All transactions are encrypted with TLS 1.3.
        </div>
      </div>
    </div>

    <!-- ── PANEL 2: Starts COLLAPSED ─────────────────────── -->
    <div class="accordion-item">
      <h2 class="accordion-header" id="headingWarranty">
        <!--
          'collapsed' class is present here because this panel starts closed.
          This means our CSS default (plus sign) shows immediately on load.
          If you forget this class, the button shows a minus sign on load
          even though the panel is closed — that's the classic mismatch bug.
        -->
        <button
          class="accordion-button collapsed"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#collapseWarranty"
          aria-expanded="false"
          aria-controls="collapseWarranty"
        >
          Does my purchase include a warranty?
        </button>
      </h2>
      <div
        id="collapseWarranty"
        class="accordion-collapse collapse"
        aria-labelledby="headingWarranty"
        data-bs-parent="#productFaqAccordion"
      >
        <div class="accordion-body">
          All hardware products carry a 24-month manufacturer warranty.
          Software licences are covered under our 30-day satisfaction guarantee.
        </div>
      </div>
    </div>

    <!-- ── PANEL 3: Starts COLLAPSED ─────────────────────── -->
    <div class="accordion-item">
      <h2 class="accordion-header" id="headingCancellation">
        <button
          class="accordion-button collapsed"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#collapseCancellation"
          aria-expanded="false"
          aria-controls="collapseCancellation"
        >
          Can I cancel my subscription at any time?
        </button>
      </h2>
      <div
        id="collapseCancellation"
        class="accordion-collapse collapse"
        aria-labelledby="headingCancellation"
        data-bs-parent="#productFaqAccordion"
      >
        <div class="accordion-body">
          Yes. Cancel from your account dashboard any time.
          You keep access until the end of your billing period.
        </div>
      </div>
    </div>

  </div>

  <script
    src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js">
  </script>

</body>
</html>
Output
Page loads with Panel 1 open, showing a '−' icon on its button.
Panels 2 and 3 are collapsed, each showing a '+' icon.
Clicking Panel 2: Panel 1 closes (icon flips to '+'), Panel 2 opens (icon flips to '−').
Clicking Panel 2 again: Panel 2 closes (icon returns to '+'), panel stays closed.
Icon transitions have a 0.15s opacity fade.
No JavaScript written. No jQuery. Zero custom event listeners.
Senior Shortcut: Use Unicode \2212 for Minus, Not a Hyphen
The hyphen character (-) is narrower than a plus sign (+) in almost every font. Using a hyphen as your 'minus' makes the button text visually jump left when the panel opens because the icon shrinks. Use the actual Unicode minus sign \2212 in your CSS content property — it's the same width as a plus and looks intentional rather than hacked together.
Production Insight
In a production settings panel, the plus/minus swap looked fine in Chrome but broke in Firefox because the hyphen width varied. The text jittered every time a panel opened.
Switching to \2212 fixed it in all browsers.
Rule: when swapping icons with CSS content, use Unicode characters with consistent width, or set a fixed width on the pseudo-element.
Key Takeaway
The collapsed class is your hook — Bootstrap adds/removes it on every toggle.
Use .accordion-button:not(.collapsed)::before to swap icons with zero JS.
Always use Unicode minus \2212 for the open state to avoid layout shifts.

Accessibility and the ARIA Attributes You Can't Skip

I've watched a Bootstrap accordion sail through QA and get flagged on the first accessibility audit because the developer treated aria-expanded as decorative. It's not. Screen readers use aria-expanded to announce whether a panel is open or closed — without it, a visually impaired user hears a button with no context and has to guess what it controls.

The good news: Bootstrap's Collapse plugin automatically toggles aria-expanded for you between true and false as panels open and close. Your only job is to make sure the initial HTML state is honest. If a panel starts collapsed, aria-expanded must be false on load. If it starts open, it must be true. Bootstrap doesn't backfill this on page load — it only manages it after the first interaction.

The other attribute people skip is aria-controls. This tells assistive technology which element the button controls — it should match the id of the accordion-collapse div. And the aria-labelledby on the collapse div should point back to the accordion-header id. These two form a two-way relationship that lets screen readers navigate the accordion as a coherent structure rather than a pile of unrelated buttons and divs. Skip either one and you'll fail WCAG 2.1 Level AA — which matters if you're building anything for a government contract, healthcare platform, or any company that takes accessibility law seriously.

AccordionAccessibility.htmlHTML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<!-- io.thecodeforge — JavaScript tutorial -->

<!--
  This shows the complete ARIA relationship map for one accordion panel.
  Annotated specifically to show what each accessibility attribute does
  and what breaks if you remove it.
-->

<div class="accordion" id="settingsAccordion">

  <div class="accordion-item">

    <!--
      aria-labelledby on the collapse div below will point to THIS id.
      The heading id must be unique per page — not just per accordion.
      Using a descriptive id ('headingNotifications') beats 'heading1'
      because it's readable in accessibility tree inspectors.
    -->
    <h2 class="accordion-header" id="headingNotifications">

      <button
        class="accordion-button collapsed"
        type="button"
        data-bs-toggle="collapse"
        data-bs-target="#collapseNotifications"

        <!--
          aria-expanded MUST match visual state on load.
          'false' here = panel is visually collapsed = correct.
          Bootstrap updates this attribute dynamically after first click.
          If you ship aria-expanded="false" on an open panel,
          screen readers announce it as closed — users can't trust the page.
        -->
        aria-expanded="false"

        <!--
          aria-controls = id of the panel this button controls.
          Screen readers use this to let users jump directly to the
          content without having to tab through every element.
        -->
        aria-controls="collapseNotifications"
      >
        Notification Preferences
      </button>
    </h2>

    <div
      id="collapseNotifications"
      class="accordion-collapse collapse"

      <!--
        aria-labelledby creates the reverse link:
        this panel announces it is labelled by the heading button above.
        Must match the accordion-header's id exactly — case-sensitive.
      -->
      aria-labelledby="headingNotifications"

      data-bs-parent="#settingsAccordion"
    >
      <div class="accordion-body">
        <p>Choose how and when you receive alerts.</p>

        <!-- Real settings form would live here -->
        <div class="form-check">
          <input class="form-check-input" type="checkbox" id="emailAlerts" />
          <label class="form-check-label" for="emailAlerts">
            Email alerts for order updates
          </label>
        </div>

        <div class="form-check mt-2">
          <input class="form-check-input" type="checkbox" id="smsAlerts" />
          <label class="form-check-label" for="smsAlerts">
            SMS alerts for shipping changes
          </label>
        </div>
      </div>
    </div>

  </div>
</div>
Output
Panel starts collapsed — button reads: 'Notification Preferences, collapsed, button' to screen readers.
After clicking: 'Notification Preferences, expanded, button' — Bootstrap flips aria-expanded automatically.
Keyboard: Tab to button, Space or Enter to toggle. No mouse required.
Accessibility tree shows button controls 'collapseNotifications' via aria-controls.
Panel announces its label as 'Notification Preferences' via aria-labelledby.
Never Do This: aria-expanded Mismatch on Page Load
If a panel starts visually open (has the show class) but its button has aria-expanded="false", screen readers announce the panel as closed. Users navigate away thinking there's no content. Bootstrap will NOT fix this mismatch on load — it only manages aria-expanded after the first user interaction. The symptom is invisible in a browser but will fail any WCAG audit instantly. Rule: show class on the panel = aria-expanded="true" on the button. No exceptions.
Production Insight
During an accessibility audit for a health insurance portal, the QA tested with NVDA. The first panel was open on load but aria-expanded was false. The screen reader said 'Apply online, collapsed, button'. Users would never know the form was visible.
The fix was a one-character change from false to true in the HTML.
Rule: always double-check initial ARIA state matches the show class.
Key Takeaway
aria-expanded must match show class on page load — Bootstrap won't correct it.
aria-controls links button to panel content for screen readers.
aria-labelledby on the panel points back to the heading — complete the two-way link.

Controlling the Accordion with JavaScript When HTML Attributes Aren't Enough

Data attributes get you 90% of the way there. But there are real production scenarios where you need JavaScript control — opening a specific panel based on a URL hash, programmatically closing all panels when a user submits a form, or listening to open/close events to trigger analytics tracking.

Bootstrap exposes a JavaScript API for the Collapse component. You get a bootstrap.Collapse instance either by constructing one with new bootstrap.Collapse(element, options) or by grabbing one that already exists with bootstrap.Collapse.getInstance(element). The key methods are .show(), .hide(), and .toggle(). The key events are show.bs.collapse, shown.bs.collapse, hide.bs.collapse, and hidden.bs.collapse — fired on the collapse element itself, not on the button.

One thing that trips people up: if you construct a new bootstrap.Collapse(element) without passing { toggle: false }, Bootstrap immediately toggles the panel on construction. That means creating an instance to attach an event listener unintentionally opens the panel. Always pass { toggle: false } when you're constructing an instance purely to get programmatic control or listen to events.

AccordionJavaScriptControl.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// io.thecodeforge — JavaScript tutorial

// ─── SCENARIO ───────────────────────────────────────────────────────────────
// A product page accordion where:
// 1. The correct panel auto-opens based on the URL hash (e.g. #collapseWarranty)
// 2. All panels can be force-closed when the user hits 'Reset filters'
// 3. A 'panel opened' event is sent to analytics
// ─────────────────────────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', function () {

  // ── 1. OPEN PANEL FROM URL HASH ──────────────────────────────────────────
  //
  // If the URL is /product#collapseWarranty, open that panel automatically.
  // Useful for deep-linking from emails or support articles.

  const urlHash = window.location.hash; // e.g. "#collapseWarranty"

  if (urlHash) {
    const targetPanel = document.querySelector(urlHash);

    if (targetPanel && targetPanel.classList.contains('accordion-collapse')) {
      /*
       * { toggle: false } is critical here.
       * Without it, constructing the instance triggers an immediate toggle —
       * which would open AND THEN CLOSE the panel before the user sees anything.
       * With toggle: false, we construct the instance without touching panel state.
       */
      const collapseInstance = new bootstrap.Collapse(targetPanel, {
        toggle: false
      });

      // Now explicitly open it — clean, predictable, one-way
      collapseInstance.show();
    }
  }

  // ── 2. FORCE-CLOSE ALL PANELS ON 'RESET' ────────────────────────────────
  //
  // A 'Reset filters' button that also collapses all open accordion panels
  // to return the UI to its default state.

  const resetButton = document.getElementById('resetFiltersButton');

  if (resetButton) {
    resetButton.addEventListener('click', function () {

      // Grab every open panel (has the 'show' class)
      const openPanels = document.querySelectorAll(
        '#productFaqAccordion .accordion-collapse.show'
      );

      openPanels.forEach(function (panel) {
        /*
         * getInstance returns the existing Collapse instance if Bootstrap
         * already manages this element. Returns null if no instance exists yet.
         * Using getInstance avoids creating duplicate instances,
         * which can cause double-fire events in Bootstrap 5.
         */
        const existingInstance = bootstrap.Collapse.getInstance(panel);

        if (existingInstance) {
          existingInstance.hide();
        } else {
          // No instance yet — create one just to hide, without toggling on construct
          new bootstrap.Collapse(panel, { toggle: false }).hide();
        }
      });
    });
  }

  // ── 3. ANALYTICS EVENT ON PANEL OPEN ─────────────────────────────────────
  //
  // Fire a tracking event every time a panel finishes opening.
  // Using 'shown.bs.collapse' (past tense) rather than 'show.bs.collapse'
  // means the animation has completed — the panel is fully visible.
  // This avoids tracking panels the user flicked open and immediately closed
  // before the animation finished.

  const accordionWrapper = document.getElementById('productFaqAccordion');

  if (accordionWrapper) {
    accordionWrapper.addEventListener('shown.bs.collapse', function (event) {
      /*
       * event.target is the collapse panel element that just opened.
       * Its id is the panel identifier we use to label the analytics event.
       */
      const openedPanelId = event.target.id;

      // Replace with your actual analytics call (GA4, Segment, Mixpanel, etc.)
      console.log('[Analytics] Accordion panel opened:', openedPanelId);

      /*
       * Real-world example:
       * window.dataLayer.push({
       *   event: 'accordion_panel_opened',
       *   panel_id: openedPanelId,
       *   page_path: window.location.pathname
       * });
       */
    });
  }

});

/*
 * BOOTSTRAP COLLAPSE EVENTSQUICK REFERENCE
 * ─────────────────────────────────────────────────────────────
 * show.bs.collapse    — fires when show() is called (animation starts)
 * shown.bs.collapse   — fires when panel is fully open (animation ends)
 * hide.bs.collapse    — fires when hide() is called (animation starts)
 * hidden.bs.collapse  — fires when panel is fully closed (animation ends)
 *
 * All four fire on the PANEL ELEMENT, not on the trigger button.
 * Delegate from the accordion wrapper to catch all panels in one listener.
 */
Output
URL /product#collapseWarranty → Warranty panel opens automatically on load.
'Reset filters' button click → all open panels collapse cleanly.
Each time a panel finishes opening → console logs:
[Analytics] Accordion panel opened: collapseWarranty
[Analytics] Accordion panel opened: collapsePayment
No duplicate instances. No double-fire events. No unintentional toggles on construction.
The Classic Bug: new bootstrap.Collapse() Without { toggle: false }
Every time I've seen a Bootstrap panel mysteriously open and immediately close on its own, the culprit is new bootstrap.Collapse(element) without { toggle: false }. Bootstrap treats construction as implicit activation — so it toggles the panel the moment you create the instance. Then your .show() call toggles it again, closing it. Always pass { toggle: false } when constructing an instance for programmatic control. Always use bootstrap.Collapse.getInstance() first to avoid creating a second instance on an element Bootstrap already manages.
Production Insight
I once integrated an accordion with a URL hash feature. The code looked clean: construct with new bootstrap.Collapse(targetPanel).show(). But on page load, the panel opened and immediately closed — visible flicker. The constructor fired one toggle, then .show() fired another.
The fix: add { toggle: false } to the constructor.
Rule: when constructing a Collapse instance, always pass options with { toggle: false } unless you explicitly want an immediate toggle.
Key Takeaway
Construct bootstrap.Collapse with { toggle: false } to avoid double-toggles.
Use getInstance to reuse existing instances — never create two on the same element.
Listen on the wrapper for shown.bs.collapse to track analytics after animation completes.

Dynamic Accordion: Adding and Removing Panels at Runtime with JavaScript

Static accordions are fine for fixed content like FAQs. But in production, you'll often need to add panels dynamically — think product filters that load options from an API, or a settings page where user roles determine which panels appear. Doing this with Bootstrap requires careful handling because the Collapse plugin doesn't automatically listen for new DOM nodes.

When you add a new accordion-item to an existing accordion, Bootstrap won't know about it until the user clicks the button. That's fine for data-attribute-driven toggling — Bootstrap listens globally on the document for clicks on [data-bs-toggle="collapse"]. So new panels will work for toggling as soon as they're inserted. But there's a catch: the data-bs-parent attribute needs to point to the correct wrapper ID, and if you're adding multiple panels, you must ensure each has a unique ID for the collapse targets and heading IDs.

For programmatic control over dynamically added panels, you need to create Collapse instances after insertion. There's a gotcha: if you call new bootstrap.Collapse() on a panel that's still being animated (e.g., you add it while another panel is opening), the methods may queue unexpectedly. The safe pattern is to insert the panel, wait a frame via requestAnimationFrame or setTimeout(fn, 0), then initialise the Collapse instance.

Also, when removing panels, you must destroy the Collapse instance with .dispose() to clean up event listeners. Failure to do so causes memory leaks and can break other panels if event handlers reference removed elements.

AccordionDynamicPanels.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// io.thecodeforge — JavaScript tutorial

// ─── SCENARIO: Load product categories from API and add as accordion panels ──
// Each category has a name and description. We add them dynamically.

const accordionWrapper = document.getElementById('categoryAccordion');
let panelCounter = 0; // For unique IDs

/**
 * Add a single accordion panel from data.
 * @param {string} headingText - The button label
 * @param {string} bodyContent - HTML content for the body
 * @param {boolean} openInitially - Whether this panel starts open
 */
function addAccordionPanel(headingText, bodyContent, openInitially = false) {
  panelCounter++;

  const headingId = `headingDynamic${panelCounter}`;
  const collapseId = `collapseDynamic${panelCounter}`;

  // Create the accordion-item element
  const item = document.createElement('div');
  item.className = 'accordion-item';

  // Header
  const header = document.createElement('h2');
  header.className = 'accordion-header';
  header.id = headingId;

  const button = document.createElement('button');
  button.className = `accordion-button${openInitially ? '' : ' collapsed'}`;
  button.type = 'button';
  button.dataset.bsToggle = 'collapse';
  button.dataset.bsTarget = `#${collapseId}`;
  button.setAttribute('aria-expanded', openInitially ? 'true' : 'false');
  button.setAttribute('aria-controls', collapseId);
  button.textContent = headingText;

  header.appendChild(button);
  item.appendChild(header);

  // Collapse body
  const collapse = document.createElement('div');
  collapse.id = collapseId;
  collapse.className = `accordion-collapse collapse${openInitially ? ' show' : ''}`;
  collapse.setAttribute('aria-labelledby', headingId);
  collapse.dataset.bsParent = `#${accordionWrapper.id}`;

  const body = document.createElement('div');
  body.className = 'accordion-body';
  body.innerHTML = bodyContent;

  collapse.appendChild(body);
  item.appendChild(collapse);

  // Insert into DOM
  accordionWrapper.appendChild(item);

  // If we need programmatic control later, create Collapse instance
  // Wait a frame to ensure DOM is ready
  requestAnimationFrame(() => {
    if (openInitially) {
      // Panel is already shown via 'show' class, but we may want to manage it
      // This instance will be used for future hide/show calls
      new bootstrap.Collapse(collapse, { toggle: false });
    } else {
      // Bootstrap will create an instance lazily on first click — that's fine
    }
  });

  return item; // Return reference for future removal
}

/**
 * Remove an accordion panel and clean up its Collapse instance.
 * @param {HTMLElement} panelItem - The .accordion-item element to remove
 */
function removeAccordionPanel(panelItem) {
  const collapseEl = panelItem.querySelector('.accordion-collapse');
  if (collapseEl) {
    const instance = bootstrap.Collapse.getInstance(collapseEl);
    if (instance) {
      instance.dispose(); // Clean up event listeners
    }
  }
  panelItem.remove();
}

// ─── EXAMPLE USAGE ────────────────────────────────────────────────────────
// fetch categories from API
setTimeout(() => {
  addAccordionPanel('Electronics', 'Phones, Laptops, Tablets', true);
  addAccordionPanel('Clothing', 'Shirts, Pants, Accessories', false);
  addAccordionPanel('Books', 'Fiction, Non-fiction, Children\'s', false);
}, 500);

// Later, remove first panel
// const item = document.querySelector('#categoryAccordion .accordion-item');
// removeAccordionPanel(item);
Output
After 500ms, three new panels appear in the accordion.
Panel 'Electronics' is open by default, showing a minus icon.
The other two are closed with plus icons.
Clicking any panel toggles it and closes others (mutual exclusivity works).
Removing a panel with `removeAccordionPanel` cleans up its Collapse instance.
No memory leaks. No double-toggles.
Mental Model: Bootstrap's Collapse Plugin Uses Event Delegation
  • When you add a new panel with the correct data attributes, it works immediately without JS re-initialisation — because the global listener catches the click.
  • However, if you need programmatic control (.show(), .hide()) on a dynamically added panel, you must create a Collapse instance after insertion.
  • Always delay instance creation by one frame (requestAnimationFrame) to avoid race conditions with DOM insertion.
  • When removing a panel, call .dispose() on the Collapse instance to prevent memory leaks — event listeners can outlive the DOM element.
Production Insight
In a dashboard with user-configurable widgets, adding a new accordion panel worked for clicks but broke programmatic close-all on logout. Because no Collapse instance existed for the dynamically added panel, getInstance returned null and .hide() failed silently.
The fix: create instances for all dynamic panels after insertion.
Rule: if you need programmatic control over dynamic panels, initialise them explicitly with { toggle: false }.
Key Takeaway
Data-attribute accordions work on new panels automatically.
For programmatic control, create a Collapse instance after insertion.
Always .dispose() when removing panels to prevent memory leaks.
● Production incidentPOST-MORTEMseverity: high

The Frozen Plus Sign — A Production Support Nightmare

Symptom
All accordion buttons displayed a plus icon regardless of panel state. Clicking a panel opened it, but the icon never changed to a minus. The panel content worked fine.
Assumption
Developers assumed Bootstrap automatically managed the icon because the docs showed a rotating chevron. They didn't realise the plus/minus was custom CSS they had to write — and their CSS selectors were targeting the wrong class.
Root cause
The CSS used .accordion-button::after { content: '+'; } but never added the overriding rule for the expanded state. The button's collapsed class was being added/removed correctly, but the CSS didn't hook into it. Also, the background-image from Bootstrap was not cleared, so the default chevron overlay remained visible underneath the plus sign.
Fix
1. Remove Bootstrap's default chevron with background-image: none. 2. Use ::before (or ::after) for the custom icon. 3. Add .accordion-button:not(.collapsed)::before { content: '−'; } using Unicode minus \2212. 4. Test with both open and closed initial states.
Key lesson
  • Never assume Bootstrap handles icon state out of the box — it only toggles the collapsed class.
  • Always verify the CSS selector chain works for both collapsed (closed) and not(.collapsed) (open) states.
  • Use the browser's DevTools to inspect the button element and confirm which class is present at each state.
Production debug guideQuick guide for the most common Bootstrap accordion issues in production5 entries
Symptom · 01
Plus/minus icon never changes when panel opens/closes
Fix
Check CSS: ensure you have background-image: none !important on .accordion-button::after and your custom content rule for the expanded state uses .accordion-button:not(.collapsed)::before. Verify the collapsed class is applied on page load for closed panels.
Symptom · 02
Multiple panels open at the same time in an accordion
Fix
Inspect every accordion-collapse div. Missing data-bs-parent="#accordionId" on even one panel causes independent opening. Use DevTools element search for 'accordion-collapse' and verify each has the attribute.
Symptom · 03
Panel doesn't open at all on click, no console error
Fix
Check that data-bs-toggle="collapse" and data-bs-target="#panelId" are present on the button. Ensure the target ID matches an existing accordion-collapse with the same ID. Also ensure Bootstrap JS is loaded (not just CSS).
Symptom · 04
Panel opens and immediately closes on first click
Fix
Look for JavaScript code that calls new bootstrap.Collapse(element) without { toggle: false }. This creates an instant toggle. Switch to bootstrap.Collapse.getInstance(element) or add { toggle: false }.
Symptom · 05
Screen reader announces wrong panel state
Fix
Check aria-expanded on the button against the show class on the panel. If initial state is open, aria-expanded="true" and panel has show. Bootstrap only updates aria-expanded after first user interaction, not on load.
★ Accordion Quick-Fix Command ReferenceRun these commands in the browser console or terminal to diagnose common accordion issues without digging through docs.
Icon stuck as plus for all panels
Immediate action
Open DevTools Elements panel, select an accordion button. Check if `collapsed` class is present when panel is closed and absent when open. If classes correct, CSS is the problem.
Commands
document.querySelectorAll('.accordion-button').forEach(b => console.log(b.id, b.classList.contains('collapsed')))
getComputedStyle(document.querySelector('.accordion-button'), '::before').content
Fix now
Add CSS rule: .accordion-button:not(.collapsed)::before { content: '\2212'; } and remove Bootstrap's default chevron.
Multiple panels open simultaneously+
Immediate action
Identify the accordion wrapper ID. Check each panel's `data-bs-parent` attribute.
Commands
document.querySelectorAll('.accordion-collapse').forEach(p => console.log(p.id, p.getAttribute('data-bs-parent')))
document.querySelectorAll('.accordion-collapse.show').length
Fix now
Add data-bs-parent="#accordionId" to every .accordion-collapse div that should be part of the mutual-exclusion group.
Panel opens then closes instantly+
Immediate action
Check for duplicate Collapse instances. Look for `new bootstrap.Collapse` calls without `{ toggle: false }`.
Commands
const panel = document.querySelector('.accordion-collapse.show'); console.log(bootstrap.Collapse.getInstance(panel))
// If getInstance returns null, no instance yet. Check your JS for `new bootstrap.Collapse()`
Fix now
Replace new bootstrap.Collapse(panel) with new bootstrap.Collapse(panel, { toggle: false }) or use bootstrap.Collapse.getOrCreateInstance(panel, { toggle: false }).show().
Screen reader says 'closed' for an open panel on page load+
Immediate action
Inspect the button's `aria-expanded` attribute and the panel's `show` class.
Commands
const btn = document.querySelector('.accordion-button[aria-expanded="false"]'); btn.closest('.accordion-item').querySelector('.accordion-collapse').classList.contains('show')
// If true, the state mismatch exists. Fix the HTML.
Fix now
Set aria-expanded to match show class on the panel. For open panels: aria-expanded="true", closed: aria-expanded="false".
CSS-Only vs JavaScript-Driven Plus/Minus Icon
AspectCSS-Only Plus/Minus (::before)JavaScript-Driven Icon Swap
Implementation complexity~8 lines of CSS, zero JS20+ lines, event listeners required
Performance costZero — pure CSS pseudo-elementSmall: DOM query + listener on every toggle
Works without JS loadedYes — icon shows correct state even if JS failsNo — icon stuck in default state if JS errors
Custom icon libraries (Font Awesome, etc.)Requires extra CSS class manipulation on pseudo-elementStraightforward — toggle classes directly on element
Animation optionsLimited to CSS transitions on content propertyFull control — can trigger any CSS class or animation
Bootstrap version couplingCoupled to 'collapsed' class name (stable since BS4)Coupled to bootstrap.Collapse API (stable since BS5)
Best forStandard plus/minus text or simple Unicode iconsSVG icons, animated icons, or complex state logic

Key takeaways

1
The collapsed class on the button is your CSS hook
.accordion-button:not(.collapsed)::before is all you need to flip a plus to a minus with zero JavaScript.
2
data-bs-parent must be present on every single accordion-collapse div in your accordion
miss it on one panel and you get silent multi-open behaviour that only your users will notice.
3
Always pass `{ toggle
false } when constructing a bootstrap.Collapse instance in JavaScript — the constructor toggles the panel immediately by default, which will visually break your UI if you then call .show() or .hide()` right after.
4
Bootstrap does not fix aria-expanded mismatches on page load
only after the first interaction. If your panel starts open, aria-expanded must be true in the raw HTML or screen readers will announce the page incorrectly from the first second.
5
When adding panels dynamically, create Collapse instances after insertion with `{ toggle
false }` and dispose them on removal to prevent memory leaks.

Common mistakes to avoid

3 patterns
×

Forgetting the 'collapsed' class on the button for panels that start closed

Symptom
The button renders with a minus icon on page load even though the panel is shut, because the CSS targets .accordion-button:not(.collapsed) — the minus state. Users see all panels as open visually, even though content is hidden.
Fix
Every button whose panel starts without the show class must also have class="accordion-button collapsed". Check your HTML: if the panel does not have class="show", the button must have class="collapsed".
×

Omitting `data-bs-parent` on one or more panels

Symptom
Multiple panels open simultaneously, no console error. Users report 'accordion is broken' because clicking a new panel doesn't close the previous one.
Fix
Grep every accordion-collapse element in your HTML and confirm data-bs-parent points to the correct wrapper ID. It must be present on every panel, not just the first.
×

Calling `new bootstrap.Collapse(panel)` without `{ toggle: false }` inside a click handler

Symptom
Panel opens and immediately closes, appearing to ignore the click. The constructor triggers one toggle and your explicit .show() call triggers a second, cancelling each other.
Fix
Always construct with new bootstrap.Collapse(panel, { toggle: false }), or use bootstrap.Collapse.getInstance(panel) to retrieve the existing managed instance instead of creating a new one.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Bootstrap's accordion relies on the 'collapsed' CSS class to manage icon...
Q02SENIOR
When would you choose to implement accordion open/close behaviour with v...
Q03SENIOR
If you fire `new bootstrap.Collapse(element).show()` on a panel that Boo...
Q01 of 03SENIOR

Bootstrap's accordion relies on the 'collapsed' CSS class to manage icon state. What happens if a developer initialises an accordion panel as open in the HTML but forgets to remove the 'collapsed' class from the trigger button — and how does this affect both the visual icon and screen reader behaviour?

ANSWER
The panel will appear open (has show class on the collapse div), but the button will have collapsed class. The CSS rule .accordion-button:not(.collapsed)::before will NOT apply, so the icon shows a plus instead of a minus — visually wrong. For screen readers, if aria-expanded="true" is set correctly, the state will be announced correctly, but the icon mismatch degrades the visual user experience. If aria-expanded is also set to false (common copy-paste error), screen readers announce the panel as closed even though content is visible. The fix is to ensure the button's collapsed class and aria-expanded attribute match the panel's show class on page load.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
How do I add a plus and minus icon to a Bootstrap 5 accordion without JavaScript?
02
What's the difference between the 'show' class and the 'collapsed' class in Bootstrap accordion?
03
How do I open a specific Bootstrap accordion panel automatically on page load using JavaScript?
04
Why does my Bootstrap accordion open multiple panels at the same time instead of closing the previous one?
05
Can I use Font Awesome or SVG icons for the plus/minus instead of Unicode characters?
🔥

That's HTML & CSS. Mark it forged?

6 min read · try the examples if you haven't

Previous
CSS Quotes: Styling Quotation Marks with CSS
15 / 16 · HTML & CSS
Next
Tailwind CSS Best Practices for Large Projects in 2026