Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Image carousel

Image carousel is a gallery built specifically for photos: a main viewing area with subtle previous and next controls, an optional strip of thumbnails below it, an optional magnifier on hover, and a full-size view that opens when the main photo is clicked, complete with its own grid of every image, the same pattern a classifieds site uses for a listing's photo viewer.

Unlike carousel, which can hold any kind of content, this gallery only ever shows photos and manages their data directly, which is what lets it drive the full-size view's grid and the thumbnail strip from the same source, and know each photo's description for accessibility.

Dependencies

eun-icon · for the nav and expand controls eun-modal · if lightbox is on
Overview API Examples Accessibility

When to use

Use an image carousel for a product or listing's own photo set, where someone needs to page through a handful of images, jump straight to one via a thumbnail, and get a closer look either by hovering or by opening a full-size view. For a row of mixed content, such as cards, testimonials, or a hero rotation, reach for carousel instead, since it takes any kind of content, not just photos, but has none of this component's photo-specific behavior, like thumbnails, a magnifier, or a full-size grid. See Alternatives below for a direct comparison.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/image-carousel.js";
import "@eunomia/elements/modal.js";
import "@eunomia/elements/icon.js";
<eun-image-carousel aria-label="Product photos" id="gallery">
</eun-image-carousel>
document.getElementById("gallery").images = [
  { src: "/photos/front.jpg", alt: "Front view" },
  { src: "/photos/side.jpg", alt: "Side view" },
  { src: "/photos/back.jpg", alt: "Back view" },
];

Importing the file registers <eun-image-carousel> as a custom element. eun-modal powers the lightbox and eun-icon renders every control's icon, so import both alongside it. images is a plain JS property (an array of objects), set like any other DOM property, since there's no clean way to author structured data as an HTML attribute by hand. This is the documented way to populate the gallery, exactly like eun-breadcrumb's own items.

Alternatives

You want .. Prefers A row of mixed content, not just photos, such as cards, testimonials, or a hero rotation eun-carousel

Guidance

  • Give every image a real alt, since it's read out for the main stage (without lightbox), the lightbox's own image, and the live region announcement
  • Set a distinct aria-label on the host when a page has more than one gallery
  • Provide a smaller thumbnail per image when you have one, so the strip and the lightbox grid don't download full-resolution photos just to show them tiny
  • Pair magnifier with real product or detail photography, since it adds little on already-small or illustrative images
  • Using it for a single image with nothing to page through: lightbox alone (no thumbnails, no nav) is still useful there, but a plain <img> may be simpler
  • Relying on the hover magnifier as the only way to see detail, since it never engages on touch, which is why the lightbox stays reachable through the always-focusable expand control
  • Mixing unrelated images into one gallery: keep one eun-image-carousel per real subject (one product, one listing), matching what the thumbnail strip itself implies

Live testing

The playground round-trips through an HTML string, so images is written above as its JSON-serialized attribute form. In real code, prefer setting the images property directly (see Install & usage).

Properties

Attributes

NameTypeDefaultDescription
active-indexnumber0The zero-based index of the currently shown image, shared between the main stage and the lightbox
magnifier-zoomnumber2.5How much the main image scales up under the cursor while hovering, with magnifier on
show-thumbnailsbooleantrueShows the thumbnail strip below the main stage, for jumping directly to an image
show-dotsbooleantrueShows pagination dots below the main stage as a position indicator when the thumbnail strip is off. Has no effect while thumbnails are shown, since they already convey the same information
lightbox-gridbooleantrueShows a scrollable grid of every image alongside the lightbox's own main image, for jumping directly to one without closing it
autoplay-intervalnumber4000Milliseconds between each autoplay advance
aria-labelstringThe accessible name for the main stage. Defaults to "Image gallery"
aria-label-previousstring'Previous image'The accessible label of the previous controls, on both the main stage and the lightbox
aria-label-nextstring'Next image'The accessible label of the next controls, on both the main stage and the lightbox
aria-label-expandstring'View full-size image'The accessible label of the main stage's expand control
aria-label-lightboxstring'Image viewer'The accessible name of the lightbox dialog
imagesArray<EunomiaImageCarouselImage>[]The gallery's images, in order. Each needs a source and alt text, plus an optional thumbnail image, falling back to the main source, for the thumbnail strip and the lightbox grid
magnifierbooleanfalseEnables a hover-to-zoom effect on the main image, for mouse and pen pointers only. Has no effect on touch, which relies on the lightbox instead
lightboxbooleantrueWhether clicking the main image, or its expand control, opens the full-size lightbox
loopbooleanfalseWraps the previous and next controls around at the bounds instead of stopping there, on both the main stage and the lightbox
transitionEunomiaImageCarouselTransition'slide'How the main stage and the lightbox animate between images
autoplaybooleanfalseAutomatically advances to the next image on a set interval. Pauses on hover or focus and while the lightbox is open, but renders no pause or play control of its own, so pair this with a visible control calling the public pause and resume methods, since a moving gallery nobody can stop fails accessibility guidelines

