/ Examples
carousel

Carousel

Infinite-loop photo carousel powered by the carousel() plugin — aligned with Material Design 3. Items wrap seamlessly with smooth snap-to-item. Switch between variant layouts to see hero, multi-browse, uncontained, and full-screen modes.

Photo Carousel

0% 0.00 / 0.00 px/ms 0 / 0 items
variant full step 1 / 24
Source
// Carousel — MD3-aligned photo carousel using the carousel() plugin
// Demonstrates infinite loop, snap-to-item, variant layouts, and real photos

import { createVList, carousel, rebuild, registerPreset, full } from "vlist";
import { getItems, getImageUrl, getItemWidth, preloadImages } from "../shared.js";
import { createStats } from "../../stats.js";
import { createInfoUpdater } from "../../info.js";

registerPreset("full-h", full);

const esc = (s) =>
  String(s).replace(
    /[&<>"]/g,
    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
  );

// =============================================================================
// State
// =============================================================================

let currentVariant = "hero";
let snapEnabled = false;
let currentIndex = 0;
let list = null;
let imagesPreloaded = false;
let items = getItems(currentVariant);
let viewVersion = 0;

// =============================================================================
// DOM references
// =============================================================================

const listContainerEl = document.getElementById("list-container");
const dotsEl = document.getElementById("carousel-dots");
const detailEl = document.getElementById("photo-detail");
const infoVariantEl = document.getElementById("info-variant");
const infoStepEl = document.getElementById("info-step");

// =============================================================================
// Template
// =============================================================================

const ITEM_HEIGHT = 480;
const ITEM_WIDTH = 720;

function isVertical() {
  return currentVariant === "full";
}

function isFull() {
  return currentVariant === "full" || currentVariant === "full-h";
}

function itemTemplate(item) {
  const isH = !isVertical();
  const isMultiAspect = currentVariant === "multi-aspect";
  const pw = item.w ?? 1;
  const ph = item.h ?? 1;
  let imgW, imgH;
  if (isMultiAspect) {
    const scale = 600 / Math.max(pw, ph);
    imgW = Math.round(pw * scale);
    imgH = Math.round(ph * scale);
  } else {
    imgW = isH ? 800 : 600;
    imgH = isH ? 500 : 800;
  }
  const url = getImageUrl(item.picId, imgW, imgH);

  if (imagesPreloaded) {
    return `
      <div class="vlist-carousel-slide photo-slide">
        <img
          class="vlist-carousel-slide__media photo-slide__img photo-slide__img--loaded"
          src="${url}"
          alt="${esc(item.title)}"
          decoding="sync"
        />
        <div class="vlist-carousel-slide__overlay photo-slide__overlay">
          <span class="vlist-carousel-slide__title photo-slide__title">${esc(item.title)}</span>
          <span class="vlist-carousel-slide__subtitle photo-slide__location">${esc(item.location)}</span>
        </div>
      </div>
    `;
  }

  return `
    <div class="vlist-carousel-slide photo-slide">
      <img
        class="vlist-carousel-slide__media photo-slide__img"
        src="${url}"
        alt="${esc(item.title)}"
        decoding="async"
        data-t="${performance.now()}"
        onload="if(performance.now()-this.dataset.t<100){this.style.transition='none';this.offsetHeight}this.classList.add('photo-slide__img--loaded')"
        onerror="this.style.transition='none';this.classList.add('photo-slide__img--loaded')"
      />
      <div class="vlist-carousel-slide__overlay photo-slide__overlay">
        <span class="vlist-carousel-slide__title photo-slide__title">${esc(item.title)}</span>
        <span class="vlist-carousel-slide__subtitle photo-slide__location">${esc(item.location)}</span>
      </div>
    </div>
  `;
}

// =============================================================================
// Stats
// =============================================================================

const stats = createStats({
  getScrollPosition: () => list?.getScrollPosition() ?? 0,
  getTotal: () => items.length,
  getItemSize: () => isVertical() ? ITEM_HEIGHT : ITEM_WIDTH,
  getContainerSize: () => {
    const el = document.querySelector("#list-container");
    return isVertical() ? (el?.clientHeight ?? 0) : (el?.clientWidth ?? 0);
  },
});

const updateInfo = createInfoUpdater(stats);

// =============================================================================
// Dots
// =============================================================================

function updateDots() {
  dotsEl.innerHTML = items
    .map(
      (_, i) =>
        `<span class="carousel-dot ${i === currentIndex ? "carousel-dot--active" : ""}" data-index="${i}"></span>`,
    )
    .join("");
}

dotsEl.addEventListener("click", (e) => {
  const dots = dotsEl.querySelectorAll(".carousel-dot");
  if (!dots.length) return;
  const x = e.clientX;
  let closest = 0;
  let minDist = Infinity;
  dots.forEach((dot, i) => {
    const rect = dot.getBoundingClientRect();
    const dist = Math.abs(x - (rect.left + rect.width / 2));
    if (dist < minDist) { minDist = dist; closest = i; }
  });
  currentIndex = closest;
  list?.goTo(closest, { behavior: "smooth", duration: 400 });
  updateDots();
  updateDetail();
  updateStep();
});

// =============================================================================
// Detail panel
// =============================================================================

function updateDetail() {
  const item = items[currentIndex];
  if (!item || !detailEl) return;
  const pw = item.w ?? 300;
  const ph = item.h ?? 300;
  const scale = 300 / Math.max(pw, ph);
  const url = getImageUrl(item.picId, Math.round(pw * scale), Math.round(ph * scale));
  detailEl.innerHTML = `
    <div class="photo-detail">
      <div class="photo-detail__frame">
        <img class="photo-detail__img" src="${url}" alt="${esc(item.title)}" />
      </div>
      <div class="photo-detail__meta">
        <strong>${esc(item.title)}</strong>
        <span>${esc(item.location)} · #${item.id}</span>
      </div>
    </div>
  `;
}

function updateStep() {
  if (infoStepEl)
    infoStepEl.textContent = `${currentIndex + 1} / ${items.length}`;
}

// =============================================================================
// Factory + rebuild
// =============================================================================

function factory() {
  const isH = !isVertical();
  const isMultiAspect = currentVariant === "multi-aspect";
  const containerH = listContainerEl.clientHeight || ITEM_HEIGHT;
  const itemWidth = isH
    ? isMultiAspect
      ? (index) => getItemWidth(index, containerH, currentVariant)
      : ITEM_WIDTH
    : undefined;

  return createVList(
    {
      container: "#list-container",
      orientation: isH ? "horizontal" : "vertical",
      scroll: { scrollbar: "none" },
      ariaLabel: "Photo carousel",
      item: {
        height: isMultiAspect ? containerH : ITEM_HEIGHT,
        width: itemWidth,
        template: itemTemplate,
      },
      items,
    },
    [
      carousel({
        variant: currentVariant,
        snap: snapEnabled,
        snapDuration: 400,
        initialIndex: currentIndex,
        gap: 8,
      }),
    ],
  );
}

function onReady(l) {
  l.on("scroll", updateInfo);
  l.on("range:change", updateInfo);
  l.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });
  l.on("carousel:change", ({ index }) => {
    currentIndex = index;
    updateDots();
    updateDetail();
    updateStep();
  });

  updateInfo();
  updateDots();
  updateDetail();
  updateStep();
  if (infoVariantEl) infoVariantEl.textContent = currentVariant;
}

