Accessibility with Bootstrap

What the framework handles for free, what you still owe, and how to test the result with a keyboard before shipping.

What Bootstrap already does

ComponentHandled for youStill your job
ModalRole, focus trap, Escape, returns focusA useful aria-labelledby target; a heading in the dialog
OffcanvasRole, focus handling, keyboard dismissalTrap focus in nested menus; label the panel
Accordionaria-expanded and aria-controls wiringMeaningful button text, sensible default open state
Dropdownaria-expanded, arrow-key navigationDistinguish a menu from a list of links
TabsRoving tabindex, arrow keys, aria-selectedA panel tabindex="0" when it has no focusable content
Form validationVisual states onlyEverything: the messaging, the association, the timing
<div class="modal fade" id="confirm" tabindex="-1"
     aria-labelledby="confirmTitle" aria-describedby="confirmBody" aria-hidden="true">
  <div class="modal-dialog modal-dialog-centered">
    <div class="modal-content">
      <div class="modal-header">
        <h2 class="modal-title fs-5" id="confirmTitle">Delete this project?</h2>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body" id="confirmBody">
        This removes 42 files and cannot be undone.
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
        <button type="button" class="btn btn-danger">Delete project</button>
      </div>
    </div>
  </div>
</div>
  • Never set aria-hidden="true" yourself — Bootstrap toggles it. A leftover value hides the whole dialog from screen readers.
  • aria-labelledby must point at an element that exists and contains text, not at an empty placeholder.
  • Focus is trapped inside the modal, so put the most likely action first in the footer rather than relying on a visually emphasised position alone.

What you still owe

<!-- 1. Skip link: visually hidden until focused -->
<a class="visually-hidden-focusable position-absolute top-0 start-0 p-2 bg-body" href="#main">Skip to content</a>

<!-- 2. Landmarks -->
<header role="banner">…</header>
<nav aria-label="Primary">…</nav>
<main id="main" tabindex="-1">…</main>

<!-- 3. An icon-only button has no accessible name without this -->
<button type="button" class="btn btn-outline-secondary" aria-label="Close panel">
  <span aria-hidden="true">&times;</span>
</button>

<!-- 4. Status regions announce themselves when the content changes -->
<div class="alert alert-success" role="status">Saved.</div>
<div class="alert alert-danger" role="alert">Could not save.</div>
Do not doDo insteadReason
class="sr-only"visually-hiddenRenamed in Bootstrap 5; old class silently does nothing
aria-hidden on a focusable elementRemove it and hide the element properlyAn element that is focusable but hidden is an accessibility bug
tabindex values above 0Reorder the DOMPositive tabindex breaks the natural focus order
Colour alone to signal stateColour plus an icon or textAround one in twelve men cannot rely on red versus green
outline: none globallyStyle :focus-visibleKeyboard users lose their position on the page
⚠️
Dark mode changes contrast ratios. Bootstrap's defaults pass, but any custom colour you add must be rechecked in both modes — a brand accent that is fine on white can drop below the 4.5:1 threshold on a dark surface.

Keyboard and reduced motion

/* Bootstrap 5 ships this; keep it if you write your own transitions */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Make focus visible and consistent instead of removing it */
:focus-visible {
  outline: 2px solid var(--bs-primary);
  outline-offset: 2px;
  border-radius: 0.25rem;
}
  • Tab through the page with the mouse untouched: every interactive element must be reachable, and focus must never disappear behind a sticky header.
  • Open the modal, press Tab until you wrap around, then Escape — focus should return to the button that opened it.
  • Zoom to 200% and check that nothing overlapping becomes unreachable; Bootstrap components reflow but fixed-width layouts do not.
  • Run the page through an automated checker for the obvious failures, then use a screen reader once — automation misses copy that makes no sense out of context.
// Programmatic focus needs a target that can receive it.
// main has tabindex="-1" in the markup above, so this works:
document.querySelector('#main').focus();

// Announce a change that is not in a live region yet
const status = document.getElementById('toasts');
status.textContent = 'Filters applied. 12 results.';

FAQ

Do I need to add ARIA to Bootstrap components?
No — the plugins already emit the attributes they need, and duplicating them usually introduces state that gets out of sync. Add ARIA where you author custom widgets, and check that every interactive element has an accessible name.
Why did my visually hidden text become visible after an upgrade?
Bootstrap 5 renamed the utility. sr-only and sr-only-focusable are now visually-hidden and visually-hidden-focusable. The old classes no longer exist, so the element renders normally.

Forms in depth Building a complete page with Bootstrap

Last refreshed 2026-09-18.