Import the exact TypeScript type behind any property above, see Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
dictionaryThis instance's locale-resolved string dictionary — see the class doc for the resolution order.

Events

NameTypeDescription
eunimagechangeImageChangeEventFired whenever the active image changes, from either the main stage or the lightbox

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--image-carousel-aspect-ratioSets the aspect ratio of the main stage
--image-carousel-object-fitSets how the main image fits its box
--image-carousel-border-radiusSets the corner radius of the main stage
--image-carousel-backgroundSets the background color behind the main stage, visible when the image is set to fit without cropping
--image-carousel-thumbnail-sizeSets the size of each thumbnail
--image-carousel-thumbnail-gapSets the gap between thumbnails, and between the strip and the main stage
--image-carousel-nav-colorSets the icon color of the main stage's previous and next controls
--image-carousel-transition-durationSets the duration of the transition animation between images, for click, keyboard, and autoplay changes only, since a touch swipe never plays this
--image-carousel-swipe-durationSets the duration of the main stage's touch swipe settling into place on release, whether committing or springing back. Forced to zero under reduced motion settings or when transitions are turned off
--image-carousel-lightbox-widthSets the fixed width of the lightbox dialog
--image-carousel-lightbox-max-heightSets the fixed height of the lightbox dialog, so it stays exactly this size regardless of which image is active instead of resizing between a portrait and a landscape photo. Size it to comfortably fit your largest image
--image-carousel-lightbox-backgroundSets the background color behind the lightbox's main image
--image-carousel-lightbox-image-radiusSets the corner radius of the lightbox's own image
--image-carousel-grid-widthSets the width of the lightbox's grid column
--image-carousel-grid-columnsSets the column count of the lightbox's grid
--image-carousel-dot-sizeSets the size of the pagination dots
--image-carousel-dot-gapSets the gap between pagination dots
--image-carousel-dot-colorSets the color of the pagination dots
--image-carousel-dot-active-colorSets the color of the active pagination dot

Basic

<eun-image-carousel aria-label="Basic gallery" id="gallery">
</eun-image-carousel>
document.getElementById("gallery").images = [
  { src: "/photos/1.jpg", alt: "Photo 1" },
  { src: "/photos/2.jpg", alt: "Photo 2" },
  { src: "/photos/3.jpg", alt: "Photo 3" },
];

Prev/next controls and the counter sit directly on the main image, kept deliberately understated (small, translucent) rather than opaque toolbar buttons, so they read as controls without competing with the photo underneath. See "Why the main stage's controls stay subtle" in the Accessibility tab. On a touch device, dragging left/right anywhere on the image works too, both here and in the lightbox, so try it on your phone, or see the Accessibility tab's own note on swipe.

At scale, with a large image

The stage sizes itself from its own container and --image-carousel-aspect-ratio (default 4 / 3), so there's nothing tuned for a small demo box here. This one runs the full width of the page with a wider 16 / 9 ratio, closer to how a real product/listing photo would actually be shown.

<eun-image-carousel
  aria-label="Large product photos"
  magnifier
  style="width: 100%; --image-carousel-aspect-ratio: 16 / 9;"
  id="gallery"
>
</eun-image-carousel>

Thumbnails

showThumbnails (on by default) renders the strip below the main stage, and clicking one jumps straight to that image. Set the property to false to fall back to a row of pagination dots instead (see "Position indicator without thumbnails" below).

