Bootstrap Accordion Plus/Minus — Fix the Frozen Icon Bug
CSS showed '+' on all states because the .
- Bootstrap accordion uses
data-bs-toggle="collapse"to toggle panels without custom JS. - Plus/minus icon is pure CSS:
::beforepseudo-element with content swapping on.accordion-button:not(.collapsed). data-bs-parentenforces 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.CollapseAPI requires{ toggle: false }to avoid double-toggles on construction.
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.
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.data-bs-parent. The developer added three more panels thinking the attribute was inherited. It's not.data-bs-parent is per-element, not inherited. Every single panel must have it.data-bs-parent attribute must be present on every accordion-collapse div.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.
\2212 in your CSS content property — it's the same width as a plus and looks intentional rather than hacked together.\2212 fixed it in all browsers.collapsed class is your hook — Bootstrap adds/removes it on every toggle..accordion-button:not(.collapsed)::before to swap icons with zero JS.\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.
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.aria-expanded was false. The screen reader said 'Apply online, collapsed, button'. Users would never know the form was visible.false to true in the HTML.show class.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.
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.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.{ toggle: false } to the constructor.{ toggle: false } unless you explicitly want an immediate toggle.bootstrap.Collapse with { toggle: false } to avoid double-toggles.getInstance to reuse existing instances — never create two on the same element.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. 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 Collapse()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.
- 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.
getInstance returned null and .hide() failed silently.{ toggle: false }..dispose() when removing panels to prevent memory leaks.The Frozen Plus Sign — A Production Support Nightmare
.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.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.- Never assume Bootstrap handles icon state out of the box — it only toggles the
collapsedclass. - Always verify the CSS selector chain works for both
collapsed(closed) andnot(.collapsed)(open) states. - Use the browser's DevTools to inspect the button element and confirm which class is present at each state.
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.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.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).new bootstrap.Collapse(element) without { toggle: false }. This creates an instant toggle. Switch to bootstrap.Collapse.getInstance(element) or add { toggle: false }.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-button:not(.collapsed)::before { content: '\2212'; } and remove Bootstrap's default chevron.Key takeaways
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.data-bs-parent must be present on every single accordion-collapse div in your accordion 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.aria-expanded mismatches on page loadaria-expanded must be true in the raw HTML or screen readers will announce the page incorrectly from the first second.Common mistakes to avoid
3 patternsForgetting the 'collapsed' class on the button for panels that start closed
.accordion-button:not(.collapsed) — the minus state. Users see all panels as open visually, even though content is hidden.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
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
.show() call triggers a second, cancelling each other.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 Questions on This Topic
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?
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.Frequently Asked Questions
That's HTML & CSS. Mark it forged?
6 min read · try the examples if you haven't