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
Dependencies
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
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.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/image-carousel.js";
import "@eunomia/elements/modal.js";
import "@eunomia/elements/icon.js";
</script>
<eun-image-carousel aria-label="Product photos" id="gallery">
</eun-image-carousel>
<script type="module">
document.getElementById("gallery").images = [
{ src: "/photos/front.jpg", alt: "Front view" },
{ src: "/photos/side.jpg", alt: "Side view" },
];
</script>
npm install @eunomia/elements
import { useEffect, useRef } from "react";
import "@eunomia/elements/image-carousel.js";
import "@eunomia/elements/modal.js";
import "@eunomia/elements/icon.js";
function ProductGallery({ photos }) {
const ref = useRef(null);
useEffect(() => {
ref.current.images = photos;
}, [photos]);
return <eun-image-carousel aria-label="Product photos" ref={ref} />;
}
npm install @eunomia/elements
"use client";
import { useEffect, useRef } from "react";
import "@eunomia/elements/image-carousel.js";
import "@eunomia/elements/modal.js";
import "@eunomia/elements/icon.js";
export function ProductGallery({ photos }) {
const ref = useRef(null);
useEffect(() => {
ref.current.images = photos;
}, [photos]);
return <eun-image-carousel aria-label="Product photos" ref={ref} />;
}
npm install @eunomia/elements
<script setup>
import { ref, onMounted } from "vue";
import "@eunomia/elements/image-carousel.js";
import "@eunomia/elements/modal.js";
import "@eunomia/elements/icon.js";
const gallery = ref(null);
const props = defineProps(["photos"]);
onMounted(() => {
gallery.value.images = props.photos;
});
</script>
<template>
<eun-image-carousel aria-label="Product photos" ref="gallery" />
</template>
npm install @eunomia/elements
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
ElementRef,
Input,
ViewChild,
AfterViewInit,
} from "@angular/core";
import "@eunomia/elements/image-carousel.js";
import "@eunomia/elements/modal.js";
import "@eunomia/elements/icon.js";
@Component({
selector: "app-product-gallery",
template: `<eun-image-carousel
aria-label="Product photos"
#gallery
></eun-image-carousel>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ProductGalleryComponent implements AfterViewInit {
@Input() photos: { src: string; alt: string }[] = [];
@ViewChild("gallery") galleryRef!: ElementRef;
ngAfterViewInit() {
this.galleryRef.nativeElement.images = this.photos;
}
}
Alternatives
eun-carouselGuidance
- Give every image a real
alt, since it's read out for the main stage (withoutlightbox), the lightbox's own image, and the live region announcement - Set a distinct
aria-labelon the host when a page has more than one gallery - Provide a smaller
thumbnailper image when you have one, so the strip and the lightbox grid don't download full-resolution photos just to show them tiny - Pair
magnifierwith 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:
lightboxalone (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-carouselper 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
| Name | Type | Default | Description |
|---|---|---|---|
| active-index | number | 0 | The zero-based index of the currently shown image, shared between the main stage and the lightbox |
| magnifier-zoom | number | 2.5 | How much the main image scales up under the cursor while hovering, with magnifier on |
| show-thumbnails | boolean | true | Shows the thumbnail strip below the main stage, for jumping directly to an image |
| show-dots | boolean | true | Shows 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-grid | boolean | true | Shows a scrollable grid of every image alongside the lightbox's own main image, for jumping directly to one without closing it |
| autoplay-interval | number | 4000 | Milliseconds between each autoplay advance |
| aria-label | string | — | The accessible name for the main stage. Defaults to "Image gallery" |
| aria-label-previous | string | 'Previous image' | The accessible label of the previous controls, on both the main stage and the lightbox |
| aria-label-next | string | 'Next image' | The accessible label of the next controls, on both the main stage and the lightbox |
| aria-label-expand | string | 'View full-size image' | The accessible label of the main stage's expand control |
| aria-label-lightbox | string | 'Image viewer' | The accessible name of the lightbox dialog |
| images | Array<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 |
| magnifier | boolean | false | Enables 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 |
| lightbox | boolean | true | Whether clicking the main image, or its expand control, opens the full-size lightbox |
| loop | boolean | false | Wraps the previous and next controls around at the bounds instead of stopping there, on both the main stage and the lightbox |
| transition | EunomiaImageCarouselTransition | 'slide' | How the main stage and the lightbox animate between images |
| autoplay | boolean | false | Automatically 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
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Events
| Name | Type | Description |
|---|---|---|
| eunimagechange | ImageChangeEvent | Fired whenever the active image changes, from either the main stage or the lightbox |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --image-carousel-aspect-ratio | Sets the aspect ratio of the main stage |
| --image-carousel-object-fit | Sets how the main image fits its box |
| --image-carousel-border-radius | Sets the corner radius of the main stage |
| --image-carousel-background | Sets the background color behind the main stage, visible when the image is set to fit without cropping |
| --image-carousel-thumbnail-size | Sets the size of each thumbnail |
| --image-carousel-thumbnail-gap | Sets the gap between thumbnails, and between the strip and the main stage |
| --image-carousel-nav-color | Sets the icon color of the main stage's previous and next controls |
| --image-carousel-transition-duration | Sets 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-duration | Sets 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-width | Sets the fixed width of the lightbox dialog |
| --image-carousel-lightbox-max-height | Sets 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-background | Sets the background color behind the lightbox's main image |
| --image-carousel-lightbox-image-radius | Sets the corner radius of the lightbox's own image |
| --image-carousel-grid-width | Sets the width of the lightbox's grid column |
| --image-carousel-grid-columns | Sets the column count of the lightbox's grid |
| --image-carousel-dot-size | Sets the size of the pagination dots |
| --image-carousel-dot-gap | Sets the gap between pagination dots |
| --image-carousel-dot-color | Sets the color of the pagination dots |
| --image-carousel-dot-active-color | Sets 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
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
<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, witharia-label(default"Image gallery", override via thearia-labelattribute). - Without
lightbox, the main image is a plain<img>carrying its ownalttext directly. Withlightboxon, the image sits inside the expand<button>instead, and itsaltis left empty (avoiding a double announcement) since the button itself carries the accessible name (aria-label, plusaria-haspopup="dialog"). - The thumbnail strip, the pagination dots (shown instead, with
showThumbnailsoff), and the lightbox grid are each arole="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") witharia-current="true"on the active one, the same conventioneun-carousel's own pagination dots use, notrole="tablist"/"tab"(there's no correspondingtabpanelto pair it with). - The lightbox itself is
eun-modal's ownrole="dialog", named viaariaLabelLightbox(default"Image viewer"). Seeeun-modal's own Accessibility tab for its focus handling andEscape/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
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