<eun-image-carousel aria-label="Gallery" id="gallery"></eun-image-carousel>
<!-- or, to hide the strip: -->
<eun-image-carousel aria-label="Gallery" show-thumbnails="false" id="gallery2">
</eun-image-carousel>
showThumbnails/showDots/lightbox/lightboxGrid all default to true (autoplay defaults to false, same caveat in reverse) and, like every boolean attribute, are presence-based, so writing show-thumbnails="false" in plain HTML still leaves it true (the attribute is present). To turn one off declaratively, set the property directly instead: .showThumbnails=${false} in a Lit template, or gallery.showThumbnails = false in plain JS.

Each image can carry its own smaller thumbnail, used for the strip above and the lightbox grid below, falling back to the full src when omitted:

gallery.images = [
  {
    src: "/photos/1-full.jpg",
    thumbnail: "/photos/1-thumb.jpg",
    alt: "Photo 1",
  },
];

Position indicator without thumbnails

With showThumbnails off, there'd otherwise be no visible way to tell which image you're on or how many are left, so eun-image-carousel automatically falls back to a row of pagination dots instead (the same idea as eun-carousel's own dots), each clickable to jump straight to that image. Set showDots to false too for neither, leaving just the prev/next controls and keyboard.

<eun-image-carousel aria-label="Gallery" show-thumbnails="false">
</eun-image-carousel>
<!-- or, to remove the dots as well: -->
<eun-image-carousel
  aria-label="Gallery"
  show-thumbnails="false"
  show-dots="false"
>
</eun-image-carousel>

Same presence-based caveat as above: set showThumbnails/showDots as properties (gallery.showThumbnails = false) to actually turn them off from a script.

Magnifier

magnifier scales the main image up under the cursor while hovering, for a closer look without leaving the stage. It only ever engages for a mouse or pen pointer, since touch has no hover to begin with and relies on the lightbox (always reachable via the expand control) for a closer look instead.

<eun-image-carousel aria-label="Gallery" magnifier magnifier-zoom="3">
</eun-image-carousel>

magnifierZoom (default 2.5) sets how far it scales in.

Lightbox

Clicking the main image (or its expand control, bottom-right) opens a full-size view: a larger version of the image with its own bigger prev/next controls, built on eun-modal, so Escape, the backdrop, and the modal's own close button all close it. Set lightbox to false to disable this entirely (no expand control, and the main image no longer opens anything).

<eun-image-carousel aria-label="Gallery"></eun-image-carousel>
<!-- or, to disable it: -->
<eun-image-carousel aria-label="Gallery" lightbox="false"></eun-image-carousel>

openLightbox(index?)/closeLightbox() are also exposed as public methods, for opening it from your own trigger (a "View all photos" link, say) instead of only the built-in expand control:

const gallery = document.getElementById("gallery");
document
  .getElementById("view-all")
  .addEventListener("click", () => gallery.openLightbox());

Photo grid

lightboxGrid (on by default) adds a scrollable grid of every image alongside the lightbox's own main image. Clicking a tile swaps it without closing the lightbox, the same pattern classifieds sites use for a listing's photo viewer. Set it to false for just the single enlarged image and its prev/next controls.

<eun-image-carousel aria-label="Gallery" lightbox-grid="false">
</eun-image-carousel>

Below 640px (measured on the viewport), the grid moves from a column on the right to a horizontally-scrolling row below the main image.

Loop

loop wraps prev/next around at the bounds instead of stopping there, shared by the main stage and the lightbox.

<eun-image-carousel aria-label="Gallery" loop></eun-image-carousel>

Transition

transition (default slide) picks how the main stage and the lightbox animate between images: slide moves the new image in from the direction it was reached from (right for next, left for previous), fade cross-fades in place, and none swaps instantly, no animation at all. Click each one's own next arrow below to compare.

<eun-image-carousel aria-label="Gallery" transition="fade"></eun-image-carousel>
<!-- or transition="none" for an instant swap, or omit for the slide default -->

Respects prefers-reduced-motion: reduce automatically: every image swaps instantly regardless of transition, the same as transition="none", whenever that OS-level setting is on.

Touch swipe

On a touch device, dragging horizontally over the main stage pages through the gallery. The current photo and the next/previous one track the finger 1:1 the whole way, not just a jump once the gesture ends, and release past the swipe threshold to commit or short of it to spring back, easing the short remaining distance into place either way. That settle is a plain position transition, not transition, which never applies to a swipe, only to a click, keyboard, or autoplay change. This works out of the box, with nothing to configure, so try it below with a touchscreen or your browser's device emulation.

Dragging past the first/last image (without loop) meets resistance instead of doing nothing. The lightbox pages through the same gallery on a touch swipe too, recognized once the gesture ends rather than tracked live. Neither changes click, keyboard, or mouse/pen dragging behavior. See the mobile swipe test page for a larger harness to try this on an actual phone, alongside eun-tab-group's own touch swipe.

Autoplay

autoplay advances automatically every autoplayInterval milliseconds, and pauses on hover, keyboard focus, or while the lightbox is open. It renders no pause/play control of its own (see "Why no built-in control" in the Accessibility tab), so pairing it with a visible control of your own, wired to the public pauseAutoplay()/resumeAutoplay() methods, is your responsibility, not optional: a moving gallery nobody can stop is a WCAG 2.2.2 failure. The demo below starts paused rather than moving the instant the page loads (press play to see it run), and its button only ever reacts to an explicit click. It never calls .focus() on itself, so a running gallery doesn't keep pulling keyboard focus back onto it on every tick.

<button id="my-toggle" aria-label="Pause automatic image rotation">
  <!-- your own icon -->
</button>
<eun-image-carousel id="my-gallery" autoplay loop autoplay-interval="2500">
</eun-image-carousel>

<script type="module">
  const gallery = document.getElementById("my-gallery");
  const toggle = document.getElementById("my-toggle");
  let paused = false;

  toggle.addEventListener("click", () => {
    paused = !paused;
    paused ? gallery.pauseAutoplay() : gallery.resumeAutoplay();
    toggle.setAttribute(
      "aria-label",
      paused
        ? "Resume automatic image rotation"
        : "Pause automatic image rotation",
    );
  });
</script>

Pairing autoplay with loop is recommended: without it, autoplay simply stops advancing once it reaches the last image, since manual and automatic navigation share the same bounds behavior.

Listening for changes

eunimagechange fires whenever the active image changes, from either the main stage or the lightbox, useful for syncing external UI (an external counter, a "N of M" label elsewhere on the page, ...):

const gallery = document.getElementById("gallery");
gallery.addEventListener("eunimagechange", (event) => {
  console.log(`Now on image ${event.index + 1} of ${event.count}`);
});

Theming

Every color and spacing value is exposed as a CSS custom property (full list in the API tab), so the gallery repaints with the active theme automatically. See Theming.

<eun-image-carousel
  aria-label="Gallery"
  style="--image-carousel-border-radius: 20px; --image-carousel-aspect-ratio: 16 / 9;"
>
</eun-image-carousel>

Keyboard interactions

Key Action
Tab / Shift+Tab Moves between the prev/next controls, the expand control, thumbnails, and grid tiles, each a normal, individually focusable <button>
Enter / Space Activates whichever control has focus (native button semantics)
ArrowLeft Moves to the previous image, from anywhere focus currently is within the stage or the lightbox (wraps with loop)
ArrowRight Moves to the next image, same scope as ArrowLeft
Home Jumps to the first image
End Jumps to the last image
Escape Closes the lightbox (native to eun-modal, see its own Keyboard interactions)

Arrow/Home/End react from anywhere focus already is inside the main stage or the lightbox, since there's no separate roving-focus region to Tab into first, unlike eun-carousel's scrollable track.

Touch swipe

Dragging left/right anywhere on the main stage or the lightbox calls the same previous()/next() the buttons and arrow keys do, only recognized as a swipe once the drag is more horizontal than vertical, so a vertical drag still scrolls the page normally. It's purely an additive touch convenience: every button stays present and independently reachable regardless of pointer type, so nothing here is exclusive to touch, or to any single input method.

The main stage tracks the drag live, moving the current photo and its incoming neighbor with the finger, then easing the short remaining distance into place once released past the swipe threshold. The lightbox instead only recognizes the gesture once released (the same threshold-based check the main stage used to use). Both land on the same activeIndex, dispatch the same eunimagechange, and respect loop identically, differing only in how the drag itself feels while it's in progress.

Aria roles and labels

  • The main stage is a role="group" region, with aria-label (default "Image gallery", override via the aria-label attribute).
  • Without lightbox, the main image is a plain <img> carrying its own alt text directly. With lightbox on, the image sits inside the expand <button> instead, and its alt is left empty (avoiding a double announcement) since the button itself carries the accessible name (aria-label, plus aria-haspopup="dialog").
  • The thumbnail strip, the pagination dots (shown instead, with showThumbnails off), and the lightbox grid are each a role="group" of buttons ("Select image" / "Image navigation" / "All images"), every tile/dot labeled "Show image N of total" (dots: "Go to image N of total") with aria-current="true" on the active one, the same convention eun-carousel's own pagination dots use, not role="tablist" / "tab" (there's no corresponding tabpanel to pair it with).
  • The lightbox itself is eun-modal's own role="dialog", named via ariaLabelLightbox (default "Image viewer"). See eun-modal's own Accessibility tab for its focus handling and Escape/backdrop behavior, inherited here unchanged.

