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
- Yarn
npm install @rcarls/rc-carousel
yarn add @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 and pagination
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
| Property | Markup | Type | Default | Description |
|---|---|---|---|---|
loop | loop | boolean | false | Wraps past the first/last slide back to the other end, seamlessly. |
navigation | navigation | boolean | false | Shows previous/next buttons. |
pagination | pagination | boolean | false | Shows a slide-picker button group. |
mouseDragging | mouse-dragging | boolean | false | Enables 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. |
activeIndex | active-index | number | Not specified | Controls the active slide. Host writes are silent. |
defaultActiveIndex | default-active-index | number | Not specified | Initial active slide for uncontrolled usage. |
trackElement | JS property only | HTMLElement | null | Not specified | The 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. |
role | JS property only | string | 'group' | No description provided. |
Methods
| Method | Description |
|---|---|
next() | Moves to the next slide. |
previous() | Moves to the previous slide. |
goToIndex(index: number, instant: unknown) | Moves directly to a slide index. |
Events
| Event | Detail type | Description |
|---|---|---|
rc-carousel-change | CustomEvent | Fires when the active slide changes, from a swipe settling, a keyboard action, or the imperative API. `detail: { index, trigger: 'swipe'|'button'|'keyboard'|'api' }` |
Slots
| Name | Description |
|---|---|
(default) | One or more `rc-carousel-item` elements. |
previous-icon | Optional icon for the previous button, replacing the default chevron. Only rendered when `navigation` is set. |
next-icon | Optional icon for the next button, replacing the default chevron. Only rendered when `navigation` is set. |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--rc-carousel-color | CanvasText | Carousel foreground color. |
--rc-carousel-gap | 8px | Space between slides. |
--rc-carousel-slide-size | calc(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-size | 40px | Previous/next button diameter. |
--rc-carousel-navigation-button-background | color-mix(in srgb, CanvasText 12%, transparent) | Previous/next button background. |
--rc-carousel-navigation-button-color | CanvasText | Previous/next button icon color. |
--rc-carousel-navigation-inset | 8px | Previous/next button inset from the track edge. |
--rc-carousel-pagination-item-size | 8px | Slide-picker dot diameter. |
--rc-carousel-pagination-item-color | color-mix(in srgb, CanvasText 40%, transparent) | Inactive slide-picker dot color. |
--rc-carousel-pagination-item-active-color | Highlight | Active slide-picker dot color. |
CSS Parts
| Part | Description |
|---|---|
track | The scrollable slide track. |
navigation | Previous/next button wrapper. |
navigation-button | A previous or next button. |
navigation-button-previous | The previous button specifically. |
navigation-button-next | The next button specifically. |
pagination | Slide-picker button group wrapper. |
pagination-item | A slide-picker button. |
pagination-item-active | The active slide's picker button. |
Properties
| Property | Markup | Type | Default | Description |
|---|---|---|---|---|
position | JS property only | string | '' | "N of M" position assigned by the parent `rc-carousel`. Internal integration point, not a public API — see the `@attr` note above. |
role | JS property only | string | '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
| Name | Description |
|---|---|
(default) | Slide content. |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--rc-carousel-item-color | CanvasText | Slide foreground color. |
--rc-carousel-item-scroll-snap-align | start | Snap alignment within the track. Override for a center-aligned layout. |
--rc-carousel-item-background | transparent | Slide surface background, e.g. for an MD3 card-like slide shape. |
--rc-carousel-item-border-radius | 0 | Slide corner radius. |
--rc-carousel-item-overflow | hidden | Overflow 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.