Making Product Images and Galleries Accessible
AI generated
A11Y
WCAG
Accessibility · Product Galleries · Image Zoom · Frontend
Making Product Images and Galleries Accessible
Keyboard operation, live announcements, and alt text without redundancy

Product galleries with multiple images, a zoom feature, and carousel navigation are among the most complex building blocks of a product page. Adding alt text alone while forgetting keyboard operation, focus management, and screen reader announcements results in a gallery that stays unusable for many groups of users. This article shows a complete, field tested pattern for accessible product galleries in Magento and Hyva.

16 min read Keyboard · ARIA Live · Alt Text · Focus Management Magento 2.4.8 · Hyva Theme · WCAG 2.2

1. Why Product Galleries Are Their Own Accessibility Challenge

A product gallery is rarely just a collection of images. It combines several related shots, a carousel with forward and back controls, a thumbnail strip for direct selection, and often a zoom or lightbox feature for detail views. Each of these components brings its own requirements: images need meaningful alt text under WCAG criterion 1.1.1, the carousel must be fully keyboard operable under criterion 2.1.1, status changes such as an image switch must be announced without a focus move under criterion 4.1.3, and auto advancing images require a pause control under criterion 2.2.2.

In practice usually only one part gets solved, typically the alt text, while keyboard operation and announcements are missing entirely. The result: a screen reader user hears an image description but cannot move to the next image because the arrow buttons are not focusable. A sighted keyboard only user cannot reach the zoom button because it only appears on mouse hover. A user with a vestibular disorder is irritated by a carousel that auto advances and cannot be paused. The following sections solve each of these sub problems individually and combine them at the end into a complete pattern for Magento and Hyva.

2. Keyboard Operable Gallery and Zoom Widgets

The fundamental mistake in most gallery implementations: previous and next arrows are built as a <div> or <span> with an onclick handler instead of a real <button> element. That leaves them neither reachable via Tab nor responsive to Enter or Space. The fix follows the same rule as any other interactive element: use a native <button>, and focusability plus key activation come along automatically. The gallery should additionally support arrow keys once focus lies inside the carousel, so experienced screen reader and keyboard users do not have to tab back to a control button after every single image change.

The zoom trigger deserves special attention because it is often implemented as nothing more than an enlarged cursor icon on hover, with no focusable element at all. An accessible zoom trigger is its own <button> with recognizable text or an aria-label, responding to Enter and Space, and reliably moving focus into the zoom dialog when opened. It is equally important that the main image itself never remains the sole click trigger without keyboard access, otherwise zoom stays completely out of reach for keyboard users.


<!-- WRONG: arrows and zoom reachable only by mouse -->
<div class="gallery-arrow" onclick="nextImage()">
  <svg aria-hidden="true">...</svg>
</div>
<img src="product-2.jpg" alt="Product" onclick="openZoom()">

<!-- RIGHT: real buttons, keyboard operable, with labels -->
<div class="relative" role="group" aria-label="Product image navigation">
  <img id="hero-image" src="product-2.jpg" alt="Sneaker model Aurora, front view">

  <button type="button" class="gallery-arrow gallery-arrow--prev"
          aria-label="Previous image" @click="prevImage()">
    <svg aria-hidden="true" focusable="false">...</svg>
  </button>
  <button type="button" class="gallery-arrow gallery-arrow--next"
          aria-label="Next image" @click="nextImage()">
    <svg aria-hidden="true" focusable="false">...</svg>
  </button>

  <button type="button" class="gallery-zoom-trigger"
          aria-label="Zoom image" @click="openZoom()">
    <svg aria-hidden="true" focusable="false">...</svg>
    <span class="sr-only">Zoom</span>
  </button>
</div>

3. Announcing Carousel Image Changes to Screen Readers

A visual image change in the carousel is immediately obvious to sighted users, but it goes unnoticed by screen reader users unless focus is placed on the new image. This is exactly what an aria-live region is for: an invisible region that still exists in the accessibility tree, whose text change gets read out automatically without moving focus. For an image change, aria-live="polite" fits well, because the announcement can wait until the screen reader has a pause in speech, whereas an abruptly interrupting assertive would feel needlessly intrusive here.

Balancing informativeness against chattiness matters: the announcement should include the position within the set and a short description, for example "Image 3 of 8: Sole detail view", but should not repeat the full alt text every time. When clicking through several images quickly, the announcement should be debounced so intermediate positions are not each read out separately, flooding the user with a cascade of announcements. aria-atomic="true" ensures that the entire text content of the region is re-read each time, not just the changed fragment.


// Debounced live-region announcement for gallery position changes
let announceTimeout = null;