Live announcements

A visually-hidden role="status" aria-live="polite" region announces the active image ("Image 2 of 5: Side view") whenever it changes, from either the main stage or the lightbox, debounced so a rapid string of prev/next clicks announces once it settles, not on every intermediate click.

Transitions and reduced motion

transition's slide/fade animation is skipped automatically whenever prefers-reduced-motion: reduce is set, so every image swap becomes an instant, unanimated one regardless of what transition is set to, exactly like transition="none". This is enforced in JavaScript (the extra transitioning layer is never even created in that case), not just in CSS, so it holds regardless of custom --image-carousel-transition-duration overrides. The main stage's own live swipe (previous section) is never subject to transition in the first place, but its own release-settling transition (see --image-carousel-swipe-duration) respects prefers-reduced-motion: reduce too, landing instantly instead of easing when that's set. The live 1:1 tracking while the finger is still moving is unaffected either way, since that's direct manipulation rather than a decorative animation.

Autoplay and WCAG 2.2.2

WCAG 2.2.2 (Pause, Stop, Hide) requires that auto-updating content lasting more than five seconds be pausable by everyone, not just someone who happens to be hovering the right spot with a mouse. eun-image-carousel itself only covers half of that: whenever autoplay is set, it pauses on hover, keyboard focus, and while the lightbox is open, and never resumes on its own until the pointer/focus actually leaves (or the lightbox closes). It renders no pause/play control of its own, on purpose (see the "Why no built-in control" note below), so satisfying 2.2.2 fully is on you: pair autoplay with a visible pause/play button of your own, wired to the public pauseAutoplay()/resumeAutoplay() methods. The Autoplay example (Examples tab) is exactly that pattern, copy-pasteable as-is. Calling pauseAutoplay() persists across hover/focus: once paused that way, hovering and leaving again doesn't quietly resume it behind the user's back, since only resumeAutoplay() does.

