ramka

A lightbox screen readers can actually use

Turn on VoiceOver and open a photo in a typical web lightbox. You hear “image”, maybe. Swipe to the next photo: nothing. Try to leave: Esc does nothing, and the close button announces itself as “clickable”. The last post was about what your thumb knows. This one is about what a screen reader hears.

The scale of the problem is measured annually. WebAIM’s 2026 scan of the top million homepages found automatically detectable WCAG 2 failures on 95.9 percent of them, averaging 56.1 errors per page. (An automated scan only catches the machine-testable failures, so the true rate is higher, not lower.) The same report repeats its most uncomfortable correlation: pages that use ARIA average significantly more detected errors than pages that don’t, 59.1 versus 42. Correlation is not a verdict on ARIA, but it is a measure of how often ARIA is applied wrong, and wrong ARIA builds barriers plain HTML never had.

The same scan breaks its results down by the JavaScript libraries it detected, and the gallery scripts cluster at the bad end of that table. Home pages running the original 2005 Lightbox script averaged 25.7 percent more errors than the million-page mean; Slick 26.4 percent more, Swiper 31.8 percent more, FancyBox 60.3 percent more. A detected library is not proof it caused those errors, sites that reach for a carousel plugin differ in a hundred other ways. But the pattern you are reading about keeps company at the wrong end of the distribution. It also has money attached: at the time of writing, UsableNet’s year-end report counts more than 5,000 ADA-related digital accessibility lawsuits filed in the US during 2025, and puts e-commerce, exactly where image galleries live, at about 70 percent of the industry breakdown. (Counts of filings, not a statement about who wins, and nothing here is legal advice.)

None of this is because dialogs are mysterious. The W3C’s dialog pattern spells out the contract. An accessible React lightbox just happens to sit on the intersection of three hard things at once: it is a fullscreen modal dialog, an image carousel, and a gesture surface. Let’s take them in order.

Watch focus fall out of the dialog

Start with the modal part, and with the failure you can see. When a lightbox opens, keyboard focus must move into it, stay inside while it is open, and return to the element that opened it when it closes. Skip the middle requirement and Tab walks straight out of the overlay into the page behind it, where a keyboard user is now operating invisible controls.

Focus is on the Close button, inside the dialog.

Press Tab a few times in each mode. The typical lightbox lets focus walk off the last button and onto the page behind the overlay, where a keyboard or screen reader user is now operating controls they cannot see. The focus ring here is simulated so the demo can never capture your real keyboard.

The trap itself is less code than its reputation suggests. ramka brackets the dialog with two invisible focus guards, the same sentinel technique Radix and Base UI use. Tab past the last control and the guard catches focus and hands it to the first one; Shift+Tab past the first and it lands on the last:

The sentinel trap
// Two invisible spans bracket the dialog. Tab past either edge and the
// guard hands focus back to the opposite end. This is the entire trap.
// Note what is *not* here: no aria-hidden. A focusable element must never
// be hidden from assistive tech (axe flags it as a serious violation) —
// an empty span announces nothing anyway, and focus never rests on it.
<span tabIndex={0} data-focus-guard onFocus={focusLastTabbable} />
<div role="dialog" aria-modal="true" tabIndex={-1}>
  {children}
</div>
<span tabIndex={0} data-focus-guard onFocus={focusFirstTabbable} />

The subtler half of the contract is the exit. When the lightbox closes, focus must return to the trigger, and here ramka does something I have not seen elsewhere: it remembers how you closed. Close with Esc or the keyboard and the trigger gets its focus ring back, because a keyboard user needs to see where they are. Close by clicking the backdrop or pulling the photo away and focus still returns, but without forcing a ring onto a screen where nobody is keyboarding. Modality-aware focus restoration, three lines of bookkeeping, and it makes both kinds of users feel like the thing was built for them.

What a screen reader hears

Focus is the failure you can see. The other one is silence. A lightbox built from divs announces nothing when it opens, nothing when the slide changes, and nothing when it closes; a screen reader user gets a click that appears to do nothing at all. The fix has two parts: real semantics on the container, and a live region for the thing that changes.

Screen reader output, simulated

Press “View photos” and browse. Announcements appear here.

Browse a few photos, then switch to plain divs and browse again. Note that the visible counter never changes: what changes is whether it is also a live region. The transcript is scripted to match typical VoiceOver phrasing, not captured from a real screen reader, and the mock stays keyboard-accessible in both modes.

