@vysmo/slideshow Component DOM Built on Transitions

Slideshow for the modern web.

Drop-in image slideshow driven by any of the 60 transitions. Opt-in chrome: arrows, dots, counter, progress bar, captions — each themeable via CSS custom properties. Click halves, keyboard, swipe, autoplay with pause-on-hover. Set every chrome option to false for pure-headless mode.

  • ~2 KB gzipped
  • 0 runtime deps
  • 60 transitions to drive it
  • headless BYO styling
$ pnpm add @vysmo/slideshow @vysmo/transitions View on GitHub

Install

Two packages — @vysmo/slideshow and the @vysmo/transitions shaders you want to drive it with. Slideshow composes @vysmo/animations and @vysmo/easings at the workspace level, so you don’t install those directly.

pnpm add @vysmo/slideshow @vysmo/transitions npm install @vysmo/slideshow @vysmo/transitions yarn add @vysmo/slideshow @vysmo/transitions

Zero runtime dependencies, SSR-safe at module load, tree-shakable to the byte. The headless core has no opinions about your UI — drop it into a container and style the chrome yourself.

Quick start

Drop a container in the DOM, hand the slideshow a list of slide sources and a transition, and you have a working carousel with click halves, keyboard nav, autoplay, and pause-on-hidden wired by default.

import { createSlideshow } from "@vysmo/slideshow";
import { paintBleed } from "@vysmo/transitions";

// 1. A container with measurable dimensions. The slideshow mounts its own
//    canvas inside; size the container via CSS — the canvas fills 100%.
const container = document.querySelector<HTMLDivElement>("#stage")!;

// 2. Slide sources: URL strings (decoded as images) or HTMLImageElement /
//    HTMLCanvasElement (used as-is). Order matters — index 0 is the
//    starting slide unless `initial` says otherwise.
const show = createSlideshow({
  container,
  slides: [
    "/photo-01.jpg",
    "/photo-02.jpg",
    "/photo-03.jpg",
  ],
  transition: paintBleed,        // any of the 60 transitions
  transitionDuration: 900,       // milliseconds
  autoplayDelay: 4000,           // 0 / omit to disable autoplay
  loop: true,                    // wrap last → first
});

// 3. Programmatic nav (click halves, arrow keys, autoplay timer, and
//    pause-on-hidden are all wired automatically — these are for your
//    own UI chrome, scroll-driven flows, etc).
await show.ready;
show.next();
show.go(2);
show.on("change", (current) => console.log("now showing", current));

Three pieces. The container holds the canvas — size it via CSS. Slide sources are URL strings (decoded to images) or DOM elements you've already prepared (use a canvas if you need rich content: pre-render with html2canvas / OffscreenCanvas, or paint video frames via drawImage). The handle is your imperative API: next, prev, go, play / pause, plus event subscriptions. All built-in interactions can be disabled per-option (see Recipes).

Using with React

Drop-in <Slideshow> component with the full chrome surface (arrows, dots, counter, progress, captions) as props. useSlideshow(ref, options) hook for imperative next / prev / go / play / pause.

Install pnpm add @vysmo/slideshow @vysmo/slideshow-react
import { Slideshow } from "@vysmo/slideshow-react";
import { paintBleed } from "@vysmo/transitions";

export function Hero() {
  return (
    <Slideshow
      slides={["/01.jpg", "/02.jpg", "/03.jpg"]}
      transition={paintBleed}
      autoplayDelay={4000}
      arrows
      dots
      style={{ width: "100%", aspectRatio: "16 / 9" }}
    />
  );
}
Full props + advanced patterns on npm

Concepts

A quick mental model before the API ref. Skip if you're already comfortable with the playground.

Chrome — opt-in, not all-or-nothing

The slideshow renders five visible UI pieces on demand: arrows, dots, counter, progress, captions. Each is false by default, accepts true for sensible defaults, or an options object for fine-tuning. Set everything to false for pure-headless mode and render your own chrome in DOM around the slideshow.

Single or per-slide transition

transition takes a single Transition (every slide change uses it) or a function (from, to) => Transition. The function form lets you vary by slide pair, by content type, or just by rolling a die. See the per-slide variety recipe below.

Autoplay vs Loop

Loop wraps last → first when you call next() (or vice versa for prev). Autoplay calls next() on a timer set by autoplayDelay. They compose: autoplay + loop is a never-ending rotator; autoplay alone stops at the last slide. play() / pause() control the autoplay timer at runtime.

Pause on hidden

