Carousel
Carousel is the rendering and gesture primitive that Gallery uses for its modal view. Reach for it directly only when you need a bare media viewport without Gallery’s chrome — for everything else, prefer Gallery (display="slider" or display="slideshow" for a one-up view; display="lightbox" if you want full control of the thumbnails).
Carousel handles:
- horizontal swipe / mouse-drag / mouse-wheel between items
- pinch-to-zoom and pan within an item
- vertical swipe-to-dismiss (when
dismissable) - crossfade or slide transitions between items
- a slow Ken-Burns-style zoom animation per item
- automatic lazy loading of distant slides
- five item types:
image,video,pdf,embed,custom
The rich renderers (video, pdf, panorama images) load themselves on demand — a gallery of plain images never pays the cost of pdfjs-dist, three, or the video player.
It does not render any controls — no arrows, no dots, no page counter. You supply those yourself by binding slide.
Import
Section titled “Import”import { Carousel, type CarouselItem } from '@delightstack/components';Basic usage
Section titled “Basic usage”Bind slide and provide your own prev/next controls. inline keeps the carousel in the page flow, and dismissable={false} disables the vertical swipe-to-close gesture (which only makes sense in a modal context).
View code
<script lang="ts"> import { Carousel, type CarouselItem } from '@delightstack/components';
const items: CarouselItem[] = [ { src: '/photos/ridge.jpg', width: 1200, height: 800 }, { src: '/photos/doorway.jpg', width: 800, height: 1200 }, // ... ];
let slide = $state(0);
</script>
<Carousel {items} bind:slide inline dismissable={false} fit="cover" />
<button onclick={() => slide = (slide - 1 + items.length) % items.length}>‹ Prev</button><button onclick={() => slide = (slide + 1) % items.length}>Next ›</button>Transitions
Section titled “Transitions”transition controls how Carousel animates between items when slide changes programmatically (swipe gestures always feel “slide”-like regardless).
none(default) — instant cut, no animation. Cheapest.slide— horizontal slide between items. Matches the swipe gesture’s direction.fade— crossfade. Pairs naturally withanimation="zoom"for a slideshow effect.
View code
<Carousel {items} bind:slide transition="fade" inline dismissable={false} />Slideshow effect
Section titled “Slideshow effect”Combine transition="fade" with animation="zoom" to get a slow Ken-Burns push between slides. Drive it from your own timer to control the dwell time. (For most use cases this is exactly what <Gallery display="slideshow" autoplay /> gives you for free.)
View code
<script lang="ts"> import { onDestroy } from 'svelte'; import { Carousel } from '@delightstack/components';
let slide = $state(0); const timer = setInterval(() => slide = (slide + 1) % items.length, 4000); onDestroy(() => clearInterval(timer));
</script>
<Carousel {items} bind:slide transition="fade" animation="zoom" inline dismissable={false} fit="cover" />Rich media (built-in)
Section titled “Rich media (built-in)”Carousel renders pdf, video, embed, and panorama images natively — no snippets, no setup. The heavy renderers (PDF.svelte + pdfjs-dist, Panorama.svelte + three, Video.svelte) are loaded with dynamic import() the first time an item of that type appears, so a gallery of plain images never pays the cost. A small CSS spinner shows during the brief module load.
Each rich type also gets gesture-aware integration that you can’t replicate from the outside:
- PDF — vertical swipe flips pages. Swiping past the last/first page transitions into the dismiss gesture instead of bouncing back.
- Video — pressing space toggles play/pause on the active slide. Changing slides automatically calls
.pause()on any playing video. - Panorama — horizontal swipe is suppressed so your finger pans the 360° view instead of changing slides; pinch-zoom is disabled (the panorama has its own zoom).
- Embed — horizontal swipe and pinch-zoom are suppressed so pointer input goes through to the iframe (3D tours, video players, etc.). The embed URL is rewritten so
autoplayreflects whether this is the active slide (works for YouTube, Vimeo, Matterport, iplayerhd out of the box).
Try: swipe up/down on the PDF to flip pages · drag the panorama to look around · press space on the video to play/pause · then advance — the video pauses automatically.
View code
<script lang="ts"> import { Carousel, type CarouselItem } from '@delightstack/components';
// Tip: interleave images between rich items so the carousel never has to // mount two heavy renderers (pdfjs, three.js, video) at once. const items: CarouselItem[] = [ { src: '/photos/valley.jpg', width: 1600, height: 1000 }, { src: '/photos/river.jpg', width: 1600, height: 1000 }, { type: 'pdf', src: '/documents/paper.pdf', width: 850, height: 1100 }, { src: '/photos/forest.jpg', width: 1600, height: 1000 }, { type: 'image', panorama: true, src: '/panoramas/valley.jpg' }, { src: '/photos/coast.jpg', width: 1600, height: 1000 }, { type: 'video', src: '/videos/intro.mp4' }, { src: '/photos/library.jpg', width: 1600, height: 1000 }, ];
let slide = $state(0);
</script>
<Carousel {items} bind:slide inline dismissable={false} />For iframe embeds (type: 'embed'), pass the URL directly. The Carousel rewrites known providers (YouTube, Vimeo, Matterport, iplayerhd) so autoplay only fires when the slide is active:
<Carousel items={[ { src: '/photos/intro.jpg' }, { type: 'embed', src: 'https://www.youtube.com/embed/aqz-KE-bpKQ' }, { type: 'embed', src: 'https://my.matterport.com/show/?m=...' }, ]} bind:slide />Embeds aren’t included in the live demo above because third-party players (YouTube, Vimeo) load multi-megabyte JS bundles per iframe that would dominate the docs page.
Custom item type
Section titled “Custom item type”For anything that isn’t one of the built-ins — a Matterport tour, a custom 3D viewer, a chart, an interactive form, etc. — use type: 'custom' and provide the custom snippet. The snippet receives the item plus a few lifecycle helpers:
| Argument | Type | Notes |
|---|---|---|
item | CarouselItem | The original item, including any extra fields you added. |
active | boolean | true when this is the current slide. Use it for play/pause-style behaviour. |
gesture_disabled | boolean | Reflects item.disable_swipe. Lets your renderer hide UI that would conflict with its own horizontal input. |
onload | () => void | Optional — call when async content is ready. Custom slides are interactive on mount, so you only need this if you have a non-trivial loading state. |
onerror | (err: unknown) => void | Optional — call if loading fails. |
Pair the custom type with disable_swipe: true (and/or disable_zoom: true) when your widget needs the carousel’s gestures out of the way:
View code
<script lang="ts"> import { Carousel, type CarouselItem } from '@delightstack/components';
const items: CarouselItem[] = [ { type: 'image', src: '/photos/before.jpg' }, { type: 'custom', src: '', name: 'Color picker', disable_swipe: true, // horizontal pointer stays inside the widget }, { type: 'image', src: '/photos/after.jpg' }, ];
let slide = $state(0); let hue = $state(200);
</script>
<Carousel {items} bind:slide inline dismissable={false}> {#snippet custom({ item, active, gesture_disabled })} <div class="my-slide" style:background="hsl({hue} 70% 35%)"> <input type="range" min="0" max="360" bind:value={hue} /> </div> {/snippet}</Carousel>| Prop | Type | Default | Notes |
|---|---|---|---|
items | Array<string | Partial<CarouselItem>> | [] | Strings are treated as image URLs. See CarouselItem. |
slide | number (bindable) | 0 | Active item index. Changing it animates the transition. |
page | number (bindable) | 0 | Active page inside a multi-page item (e.g. PDF). |
num_pages | number (bindable) | 0 | Number of pages in the current item. Set automatically for PDFs. |
dismissing | number (bindable) | 0 | Progress (0–1) of an in-flight vertical swipe-to-dismiss gesture. Useful for fading a backdrop in sync. |
dismissable | boolean | true | Enable vertical swipe-to-dismiss. Pair with onclose to react. |
fit | 'cover' | 'contain' | 'contain' | object-fit for items. |
inline | boolean | false | Disable vertical gestures + mouse-wheel pan. Use this when embedded in the page flow rather than a modal overlay. |
transition | 'none' | 'slide' | 'fade' | 'none' | Between-slide transition for programmatic changes to slide. |
animation | 'none' | 'zoom' | 'none' | Per-item animation. 'zoom' is a slow Ken-Burns push — combine with transition="fade" for slideshows. |
autoplay_video | boolean | false | Auto-play the active slide when it’s a video, on open only (not when changing slides). Only the slide opened onto; the opening gesture lets the browser play with sound. |
animation_target | HTMLElement | undefined | Anchor the entry animation to this element (used by Gallery to zoom out of the clicked thumbnail). |
disable_entry_exit_animation | boolean | false | Skip the entry/exit zoom. Useful when the Carousel is permanently inline. |
custom | Snippet | undefined | Renderer for items with type: 'custom'. See Custom item type. |
oninteraction | () => void | undefined | Fires on any user interaction (swipe, zoom, click). Common use: pause your autoplay timer. |
onclose | () => boolean | void | undefined | Fires on dismiss / close. Return false to keep the carousel open. |
class | string | '' | Extra class names on the items list. |
style | string | '' | Inline style on the items list. |
Methods
Section titled “Methods”Bind the Carousel with bind:this={carousel} and call these directly:
| Method | Description |
|---|---|
goToPage(itemIndex, pageIndex) | Jump to a specific page within a specific item. |
nextSlide(amount?) / prevSlide(amount?) | Step between items. |
nextPage(amount?) / prevPage(amount?) | Step between pages within the current item (e.g. PDF). |
up() / down() / left() / right() | Directional navigation (handles page-vs-slide depending on zoom and content type). |
zoomIn(targetScale?, x?, y?) | Programmatically zoom into the current item. |
zoomOut(targetScale?) | Programmatically zoom out. |
reset() | Reset zoom and pan on the current item. |
CarouselItem
Section titled “CarouselItem”interface CarouselItem { id?: string; type?: 'image' | 'video' | 'pdf' | 'embed' | 'custom'; // default: 'image' src: string; // URL or srcset string. May be empty for 'custom'. width?: number; height?: number; name?: string; caption?: string; alt?: string; thumbhash?: string; panorama?: boolean; // when type === 'image', renders as a 360° panorama priority?: boolean; disable_swipe?: boolean; // suppress horizontal swipe-to-change-slide disable_zoom?: boolean; // suppress pinch-zoom / double-tap zoom}For the src field on images, you can pass either a single URL or a srcset string with width descriptors:
src: '/photos/sunset-sm.jpg 400w, /photos/sunset-md.jpg 800w, /photos/sunset-lg.jpg 1600w';The carousel parses the srcset and picks the largest source for the active slide while letting the browser pick the right one for thumbnails.
CarouselItemType
Section titled “CarouselItemType”type CarouselItemType = 'image' | 'video' | 'pdf' | 'embed' | 'custom';image(default) —<img>with optional ThumbHash blur placeholder. Supports pinch-zoom and pan. Setpanorama: trueto render the image as a 360° panorama instead — drag pans the view, pinch/scroll zooms, horizontal swipe stays inside the panorama.video— rendered viaVideo, loaded lazily on first use. Spacebar toggles play/pause; changing slides pauses any playing video.pdf— rendered viaPDF(in single-page mode), loaded lazily on first use. Vertical swipe flips pages; swiping past the last/first page transitions into the dismiss gesture.embed— rendered in an<iframe>with permissiveallowflags. URLs for YouTube, Vimeo, Matterport, and iplayerhd are rewritten to setautoplaybased on whether this is the active slide.custom— rendered via thecustomsnippet you provide. The carousel hands the snippet{ item, active, gesture_disabled, onload, onerror }. Pair withdisable_swipe/disable_zoomwhen your widget needs the carousel’s gestures out of the way.
GalleryGesture
Section titled “GalleryGesture”type GalleryGesture = | 'pinch-zoom' // two-finger zoom + pan | 'pan-x' // horizontal pan between slides | 'pan-y-dismiss' // vertical pan to close | 'pan-y-page' // vertical pan between PDF pages | 'pinch' // pinch gesture (mid-flight) | 'indeterminate' // touch started but direction unknown | 'none'; // not interactingHelpers
Section titled “Helpers”The media module re-exports a handful of helpers that are useful when building your own thumbnails or wrappers around Carousel:
import { pickLargestSrc, decodeThumbHash, isSwipeable, isScalable, normalizeEmbedSrc, normalizeCarouselItem,} from '@delightstack/components';
// Returns the highest-resolution URL from a srcset string.const fullRes = pickLargestSrc(item.src);
// Decodes a ThumbHash into a tiny data: URL you can render as a blurred placeholder.const blurDataUrl = decodeThumbHash(item.thumbhash);
// Same predicates the Carousel uses internally — handy for matching cursor / hint UI.isSwipeable(item); // false for embed, panorama, custom-with-disable_swipeisScalable(item); // false for video, embed, panorama, custom
// Coerces a string or partial item into a fully-formed CarouselItem.const item = normalizeCarouselItem(input);
// Rewrites a YouTube / Vimeo / Matterport / iplayerhd embed URL so autoplay// reflects whether the iframe is currently the active slide.const embedSrc = normalizeEmbedSrc(url, isActive);Accessibility
Section titled “Accessibility”- The active slide is announced via a polite live region (
Media Item X of Y). - Inactive slides are marked
inertso screen readers and keyboard focus skip them. - Each slide has
aria-label="N of M". - All gestures have keyboard equivalents through Gallery’s controls — when using Carousel directly, you’re responsible for wiring up arrow-key handlers on your container.
- Pinch-zoom is disabled when the user prefers reduced motion (via the zoom animation only — direct gestures are always available).