The container part is the dialog contract again: role="dialog", aria-modal, and an accessible name, so opening announces “Photos, dialog” instead of nothing. The changing part is the interesting one. Swiping to the next slide moves a scroller; no focus changes, so nothing announces itself. ramka’s Counter primitive is the fix: it renders the “2 of 9” you can see, and it is also an aria-live="polite" region, so every slide change speaks. Caption announces the same way, and because both regions are polite they queue rather than interrupt: with both mounted, a slide change reads as the position, then the caption. If you would rather have a single voice, pass aria-live="off" to the one you want visual-only; your props win. Mount the Counter and navigation narrates itself; leave it out and it is silent, which is exactly what the demo above shows.

The docs chrome uses a trick worth stealing: on desktop, the thumbnail strip already shows position, so a visible counter would be noise. Instead of unmounting it, the counter collapses to sr-only and keeps announcing:

The counter you hear but do not see
{/* Visible on mobile, where there is no thumbnail strip to show
    position. On desktop the strip covers that, so the counter
    collapses to sr-only and keeps only its announcement role. */}
<Lightbox.Counter className="text-xs md:sr-only">
  {({ current, total }) => `${current} of ${total}`}
</Lightbox.Counter>

Off-screen slides get the opposite treatment. Every inactive slide is inert and aria-hidden, so a screen reader exploring the dialog finds one photo, not nine, and keyboard focus physically cannot wander into a slide you cannot see. ramka goes a step further and makes the zoom surface non-focusable precisely so that a slide being deactivated mid-gesture can never strand focus inside an inert subtree. That is the kind of edge case you only find by testing, and the kind a library should own so you never think about it.

Two smaller pieces of the same structure are easy to miss. Thumbnails are a real tabs pattern: a tablist of tabs with roving tabindex, Left arrowRight arrow, Home and End, and the selection tracked from the active slide rather than from DOM focus, so swiping can never desync what the keyboard thinks is selected. And a refinement the dialog pattern does not require but disclosure buttons taught us to appreciate: the trigger that opened the lightbox carries aria-haspopup="dialog" and aria-expanded, on that specific element, so a screen reader user browsing a grid of twenty thumbnails can tell which one is currently open.

Why ramka ships no English

One thing ramka deliberately does not do: bake in default labels. There is no built-in aria-label="Close" on the close button, no “carousel” roledescription hard-coded on the slides. A comment in the source explains why: an English string baked into a primitive is wrong for most of the world. The numbers sketch who that world is. In WebAIM’s tenth screen reader survey (2024), NVDA leads in every surveyed region outside North America and Australia — Europe, Africa and the Middle East, and Asia — and 91.3 percent of respondents use a screen reader on a phone, where 70.6 percent of them reach for VoiceOver. Market share says nothing about the languages those users prefer; what it says is that a React primitive ships to a global, mostly mobile audience. A built-in English “Close” is wrong the moment the interface around it is not in English, and a primitive cannot know that. The application can. So ramka owns the semantic requirement, the button must have a name, and leaves the localized words to the application.

So the words are yours, and the structure is the library’s. In development, ramka warns when a dialog or an icon button has no accessible name, which keeps the contract visible without shipping anyone’s locale. Passing it is a few attributes, following the APG carousel pattern:

Notice what makes this possible at all: every element in the viewer is one you render, so there is nothing you cannot label, describe, or hang a tooltip on. A config-object lightbox inverts that. You get whatever escape hatches its author remembered to expose, an ariaLabelledby option here, a strings map there, and anything they forgot is unfixable without a fork. The case for a headless lightbox makes that argument in full.

Naming the carousel, your words
<Lightbox.Slides
  aria-label="Full-size images"
  // The library ships no copy for these; the words are yours to pass.
  // In a localized app they come from your translation catalogue.
  aria-roledescription="carousel"
>
  {items.map((item, i) => (
    <Lightbox.Slide key={item.id}>
      <Lightbox.Item
        index={i}
        aria-roledescription="slide"
        aria-label={`${i + 1} of ${items.length}`}
      >

      </Lightbox.Item>
    </Lightbox.Slide>
  ))}
</Lightbox.Slides>

Keyboard and motion

The rest of the keyboard contract: Esc closes, Left arrowRight arrow move between slides, Home and End jump to the ends, Plus and Minus zoom, and 0 resets. Two details are easy to get wrong. When the photo is zoomed in, ramka’s arrow keys stop navigating and start panning, because “move within the thing I am looking at” is what arrows mean everywhere else in an OS. And every shortcut steps aside when focus is in an input or textarea, so a comment field inside a lightbox does not eat your arrow keys.