When the tab becomes hidden, the autoplay timer is suspended; when it returns, the timer resumes. Stops you from rendering frames nobody’s looking at and prevents the slideshow from racing through dozens of transitions while the user was on another tab. Toggle off via pauseOnHidden: false if you want a different behavior.

Slides are decoded once

URL strings are fetched and image.decode()'d at construction time; the ready promise resolves when every source is ready. Calling next() / go() before ready is safe — they queue. Pre-decoded sources (HTMLImageElement / HTMLCanvasElement) resolve instantly.

Transitions, not navigation, are async

next(), prev(), and go() all return Promises. The Promise resolves when the transition settles (or right away if instant: true). Calling next() while a transition is in flight is a no-op — isTransitioning tells you when the slideshow is busy.

API reference

Five things to know. Everything else is composition.

createSlideshow(options)

Constructs a slideshow in the given container and returns a handle. All options except container and slides have sensible defaults. Mutating the returned handle is the only way to drive the slideshow at runtime — there's no React-style re-render-with-new-options pattern.

createSlideshow({
  container: HTMLElement,
  slides: readonly SlideSource[],
  initial?: number,                // start index (default 0)
  transition?: TransitionSelector, // default `dissolve`
  transitionDuration?: number,     // milliseconds (default 800)
  ease?: EasingFn,                 // from @vysmo/easings (default linear)

  // — Behavior —
  autoplayDelay?: number,          // ms between slides; 0 disables (default 0)
  autoplay?: boolean,              // start playing on mount; default autoplayDelay > 0
  loop?: boolean,                  // wrap at the ends (default true)
  pauseOnHidden?: boolean,         // honor visibilitychange (default true)
  pauseOnHover?: boolean,          // pause autoplay while hovered (default false)

  // — Input —
  clickNavigation?: boolean,       // click halves to advance/retreat (default true)
  keyboardNavigation?: boolean,    // arrows / Home / End / Space (default true)
  swipeNavigation?: boolean | { threshold?: number }, // touch swipe (default false)

  // — Chrome (visible UI overlays — opt in per piece) —
  arrows?: boolean | ArrowsOptions,     // nav buttons
  dots?: boolean | DotsOptions,         // page indicator
  counter?: boolean | CounterOptions,   // "1 / N" text
  progress?: boolean | ProgressOptions, // autoplay countdown bar
  captions?: false | CaptionsOptions,   // per-slide text overlay

  ariaLabel?: string,              // default "Slideshow"
}): SlideshowHandle

SlideshowHandle

The return value of createSlideshow. Reactive state via getters (current, length, isPlaying, isTransitioning), imperative navigation, autoplay control, event subscriptions. destroy() tears down the WebGL context, removes event listeners, and detaches the canvas.

interface SlideshowHandle {
  // Reactive state (getters)
  readonly current: number;
  readonly length: number;
  readonly isPlaying: boolean;
  readonly isTransitioning: boolean;

  // Resolves once every URL/image source has decoded. Canvas sources
  // resolve instantly. Methods called before `ready` are queued.
  readonly ready: Promise<void>;

  // Imperative navigation. All return Promises that resolve when the
  // transition (or instant snap) completes.
  next(): Promise<void>;
  prev(): Promise<void>;
  go(index: number, options?: { instant?: boolean }): Promise<void>;

  // Autoplay control. play() and pause() are no-ops if already in state.
  // play() requires `autoplayDelay > 0` to do anything.
  play(): void;
  pause(): void;

  // Events
  on(event: "change",          cb: (current: number, previous: number) => void): () => void;
  on(event: "transitionstart", cb: (from: number,    to: number) => void): () => void;
  on(event: "transitionend",   cb: (from: number,    to: number) => void): () => void;

  destroy(): void;
}

SlideSource

Anything you can hand to a slide slot. Strings are URLs; DOM-element variants are used as-is. Native video sources are deferred to v2 — for now, paint video frames into a canvas externally and pass that.

// Anything you can hand to a slide slot.
type SlideSource =
  | string                 // URL — decoded as an image at construction time
  | HTMLImageElement       // already-decoded image, used as-is
  | HTMLCanvasElement;     // pre-rendered canvas (paint video frames here)

TransitionSelector

Single transition or function-of-indices. The function form runs on every slide change, so callers can vary per pair, per content type, or by random pool draw — and the slideshow handles the program-cache warmup automatically across the set.

// Pick a single transition for every slide change, or vary per-slide.
type TransitionSelector =
  | Transition                                              // single transition
  | ((fromIndex: number, toIndex: number) => Transition);   // function form

Events

