Carousel
An accessible, keyboard-navigable carousel and slideshow. Place <sg-carousel-slide> children directly inside — no JS array or data binding required. Supports autoplay, swipe gestures, indicator dots, and five layout variants including continuous marquee scrolling.
Features
Slot-based slides — <sg-carousel-slide>children; no JS array required5 layout variants — default,fade,filmstrip,gallery,marqueeVertical orientation — all variants support orientation="vertical"Keyboard navigation — Arrow keys, Home, End; direction-aware Touch/pointer swipe — 48 px threshold, horizontal or vertical axis Loop — wraps last→first and first→last (default on); configurable per-variant Autoplay — opt-in; pauses on hover and focus; configurable interval sg-progressindicators — animated fill countdown during autoplay;role="tablist"Accessible — ARIA Carousel pattern; live-region announcements on every slide change CSS custom properties — full theming via --carousel-*tokens
Source Code
View Source (sg-carousel)
import { define, html, prop } from '@vielzeug/craft';
import { computed, signal, watch } from '@vielzeug/ripple';
import type { ThemeColor } from '../../types';
import { warn } from '../../_warn';
import '../../content/icon/icon';
import '../../feedback/progress/progress';
import { announce, createSwipeControl } from '../../headless';
import componentStyles from './carousel.css?inline';
import './carousel-slide';
export { CAROUSEL_SLIDE_TAG } from './carousel-slide';
// ── Types ──────────────────────────────────────────────────────────────────────
export type CarouselOrientation = 'horizontal' | 'vertical';
export type CarouselVariant = 'default' | 'fade' | 'filmstrip' | 'gallery' | 'marquee';
export type SgCarouselEvents = {
/** Fired when the active slide changes. */
change: { index: number };
};
export type SgCarouselProps = {
/**
* Whether to advance slides automatically. Defaults to `false`.
* Opt in explicitly — autoplay on by default is a WCAG 2.1 SC 2.2.2 violation for many use cases.
*/
autoplay?: boolean;
/** Interval in milliseconds between automatic slide advances. Defaults to `5000`. */
'autoplay-interval'?: number;
/** Theme color passed to the prev/next navigation buttons. */
color?: ThemeColor;
/** Accessible label for the carousel region. */
label?: string;
/**
* Whether the carousel loops from the last slide back to the first. Defaults to `true`.
*
* > **Note for `marquee` variant:** `loop="false"` runs the scroll animation once then stops,
* > rather than controlling navigation wrap-around (marquee navigation always loops).
* > If you intend to stop wrapping navigation in another variant, use `loop` there as expected.
*/
loop?: boolean;
/**
* Duration in seconds for one full marquee loop cycle. Defaults to `10`.
* Shorter values = faster scroll; longer values = slower scroll.
*/
'marquee-duration'?: number;
/** Carousel orientation. Defaults to `'horizontal'`. */
orientation?: CarouselOrientation;
/** Show next/prev navigation buttons. Defaults to `true`. */
'show-controls'?: boolean;
/** Show dot/indicator navigation. Defaults to `true`. */
'show-indicators'?: boolean;
/** Index of the currently active slide (zero-based). Defaults to `0`. */
'slide-index'?: number;
/**
* Layout variant.
* - `'default'` — slides translate in/out (default)
* - `'fade'` — slides crossfade; no movement
* - `'filmstrip'` — all slides visible side-by-side; active expands
* - `'gallery'` — active slide fills the majority; adjacent slides show as thumbnails
* - `'marquee'` — continuous auto-scroll ticker; `loop` controls whether it repeats
*/
variant?: CarouselVariant;
};
// ── Internal marquee instance type ────────────────────────────────────────────
type MarqueeInstance = {
cleanup: () => void;
seekTo: (index: number, slideSnapshot: HTMLElement[]) => void;
};
/**
* An accessible, keyboard-navigable carousel / slideshow with optional
* autoplay, swipe support, and indicator dots.
*
* Place `<sg-carousel-slide>` elements as direct children.
*
* @element sg-carousel
* @element sg-carousel-slide - Child element for individual slides
*
* @attr {string} color - Theme color for navigation buttons: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {boolean} autoplay - Advance slides automatically (default false)
* @attr {number} autoplay-interval - Milliseconds between automatic advances (default 5000)
* @attr {string} label - Accessible label for the carousel region
* @attr {boolean} loop - Loop from last slide to first (default true); in marquee mode: repeat vs run-once
* @attr {string} orientation - 'horizontal' (default) | 'vertical'
* @attr {string} variant - 'default' | 'fade' | 'filmstrip' | 'gallery' | 'marquee'
* @attr {boolean} show-controls - Show prev/next buttons (default true)
* @attr {boolean} show-indicators - Show indicator dots (default true)
* @attr {number} slide-index - Active slide index (zero-based, default 0)
* @attr {number} marquee-duration - Loop cycle duration in seconds for marquee variant (default 10)
*
* @fires change - Fires when the active slide changes. detail: { index: number }
*
* @slot - Place `<sg-carousel-slide>` elements here
*
* @cssprop --carousel-bg - Slide area background
* @cssprop --carousel-radius - Border radius of the carousel
* @cssprop --carousel-dot-bg - Inactive indicator color (default: var(--color-contrast-300))
* @cssprop --carousel-dot-active-bg - Active indicator / fill color (default: var(--color-contrast-700))
* @cssprop --carousel-transition-duration - Slide transition duration (default 0.35s; 0s under prefers-reduced-motion). Also controls marquee seek animation duration.
* @cssprop --carousel-min-height - Minimum height when no explicit height is set (default 240px)
* @cssprop --carousel-filmstrip-inactive - Width (horizontal) or height (vertical) of inactive slides in filmstrip mode
* @cssprop --carousel-filmstrip-gap - Gap between slides in filmstrip mode
* @cssprop --carousel-gallery-thumbnail - Width (horizontal) or height (vertical) of thumbnail slides in gallery mode (default var(--size-24))
* @cssprop --carousel-gallery-gap - Gap between slides in gallery mode (default var(--size-2))
* @cssprop --carousel-marquee-gap - Gap between slides in marquee mode (default var(--size-4))
*
* @part track - The scrolling slide track element
* @part controls - The prev/next button container
* @part indicators - The indicator dots container
* @part prev-btn - The previous-slide button
* @part next-btn - The next-slide button
*
* @example
* ```html
* <sg-carousel label="Product highlights" loop>
* <sg-carousel-slide>Slide 1</sg-carousel-slide>
* <sg-carousel-slide>Slide 2</sg-carousel-slide>
* <sg-carousel-slide>Slide 3</sg-carousel-slide>
* </sg-carousel>
* ```
*/
export const CAROUSEL_TAG = 'sg-carousel' as const;
// ── Slide-state sync helpers ──────────────────────────────────────────────────
// Each function handles exactly one variant's attribute bookkeeping.
// Called from a dispatch table in syncActiveState.
const syncDefaultSlides = (slides: HTMLElement[], current: number): void => {
const count = slides.length;
slides.forEach((slide, i) => {
const active = i === current;
slide.toggleAttribute('data-active', active);
slide.setAttribute('aria-hidden', String(!active));
slide.setAttribute('aria-label', slide.getAttribute('aria-label') ?? `Slide ${i + 1} of ${count}`);
slide.removeAttribute('data-gallery-visible');
slide.toggleAttribute('data-before', i < current);
slide.toggleAttribute('data-after', i > current);
});
};
const syncFilmstripSlides = (slides: HTMLElement[], current: number): void => {
const count = slides.length;
slides.forEach((slide, i) => {
const active = i === current;
slide.toggleAttribute('data-active', active);
slide.setAttribute('aria-hidden', String(!active));
slide.setAttribute('aria-label', slide.getAttribute('aria-label') ?? `Slide ${i + 1} of ${count}`);
slide.removeAttribute('data-gallery-visible');
slide.removeAttribute('data-before');
slide.removeAttribute('data-after');
});
};
const syncGallerySlides = (slides: HTMLElement[], current: number): void => {
const count = slides.length;
slides.forEach((slide, i) => {
const active = i === current;
const isPrev = current > 0 && i === current - 1;
const isNext = current < count - 1 && i === current + 1;
slide.toggleAttribute('data-active', active);
slide.setAttribute('aria-hidden', String(!active));
slide.setAttribute('aria-label', slide.getAttribute('aria-label') ?? `Slide ${i + 1} of ${count}`);
slide.removeAttribute('data-before');
slide.removeAttribute('data-after');
slide.toggleAttribute('data-gallery-visible', active || isPrev || isNext);
});
};
const syncMarqueeSlides = (slides: HTMLElement[], current: number): void => {
slides.forEach((slide, i) => {
slide.toggleAttribute('data-active', i === current);
slide.setAttribute('aria-hidden', 'false');
slide.removeAttribute('data-before');
slide.removeAttribute('data-after');
slide.removeAttribute('data-gallery-visible');
});
};
// ── sg-carousel ──────────────────────────────────────────────────────────────
define<SgCarouselProps, SgCarouselEvents>(CAROUSEL_TAG, {
props: {
autoplay: prop.bool(false),
'autoplay-interval': prop.number(5000),
color: prop.string<ThemeColor>(),
label: prop.string('Carousel'),
loop: prop.bool(true),
'marquee-duration': prop.number(10),
orientation: prop.string<CarouselOrientation>('horizontal'),
'show-controls': prop.bool(true),
'show-indicators': prop.bool(true),
'slide-index': prop.number(0),
variant: prop.string<CarouselVariant>('default'),
},
setup(props, { bind, el, emit, onMounted }) {
// ── State ────────────────────────────────────────────────────────────────
const activeIndex = signal<number>(props['slide-index'].value ?? 0);
const isMarquee = computed(() => props.variant.value === 'marquee');
let autoplayTimer: ReturnType<typeof setInterval> | null = null;
// Slide cache — populated immediately, refreshed on slotchange.
let slides: HTMLElement[] = Array.from(el.querySelectorAll<HTMLElement>(':scope > sg-carousel-slide'));
const slideCount = signal(slides.length);
const refreshSlides = (): void => {
slides = Array.from(el.querySelectorAll<HTMLElement>(':scope > sg-carousel-slide'));
slideCount.value = slides.length;
};
const isHorizontal = computed(() => props.orientation.value !== 'vertical');
const looping = computed(() => props.loop.value !== false);
const showControls = computed(() => props['show-controls'].value !== false);
const showIndicators = computed(() => props['show-indicators'].value !== false);
const canGoPrev = computed(() => looping.value || activeIndex.value > 0);
const canGoNext = computed(() => looping.value || activeIndex.value < slideCount.value - 1);
// ── Navigation ───────────────────────────────────────────────────────────
// marqueeInstance is set in onMounted; accessed here via module-level ref
// stored on the setup closure. No separate side-channel variable needed.
let marqueeInstance: MarqueeInstance | null = null;
const goTo = (index: number, announce_: boolean = true): void => {
const count = slideCount.value;
if (count === 0) return;
const next = looping.value ? ((index % count) + count) % count : Math.max(0, Math.min(index, count - 1));
if (next === activeIndex.value) return;
activeIndex.value = next;
el.setAttribute('slide-index', String(next));
emit('change', { index: next });
// Pass current slides snapshot so seekTo never reads a stale closure.
marqueeInstance?.seekTo(next, slides);
if (announce_) {
announce(slides[next]?.getAttribute('aria-label') ?? `Slide ${next + 1} of ${count}`);
}
};
const prev = (): void => goTo(activeIndex.value - 1);
const next = (): void => goTo(activeIndex.value + 1);
// ── Sync prop → state ────────────────────────────────────────────────────
watch(props['slide-index'], (v) => {
if (typeof v === 'number' && v !== activeIndex.value) goTo(v, false);
});
// ── Sync variant + orientation → slides ──────────────────────────────────
const syncSlideVariants = (): void => {
const variant = props.variant.value ?? 'default';
const orientation = props.orientation.value ?? 'horizontal';
slides.forEach((slide) => {
slide.setAttribute('data-variant', variant);
slide.setAttribute('data-orientation', orientation);
});
};
watch(
computed(() => ({ orientation: props.orientation.value, variant: props.variant.value })),
syncSlideVariants,
{ immediate: true },
);
// ── Sync active index → slides — dispatch table ───────────────────────────
const syncTable: Record<CarouselVariant, (slides: HTMLElement[], current: number) => void> = {
default: syncDefaultSlides,
fade: syncDefaultSlides,
filmstrip: syncFilmstripSlides,
gallery: syncGallerySlides,
marquee: syncMarqueeSlides,
};
const syncActiveState = (): void => {
const variant = (props.variant.value ?? 'default') as CarouselVariant;
syncTable[variant](slides, activeIndex.value);
};
watch(activeIndex, syncActiveState, { immediate: true });
// ── Autoplay ─────────────────────────────────────────────────────────────
const startAutoplay = (): void => {
if (autoplayTimer !== null) return;
autoplayTimer = setInterval(next, props['autoplay-interval'].value ?? 5000);
};
const stopAutoplay = (): void => {
if (autoplayTimer !== null) {
clearInterval(autoplayTimer);
autoplayTimer = null;
}
};
watch(
computed(() => ({ enabled: props.autoplay.value, interval: props['autoplay-interval'].value })),
({ enabled }) => {
stopAutoplay();
if (enabled) startAutoplay();
},
);
// ── Keyboard navigation ──────────────────────────────────────────────────
const handleKeydown = (e: KeyboardEvent): void => {
const isH = isHorizontal.value;
const prevKey = isH ? 'ArrowLeft' : 'ArrowUp';
const nextKey = isH ? 'ArrowRight' : 'ArrowDown';
if (e.key === prevKey) {
e.preventDefault();
prev();
} else if (e.key === nextKey) {
e.preventDefault();
next();
} else if (e.key === 'Home') {
e.preventDefault();
goTo(0);
} else if (e.key === 'End') {
e.preventDefault();
goTo(slideCount.value - 1);
}
};
// ── Swipe ────────────────────────────────────────────────────────────────
const swipe = createSwipeControl({
axis: () => (isHorizontal.value ? 'x' : 'y'),
onCommit: (detail) => {
if (detail.distance < 0) next();
else prev();
},
threshold: () => 48,
});
// ── Host bindings ────────────────────────────────────────────────────────
bind({
attr: {
'aria-label': () => props.label.value ?? 'Carousel',
'aria-roledescription': () => 'carousel',
orientation: () => props.orientation.value ?? 'horizontal',
role: () => 'region',
style: () => `touch-action:${isHorizontal.value ? 'pan-y' : 'pan-x'}`,
variant: () => props.variant.value ?? 'default',
},
on: {
focusin: () => {
stopAutoplay();
},
focusout: () => {
if (props.autoplay.value) startAutoplay();
},
keydown: handleKeydown,
pointercancel: (e: PointerEvent) => swipe.handlePointerCancel(e),
pointerdown: (e: PointerEvent) => {
const path = e.composedPath();
const onButton = path.some(
(n) =>
n instanceof HTMLElement &&
(n.tagName === 'BUTTON' || n.tagName === 'SG-BUTTON' || n.tagName === 'SG-PROGRESS'),
);
if (!onButton) swipe.handlePointerDown(e);
},
pointerenter: () => {
stopAutoplay();
},
pointerleave: () => {
if (props.autoplay.value) startAutoplay();
},
pointermove: (e: PointerEvent) => swipe.handlePointerMove(e),
pointerup: (e: PointerEvent) => swipe.handlePointerUp(e),
},
});
// ── Marquee ───────────────────────────────────────────────────────────────
// Clones slides for a seamless loop. Seek uses WAAPI when available
// (all modern browsers) so it can be cancelled cleanly. Falls back to
// instant negative-delay repositioning in environments without WAAPI
// (e.g. jsdom in tests).
const setupMarquee = (track: HTMLElement): MarqueeInstance => {
const orientation = props.orientation.value ?? 'horizontal';
const horizontal = orientation !== 'vertical';
if (isMarquee.value && props.loop.value === false) {
warn(
'sg-carousel: loop="false" on the marquee variant stops the scroll animation rather than ' +
'disabling navigation wrap-around. Navigation in marquee always loops. ' +
'This is different from loop="false" on other variants.',
);
}
const clones = slides.map((s) => {
const clone = s.cloneNode(true) as HTMLElement;
clone.setAttribute('aria-hidden', 'true');
clone.setAttribute('data-variant', 'marquee');
clone.setAttribute('data-orientation', orientation);
clone.setAttribute('data-marquee-clone', '');
track.appendChild(clone);
return clone;
});
track.style.setProperty('--_marquee-duration', `${props['marquee-duration'].value ?? 10}s`);
if (!looping.value) {
track.style.setProperty('animation-iteration-count', '1');
}
let isHovered = false;
const pause = (): void => {
isHovered = true;
track.style.setProperty('animation-play-state', 'paused');
};
const resume = (): void => {
isHovered = false;
track.style.removeProperty('animation-play-state');
};
// mouseenter/mouseleave fire only when the pointer crosses the host
// boundary — never when moving between children (e.g. onto controls).
// This means clicking buttons never accidentally resumes the animation.
el.addEventListener('mouseenter', pause);
el.addEventListener('mouseleave', resume);
// ── Seek ──────────────────────────────────────────────────────────────
// Smoothly slides to the target position using a CSS transition on the
// inline transform (the CSS keyframe animation is paused throughout so
// there is no compositing conflict). On transitionend, switches back to
// the keyframe animation via a negative animation-delay.
let seekTimer: ReturnType<typeof setTimeout> | null = null;
const seekTo = (index: number, slideSnapshot: HTMLElement[]): void => {
const slide = slideSnapshot[index];
if (!slide) return;
const targetOffset = horizontal ? slide.offsetLeft : slide.offsetTop;
const halfSize = horizontal ? track.scrollWidth / 2 : track.scrollHeight / 2;
const cycleDuration = props['marquee-duration'].value ?? 10;
const delay = halfSize > 0 ? -((targetOffset / halfSize) * cycleDuration) : 0;
// Cancel any in-flight seek timer.
if (seekTimer !== null) {
clearTimeout(seekTimer);
seekTimer = null;
// Snap the previous transition to its end state immediately.
track.style.removeProperty('transition');
}
// No layout (jsdom): instant seek with no visual transition.
if (halfSize === 0) {
track.style.setProperty('animation-delay', `${delay}s`);
if (!isHovered) {
track.style.removeProperty('animation-play-state');
}
return;
}
const seekMs =
parseFloat(getComputedStyle(el).getPropertyValue('--carousel-transition-duration') || '0.35') * 1000 || 350;
const targetTransform = horizontal ? `translateX(-${targetOffset}px)` : `translateY(-${targetOffset}px)`;
// 1. Read the current visual position from the live animation, then
// kill the animation entirely so nothing else drives transform.
// getBoundingClientRect() flushes layout so the matrix is current.
void track.getBoundingClientRect();
const computedTransform = getComputedStyle(track).transform;
const matrixValues = computedTransform
.match(/matrix\(([^)]+)\)/)?.[1]
.split(',')
.map(Number);
const frozenOffset = horizontal ? (matrixValues?.[4] ?? 0) : (matrixValues?.[5] ?? 0);
const frozenTransform = horizontal ? `translateX(${frozenOffset}px)` : `translateY(${frozenOffset}px)`;
// Stop the CSS animation — inline transform is now the sole driver.
track.style.setProperty('animation', 'none');
track.style.setProperty('transform', frozenTransform);
// 2. Next frame: apply transition and slide to target.
requestAnimationFrame(() => {
track.style.setProperty('transition', `transform ${seekMs}ms ease-in-out`);
track.style.setProperty('transform', targetTransform);
// 3. After the transition completes, restore the keyframe animation
// at the correct position via negative animation-delay.
seekTimer = setTimeout(() => {
seekTimer = null;
track.style.removeProperty('transition');
track.style.removeProperty('transform');
track.style.removeProperty('animation');
track.style.setProperty('animation-delay', `${delay}s`);
if (isHovered) {
track.style.setProperty('animation-play-state', 'paused');
}
}, seekMs);
});
};
return {
cleanup: () => {
if (seekTimer !== null) clearTimeout(seekTimer);
seekTimer = null;
clones.forEach((c) => c.remove());
track.style.removeProperty('transition');
track.style.removeProperty('transform');
track.style.removeProperty('animation');
track.style.removeProperty('--_marquee-duration');
track.style.removeProperty('animation-iteration-count');
track.style.removeProperty('animation-delay');
track.style.removeProperty('animation-play-state');
el.removeEventListener('mouseenter', pause);
el.removeEventListener('mouseleave', resume);
},
seekTo,
};
};
// ── Lifecycle ────────────────────────────────────────────────────────────
onMounted(() => {
const shadowRoot = el.shadowRoot!;
const track = shadowRoot.querySelector<HTMLElement>('.track')!;
const slot = shadowRoot.querySelector<HTMLSlotElement>('slot')!;
// slotchange replaces MutationObserver — fires exactly when assigned
// nodes change, scoped to this component's slot, no polling overhead.
const onSlotChange = (): void => {
refreshSlides();
syncSlideVariants();
syncActiveState();
if (isMarquee.value) {
marqueeInstance?.cleanup();
marqueeInstance = setupMarquee(track);
}
};
slot.addEventListener('slotchange', onSlotChange);
watch(
computed(() => ({
duration: props['marquee-duration'].value,
isMarquee: isMarquee.value,
loop: props.loop.value,
orientation: props.orientation.value,
})),
({ isMarquee: active }) => {
marqueeInstance?.cleanup();
marqueeInstance = active ? setupMarquee(track) : null;
},
{ immediate: true },
);
if (props.autoplay.value) {
startAutoplay();
}
return () => {
stopAutoplay();
swipe.dispose();
slot.removeEventListener('slotchange', onSlotChange);
marqueeInstance?.cleanup();
};
});
// ── Template ─────────────────────────────────────────────────────────────
return html`
<div class="track" part="track" :aria-live=${() => (props.autoplay.value ? 'off' : 'polite')}>
<slot></slot>
</div>
${() =>
showControls.value
? html`<div class="controls" part="controls">
<sg-button
class="nav-btn prev-btn"
part="prev-btn"
variant="ghost"
:color=${() => props.color.value}
rounded
icon-only
aria-label="Previous slide"
:disabled=${() => (!canGoPrev.value ? true : undefined)}
@click=${(e: Event) => {
e.stopPropagation();
prev();
}}>
<sg-icon
:name=${() => (isHorizontal.value ? 'chevron-left' : 'chevron-up')}
size="20"
stroke-width="2"
aria-hidden="true"></sg-icon>
</sg-button>
<sg-button
class="nav-btn next-btn"
part="next-btn"
variant="ghost"
:color=${() => props.color.value}
rounded
icon-only
aria-label="Next slide"
:disabled=${() => (!canGoNext.value ? true : undefined)}
@click=${(e: Event) => {
e.stopPropagation();
next();
}}>
<sg-icon
:name=${() => (isHorizontal.value ? 'chevron-right' : 'chevron-down')}
size="20"
stroke-width="2"
aria-hidden="true"></sg-icon>
</sg-button>
</div>`
: html``}
${() =>
showIndicators.value && slideCount.value > 1
? html`<div class="indicators" part="indicators" role="tablist" aria-label="Slide indicators">
${() =>
Array.from(
{ length: slideCount.value },
(_, i) =>
html`<sg-progress
role="tab"
:class=${() => `indicator${i === activeIndex.value ? ' indicator-active' : ''}`}
:color=${() => props.color.value}
:type=${() => (isHorizontal.value ? 'linear' : 'vertical')}
:value=${() => (i === activeIndex.value ? 100 : 0)}
:aria-selected=${() => String(i === activeIndex.value)}
:aria-label=${`Go to slide ${i + 1}`}
:style=${() => {
const fillAnim = isHorizontal.value ? 'carousel-fill' : 'carousel-fill-v';
return `--carousel-timeout:${props['autoplay-interval'].value ?? 5000};--carousel-animation-name:${i === activeIndex.value && props.autoplay.value ? fillAnim : 'none'}`;
}}
@click=${() => goTo(i)}></sg-progress>`,
)}
</div>`
: html``}
`;
},
styles: [componentStyles],
});View Source (sg-carousel-slide)
import { define, html } from '@vielzeug/craft';
import slideStyles from './carousel-slide.css?inline';
export const CAROUSEL_SLIDE_TAG = 'sg-carousel-slide' as const;
define(CAROUSEL_SLIDE_TAG, {
props: {},
setup(_props, { bind }) {
bind({
attr: {
'aria-roledescription': () => 'slide',
role: () => 'group',
},
});
return html`<slot></slot>`;
},
styles: [slideStyles],
});Basic Usage
Give the carousel an explicit height and a descriptive label. The default variant translates slides in and out horizontally.
Autoplay
Autoplay is off by default. Add the autoplay attribute to enable timed slide advances. The timer pauses automatically when the pointer enters the carousel or any element inside receives keyboard focus, and resumes on leave.
Use autoplay-interval (in milliseconds, default 5000) to control the delay. Changing autoplay-interval at runtime restarts the timer immediately.
No Loop
By default the carousel wraps: advancing past the last slide returns to the first. Set loop="false" to stop at the boundaries — the prev/next buttons disable automatically at the edges.
Programmatic Control
Set slide-index as a property at any time to jump to a specific slide (zero-based). The host element reflects the current index back on its slide-index attribute after every navigation. Listen to the change event to react to user- or autoplay-driven advances.
Button Color
Pass color to theme the prev/next navigation buttons with any design-system color token.
No Controls / No Indicators
show-controls and show-indicators are independent. Set either to "false" to hide it.
Vertical Orientation
Add orientation="vertical" to any variant. Slides transition top/bottom, arrow keys swap to Up/Down, indicators move to the left edge, and nav buttons group at the right-center edge.
Variants
The variant attribute switches the slide layout and transition style. All variants support orientation="vertical".
Fade
Slides crossfade in-place — no lateral movement. Use for image-heavy content where translation motion may be distracting.
Filmstrip
All slides are visible simultaneously. The active slide expands to fill the remaining space; inactive slides collapse to --carousel-filmstrip-inactive (default var(--size-16)).
Vertical Filmstrip
Gallery
The active slide dominates (~4× the size of thumbnails); the immediately adjacent slides show as thumbnails. Slides beyond the adjacent pair are hidden. Thumbnail size is controlled by --carousel-gallery-thumbnail.
Vertical Gallery
Marquee
A continuously scrolling ticker. Slides are cloned internally to create a seamless loop. Scrolling pauses on pointerenter and resumes on pointerleave. Use marquee-duration (seconds, default 10) to control speed — lower values scroll faster.
Set loop="false" to run the animation once then stop. Controls and indicators are shown by default and work the same as other variants.
Vertical Marquee
Add orientation="vertical" for a top-to-bottom ticker. Set an explicit height on each slide to control row size.
Keyboard Navigation
| Key | Action |
|---|---|
ArrowRight / ArrowDown | Next slide |
ArrowLeft / ArrowUp | Previous slide |
Home | First slide |
End | Last slide |
Arrow key direction adjusts automatically for orientation="vertical". When loop="false", navigation stops at the boundaries.
Accessibility
The carousel follows the ARIA Carousel pattern.
role="region"+aria-roledescription="carousel"on the hostrole="group"+aria-roledescription="slide"on each<sg-carousel-slide>aria-hidden="true"on all inactive slides;aria-labelauto-set to"Slide N of M"if not providedaria-live="polite"on the track; switches to"off"during autoplay to suppress timed announcements- Prev/next buttons carry
aria-label="Previous slide"/aria-label="Next slide";disabledset at boundaries whenloop="false" role="tablist"on the indicators container; each dot hasrole="tab"andaria-selected- Screen-reader announcement via the internal
announce()helper on every slide change
When autoplay is on, the track uses aria-live="off" so automatic advances don't trigger screen reader speech. The timer stops on focusin or pointerenter so keyboard and pointer users can read slide content uninterrupted, and restarts on focusout or pointerleave.
The carousel responds to prefers-reduced-motion: reduce automatically:
--carousel-transition-durationis set to0s, eliminating slide translation and fade transitions.- The marquee CSS animation is disabled entirely.
Always set label
The label attribute becomes the aria-label of the role="region" landmark. Without it, the region is announced as "Carousel" — too generic when a page has multiple carousels.
Marquee and motion sensitivity
Even with prefers-reduced-motion support, the marquee variant still presents rapidly-changing content that can be distracting. Consider hiding the marquee variant entirely for users with motion sensitivity who may not have the OS preference set.
Best Practices
Do:
- Always set a descriptive
label— the default"Carousel"is not specific enough for pages with multiple carousels. - Give the host an explicit height via
styleor CSS. The--carousel-min-heightfallback (240px) is insufficient forgalleryandfilmstripvariants which distribute space flexibly. - Use
autoplayonly for decorative or media carousels (image galleries, hero banners). Omit it for instructional or interactive content. - Use
loop="false"for wizard-style or sequential flows where step order matters. - Set an explicit
widthon each slide inmarqueemode so the seamless loop transition point is predictable.
Don't:
- Enable
autoplayon carousels containing forms or interactive controls — the timed advance will move content away from a user mid-interaction. - Rely on
--carousel-min-heightforfilmstriporgallery— set an explicit height instead. - Use the
marqueevariant for content that users need to read carefully; the continuous motion is unsuitable for anything requiring sustained attention.
API Reference
sg-carousel Attributes / Properties
| Name | Type | Default | Description |
|---|---|---|---|
label | string | 'Carousel' | aria-label for the role="region" landmark |
variant | 'default' | 'fade' | 'filmstrip' | 'gallery' | 'marquee' | 'default' | Layout and transition style |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Slide direction and keyboard axis |
slide-index | number | 0 | Active slide (zero-based). Writable at any time; reflected as an attribute after navigation |
loop | boolean | true | Wrap last→first and first→last. In marquee mode controls animation repeat (loop indefinitely vs. play once) — navigation always loops in marquee. Setting loop="false" on marquee emits a dev-mode warning. |
autoplay | boolean | false | Advance slides on a timer; pauses on hover and focus |
autoplay-interval | number | 5000 | Milliseconds between automatic advances; reactive — changing it restarts the timer |
marquee-duration | number | 10 | Duration in seconds for one full marquee cycle; lower = faster |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Theme color for prev/next navigation buttons |
show-controls | boolean | true | Show prev/next navigation buttons |
show-indicators | boolean | true | Show indicator dot navigation |
sg-carousel Events
| Event | Detail | Description |
|---|---|---|
change | { index: number } | Fired on every slide change (user- or autoplay-driven) |
sg-carousel CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--carousel-bg | var(--color-canvas) | Slide area background |
--carousel-radius | var(--rounded-xl) | Host border radius |
--carousel-min-height | 240px | Fallback minimum height — set an explicit height for filmstrip and gallery |
--carousel-transition-duration | 0.35s | Slide transition duration (default and fade); also controls marquee seek animation duration. Auto-set to 0s under prefers-reduced-motion |
--carousel-dot-bg | var(--color-contrast-300) | Inactive indicator dot color |
--carousel-dot-active-bg | var(--color-contrast-700) | Active indicator fill color |
--carousel-filmstrip-inactive | var(--size-16) | Collapsed width (horizontal) or height (vertical) of inactive filmstrip slides |
--carousel-filmstrip-gap | var(--size-2) | Gap between slides in filmstrip mode |
--carousel-gallery-thumbnail | var(--size-24) | Thumbnail width (horizontal) or height (vertical) in gallery mode |
--carousel-gallery-gap | var(--size-2) | Gap between slides in gallery mode |
--carousel-marquee-gap | var(--size-4) | Gap between slides in marquee mode |
sg-carousel CSS Parts
| Part | Element | Description |
|---|---|---|
track | <div> | The slide track — also the aria-live region |
controls | <div> | Prev/next button wrapper |
prev-btn | <sg-button> | Previous-slide button |
next-btn | <sg-button> | Next-slide button |
indicators | <div> | Indicator tablist container |
sg-carousel-slide
A transparent wrapper. It carries role="group" and aria-roledescription="slide" automatically. No public attributes or properties — all attributes below are set by sg-carousel to drive CSS layout and should not be set manually.
| Attribute | Set by | Description |
|---|---|---|
data-variant | sg-carousel | Mirrors the parent variant value |
data-orientation | sg-carousel | Mirrors the parent orientation value |
data-active | sg-carousel | Present on the currently active slide |
data-before | sg-carousel | Present on slides before the active one (default / fade variants) |
data-after | sg-carousel | Present on slides after the active one (default / fade variants) |
data-gallery-visible | sg-carousel | Present on the active slide and its immediate neighbours in gallery mode |