async function createList() {
  items = getItems(currentVariant);
  imagesPreloaded = false;

  const wrap = document.querySelector(".carousel-wrap");
  wrap.classList.toggle("carousel-wrap--vertical", isVertical());

  const version = ++viewVersion;
  const newList = await rebuild(list, factory, {
    key: "carousel",
    transition: { fadeIn: 160, fadeOut: 120, fadeOutDelay: 40 },
  });
  if (version !== viewVersion) {
    newList.destroy();
    return;
  }
  list = newList;
  onReady(list);

  const isH = !isVertical();
  const preloadW = isH ? 800 : 600;
  const preloadH = isH ? 500 : 800;
  preloadImages(currentVariant, preloadW, preloadH).then(() => {
    imagesPreloaded = true;
    listContainerEl
      .querySelectorAll(".photo-slide__img:not(.photo-slide__img--loaded)")
      .forEach((img) => {
        img.style.transition = "none";
        img.classList.add("photo-slide__img--loaded");
      });
  });
}

// =============================================================================
// Prev / Next buttons
// =============================================================================

document.getElementById("btn-prev").addEventListener("click", () => {
  list?.prev(1, { behavior: "smooth", duration: 400 });
});

document.getElementById("btn-next").addEventListener("click", () => {
  list?.next(1, { behavior: "smooth", duration: 400 });
});

// =============================================================================
// Variant buttons
// =============================================================================

document.getElementById("variant-buttons").addEventListener("click", (e) => {
  const btn = e.target.closest("[data-variant]");
  if (!btn) return;
  const variant = btn.dataset.variant;
  if (variant === currentVariant) return;

  currentVariant = variant;
  document.querySelectorAll("#variant-buttons .ui-ctrl-btn").forEach((b) => {
    b.classList.toggle("ui-ctrl-btn--active", b.dataset.variant === variant);
  });

  createList();
});