function announceImageChange(index, total, shortLabel) {
  clearTimeout(announceTimeout);

  // Wait briefly so rapid arrow presses only announce the final position
  announceTimeout = setTimeout(() => {
    const region = document.getElementById('gallery-live-region');
    region.textContent = `Image ${index + 1} of ${total}: ${shortLabel}`;
  }, 300);
}

// Usage inside the gallery controller
function nextImage() {
  activeIndex = (activeIndex + 1) % images.length;
  announceImageChange(activeIndex, images.length, images[activeIndex].shortLabel);
}

The live region itself belongs firmly in the markup, not inserted dynamically via JavaScript afterward, because some screen readers do not reliably detect newly inserted live regions. An empty <div aria-live="polite" aria-atomic="true" class="sr-only"></div> right in the gallery's initial HTML is the most robust option.

4. An Alt Text Strategy for Product Galleries Without Redundancy

The most common alt text mistake in product galleries is redundancy: every image gets the same alt text as the first one, usually the full product name, sometimes with "Image 1", "Image 2" appended. For a screen reader user clicking through eight images of a sneaker, that means hearing the same product name eight times without ever learning what each image actually shows. The better strategy: the first image gets the full, descriptive alt text with product name and main view, every further image describes only what it additionally or differently shows, such as the angle, the detail, or the color variant.

The position within the set, "Image 3 of 8", does not belong in the alt text itself, it belongs in the live region or in a separate aria-label on the thumbnail button, otherwise it gets read out redundantly every time an image is opened. Purely decorative extra elements inside the gallery, such as a "New" badge icon or a watermark, get alt="" so they are marked as pure decoration in the accessibility tree and are not read out. Product variant images, the same view in different colors, for example, should explicitly name the color in the alt text, because they often differ visually only by color.


<!-- WRONG: identical, redundant alt text on every image -->
<img src="sneaker-1.jpg" alt="Sneaker Aurora">
<img src="sneaker-2.jpg" alt="Sneaker Aurora">
<img src="sneaker-3.jpg" alt="Sneaker Aurora">
<img src="sneaker-4.jpg" alt="Sneaker Aurora, image 4">

<!-- RIGHT: first image complete, further images differentiate -->
<img src="sneaker-1.jpg" alt="Sneaker model Aurora in charcoal, front view">
<img src="sneaker-2.jpg" alt="Back view with heel logo">
<img src="sneaker-3.jpg" alt="Detail view of the sole and tread pattern">
<img src="sneaker-4.jpg" alt="Side view, worn on foot">

<!-- Decorative badge: empty alt text -->
<img src="badge-new.svg" alt="" class="absolute top-2 left-2">

<!-- Color variant: name the color explicitly -->
<img src="sneaker-blue-1.jpg" alt="Sneaker model Aurora in ocean blue, front view">

5. Thumbnail Navigation: Roles, States, and Focus Order

For the thumbnail strip, the question regularly comes up whether an explicit ARIA role like role="listbox" with role="option" children is required. In most cases the answer is no: a group of plain <button> elements with aria-current="true" on the active thumbnail achieves the same accessibility with far less implementation effort, and without having to rebuild the full keyboard behavior of a listbox pattern. aria-current exists specifically for this case: marking an element as currently active within a set of related elements, without requiring a complex widget role.

For focus order, the roving tabindex pattern is the right choice: only the currently active thumbnail button gets tabindex="0", all others get tabindex="-1", navigation between thumbnails happens via arrow keys. That way a keyboard user does not have to tab through eight individual thumbnail buttons to reach the actual page content afterward, but instead leaves the group with a single Tab press. Each thumbnail image's alt text should be identical to its corresponding main image, while the position information is added separately via aria-label on the button, for example aria-label="Show image 3 of 8: Sole detail view".

6. Zoom and Lightbox: Avoiding Focus Traps and Returning Focus

As soon as a zoom dialog or lightbox opens, focus must reliably move inside it, typically to the close button or the enlarged image itself. The dialog needs role="dialog" and aria-modal="true" so screen readers treat the rest of the page as temporarily inactive. Inside the open dialog, Tab focus must stay confined to its contained interactive elements, a focus trap in the good sense: pressing Tab again at the last focusable element in the dialog lands back on the first one, not outside in the page's background content.

Escape must reliably close the dialog and return focus exactly to the element that originally opened it, usually the zoom trigger button. Without this focus return, keyboard focus lands somewhere at the top of the page after closing, and the user has to reorient completely from scratch. A second, unintentional mistake concerns native pinch to zoom: user-scalable=no or maximum-scale=1 in the viewport meta tag prevents users with low vision from magnifying the page with native browser controls, and violates WCAG criterion 1.4.4. A custom zoom dialog must never replace or block native browser zoom.


