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

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

eun-icon · unless the prev/next buttons are hidden
Overview API Examples Accessibility
Slide 1

A plain eun-card.

Slide 2

Any element works.

Slide 3

Swipe, drag, or use the arrows.

Slide 4

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).

Alternatives

You want .. Prefers A long, uniform list that only ever grows downward (infinite scroll) eun-infinite-list A photo set with thumbnails, a magnifier, and a lightbox eun-image-carousel

Guidance

  • Give every carousel on a page a distinct aria-label ("Featured products")
  • Pair autoplay with loop, 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 autoplay to 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

NameTypeDefaultDescription
responsive-breakpointnumber480Below 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-placementEunomiaCarouselNavPlacement'end'Where the previous and next buttons sit within the header row. Has no effect while they're overlaid on the viewport instead
aria-labelstringThe 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-previousstring'Previous slide'The accessible label of the previous control
aria-label-nextstring'Next slide'The accessible label of the next control
layoutEunomiaCarouselLayout'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-slidesnumber1How many slides are shown at once
full-slidesbooleantrueWhether the visible slides fill the viewport exactly, or leave part of the next one peeking at the edge
scroll-stepnumber1How many slides a previous, next, or dot click advances by, independently of visibleSlides
center-snapbooleanfalseFree continuous scrolling that snaps to whichever slide ends up nearest the center, instead of discrete paging
edge-fadebooleantrueFades the viewport's edges to hint there's more to scroll, hidden automatically on whichever side is already at its bound. On by default
loopbooleanfalseWraps 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
autoplaybooleanfalseAutomatically 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-intervalnumber4000Milliseconds between each autoplay advance
show-arrowsbooleantrueShows the previous and next buttons
show-dotsbooleantrueShows the pagination dots

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.

Slots

NameDescription
(default)The carousel's slides, any element, one per slide
titleOptional 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

NameTypeDescription
eunslidechangeSlideChangeEventFired once the active slide settles on a new index

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

CSS custom properties

NameDescription
--carousel-gapSets the gap between slides
--carousel-track-paddingSets 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-widthSets the minimum slide width. On a narrow viewport, this wins over visibleSlides so slides never get uncomfortably small
--carousel-peek-sizeSets how much of the next slide peeks at the edge, when fullSlides is off
--carousel-fade-widthSets the width of the edge fades
--carousel-fade-colorSets the color of the edge fades, which fade to transparent
--carousel-active-scaleSets the scale applied to the active slide
--carousel-header-gapSets the gap between the title slot and the header navigation buttons
--carousel-header-alignSets 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-displaySet to none to hide the previous and next buttons through pure CSS, regardless of showArrows
--carousel-nav-sizeSets the size of the previous and next buttons
--carousel-nav-icon-sizeSets the icon size of the previous and next buttons
--carousel-nav-colorSets the icon color of the previous and next buttons
--carousel-nav-backgroundSets the background color of the previous and next buttons
--carousel-nav-border-colorSets the border color of the previous and next buttons
--carousel-nav-shadowSets 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-backgroundSets the background and border color of a disabled previous or next button
--carousel-nav-disabled-colorSets the icon color of a disabled previous or next button
--carousel-dots-displaySet to none to hide the pagination dots through pure CSS, regardless of showDots
--carousel-dots-marginSets the margin between the pagination dots and the slides above them
--carousel-dot-gapSets the gap between pagination dots
--carousel-dot-sizeSets the size of the pagination dots
--carousel-dot-border-radiusSets the corner radius of the pagination dots
--carousel-dot-colorSets the color of the pagination dots
--carousel-dot-active-colorSets 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.

Card 1 Card 2 Card 3 Card 4 Card 5 Card 6
<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.

1 2 3 4 5
<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.

Card 1 Card 2 Card 3 Card 4
<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.

1 2 3 4 5
<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.

1 2 3 4 5
<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.

Slide 1 Slide 2 Slide 3
<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.

1 2 3 4
<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

Slide 1 Slide 2 Slide 3
<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).

1 2 3
<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

1 2 3 4
<eun-carousel>
  <h3 slot="title">Featured products</h3>
  <div>Slide 1</div>
  <!-- ... -->
</eun-carousel>

Header layout

Featured products

1 2 3 4
<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

Weekly digest

A short summary sent every Monday.

Release notes

What shipped this sprint.

Roadmap update

What's shipping next quarter.

Incident report

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

Fast setup Drop it in and go. Accessible by default Keyboard and screen-reader ready. Themeable Every color is a CSS custom property. Framework-agnostic A standard web component, works anywhere.
<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

Studio session On location Backstage Behind the scenes
<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.

Revenue
Breakdown

Subscriptions, one-time sales, and refunds.

Signups
Breakdown

Organic, referral, and paid acquisition.

Churn
Breakdown

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.

Fully custom markup
Still just a slide
No base class needed
Any element works
<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 Theming.

Custom Theme Colors Everywhere
<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" and aria-roledescription="carousel", with aria-label (default "Carousel", override via the aria-label attribute, see below).
  • Every slide gets role="group", aria-roledescription="slide", and aria-label="N of total", applied directly on whatever element you slotted in, re-applied automatically whenever slides are added, removed, or reordered. This is the WAI-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 (not role="tablist"/"tab", since there's no corresponding tabpanel, so overloading the tabs pattern here would be a misapplied, misleading pattern), each labeled "Go to slide N of total", with aria-current="true" on whichever one matches the active page.
  • Prev/next buttons carry aria-controls="track" and disable themselves at the bounds (unless loop is 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

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-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.