// =============================================================================
// Snap toggle
// =============================================================================

document.getElementById("toggle-snap").addEventListener("change", (e) => {
  snapEnabled = e.target.checked;
  createList();
});

// =============================================================================
// Init
// =============================================================================

createList();
<div class="container">
    <header>
        <h1>Carousel</h1>
        <p class="description">
            Infinite-loop photo carousel powered by the
            <code>carousel()</code> plugin — aligned with
            <a
                href="https://m3.material.io/components/carousel/overview"
                target="_blank"
                >Material Design 3</a
            >. Items wrap seamlessly with smooth snap-to-item. Switch between
            <strong>variant</strong> layouts to see hero, multi-browse,
            uncontained, and full-screen modes.
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main">
            <h2 class="sr-only">Photo Carousel</h2>
            <div class="carousel-wrap">
                <div id="list-container"></div>

                <!-- Navigation overlay -->
                <button
                    id="btn-prev"
                    class="carousel-nav carousel-nav--prev"
                    title="Previous"
                >
                    <svg
                        width="24"
                        height="24"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        stroke-width="2"
                    >
                        <polyline points="15 18 9 12 15 6" />
                    </svg>
                </button>
                <button
                    id="btn-next"
                    class="carousel-nav carousel-nav--next"
                    title="Next"
                >
                    <svg
                        width="24"
                        height="24"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        stroke-width="2"
                    >
                        <polyline points="9 18 15 12 9 6" />
                    </svg>
                </button>

                <!-- Dot indicators -->
                <div class="carousel-dots" id="carousel-dots"></div>
            </div>
        </div>

        <aside class="split-panel">
            <!-- Variant -->
            <section class="ui-section">
                <h3 class="ui-title">Variant</h3>
                <div class="ui-row">
                    <div
                        class="ui-btn-group ui-btn-group--wrap"
                        id="variant-buttons"
                    >
                        <button
                            class="ui-ctrl-btn ui-ctrl-btn--active"
                            data-variant="hero"
                        >
                            Hero
                        </button>
                        <button class="ui-ctrl-btn" data-variant="hero-center">
                            Hero Center
                        </button>
                        <button class="ui-ctrl-btn" data-variant="full">
                            Full
                        </button>
                        <button class="ui-ctrl-btn" data-variant="full-h">
                            Full-H
                        </button>
                        <button class="ui-ctrl-btn" data-variant="multi">
                            Multi
                        </button>
                        <button class="ui-ctrl-btn" data-variant="uncontained">
                            Uncontained
                        </button>
                        <button class="ui-ctrl-btn" data-variant="multi-aspect">
                            Multi-Aspect
                        </button>
                    </div>
                </div>
            </section>

            <!-- Options -->
            <section class="ui-section">
                <h3 class="ui-title">Options</h3>

                <div class="ui-row">
                    <label class="ui-label">Snap</label>
                    <label class="ui-switch">
                        <input type="checkbox" id="toggle-snap" />
                        <span class="ui-switch__track"></span>
                    </label>
                </div>
            </section>

            <!-- Photo detail -->
            <section class="ui-section">
                <h3 class="ui-title">Current</h3>
                <div class="ui-detail" id="photo-detail">
                    <span class="ui-detail__empty">Scroll to explore</span>
                </div>
            </section>
        </aside>
    </div>

    <div class="example-info" id="example-info">
        <div class="example-info__left">
            <span class="example-info__stat">
                <strong id="info-progress">0%</strong>
            </span>
            <span class="example-info__stat">
                <span id="info-velocity">0.00</span> /
                <strong id="info-velocity-avg">0.00</strong>
                <span class="example-info__unit">px/ms</span>
            </span>
            <span class="example-info__stat">
                <span id="info-dom">0</span> /
                <strong id="info-total">0</strong>
                <span class="example-info__unit">items</span>
            </span>
        </div>
        <div class="example-info__right">
            <span class="example-info__stat">
                variant <strong id="info-variant">full</strong>
            </span>
            <span class="example-info__stat">
                step <strong id="info-step">1 / 24</strong>
            </span>
        </div>
    </div>
</div>
// Carousel — Shared data re-exported from curated carousel data
// Each variant uses unique photos with hand-written descriptions

