SwCarousel
SwCarousel is an auto-rotating carousel built on transform: translateX, for banners / hero images. It clones the first and last slides for a seamless loop (when reaching a clone frame it jumps back to the real frame without animation — no visible flicker), with built-in autoplay, touch threshold paging, and dots / thumbnail indicators.
items-based: pass :items data + a #default="{ item, index }" scoped slot to render each slide (the component clones based on items; index is the real logical index).
The
#defaultslot is required: the component doesn't know how to render youritem. Passing:itemswithout a#defaultslot renders blank frames (dev console warns).
Use
SwRailfor free horizontal scrolling: SwCarousel is "one screen at a time + autoplay"; for "a row of N items that scrolls" useSwRail.
Basic usage
Auto-rotating banner
<SwCarousel :items="banners" aria-label="Homepage banner">
<template #default="{ item }">
<SwLink :to="item.link"><SwImage :src="item.img" ratio="16/9" /></SwLink>
</template>
</SwCarousel>
Banner links (SwCarousel has no built-in link)
SwCarousel provides no link / text / button of its own — each banner's content (image / title / button / target URL) comes entirely from item data, rendered via the #default="{ item, index }" scoped slot. For clickable banners, pick one of the two shapes below:
- Image-only banner (whole slide clickable): wrap the whole slide in
SwLink(see Basic usage above). - Banner with text + button: put the
SwLink/SwButtonCTA inside the slide, and don't wrap the whole thing in<a>(nesting interactive elements inside a link is invalid HTML).
Image + title + CTA link (all driven by item)
<!-- Real banner: image + title + CTA button, link from item.link -->
<SwCarousel :items="banners" aria-label="Homepage banner">
<template #default="{ item }">
<div class="banner">
<SwImage :src="item.img" ratio="12/5" />
<h3 class="title">{{ item.title }}</h3>
<SwLink :to="item.link" class="cta">{{ item.cta }}</SwLink>
</div>
</template>
</SwCarousel>
Autoplay & pausing
Autoplay is on by default (autoplayDelay controls dwell time per slide in ms, default 4000 — enough time to read banners with text; pass 3000 for image-only). It auto-pauses on desktop hover, keyboard focus, pointer drag, and when the page goes to the background (visibilitychange), resuming on leave / return; it does not autoplay when the system "reduce motion" preference is active. A single slide or :autoplay="false" disables it. Manual navigation (indicators / thumbnails / keyboard / ref methods) resets the autoplay timer — the slide you just picked gets the full autoplayDelay, instead of being swapped away by an imminent tick.
<SwCarousel :items="banners" :autoplay-delay="5000" :pause-on-hover="true" />
<SwCarousel :items="banners" :autoplay="false" /> <!-- autoplay off -->
Loop & single-slide degradation
loop(defaulttrue): seamless loop. With:loop="false"it stops at the first / last slide without wrapping.- Single-slide auto-degradation: when
items.length <= 1, no clones, no autoplay, no indicator.
Touch paging
On mobile, a finger swipe beyond dragThreshold (fraction of container width, default 0.15) pages; otherwise it springs back.
Behavior knobs: no loop / initial-index / speed / drag-threshold
Indicators (dots / thumbnails / custom)
indicator is 'dots' (default) / 'thumbnails' / 'none'; position is controlled by the unified indicatorPlacement (shared by dots and thumbnails):
| placement | Effect |
|---|---|
bottom-center (default) | Bottom center, horizontal |
bottom-left / bottom-right | Bottom left / right aligned |
left / right | Left / right side, vertical column |
outside | Below the banner in normal flow (not overlaid) |
Dot placement: right / bottom-right / outside
Dot styling
By default dots are white with a shadow (visible on any background, not tied to the brand color); the active dot becomes an elongated bar with animation (longer when horizontal / taller when vertical). Dots also have an enlarged click hit-area (transparent, doesn't change the visuals) for easier tapping. All styling is customizable via CSS variables:
Customize dots via CSS variables (active bar / brand color / gap)
<SwCarousel
:items="banners"
style="--sw-carousel-dot-active-color: var(--primary); --sw-carousel-dot-size: 0.625rem"
>
<template #default="{ item }"><SwImage :src="item.img" ratio="16/9" /></template>
</SwCarousel>
Thumbnail navigation
indicator="thumbnails" + :thumbnails (an image array, length must match items) renders a thumbnail strip. The active thumbnail uses a white box-shadow outline (not a hard border, reflow-safe); inactive ones are dimmed via brightness and brighten on hover. With many thumbnails the strip becomes a horizontal-scroll filmstrip that auto-scrolls the active one into view on change (the mouse wheel also scrolls it).
thumbnailTrigger controls how thumbnails switch slides: 'click' (default, click to switch) / 'hover' (hover switches immediately).
thumbnailLabels supplies each outer thumbnail button with its complete accessible name, for example :thumbnail-labels="movies.map(m => m.title)". The full label remains available when visual titles are truncated or #thumbnail renders custom content. Leading/trailing whitespace is trimmed; omitted, blank or missing entries retain localized ordinal names. Keep labels and thumbnails equal in length and order. A length mismatch warns in development: supplied labels remain usable and missing entries fall back per index. aria-current, keyboard and touch behavior remain unchanged.
Thumbnail navigation (bottom center, click to switch)
Thumbnail navigation (hover to switch)
Thumbnails render as bare <img> by default (no SwImage, no decryption). To customize how each thumbnail renders — most typically for .bnc encrypted images (broken with a bare img) — use the #thumbnail scoped slot ({ thumb, index, active }) with SwImage; without the slot the behavior is exactly as before:
#thumbnail slot: thumbnails via SwImage (same recipe for .bnc encrypted images)
<SwCarousel :items="movies" indicator="thumbnails" :thumbnails="movies.map((m) => m.cover)">
<template #default="{ item }"><SwImage :src="item.cover" alt="" fill /></template>
<!-- the bare-img fallback cannot decrypt; SwImage fill fills the thumb button and decrypts .bnc -->
<template #thumbnail="{ thumb }"><SwImage :src="thumb" alt="" fill /></template>
</SwCarousel>
<!-- Thumbnail nav (vertical, right, hover to switch) -->
<SwCarousel
:items="list"
indicator="thumbnails"
:thumbnails="list.map(x => x.thumb)"
indicator-placement="right"
thumbnail-trigger="hover"
>
<template #default="{ item }"><SwImage :src="item.img" ratio="16/9" /></template>
</SwCarousel>
Custom indicator
For a fully custom indicator (progress bar / counter / titled thumbnails) use the #indicator slot:
#indicator slot: n / total counter
<SwCarousel :items="list">
<template #default="{ item }"><img :src="item.img"></template>
<template #indicator="{ current, count, goTo }">
<div class="my-pager">{{ current + 1 }} / {{ count }}</div>
</template>
</SwCarousel>
CSS variables
Indicator (dots / thumbnails) styling is fully customizable via CSS variables — no need to override internal selectors:
| Variable | Default | Description |
|---|---|---|
--sw-carousel-indicator-z | 2 | Indicator z-index (navigation chrome; sibling overlays at 1 won't cover the dots) |
--sw-carousel-indicator-offset | 0.75rem | Distance from edge (shared by both axes) |
--sw-carousel-indicator-inline-offset | falls back to --sw-carousel-indicator-offset | Inline-axis (left / right) distance from edge; use it to align the indicator with a max-width content container instead of the viewport when the carousel is full-bleed. Not applicable to bottom-center (inline-axis centered). The thumbnail strip's max-width derives from the same fallback chain (100% − offset×2) |
--sw-carousel-dot-size | 0.5rem | Dot size |
--sw-carousel-dot-active-size | 1.25rem | Active dot "bar" size (longer when horizontal / taller when vertical) |
--sw-carousel-dot-gap | 0.5rem | Gap between dots |
--sw-carousel-dot-color | white rgb(255 255 255) | Dot color |
--sw-carousel-dot-active-color | white | Active dot color |
--sw-carousel-dot-shadow | 0 0 0.125rem rgb(0 0 0 / 35%) | Dot shadow (visible on white too) |
--sw-carousel-thumb-width | 3.5rem | Thumbnail width |
--sw-carousel-thumb-height | 2.25rem | Thumbnail height |
--sw-carousel-thumb-gap | 0.5rem | Gap between thumbnails |
--sw-carousel-thumb-radius | 0.5rem | Thumbnail corner radius |
--sw-carousel-thumb-dim | 0.5 | Inactive thumbnail dim brightness (brightness) |
--sw-carousel-thumb-hover | 0.8 | Thumbnail hover brightness (brightness) |
--sw-carousel-thumb-active-shadow | 0 0 0 2px rgb(255 255 255 / 90%) | Active thumbnail outline (box-shadow, reflow-safe) |
Imperative control
<script setup>
const carouselRef = ref()
</script>
<template>
<SwCarousel ref="carouselRef" :items="list"><template #default="{ item }">…</template></SwCarousel>
<button @click="carouselRef.next()">Next</button>
</template>
Multiple slides per view (slidesPerView)
The classic "N cards per row, auto-rotating" recommendation-row shape — slidesPerView sets how many slides fit per view (responsive via semantic breakpoints), gap sets spacing; stepping is always one slide, and loop / autoplay / dragging / dots all keep working. Complements SwRail (free scrolling, no loop/autoplay): pick this component for auto-rotation, SwRail for free scrolling.
Multi per view: 3 desktop / 2 tablet / 1 mobile + gap + autoplay
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
items | T[] | ✅ | — | Carousel data source |
loop | boolean | true | Seamless loop | |
autoplay | boolean | true | Auto-rotation | |
autoplayDelay | number | 4000 | Dwell time per slide (ms) — enough to read text banners; pass 3000 for image-only | |
initialIndex | number | 0 | Initial logical index | |
speed | number | 300 | Transition duration (ms) | |
pauseOnHover | boolean | true | Pause while hovered or keyboard-focused (same switch, same semantics) | |
dragThreshold | number | 0.15 | Swipe paging threshold (fraction of container width) | |
indicator | 'dots' | 'thumbnails' | 'none' | 'dots' | Indicator type | |
indicatorPlacement | 'bottom-center' | 'bottom-left' | 'bottom-right' | 'left' | 'right' | 'outside' | 'bottom-center' | Indicator placement (shared by dots / thumbnails; left / right = vertical) | |
thumbnails | string[] | undefined | Thumbnail strip images when indicator='thumbnails' (length must match items) | |
thumbnailLabels | string[] | undefined | Full button names aligned with thumbnails; trimmed; omitted, blank or missing entries fall back to localized ordinals | |
thumbnailTrigger | 'click' | 'hover' | 'click' | How thumbnails switch slides ('hover' = switch on hover) | |
ariaLabel | string | undefined | Only when set, outputs role="region" + aria-label | |
track | CarouselTrackProp | undefined | Built-in per-slide exposure tracking (opt-in; no prop, no behavior) — see "Per-slide exposure tracking" below | |
slidesPerView | number | { mobile?, tablet?, desktop? } | 1 | Slides per view (#220): a number applies to all breakpoints; an object uses semantic breakpoints (mobile <768 / tablet 768–1023 / desktop ≥1024, missing tiers fall back to the nearest smaller one). With multiple slides the carousel still steps one slide at a time, loop clones max(spv) slides at each end, dots count = items count (each dot = leftmost slide), and change carries the leftmost logical index. Not reactive after initialization (changing this prop after mount has no effect; breakpoint-tier switching is reactive) | |
gap | string | '0px' | Gap between slides (CSS length like '1rem'), for use with slidesPerView. Not reactive after initialization (same as slidesPerView) |
Per-slide exposure tracking (:track)
A carousel's exposure semantics differ from SwLink's "entered viewport once": under loop / autoplay, non-active slides are translated out of the visible area, so IntersectionObserver on a slot-level SwLink is unreliable — only the carousel engine knows "which slide is showing", hence the component-level :track:
<SwCarousel :items="banners" :track="{
exposure: (item, index) => ({
event: 'appPageitemExposure',
payload: {
item_id: (item as Banner).id, item_type: 'banner', item_name: (item as Banner).title,
parent_item_id: '', position: index + 1, page_key: 'home', page_name: 'Home',
},
}),
}">
<template #default="{ item }">
<SwLink :to="item.link"><SwImage :src="item.img" ratio="16/9" /></SwLink>
</template>
</SwCarousel>
- Timing: each slide reports when it becomes current and the container has entered the viewport (one-shot IO ≥50%); each slide reports at most once per component instance (autoplay loops don't re-report); replacing
items(new content) resets the dedupe set. exposure(item, index)maps a carousel item to a tracking spec; returningundefinedskips that slide.- Click tracking is not built in — slide content is a business slot; click semantics belong to the SwLink
:track.clickon the content (see example above). - Reporting goes through
useTracker(silent no-op when the tracker gate is off).
Events
| Name | Payload | Description |
|---|---|---|
change | index: number | Current slide changed (real logical index) |
Slots
| Name | Scope | Description |
|---|---|---|
default | { item, index, active } | Slide content (index is the real logical index). Required — missing it renders blank frames and warns in dev |
thumbnail | { thumb, index, active } | Custom content for each thumbnail (e.g. SwImage for .bnc encrypted images); falls back to a bare <img> when omitted. Slot content must bring its own sizing (SwImage fill and bare img both fill the thumb button) |
indicator | { current, count, goTo } | Custom indicator (for non-standard cases: progress bar / counter / titled thumbnails); replacing the whole strip loses the built-in wheel scrolling / scroll-into-view chrome — use #thumbnail if you only need custom thumbnail rendering |
Exposed methods (defineExpose)
| Member | Signature | Description |
|---|---|---|
next / prev | () => void | Next / previous slide (seamless under loop) |
goTo | (index: number, opts?: { immediate?: boolean }) => void | Jump to slide N (immediate = no animation) |
play / pause | () => void | Resume / pause autoplay |
current | Readonly<Ref<number>> | Current real logical index |
Notes
- items-based + required
#default: render each slide via the#default="{ item, index }"scoped slot;indexis the real logical index (clone frames also map back to the real index). Passing:itemswithout a#defaultslot renders blank frames and warns in the dev console. - Banner links come from
itemdata: the component has no built-in link; wrap the whole slide inSwLinkfor image-only banners, or put the CTA (SwLink/SwButton) inside the slide for banners with buttons — don't wrap the whole thing in<a>. - Muted media for autoplay: if a slide contains a video, autoplay scenarios follow browser policy (muted).
- Use
SwRailfor horizontal scrolling: SwCarousel is a one-screen-at-a-time carousel, not a free rail.
active is true only for the current real slide, never for loop clones. autoplay is reactive: disable it while video prepares/plays, then enable it after poster fallback to restart the full image delay. See SwVideoBackground.