/* Accessible zoom dialog: visible focus, no removed native zoom */
.gallery-zoom-dialog {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: rgba(15, 23, 42, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
}

.gallery-zoom-dialog:focus-visible,
.gallery-zoom-dialog *:focus-visible {
  outline: 3px solid #f4f4f5;
  outline-offset: 3px;
  border-radius: 4px;
}

.gallery-zoom-close {
  position: absolute;
  top: 1rem;
  right: 1rem;
}

/* Never disable native pinch zoom on the zoomed image */
.gallery-zoom-dialog img {
  touch-action: pinch-zoom;
  max-width: 100%;
  max-height: 90vh;
}

7. Practical Example: An Accessible Product Gallery for Magento/Hyva

In a Hyva theme, the entire pattern can be bundled into a single Alpine.js component: an active index as reactive state, roving tabindex for the thumbnails, a fixed live region for announcements, and a dialog with a focus trap for zoom. The advantage over several separate components: state, focus management, and announcements stay synchronized in one place, instead of drifting apart across multiple phtml templates and falling out of sync.

Important for the Magento integration: alt text should already be maintained with proper differentiation in the product media gallery record, not generated after the fact in the frontend. A label attribute per image in the admin backend enables exactly the redundancy free alt text strategy described in section 4, without the template itself having to generate text. For the zoom feature, it is advisable to take the native Hyva media gallery viewer as a base and add only focus management and a live region, rather than pulling in an entirely new zoom library.


<!-- Hyva phtml: accessible product gallery with keyboard, live region and zoom -->
<div x-data="productGallery({
       images: [
         { src: 'sneaker-1.jpg', alt: 'Sneaker model Aurora in charcoal, front view' },
         { src: 'sneaker-2.jpg', alt: 'Back view with heel logo' },
         { src: 'sneaker-3.jpg', alt: 'Detail view of the sole and tread pattern' }
       ]
     })" x-init="init()">

  <div class="relative" role="group" aria-label="Product images">
    <img :src="images[active].src" :alt="images[active].alt" class="w-full">

    <button type="button" aria-label="Previous image" @click="prev()">‹</button>
    <button type="button" aria-label="Next image" @click="next()">›</button>

    <button type="button" aria-label="Zoom image" @click="zoomOpen = true; $nextTick(() => $refs.zoomClose.focus())">
      Zoom
    </button>
  </div>

  <!-- Live region: fixed in markup, never inserted dynamically -->
  <div aria-live="polite" aria-atomic="true" class="sr-only" x-text="liveMessage"></div>

  <!-- Thumbnails: roving tabindex, aria-current instead of role=listbox -->
  <div class="flex gap-2 mt-3" role="group" aria-label="Image selection">
    <template x-for="(img, index) in images" :key="index">
      <button type="button"
              :tabindex="index === active ? 0 : -1"
              :aria-current="index === active ? 'true' : 'false'"
              :aria-label="`Show image ${index + 1} of ${images.length}: ${img.alt}`"
              @click="select(index)"
              @keydown.arrow-right.prevent="focusNext(index)"
              @keydown.arrow-left.prevent="focusPrev(index)">
        <img :src="img.src" :alt="img.alt" class="w-16 h-16 object-cover">
      </button>
    </template>
  </div>

  <!-- Zoom dialog: focus trap, Escape closes, focus returns to trigger -->
  <div x-show="zoomOpen" x-trap.inert.noscroll="zoomOpen"
       @keydown.escape="zoomOpen = false"
       role="dialog" aria-modal="true" aria-label="Enlarged image"
       class="gallery-zoom-dialog">
    <button type="button" x-ref="zoomClose" class="gallery-zoom-close"
            aria-label="Close zoom" @click="zoomOpen = false">
      Close
    </button>
    <img :src="images[active].src" :alt="images[active].alt">
  </div>
</div>

8. Responsive Images, Performance, and Accessibility

Accessibility and image performance are not at odds, they complement each other as long as responsive delivery is implemented cleanly. srcset and sizes serve each device the appropriate resolution without changing anything about the alt text or role structure, accessibility attributes always belong on the <img> element itself, regardless of which source is chosen. loading="lazy" makes sense for images below the fold, but should never sit on the gallery's initially visible hero image, because a delayed loading first image both worsens Largest Contentful Paint and briefly leaves screen reader users without image content.

The gallery's controls themselves, arrows, zoom button, and thumbnail borders, fall under WCAG criterion 1.4.11 for non-text contrast: the contrast between a control's border and its background must be at least 3:1, a very muted gray arrow on a light image background often falls short of that value. A pure color change is not enough to convey a thumbnail's active state, because color alone is never a reliable distinguishing feature, an additional border or checkmark icon alongside the color change makes the active state recognizable for users with color vision deficiencies too.

9. Testing and Gallery Patterns Compared

