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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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;
}