Accessibility plugin hides visible slides when a snap group is smaller than the viewport fits
Description
embla-carousel-accessibility decides slide suppression from snap-group membership rather than actual visibility. When a snap group covers fewer slides than the viewport shows, fully visible slides are given aria-hidden="true" and their focusable children tabindex="-1".
updateSlides in components/Accessibility.ts:
const slidesInSnap = slidesBySnap[snapIndex];
const isActive = snapIndex === emblaApi.selectedSnap();
slidesInSnap.forEach(slideIndex => {
// …
slideAttributes.toggle('aria-hidden', !isActive && !hasFocusedElement);
slideFocusNodes.forEach(slideFocusNode => {
const tabindex = !isActive ? '-1' : prevTabIndex;
focusAttributes.toggle('tabindex', tabindex);
});
});slidesBySnap answers "which slides does this snap scroll to", not "which slides can the user see right now". The two agree while the overflow is large. They diverge when the carousel only just overflows its viewport.
Reproduction
Six slides, five per view (CSS flex-basis, so each slide is a fifth of the viewport minus gap and peek), a 2rem gap, viewport 1440px, loop: true, align: "center", slidesToScroll left at its default.
Embla produces two snaps and groups the slides [[0,1,2],[3,4,5]]. Measured DOM state at the first snap:
| slide | fully inside viewport | aria-hidden |
child button tabindex |
slide aria-label |
|---|---|---|---|---|
| 1 | no (centre align) | – | – | Slides 1-3 of 6 (group 1 of 2) |
| 2 | yes | – | – | Slides 1-3 of 6 (group 1 of 2) |
| 3 | yes | – | – | Slides 1-3 of 6 (group 1 of 2) |
| 4 | yes | true | -1 | Slides 4-6 of 6 (group 2 of 2) |
| 5 | yes | true | -1 | Slides 4-6 of 6 (group 2 of 2) |
| 6 | no (clipped) | true | -1 | Slides 4-6 of 6 (group 2 of 2) |
Slides 4 and 5 are wholly on screen. A sighted mouse user can click their buttons; a keyboard user cannot reach them, and a screen reader does not announce them.
Impact
The gap between snap grouping and visibility is what makes this easy to hit rather than exotic: any carousel whose slide count lands just above what the viewport fits ends up here. In our case a product slider showed six cards where five fit, and two visible "add to cart" buttons dropped out of the tab order.
Suggested fix
Drive the suppression from emblaApi.slidesInView() and keep slidesBySnap for the aria text, which is where the grouping genuinely belongs. A slide that is in view should never be aria-hidden, regardless of which snap owns it.
If snap-group semantics are deliberate, an option to opt out of the slide handling would let consumers supply their own pass — right now the plugin has no such switch, so working around it means writing the same two attributes after the plugin on every select, slidesInView and reInit, which collides with the prevTabIndex bookkeeping in AttributeHandler.
Environment
embla-carousel/embla-carousel-accessibility/embla-carousel-react9.0.0-rc03- React 19, Chromium via Playwright, measured at 1440x900
Source: davidjerleke/embla-carousel