Keyboard zoom is where testing gets almost comic. While writing this post I tried the two most popular lightbox libraries: in both, Plus and Minus do nothing at all. Muscle memory then reaches for CommandPlus, and the browser does what that shortcut has always done: it zooms the page. The entire UI inflates, thumbnails included, and in one of the two the enlarged thumbnail strip claims so much of the viewport that the photo you were trying to zoom into gets smaller. ramka’s answer is to give the image its own keyboard zoom without taking the browser’s away: unmodified Plus, Minus and 0 operate the photo, while the CommandPlus family stays with the browser. Page zoom is how low-vision users resize text, WCAG 1.4.4 in practice, so a viewer that swallows it is fixing one zoom by breaking another. Keeping both means you can enlarge the viewer’s controls and captions independently of the photo.

Motion is an accessibility setting too. Vestibular disorders make large moving surfaces genuinely nauseating, and the OS-level reduced motion toggle is how people say so. ramka treats that setting as a hard gate on the morph transition: if it is on, the morph simply is not attempted, and open and close fall back to a plain fade. Gesture springs collapse to instant snaps the same way.

Reduced motion as a gate, not a theme
// The morph never runs for users who asked for less motion.
// They get the plain fade instead; nothing to configure.
export function supportsViewTransitions(): boolean {
  return (
    typeof document !== 'undefined' &&
    'startViewTransition' in document &&
    !window.matchMedia('(prefers-reduced-motion: reduce)').matches
  );
}

Every input, its native idiom

Most lightboxes navigate by pointer drag: press the photo, pull it sideways, release. It works, and it is the least accessible input on the menu. Dragging is the hardest pointer operation there is for anyone with a tremor or limited dexterity, press and hold and move and release without slipping, which is why WCAG 2.2 added 2.5.7 Dragging Movements: anything operated by drag needs a single-pointer alternative that does not drag. A drag-to-scroll gallery that hides its next button for a cleaner look is a straight failure. And with VoiceOver or TalkBack running it is worse than a failure, because mobile screen readers consume single-finger gestures before the page ever sees them. To a blind user, a drag-only carousel is not hard to use. It does not exist.

Drag-first carousels also fail an audience nobody thinks to test: trackpads. Pointer-drag code listens for pointer events, but a two-finger trackpad swipe emits wheel events, so in a surprising number of galleries mouse drag works and the trackpad does nothing. The fix on their side is a non-passive wheel handler, which is its own performance story. ramka’s slides are a real scroller, so every scroll input the operating system knows about works without a line of code: touch, trackpad swipe, shift plus wheel, tilt wheels, pen.

The parity goes past swiping. A trackpad pinch zooms the photo, through Safari’s gesture events and through the synthesized ctrl-wheel stream Chrome and Firefox emit for pinches, with the same elastic overshoot and snap-back as fingers on glass. A two-finger downward swipe pulls the photo away to dismiss, exactly like the touch gesture. Pinching in the middle of a swipe hands the gesture from the scroller to the zoom instead of fighting for it. Drag is welcome as one input among many. It just is not allowed to be the foundation, because everyone whose hands or eyes or hardware work differently is standing on it.

What is still hard

The honest section. The dialog pattern requires that content behind a modal be non-interactive for every user, not just keyboard users. ramka covers the inputs it controls: the focus trap contains Tab, the scroll lock and the viewport-covering surface block pointers and scrolling, and aria-modal tells assistive technology to treat the background as off limits, the mechanism ARIA 1.1 introduced to replace hiding the page by hand. What it does not yet do is also set inert on the rest of the page, the belt-and-suspenders that covers older mobile screen readers with weak aria-modal support. That one is on the list, carefully: blindly inerting everything outside the portal would break the menus and nested dialogs consumers legitimately portal out of the viewer. Slide announcements depend on you mounting Counter or Caption; a lightbox without either is visually fine and aurally silent. And panning a zoomed image is a drag gesture without a single-tap alternative yet, which the zoom docs mark plainly as partial WCAG 2.5.7 support. All of it is on the roadmap. A library that claims its accessibility story is finished is describing a library nobody has tested.

Test it yourself in five minutes

You do not need an audit to catch the failures in this post; you need five minutes and the software already on your machine. On a Mac, press CommandF5 to start VoiceOver. On Windows, NVDA is free. Then, on your own photo gallery: open the lightbox and listen for a dialog announcement with a name. Press Tab repeatedly and watch whether focus stays inside. Change slides and listen for the new position. Press Esc, and check focus is back on the thumbnail you started from. Turn on reduced motion and open it again. Five checks, one per failure that actually ships.

Here is that checklist running against ramka. Try it with your screen reader on.

If you are building an accessible image gallery, the docs take about ten minutes, and the RTL and copy guide covers the naming patterns above. ramka is free for open source under the GPL, and one payment covers proprietary use for your whole team. Either way, mount the Counter.