export {
  getItems,
  getItemCount,
  getItemWidth,
  getImageUrl,
  preloadImages,
} from "../../src/data/carousel.js";
/* Carousel Example — MD3-aligned photo carousel
   Common styles (.container, h1, .description, .split-*, .ui-*)
   are provided by styles/ui.css and examples/styles.css using design tokens. */

/* ============================================================================
   Carousel wrap — container for list + nav + dots
   ============================================================================ */

.carousel-wrap {
    position: relative;
    width: 100%;
}

.carousel-wrap--vertical {
    width: 300px;
    height: 522px;
    margin: 0 auto;
}

/* ============================================================================
   List container
   ============================================================================ */

.split-main {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 520px;
    padding: 32px;
}

#list-container {
    width: 100%;
    height: 360px;
    overflow: hidden;
    border-radius: 28px;
}

.carousel-wrap--vertical #list-container {
    height: 522px;
}

#list-container .vlist {
    border: none !important;
    border-radius: 28px !important;
    background: transparent;
    width: 100%;
}

#list-container .vlist-viewport {
    width: 100% !important;
}

#list-container .vlist-item {
    padding: 0 !important;
    border: none !important;
    background: transparent;
}

/* ============================================================================
   Photo slide — adapts to --vlist-carousel-width
   ============================================================================ */

.photo-slide {
    --vlist-carousel-radius: 28px;
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
    border-radius: 28px;
    background: var(--surface-container);
}

.photo-slide__img {
    opacity: 0;
    transition: opacity 0.4s ease;
}

.photo-slide__img--loaded {
    opacity: 1;
}

.photo-slide__overlay {
    padding: 32px 24px 24px;
    background: linear-gradient(
        0deg,
        rgba(0, 0, 0, 0.7) 0%,
        rgba(0, 0, 0, 0.3) 60%,
        transparent 100%
    );
    display: flex;
    flex-direction: column;
    gap: 4px;
    transition: opacity 0.15s ease;
}

.photo-slide__title {
    font-size: 20px;
    font-weight: var(--fw-bold, 700);
    color: #fff;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

.photo-slide__location {
    font-size: 14px;
    color: rgba(255, 255, 255, 0.8);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}

/* ============================================================================
   Navigation arrows
   ============================================================================ */

.carousel-nav {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 10;
    width: 48px;
    height: 48px;
    border-radius: 50%;
    border: none;
    background: rgba(0, 0, 0, 0.45);
    color: #fff;
    cursor: pointer;
    display: none;
    align-items: center;
    justify-content: center;
    transition:
        background 0.2s,
        opacity 0.2s,
        transform 0.15s;
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    opacity: 0;
}

.carousel-wrap:hover .carousel-nav {
    opacity: 1;
}

.carousel-nav:hover {
    background: rgba(0, 0, 0, 0.65);
    transform: translateY(-50%) scale(1.08);
}

.carousel-nav:active {
    transform: translateY(-50%) scale(0.95);
}

.carousel-nav--prev {
    left: 16px;
}

.carousel-nav--next {
    right: 16px;
}

.carousel-wrap--vertical .carousel-nav {
    display: none;
}

/* ============================================================================
   Dot indicators
   ============================================================================ */

.carousel-dots {
    display: flex;
    justify-content: center;
    gap: 8px;
    padding: 16px 0 4px;
    flex-wrap: wrap;
    cursor: pointer;
}

.carousel-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--text-muted, #666);
    opacity: 0.4;
    pointer-events: none;
    transition:
        opacity 0.2s,
        transform 0.2s,
        background 0.2s;
}

.carousel-dot:hover {
    opacity: 0.7;
    transform: scale(1.3);
}

.carousel-dot--active {
    opacity: 1;
    background: var(--accent, #667eea);
    transform: scale(1.3);
}

.carousel-wrap--vertical .carousel-dots {
    flex-direction: column;
    position: absolute;
    right: -24px;
    top: 50%;
    transform: translateY(-50%);
    padding: 0;
}

/* ============================================================================
   Photo detail (panel)
   ============================================================================ */

.photo-detail {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.photo-detail__frame {
    padding: 16px;
    border-radius: 16px;
    background: var(--surface-container, #e8e8f0);
}

.photo-detail__img {
    width: 100%;
    aspect-ratio: 1;
    object-fit: contain;
    border-radius: 12px;
}

.photo-detail__meta {
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.photo-detail__meta strong {
    font-weight: var(--fw-bold, 700);
    font-size: 14px;
}

.photo-detail__meta span {
    font-size: 12px;
    color: var(--text-muted, #888);
}

/* ============================================================================
   Variant button group wrap
   ============================================================================ */

.ui-btn-group--wrap {
    flex-wrap: wrap;
}