Carousel
Carousel pages through a row of slides using real browser scrolling, so a mouse wheel, trackpad, touch drag, or the keyboard all move it the same way native scrolling would. It layers its own navigation buttons, pagination dots, autoplay, and edge fades on top, and works with any slide content you give it.
Dependencies
A plain eun-card.
Any element works.
Swipe, drag, or use the arrows.
Several cards, paged a few at a time.
When to use
Use a carousel for a row of slides too numerous, or too visually prominent, to show all at once, such as product cards, testimonials, a media gallery, or a hero banner rotation, where someone should be able to page through them with a click, a swipe, or a keyboard. Its slides can be any card variant, an image card, a sliding card, or a fully custom element. See the Slide content section below. If the slides are only ever photos, needing thumbnails, a magnifier, or a lightbox, or if the list should keep growing indefinitely instead of staying a fixed, paged set, see Alternatives below for a better fit.
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/carousel.js";
import "@eunomia/elements/icon.js";
<eun-carousel>
<div>Slide 1</div>
<div>Slide 2</div>
<div>Slide 3</div>
</eun-carousel>
Importing the file registers <eun-carousel> as a custom element.
eun-icon is used internally for the prev/next chevrons, so import it
alongside, unless you hide those buttons (see the dependency badge
above).
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/carousel.js";
import "@eunomia/elements/icon.js";
</script>
<eun-carousel>
<div>Slide 1</div>
<div>Slide 2</div>
</eun-carousel>
npm install @eunomia/elements
import "@eunomia/elements/carousel.js";
import "@eunomia/elements/icon.js";
function Highlights() {
return (
<eun-carousel>
<div>Slide 1</div>
<div>Slide 2</div>
</eun-carousel>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/carousel.js";
import "@eunomia/elements/icon.js";
export function Highlights() {
return (
<eun-carousel>
<div>Slide 1</div>
<div>Slide 2</div>
</eun-carousel>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/carousel.js";
import "@eunomia/elements/icon.js";
</script>
<template>
<eun-carousel>
<div>Slide 1</div>
<div>Slide 2</div>
</eun-carousel>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/carousel.js";
import "@eunomia/elements/icon.js";
@Component({
selector: "app-highlights",
template: `
<eun-carousel>
<div>Slide 1</div>
<div>Slide 2</div>
</eun-carousel>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class HighlightsComponent {}
Alternatives
eun-infinite-listeun-image-carouselGuidance
- Give every carousel on a page a distinct
aria-label("Featured products") - Pair
autoplaywithloop, so it doesn't stall once it reaches the last slide - Keep slide count reasonable, since dozens of slides make the dots row impractical, and prefer paging (
scroll-step) instead - Let each slide's own content (a card's
href, a button, ...) stay independently interactive and focusable
- Using a carousel for a single hero image with no other slides. It's just an image at that point
- Cramming unrelated content into one carousel to save space. Split into sections instead
- Relying on
autoplayto convey time-sensitive information nobody can pause in time to read - Nesting a carousel's slide inside another carousel's own track
Live testing
Properties
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| responsive-breakpoint | number | 480 | Below this host width in pixels, an inline layout renders its buttons in the header row instead of overlaid on the slides, the same as the header layout would. Zero disables the automatic switch |
| nav-placement | EunomiaCarouselNavPlacement | 'end' | Where the previous and next buttons sit within the header row. Has no effect while they're overlaid on the viewport instead |
| aria-label | string | — | The accessible name for the carousel region. Defaults to Carousel, and is recommended whenever a page has more than one carousel, or its purpose isn't already clear from a preceding heading |
| aria-label-previous | string | 'Previous slide' | The accessible label of the previous control |
| aria-label-next | string | 'Next slide' | The accessible label of the next control |
| layout | EunomiaCarouselLayout | 'inline' | Where the previous and next buttons sit: overlaid on the slides and centered over them, or in a header row above the track, aligned next to the title slot |
| visible-slides | number | 1 | How many slides are shown at once |
| full-slides | boolean | true | Whether the visible slides fill the viewport exactly, or leave part of the next one peeking at the edge |
| scroll-step | number | 1 | How many slides a previous, next, or dot click advances by, independently of visibleSlides |
| center-snap | boolean | false | Free continuous scrolling that snaps to whichever slide ends up nearest the center, instead of discrete paging |
| edge-fade | boolean | true | Fades the viewport's edges to hint there's more to scroll, hidden automatically on whichever side is already at its bound. On by default |
| loop | boolean | false | Wraps the previous, next, and autoplay controls around at the bounds instead of stopping there, and keeps the previous and next buttons enabled at the bounds instead of disabling them |
| autoplay | boolean | false | Automatically advances by scrollStep on a set interval. Pauses on hover or focus, but renders no pause or play control of its own, so pair this with a visible control of your own calling the public pause and resume methods, since a moving carousel nobody can stop fails accessibility guidelines |
| autoplay-interval | number | 4000 | Milliseconds between each autoplay advance |
| show-arrows | boolean | true | Shows the previous and next buttons |
| show-dots | boolean | true | Shows the pagination dots |
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. |
Slots
| Name | Description |
|---|---|
| (default) | The carousel's slides, any element, one per slide |
| title | Optional content, typically a heading, shown at the top left above the carousel. Rendered the same way regardless of layout, so it works whether the previous and next buttons sit above alongside it, or stay overlaid on the slides |
Events
| Name | Type | Description |
|---|---|---|
| eunslidechange | SlideChangeEvent | Fired once the active slide settles on a new index |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --carousel-gap | Sets the gap between slides |
| --carousel-track-padding | Sets the vertical padding on the track, giving slides' own effects that bleed outside their box, such as a hover shadow, room to render before it gets clipped |
| --carousel-min-slide-width | Sets the minimum slide width. On a narrow viewport, this wins over visibleSlides so slides never get uncomfortably small |
| --carousel-peek-size | Sets how much of the next slide peeks at the edge, when fullSlides is off |
| --carousel-fade-width | Sets the width of the edge fades |
| --carousel-fade-color | Sets the color of the edge fades, which fade to transparent |
| --carousel-active-scale | Sets the scale applied to the active slide |
| --carousel-header-gap | Sets the gap between the title slot and the header navigation buttons |
| --carousel-header-align | Sets the cross-axis alignment (align-items) between the title slot and the header navigation buttons. Defaults to baseline, which keeps the nav buttons close to the track below regardless of how tall a multi-line title ends up |
| --carousel-nav-display | Set to none to hide the previous and next buttons through pure CSS, regardless of showArrows |
| --carousel-nav-size | Sets the size of the previous and next buttons |
| --carousel-nav-icon-size | Sets the icon size of the previous and next buttons |
| --carousel-nav-color | Sets the icon color of the previous and next buttons |
| --carousel-nav-background | Sets the background color of the previous and next buttons |
| --carousel-nav-border-color | Sets the border color of the previous and next buttons |
| --carousel-nav-shadow | Sets the shadow of the previous and next buttons in the inline layout, when enabled. Unset for the header layout, and always unset while a button is disabled |
| --carousel-nav-disabled-background | Sets the background and border color of a disabled previous or next button |
| --carousel-nav-disabled-color | Sets the icon color of a disabled previous or next button |
| --carousel-dots-display | Set to none to hide the pagination dots through pure CSS, regardless of showDots |
| --carousel-dots-margin | Sets the margin between the pagination dots and the slides above them |
| --carousel-dot-gap | Sets the gap between pagination dots |
| --carousel-dot-size | Sets the size of the pagination dots |
| --carousel-dot-border-radius | Sets the corner radius of the pagination dots |
| --carousel-dot-color | Sets the color of the pagination dots |
| --carousel-dot-active-color | Sets the color of the active pagination dot |
Visible slides and scroll step
visibleSlides controls how many slides are shown at once.
scrollStep controls how many a prev/next click or a dot jump advances
by. The two are independent.
<eun-carousel visible-slides="3" scroll-step="1"> ... </eun-carousel>
Responsive slide count
--carousel-min-slide-width (default 220px) sets a floor under how
narrow a slide is allowed to get. On a wide viewport, visibleSlides
wins as usual. On a narrow one, dividing the track's width by
visibleSlides would otherwise produce slides smaller than this
minimum. When that happens, the minimum wins instead, and the "extra"
requested slides simply become scrollable off-screen rather than being
squeezed. This is pure CSS (max()), no JS/ResizeObserver involved,
so it reacts to any resize for free.
<eun-carousel visible-slides="4" style="--carousel-min-slide-width: 220px;">
...
</eun-carousel>
Capped at 340px above to reproduce a narrow viewport regardless of your actual window size. Resize your browser on a real page and the same thing happens automatically, no separate "mobile" prop to set.
Peeking slides
By default (fullSlides), the visible slides fill the viewport exactly.
Set the fullSlides property to false (see the note below) to leave
--carousel-peek-size of the next slide peeking at the edge, hinting
there's more to scroll.
<eun-carousel id="my-carousel" visible-slides="2"> ... </eun-carousel>
<script type="module">
document.getElementById("my-carousel").fullSlides = false;
</script>
fullSlides/showArrows/showDots all
default to true and, like every boolean attribute, are
presence-based. Writing full-slides="false" in plain HTML
still leaves it true (the attribute is present).
To turn one off declaratively, set the property directly instead:
.fullSlides=${false} in a Lit template, or
carousel.fullSlides = false in plain JS.
Center snap
centerSnap switches from discrete paging to free continuous scrolling
(drag it with your finger or trackpad) that settles on whichever slide
ends up nearest the center. That slide also gets the
eun-carousel-active-slide class, scaled up here via
--carousel-active-scale, but usable for any custom styling. Scaling
the active slide up pushes it past its own unscaled box on every side,
which the track's overflow-y: hidden would otherwise clip flat. Bump
--carousel-track-padding to give it room, the same fix the Sliding
card example below uses for its own bleed.
<eun-carousel
center-snap
style="--carousel-active-scale: 1.06; --carousel-track-padding: 12px;"
>
...
</eun-carousel>
Edge fade
edgeFade fades the viewport's left/right edges to hint there's more
to scroll on that side, on by default, as in every demo on this
page. Each side fades out automatically once its own scroll bound is
reached (no left fade right at the start, no right fade right at the
end), so it only ever shows where it's actually true. Scroll the demo
below and watch the left fade appear only once you've moved past the
first card.
<eun-carousel> ... </eun-carousel>
Turn it off by setting the property to false (see the note above
for the same presence-based caveat that applies to
fullSlides/showArrows/showDots):
document.querySelector("eun-carousel").edgeFade = false;
Loop
loop wraps previous()/next()/autoplay around at the bounds instead
of stopping there. The prev/next buttons also stay enabled at both
ends instead of disabling themselves.
<eun-carousel loop> ... </eun-carousel>
Autoplay
autoplay advances automatically every autoplayInterval
milliseconds, and pauses on hover or keyboard focus. 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 carousel nobody can stop
is a WCAG 2.2.2 failure. The demo below is exactly that pattern. It
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 carousel
doesn't keep pulling keyboard focus back onto it on every tick.
<button id="my-toggle" aria-label="Pause automatic slide rotation">
<!-- your own icon -->
</button>
<eun-carousel id="my-carousel" autoplay loop autoplay-interval="2500">
...
</eun-carousel>
<script type="module">
const carousel = document.getElementById("my-carousel");
const toggle = document.getElementById("my-toggle");
let paused = false;
toggle.addEventListener("click", () => {
paused = !paused;
paused ? carousel.pauseAutoplay() : carousel.resumeAutoplay();
toggle.setAttribute(
"aria-label",
paused
? "Resume automatic slide rotation"
: "Pause automatic slide rotation",
);
});
</script>
Pairing autoplay with loop is recommended. Without it, autoplay simply stops advancing once it reaches the last slide, since manual and automatic navigation share the same bounds behavior.
No controls
Set the showArrows/showDots properties to false (see the note
above, same presence-based caveat) to rely purely on scrolling: wheel,
trackpad, touch, or keyboard once the track is focused.
<eun-carousel id="my-carousel"> ... </eun-carousel>
<script type="module">
const carousel = document.getElementById("my-carousel");
carousel.showArrows = false;
carousel.showDots = false;
</script>
--carousel-nav-display/--carousel-dots-display
(both default flex) hide the buttons/dots via CSS alone
when set to none, e.g. inside a @media
query, to hide arrows on narrow viewports without touching
showArrows/showDots in JS.
External controls
Nothing about navigating eun-carousel requires its own built-in
buttons or dots. previous()/next()/goTo(index) and the
eunslidechange event (see the API tab) are the same public surface
the built-in controls use internally, so a fully custom set of
controls (differently placed, differently styled, or driven by
something other than a button) is exactly as capable. This demo turns
the built-in arrows/dots off and rebuilds both externally from that
public API alone.
<eun-carousel id="my-carousel">...</eun-carousel>
<button id="my-prev" aria-label="Previous slide">...</button>
<button id="my-next" aria-label="Next slide">...</button>
<script type="module">
const carousel = document.getElementById("my-carousel");
carousel.showArrows = false;
carousel.showDots = false;
document
.getElementById("my-prev")
.addEventListener("click", () => carousel.previous());
document
.getElementById("my-next")
.addEventListener("click", () => carousel.next());
carousel.addEventListener("eunslidechange", (event) => {
console.log(`Now on slide ${event.index + 1} of ${event.count}`);
});
</script>
The Autoplay example above is the same idea applied to pauseAutoplay()/resumeAutoplay() instead. Both examples build on the exact same public API, just different pieces of it.
Nav button placement
layout picks where the prev/next buttons sit, independently of
everything else on this page:
inline(the default): overlaid on the slides themselves, vertically centered over them at the left/right edges. Since they sit on top of arbitrary, sometimes busy, image-heavy slide content, they carry a shadow by default (--carousel-nav-shadow) so they always read as floating controls instead of blending into whatever's behind them.header: moved into a header row above the track, aligned to the right.
Responsive button placement
Below responsiveBreakpoint (default 480px, measured on the
carousel itself), an inline layout automatically borrows header's
button placement instead. Overlay buttons need real space on either
side of the slides to read as controls rather than obscure them, which
a narrow/mobile-width carousel doesn't have. layout="header" is
unaffected, since it's already there. Resize your browser window
narrower than 480px (or shrink this one) and watch the demo below
switch on its own, with nothing to configure beyond the default.
Highlights
<eun-carousel> ... </eun-carousel>
<!-- or, with a custom threshold : -->
<eun-carousel responsive-breakpoint="600"> ... </eun-carousel>
<!-- or, to keep inline overlaid at every width : -->
<eun-carousel responsive-breakpoint="0"> ... </eun-carousel>
Capped at 360px above purely to demonstrate the switch regardless of your actual window size, the same idea as "Responsive slide count" earlier. On a real page at full width, this only kicks in once the carousel itself is actually that narrow.
Placing the buttons within the header row
Once the buttons are in a header row (layout="header", or an inline
layout narrowed below responsiveBreakpoint), navPlacement picks
where within that row they sit: at the trailing edge, opposite title
(end, the default, shown above), at the leading edge, swapping places
with title (start), or centered together with title as one group
(center).
<eun-carousel layout="header" nav-placement="start">
<h3 slot="title">Featured</h3>
<!-- ... -->
</eun-carousel>
<!-- or nav-placement="center" -->
Has no effect while the buttons are still overlaid on the viewport (inline layout, above the breakpoint), since there's no header row to place them within yet.
The title slot
Independently of layout, anything slotted into title (typically a
heading) renders at the top-left, in a row above the track. It shows
up the same way whether the nav buttons are overlaid on the slides
(inline) or sharing that same row, to its right (header). The two
examples below use the same title slot with each placement in turn.
Pagination dots (if shown) always stay below the track, regardless of
either choice.
Inline layout
Featured products
<eun-carousel>
<h3 slot="title">Featured products</h3>
<div>Slide 1</div>
<!-- ... -->
</eun-carousel>
Header layout
Featured products
<eun-carousel layout="header">
<h3 slot="title">Featured products</h3>
<div>Slide 1</div>
<!-- ... -->
</eun-carousel>
Slot a real heading element (h2/h3/...) at whatever level fits the surrounding page structure. eun-carousel doesn't enforce one. With no title content and layout="inline", the row above the track takes up no space at all. It only appears once there's something to show there, whether that's a title, the header-layout buttons, or both.
Slide content
eun-carousel is entirely slot-based. It doesn't render or know
anything about slide content itself. Every card in this library works
as-is, and so does a fully custom element.
Plain card
A short summary sent every Monday.
What shipped this sprint.
What's shipping next quarter.
What happened, and what changed.
<eun-carousel visible-slides="2">
<eun-card>
<strong>Weekly digest</strong>
<p>A short summary sent every Monday.</p>
</eun-card>
<!-- ... -->
</eun-carousel>
Item card
<eun-carousel visible-slides="2">
<eun-card variant="item">
<eun-icon slot="start" name="bolt"></eun-icon>
<span slot="title">Fast setup</span>
<span slot="description">Drop it in and go.</span>
</eun-card>
<!-- ... -->
</eun-carousel>
Image card
<eun-carousel visible-slides="2">
<eun-image-card>
<img slot="image" src="/photo.jpg" alt="" />
<span slot="title">Studio session</span>
</eun-image-card>
<!-- ... -->
</eun-carousel>
Sliding card
eun-sliding-card paints a permanent bottom fade that bleeds
--sliding-card-fade-height (60px by default) below its own box,
which the carousel's own overflow-y: hidden track would otherwise
clip flat, since --carousel-track-padding is 0 by default. Bump
the padding to match so the fade renders in full and the pagination
dots stay clearly separated below it, not crowded right up against
the cards. visibleSlides (here 2) and a max-width on each card
(the same sizing attributes used throughout this page) keep the
cards from growing as large as their 300px default.
Subscriptions, one-time sales, and refunds.
Organic, referral, and paid acquisition.
Voluntary cancellations vs. failed payments.
<eun-carousel visible-slides="2" style="--carousel-track-padding: 64px;">
<eun-sliding-card style="max-width: 280px;"> ... </eun-sliding-card>
<!-- ... -->
</eun-carousel>
Hover, focus, or tap a card above to reveal its back layer, right inside the carousel. The dots below stay fully visible throughout. See eun-sliding-card's own page for its full API.
Custom slide
Any element works, with no special markers or base class required.
eun-carousel never inspects what it's given.
<eun-carousel>
<div class="my-own-slide">...</div>
</eun-carousel>
Theming
Every color and spacing value is exposed as a CSS custom property (full
list in the API tab), so the carousel repaints with the active theme
automatically. See
<eun-carousel
style="--carousel-dot-active-color: #b45309; --carousel-nav-border-color: #b45309;"
>
...
</eun-carousel>
Keyboard interactions
When focus is on the track (Tab reaches it as a single stop, exactly
like a native scrollable region):
| Key | Action |
|---|---|
ArrowRight |
Advances by scrollStep (wraps with loop) |
ArrowLeft |
Retreats by scrollStep (wraps with loop) |
Home |
Jumps to the first slide |
End |
Jumps to the last reachable page |
Tab / Shift+Tab |
Moves to the prev/next buttons, the dots, or the next focusable element on the page, each a normal stop, not a roving composite |
The prev/next buttons and pagination dots are all plain, individually
focusable <button> elements. Every native button behavior
(Enter/Space activation, focus visibility) comes for free, with
nothing custom to implement.
Aria roles
- The track exposes
role="region"andaria-roledescription="carousel", witharia-label(default"Carousel", override via thearia-labelattribute, see below). - Every slide gets
role="group",aria-roledescription="slide", andaria-label="N of total", applied directly on whatever element you slotted in, re-applied automatically whenever slides are added, removed, or reordered. This is theWAI-ARIA carousel pattern 's own recommended labeling, and the reason a screen reader announces "group, 2 of 5" when landing on a slide. - Pagination dots are a plain
role="group"of buttons (notrole="tablist"/"tab", since there's no correspondingtabpanel, so overloading the tabs pattern here would be a misapplied, misleading pattern), each labeled "Go to slide N of total", witharia-current="true"on whichever one matches the active page. - Prev/next buttons carry
aria-controls="track"and disable themselves at the bounds (unlessloopis set) rather than wrapping silently, so their state always reflects what activating them will do.
Labeling the carousel
When a page has more than one carousel, or its purpose isn't already clear from a preceding heading, give it an accessible name:
<eun-carousel aria-label="Featured products"> ... </eun-carousel>
aria-label set on <eun-carousel> is forwarded to the internal
role="region" element. It isn't visible, only announced. It defaults
to the generic "Carousel" when unset.
Live announcements
A visually-hidden role="status" aria-live="polite" region announces
the active slide ("Slide 2 of 5", or "Showing slides 2 to 4 of 9" once
visibleSlides is greater than 1) once scrolling settles. It's debounced
so it fires once per navigation, not on every intermediate frame while a
drag or a smooth-scroll animation is still moving. eunslidechange is
dispatched at the same moment, for syncing external UI (see the API
tab).
Autoplay and WCAG 2.2.2
eun-carousel itself only covers half of
that. Whenever autoplay is set, it pauses on hover and keyboard
focus, and never resumes on its own until the pointer/focus actually
leaves. 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: only resumeAutoplay() does.
Why no built-in control
Earlier versions of this component rendered an always-visible pause/play
button itself. It was pulled in favor of the public
pauseAutoplay()/resumeAutoplay() methods, since autoplay's placement,
icon, and label are highly product-specific (a hero banner's controls
look nothing like a testimonial strip's), and baking in one fixed
design meant every consumer either lived with it or fought it. Exposing
the two methods keeps the underlying pause/resume behavior (including
the hover/focus handling above) in the component, while leaving the
actual control's markup and styling entirely up to whoever's building
the page.
Reduced motion
Slide transitions and the edge fades respect
prefers-reduced-motion: reduce. With that OS-level setting on,
smooth-scroll navigation (prev/next, dots, keyboard) becomes an instant
jump instead of an animated scroll, and the active-slide/fade
transitions are disabled outright.