Why no built-in control

Autoplay's placement, icon, and label are highly product-specific (a hero banner's controls look nothing like a product gallery's), and baking in one fixed design would mean every consumer either lives with it or fights it, the same reasoning eun-carousel follows for its own autoplay. Exposing the two methods keeps the underlying pause/resume behavior, including the hover/focus/lightbox handling above, in the component, while leaving the actual control's markup and styling entirely up to whoever's building the page.

The magnifier is a hover-only enhancement

magnifier only ever engages for a mouse/pen pointer hovering the main image. It never triggers on touch (there's no hover to begin with), and it isn't operable by keyboard, since it doesn't reveal anything a keyboard/touch user couldn't already reach another way: the expand control (always focusable, always tappable) opens the same image at full size in the lightbox. Nothing the magnifier shows is exclusive to it.

Why the main stage's controls stay subtle

The prev/next controls, the expand control, and the counter sit directly on top of the photo, so they're deliberately small and semi-translucent rather than opaque toolbar buttons that would compete with the image, while staying always visibly present and immediately actionable at every breakpoint and input type, never hidden behind a hover state a keyboard/touch/switch-access user couldn't trigger. A disabled prev/next button (at the bounds, without loop) stays visible too, dimmed rather than removed, since its state is still worth seeing, not just its absence.

Reference links

WAI-ARIA Dialog (Modal) Pattern
WCAG 2.2.2: Pause, Stop, Hide