show.on(event, cb) returns an unsubscriber. Call it to detach without tearing down the whole slideshow.

  • change(current, previous) — emitted after a transition commits. current is the new index.
  • transitionstart(from, to) — emitted when a slide change begins. Fires once per next / prev / go / autoplay tick.
  • transitionend(from, to) — emitted when the transition tween settles. change fires fractionally before transitionend.

Keyboard map

When keyboardNavigation is on (default), the container responds to:

  • ArrowLeft / ArrowRightprev / next
  • Home / End — first / last slide
  • Space — toggle autoplay (no-op if autoplayDelay is 0)

Chrome option types

Each chrome option accepts true (defaults), false (off), or an options object. Mix and match to taste — or set them all to false for a pure-headless slideshow with just the canvas + click halves.

interface ArrowsOptions {
  position?: "inside-edges" | "outside-edges" | "bottom-center" | "bottom-right";
  style?: "minimal" | "circle" | "square";
}
interface DotsOptions {
  position?: "bottom-center" | "top-center" | "left-center" | "right-center";
  style?: "dots" | "dashes" | "numbers" | "lines";
}
interface CounterOptions {
  position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
  format?: (current: number, length: number) => string;  // default `${i + 1} / ${n}`
}
interface ProgressOptions {
  position?: "top" | "bottom";  // edge of stage; only visible while autoplay is on
}
interface CaptionsOptions {
  texts: readonly string[] | ((index: number) => string);
  position?: "top" | "bottom" | "center";
  alignment?: "left" | "center" | "right";
}

Recipes

Common patterns. Each one stands alone — copy, adapt, ship.

Auto-rotating hero, pause on hover

A landing-page slideshow that advances on its own, pauses on hover, and shows arrows + dots + a countdown progress bar. The pauseOnHover + progress combination is the autoplay UX users expect from polished carousels.

import { createSlideshow } from "@vysmo/slideshow";
import { lightLeak } from "@vysmo/transitions";

const show = createSlideshow({
  container,
  slides: heroPhotos,
  transition: lightLeak,
  autoplayDelay: 5000,    // one new slide every 5 seconds
  loop: true,
  pauseOnHover: true,     // pause when the pointer is over the slideshow
  arrows: true,
  dots: true,
  progress: true,         // countdown bar for the autoplay timer
});

Tune every visible piece

Each chrome option accepts true (sensible defaults), false (off), or an options object for fine-tuning. Mix freely — the slideshow renders only the pieces you opt in.

import { createSlideshow } from "@vysmo/slideshow";
import { paintBleed } from "@vysmo/transitions";

// Tune every visible piece to taste. Defaults are picked so `true` ships
// something good-looking; the option-object form lets you override.
createSlideshow({
  container,
  slides: photos,
  transition: paintBleed,
  arrows: { position: "outside-edges", style: "circle" },
  dots: { position: "bottom-center", style: "lines" },
  counter: { position: "top-right",
             format: (i, n) => `${String(i+1).padStart(2,"0")} / ${n}` },
  progress: { position: "top" },
  captions: {
    texts: photos.map((p) => p.alt),
    position: "bottom",
    alignment: "left",
  },
  swipeNavigation: { threshold: 60 },
});

Theme via CSS custom properties

Chrome inline styles read --vysmo-chrome-* variables with fallbacks. Override on any ancestor of the slideshow container to retheme without forking.

/* Theme the slideshow chrome via CSS custom properties on the
   container or any ancestor. The package ships minimal inline styles
   that read these variables with fallbacks, so you can override
   without forking. */

:root {
  --vysmo-chrome-color: white;
  --vysmo-chrome-bg: rgba(0, 0, 0, 0.42);
  --vysmo-chrome-bg-strong: rgba(0, 0, 0, 0.62);
  --vysmo-chrome-active: white;
  --vysmo-chrome-inactive: rgba(255, 255, 255, 0.5);
  --vysmo-chrome-shadow: drop-shadow(0 1px 2px rgba(0,0,0,0.45));
}

/* Scope to a specific slideshow if you want different themes per page. */
.brand-hero {
  --vysmo-chrome-active: oklch(70% 0.18 280);  /* magenta dots */
  --vysmo-chrome-bg: rgba(20, 8, 30, 0.55);
}

Per-slide transition variety

Round-robin through a curated pool so each slide change uses a different transition. The function form of transition gets called fresh on every change.

import { createSlideshow } from "@vysmo/slideshow";
import {
  paintBleed,
  lightLeak,
  pageCurl,
  swirl,
} from "@vysmo/transitions";

