Skip to main content

rc-carousel

Swipeable slide carousel following the WAI-ARIA APG Carousel pattern, built on native CSS scroll-snap rather than a hand-rolled drag engine. rc-carousel is the track and chrome (navigation buttons, a slide-picker, and consumer-controlled slide sizing); rc-carousel-item is the slide wrapper, keeping authored light-DOM content — images with real alt text, interactive controls — directly available to assistive technology.

Package
@rcarls/rc-carousel
Element
<rc-carousel>
Native dependency
rc-carousel-item children are slotted light DOM
State model
Controlled or uncontrolled active-index
Main events
rc-carousel-change

Installation

npm install @rcarls/rc-carousel
import '@rcarls/rc-carousel/define';

Markup

<rc-carousel navigation pagination loop aria-label="Recipe photos">
<rc-carousel-item><img src="/pie.jpg" alt="Apple pie, sliced" /></rc-carousel-item>
<rc-carousel-item><img src="/risotto.jpg" alt="Mushroom risotto" /></rc-carousel-item>
</rc-carousel>

Give the host a definite inline-size/block-size (or place it in a layout that provides one) — navigation and pagination are absolutely positioned over the track, so an unsized host's auto height is just its content's own line height, too short for them not to overhang.

Live demo

Active slide

active-index/default-active-index follow this monorepo's controlled/ uncontrolled property convention. Host writes and calls to next(), previous(), and goToIndex(index, instant?) are silent in controlled usage — settling reports the new index back through rc-carousel-change (detail: { index, trigger }, trigger one of 'api' | 'button' | 'keyboard' | 'swipe') and the consumer feeds it back in.

<rc-carousel id="photos" active-index="0">
<rc-carousel-item>One</rc-carousel-item>
<rc-carousel-item>Two</rc-carousel-item>
</rc-carousel>

<script>
const carousel = document.querySelector('#photos');

carousel.addEventListener('rc-carousel-change', (event) => {
carousel.activeIndex = event.detail.index;
});
</script>

Uncontrolled usage only needs default-active-index; the component tracks its own active slide from there.

navigation renders previous/next buttons; pagination renders an APG "grouped" slide-picker (a role="group" of plain buttons with aria-current, not role="tablist" — see Accessibility). Both step through next()/previous()/goToIndex() under the hood and report trigger: 'button'.

Arrow keys move focus-independent of a rendered picker: with focus on the track, ArrowLeft/ArrowRight (RTL-aware) step one slide and Home/End jump to the first/last, reporting trigger: 'keyboard'.

Loop