Automated tools like axe-core reliably find missing or empty alt text, but detect neither a missing live region announcement nor a focus trap issue in the zoom dialog, because both only become visible during actual interaction. A thorough test of a product gallery therefore always consists of three steps: automated checking with axe-core or Lighthouse for structural errors, a complete pass using only the keyboard with no mouse, and at least one pass with a real screen reader such as NVDA or VoiceOver, to verify what actually gets announced on an image change.

Task Unsafe / Wrong Recommended Pattern Benefit
Announcing an image change No indication for screen readers aria-live="polite" with position User knows which image is active
Alt text for 8 images Identical product name on every image Differentiating alt text per view No redundant reading out
Opening zoom onclick without keyboard access Button with Enter/Space and focus trap Keyboard users can reach zoom
Thumbnail navigation Tab through every single thumbnail Roving tabindex with arrow keys Faster, expected navigation
Closing zoom Focus disappears into nowhere Escape returns focus to the trigger No loss of orientation

The table shows the same common thread as with other ARIA widgets: a single correct attribute solves nothing if keyboard behavior and focus management are missing. A zoom button with a correct aria-label but no focus trap in the dialog is just as unusable for screen reader users as a zoom button with no ARIA at all. Only the complete interplay of semantics, keyboard support, and announcements makes a product gallery genuinely accessible.

Mironsoft

Accessible product galleries and accessibility audits for Magento stores

A product gallery that is genuinely operable for everyone?

We rebuild existing image carousels and zoom widgets into a complete accessibility pattern: keyboard operation, live announcements, redundancy free alt text, and focus management that passes a real screen reader test.

Gallery Audit

axe-core analysis and manual review of keyboard, focus, and live regions

Hyva Implementation

Alpine.js gallery with zoom dialog, roving tabindex, and focus trap

Alt Text Strategy

Redundancy free image descriptions maintained directly in the Magento Media Gallery

10. Summary

An accessible product gallery solves several sub problems at once: keyboard operation for arrows, thumbnails, and the zoom trigger, an aria-live region for image changes without moving focus, a redundancy free alt text strategy where only the first image is fully described, and a zoom dialog with a correct focus trap and reliable focus return after closing. None of these elements replaces the others, only the full interplay makes the gallery genuinely operable for keyboard users, screen reader users, and people with low vision.

The biggest lever lies in maintaining alt text early, directly in the Magento backend, instead of generating it after the fact in the frontend, and in consistently using native <button> elements instead of rebuilt clickable areas. Automated tools like axe-core cover only the structural part, manual testing with a keyboard and a real screen reader remains indispensable for every product gallery before it goes live.

Making Product Images and Galleries Accessible: The Key Takeaways

Keyboard first

Arrows, thumbnails, and the zoom trigger as real <button> elements, never as a <div onclick> without keyboard access.

Live region for changes

aria-live="polite" with position and a short description, fixed in the markup, debounced on rapid clicking.

Alt text without redundancy

First image complete, further images differentiating. Position belongs in aria-label, not in the alt text.

Zoom with a focus trap

role="dialog", aria-modal="true", Escape closes and returns focus to the trigger.

11. FAQ: Accessible Product Images and Galleries

1Why isn't alt text alone enough for product galleries?
Alt text only describes the single image, it solves neither keyboard operation nor announcements on image changes nor focus management in the zoom dialog. All three are needed at once.
2How does a user navigate a product gallery with only the keyboard?
Arrows and the zoom trigger as real button elements with Enter/Space support. Arrow keys should additionally work inside the carousel once focus lies there.
3How should an image change be announced to screen readers?
Via a fixed aria-live=polite region with position and a short description, debounced on rapid clicking to avoid a cascade of announcements.
4How do you avoid redundant alt text across multiple images?
Describe the first image fully with the product name, further images only describe the differentiating detail: angle, close-up, or color variant.
5What ARIA role do thumbnail buttons actually need?
Usually no complex role. Simple button elements with aria-current and roving tabindex achieve the same accessibility.
6How do you prevent a focus trap issue in the zoom dialog?
Set role=dialog and aria-modal=true, confine Tab focus to the dialog's elements so Tab at the last element jumps back to the first.
7What happens to focus when the zoom dialog closes?
Focus must return exactly to the trigger button. Without that return it lands at the top of the page and the user loses orientation.
8Is it allowed to block native pinch-to-zoom?
No. user-scalable=no violates WCAG criterion 1.4.4 and prevents users with low vision from magnifying the page with native browser controls.
9How do you test a product gallery for accessibility?
Automated checking with axe-core, a complete pass using only the keyboard, and at least one test with a real screen reader such as NVDA or VoiceOver.
10What is the most common mistake in accessible product galleries?
Only alt text gets added while controls stay a div with onclick and no live region exists. The gallery remains unusable regardless.