Skip to content

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 { Carousel, type CarouselItem } from '@delightstack/components';

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).

1 / 4
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>

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 with animation="zoom" for a slideshow effect.
View code
<Carousel {items} bind:slide transition="fade" inline dismissable={false} />

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.)

1 / 4
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" />

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 autoplay reflects whether this is the active slide (works for YouTube, Vimeo, Matterport, iplayerhd out of the box).
1 / 9

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.

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:

ArgumentTypeNotes
itemCarouselItemThe original item, including any extra fields you added.
activebooleantrue when this is the current slide. Use it for play/pause-style behaviour.
gesture_disabledbooleanReflects item.disable_swipe. Lets your renderer hide UI that would conflict with its own horizontal input.
onload() => voidOptional — 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) => voidOptional — 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:

1 / 3
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>
PropTypeDefaultNotes
itemsArray<string | Partial<CarouselItem>>[]Strings are treated as image URLs. See CarouselItem.
slidenumber (bindable)0Active item index. Changing it animates the transition.
pagenumber (bindable)0Active page inside a multi-page item (e.g. PDF).
num_pagesnumber (bindable)0Number of pages in the current item. Set automatically for PDFs.
dismissingnumber (bindable)0Progress (0–1) of an in-flight vertical swipe-to-dismiss gesture. Useful for fading a backdrop in sync.
dismissablebooleantrueEnable vertical swipe-to-dismiss. Pair with onclose to react.
fit'cover' | 'contain''contain'object-fit for items.
inlinebooleanfalseDisable 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_videobooleanfalseAuto-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_targetHTMLElementundefinedAnchor the entry animation to this element (used by Gallery to zoom out of the clicked thumbnail).
disable_entry_exit_animationbooleanfalseSkip the entry/exit zoom. Useful when the Carousel is permanently inline.
customSnippetundefinedRenderer for items with type: 'custom'. See Custom item type.
oninteraction() => voidundefinedFires on any user interaction (swipe, zoom, click). Common use: pause your autoplay timer.
onclose() => boolean | voidundefinedFires on dismiss / close. Return false to keep the carousel open.
classstring''Extra class names on the items list.
stylestring''Inline style on the items list.

Bind the Carousel with bind:this={carousel} and call these directly:

MethodDescription
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.
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.

type CarouselItemType = 'image' | 'video' | 'pdf' | 'embed' | 'custom';
  • image (default)<img> with optional ThumbHash blur placeholder. Supports pinch-zoom and pan. Set panorama: true to render the image as a 360° panorama instead — drag pans the view, pinch/scroll zooms, horizontal swipe stays inside the panorama.
  • video — rendered via Video, loaded lazily on first use. Spacebar toggles play/pause; changing slides pauses any playing video.
  • pdf — rendered via PDF (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 permissive allow flags. URLs for YouTube, Vimeo, Matterport, and iplayerhd are rewritten to set autoplay based on whether this is the active slide.
  • custom — rendered via the custom snippet you provide. The carousel hands the snippet { item, active, gesture_disabled, onload, onerror }. Pair with disable_swipe / disable_zoom when your widget needs the carousel’s gestures out of the way.
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 interacting

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_swipe
isScalable(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);
  • The active slide is announced via a polite live region (Media Item X of Y).
  • Inactive slides are marked inert so 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).