loop wraps past the first/last slide back to the other end, seamlessly — not a discontinuous index jump. It clones the lead and trail slides (Shoelace's proven technique) and instantly re-anchors to the real slide once a clone settles into view, so the wrap is invisible. Without loop, navigation buttons show aria-disabled="true" at the boundary instead of being removed from the tab sequence — see Accessibility.

Slide sizing

The default --rc-carousel-slide-size: calc(100% - 4rem) shows one large slide with a peek of the next. Set the token directly for a multi-browse or full-width layout:

.recipe-carousel {
--rc-carousel-slide-size: min(75%, 300px);
}

Because the token is pure track geometry, a consumer container query can adapt it without changing carousel state, keyboard behavior, or accessibility:

.carousel-region {
container-type: inline-size;
}

.carousel-region rc-carousel {
--rc-carousel-slide-size: 100%;
}

@container (min-width: 40rem) {
.carousel-region rc-carousel {
--rc-carousel-slide-size: min(40%, 300px);
}
}

MD3's center-aligned hero, full-screen, and vertical orientation are deliberate non-goals for now — neither current consumer needs them, and nothing here forecloses adding them later.

Mouse dragging

mouse-dragging adds desktop click-and-drag scrolling. Touch and pen already get native scroll-snap physics; a mouse has no built-in equivalent. A real drag still ends in a native click on release, wherever the pointer lands — often unrelated slide content the drag scrolled past, not a deliberate activation of it — so a drag that moves far enough to activate (clearing an 8px threshold) suppresses exactly that one trailing click, matching Shoelace's own sl-carousel handling of the same edge case. Releasing with enough velocity pre-nudges to the next slide in that direction (a "decisive swipe") rather than settling back to where the drag started.

Accessibility

The host defaults to role="group" with aria-roledescription="carousel" (APG allows either region or group depending on page IA — override role if the surrounding document calls for a landmark). Each rc-carousel-item gets role="group" and aria-roledescription="slide", plus an auto-computed "Slide N of M" aria-label when left unlabeled — an authored aria-label (e.g. describing a photo's actual content) always wins. Off-screen and peeking slides get aria-hidden/inert via an IntersectionObserver, so their interactive descendants (a "Start timer" button, a link) aren't Tab-reachable while inactive.

Navigation and pagination buttons use aria-disabled at a boundary, never native disabled — native disabled removes a button from the Tab sequence entirely, so a keyboard or screen-reader user reaching the end could no longer even locate it to confirm they're there. This is APG's own stated preference for the analogous case.

The picker is APG's "grouped" (non-tab) style only — a role="group" of plain buttons with aria-current on the active one, not role="tablist"/ role="tabpanel". The tabbed variant is a deliberate omission: Shoelace's own accessibility-PR history found it hitting real shadow-DOM-boundary ARIA cross-reference failures, including non-recognition by some screen readers entirely.

The track carries aria-busy/aria-atomic while a settle or programmatic scroll is in flight, cleared on settle — a less disruptive alternative to toggling aria-live, which Shoelace's own PR #1218 found could interrupt a screen reader's virtual cursor mid-navigation.

API

Properties

PropertyMarkupTypeDefaultDescription
looploopbooleanfalseWraps past the first/last slide back to the other end, seamlessly.
navigationnavigationbooleanfalseShows previous/next buttons.
paginationpaginationbooleanfalseShows a slide-picker button group.
mouseDraggingmouse-draggingbooleanfalseEnables click-and-drag scrolling with a mouse — native scroll-snap touch physics already cover touch/pen, but a mouse has no built-in equivalent. Off by default.
activeIndexactive-indexnumberNot specifiedControls the active slide. Host writes are silent.
defaultActiveIndexdefault-active-indexnumberNot specifiedInitial active slide for uncontrolled usage.
trackElementJS property onlyHTMLElement | nullNot specifiedThe scroll-snap track element. Internal integration point consumed by `rc-carousel-item`'s IntersectionObserver (its `root` must be this track, not the viewport, so peeking/clipped-but-viewport-visible slides are still correctly detected as off-screen) — not a public API.
roleJS property onlystring'group'No description provided.

Methods

MethodDescription
next()Moves to the next slide.
previous()Moves to the previous slide.
goToIndex(index: number, instant: unknown)Moves directly to a slide index.

Events

EventDetail typeDescription
rc-carousel-changeCustomEventFires when the active slide changes, from a swipe settling, a keyboard action, or the imperative API. `detail: { index, trigger: 'swipe'|'button'|'keyboard'|'api' }`

Slots

NameDescription
(default)One or more `rc-carousel-item` elements.
previous-iconOptional icon for the previous button, replacing the default chevron. Only rendered when `navigation` is set.
next-iconOptional icon for the next button, replacing the default chevron. Only rendered when `navigation` is set.

CSS Custom Properties

PropertyDefaultDescription
--rc-carousel-colorCanvasTextCarousel foreground color.
--rc-carousel-gap8pxSpace between slides.
--rc-carousel-slide-sizecalc(100% - 4rem)Rendered size of each slide along the scroll axis. Set this directly or from a consumer container query to coordinate hero and multi-browse layouts.
--rc-carousel-navigation-button-size40pxPrevious/next button diameter.
--rc-carousel-navigation-button-backgroundcolor-mix(in srgb, CanvasText 12%, transparent)Previous/next button background.
--rc-carousel-navigation-button-colorCanvasTextPrevious/next button icon color.
--rc-carousel-navigation-inset8pxPrevious/next button inset from the track edge.
--rc-carousel-pagination-item-size8pxSlide-picker dot diameter.
--rc-carousel-pagination-item-colorcolor-mix(in srgb, CanvasText 40%, transparent)Inactive slide-picker dot color.
--rc-carousel-pagination-item-active-colorHighlightActive slide-picker dot color.

CSS Parts

PartDescription
trackThe scrollable slide track.
navigationPrevious/next button wrapper.
navigation-buttonA previous or next button.
navigation-button-previousThe previous button specifically.
navigation-button-nextThe next button specifically.
paginationSlide-picker button group wrapper.
pagination-itemA slide-picker button.
pagination-item-activeThe active slide's picker button.

Properties

PropertyMarkupTypeDefaultDescription
positionJS property onlystring''"N of M" position assigned by the parent `rc-carousel`. Internal integration point, not a public API — see the `@attr` note above.
roleJS property onlystring'group'No description provided.

Methods

No public methods are documented in the custom elements manifest.

Events

No custom events are documented in the custom elements manifest.

Slots

NameDescription
(default)Slide content.

CSS Custom Properties

PropertyDefaultDescription
--rc-carousel-item-colorCanvasTextSlide foreground color.
--rc-carousel-item-scroll-snap-alignstartSnap alignment within the track. Override for a center-aligned layout.
--rc-carousel-item-backgroundtransparentSlide surface background, e.g. for an MD3 card-like slide shape.
--rc-carousel-item-border-radius0Slide corner radius.
--rc-carousel-item-overflowhiddenOverflow behavior for slotted content that exceeds the slide's own box. `hidden` clips to the corner radius (matching media-item slides); text-heavy slides that need their own internal scroll may want `auto` instead.

CSS Parts

No CSS parts are documented in the custom elements manifest.