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
| Component | Handled for you | Still your job |
|---|---|---|
| Modal | Role, focus trap, Escape, returns focus | A useful aria-labelledby target; a heading in the dialog |
| Offcanvas | Role, focus handling, keyboard dismissal | Trap focus in nested menus; label the panel |
| Accordion | aria-expanded and aria-controls wiring | Meaningful button text, sensible default open state |
| Dropdown | aria-expanded, arrow-key navigation | Distinguish a menu from a list of links |
| Tabs | Roving tabindex, arrow keys, aria-selected | A panel tabindex="0" when it has no focusable content |
| Form validation | Visual states only | Everything: 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-labelledbymust 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">×</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 do | Do instead | Reason |
|---|---|---|
class="sr-only" | visually-hidden | Renamed in Bootstrap 5; old class silently does nothing |
aria-hidden on a focusable element | Remove it and hide the element properly | An element that is focusable but hidden is an accessibility bug |
tabindex values above 0 | Reorder the DOM | Positive tabindex breaks the natural focus order |
| Colour alone to signal state | Colour plus an icon or text | Around one in twelve men cannot rely on red versus green |
outline: none globally | Style :focus-visible | Keyboard 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
Tabuntil you wrap around, thenEscape— 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.Related
Forms in depth Building a complete page with Bootstrap
Last refreshed 2026-09-18.