// Function form — receives the slide indices, returns the transition to
// use for that change. Round-robin, hash, random — whatever you want.
const pool = [paintBleed, lightLeak, pageCurl, swirl];

createSlideshow({
  container,
  slides: photos,
  transition: (from, to) => pool[to % pool.length]!,
  autoplayDelay: 4500,
});

Custom controls + dot indicator

Disable the built-in click / keyboard nav, build your own prev / next / dot UI, and wire it through the handle.

const show = createSlideshow({
  container,
  slides: photos,
  // Disable built-in nav; build your own.
  clickNavigation: false,
  keyboardNavigation: false,
});

document.querySelector("#prev")!.addEventListener("click", () => show.prev());
document.querySelector("#next")!.addEventListener("click", () => show.next());

// Dot indicator — wire one button per slide.
document.querySelectorAll<HTMLButtonElement>("[data-go]").forEach((btn) => {
  btn.addEventListener("click", () => show.go(Number(btn.dataset.go)));
});

// Mirror slideshow state into your UI.
show.on("change", (current) => {
  document.querySelector("#counter")!.textContent =
    `${current + 1} / ${show.length}`;
});

Scroll-driven slideshow

Tie slide changes to scroll position. Each quarter of the scroll zone moves to the next slide; prefers-reduced-motion users will appreciate instant: true on the go() call.

import { createSlideshow } from "@vysmo/slideshow";
import { createScrollProgress } from "@vysmo/scroll";

const show = createSlideshow({
  container,
  slides: photos,
  // Disable autoplay + click — scroll position drives navigation.
  autoplayDelay: 0,
  clickNavigation: false,
});

// Snap to a different slide every quarter of the scroll zone.
let lastIndex = 0;
createScrollProgress({
  target: container,
  onProgress: (p) => {
    const target = Math.floor(p * show.length);
    if (target !== lastIndex && target < show.length) {
      lastIndex = target;
      void show.go(target, { instant: false });
    }
  },
});

FAQ & gotchas

The handful of things that catch people the first time they ship a slideshow.

Why isn’t my slideshow rendering?

The container needs measurable dimensions. Set width + height (or width + aspect-ratio) via CSS — the canvas fills 100% of both. A container with display: inline, zero height, or no laid-out width will silently show nothing. Verify with container.getBoundingClientRect() in the console: width and height should both be > 0 before you call createSlideshow.

Does it respect prefers-reduced-motion?

Not automatically. If you want to honor user preference, detect with matchMedia and call go with instant: true so slides snap rather than animate:

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  // Skip the transition; snap directly to the next slide.
  show.go(target, { instant: true });
} else {
  show.go(target);
}

Can I have multiple slideshows on one page?

Yes. Each createSlideshow call creates an isolated instance with its own WebGL context. Browsers cap WebGL contexts per page (~16 in Chrome) — well above what you’d reasonably ship. If you ever need many small slideshows on one page, consider whether they really all need to be live or could render lazily on intersection.

How do I clean up properly?

Call show.destroy(). Disposes the WebGL context, removes pointer / keyboard / visibility listeners, clears the autoplay timer, detaches the canvas from the container, and unsubscribes any in-flight transition tween. Always run it on unmount in component frameworks — otherwise you leak GPU memory and event handlers across re-renders.

Is there a React / Vue / Svelte adapter?

The vanilla API is the surface today. It's small enough that a framework hook is a few lines:

// React
import { useEffect, useRef } from "react";
import { createSlideshow } from "@vysmo/slideshow";
import { paintBleed } from "@vysmo/transitions";

function Slideshow({ slides }: { slides: string[] }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const show = createSlideshow({
      container: ref.current!,
      slides,
      transition: paintBleed,
      autoplayDelay: 5000,
    });
    return () => show.destroy();
  }, [slides]);
  return <div ref={ref} style={{ width: "100%", aspectRatio: "16/9" }} />;
}

Can I show videos?

Not directly — native HTMLVideoElement support is deferred to v2 because driving frame uploads while a transition is rendering needs careful coordination. For now, paint video frames into an HTMLCanvasElement via drawImage in your own requestAnimationFrame loop and pass the canvas as the slide source. Each slide change re-uploads the canvas as a texture.

Touch / swipe?

Click halves work via pointer events, so taps register on touch devices — tap the left half to go back, the right half to go forward. Swipe gestures are opt-in via swipeNavigation: true (or { threshold: 60 } for a custom threshold). The runtime uses pointer events so it covers touch + mouse drag with one code path.