Carousel #
Infinite-loop scrolling with snap-to-item, aligned with Material Design 3 Carousel patterns.
import { createVList, carousel } from "vlist";
const list = createVList({
container: "#app",
item: { height: 400, template: renderSlide },
items: slides,
}, [carousel()]);
list.on("carousel:change", ({ index }) => {
console.log("Active slide:", index);
});
Config #
| Option | Type | Default | Description |
|---|---|---|---|
variant |
CarouselVariant | SlotConfig | SlotConfigResolver |
"full" |
Layout variant name, a SlotConfig object, or a resolver function |
snap |
boolean |
true (false for "free") |
Snap to nearest item on scroll idle |
snapDuration |
number |
400 |
Snap animation duration in ms |
peek |
number | string | "auto" |
"auto" |
Visible peek of adjacent items (px, "20%", or "auto") |
gap |
number |
0 |
Gap between items in px |
initialIndex |
number |
0 |
Initial item index |
type CarouselVariant = "full" | "hero" | "hero-center" | "multi" | "uncontained" | "static" | "free" | (string & {});
The type accepts any string — built-in names are autocompleted, but you can pass any name registered via registerPreset().
Custom variant #
There are three ways to define a custom layout:
Inline SlotConfig — a fixed slot layout:
carousel({
variant: { slots: [0.7, 0.2, 0.1], focalSlot: 0 },
});
SlotConfigResolver function — a dynamic resolver that receives the container size and peek value:
carousel({
variant: (containerSize, peek) => ({
slots: [0.6, 0.4],
focalSlot: 0,
}),
});
Registered preset — a named resolver added to the global registry (see Presets).
Variants #
| Variant | Description | Snap |
|---|---|---|
"full" |
One item fills the viewport edge-to-edge | Required |
"hero" |
One large item + one small peek item | Required |
"hero-center" |
One large centered item + two small peek items | Required |
"multi" |
Multiple visible items: large + medium + small | Required |
"uncontained" |
Single-size items scroll past the container edge | Optional |
"multi-aspect" |
Variable-width items using native aspect ratios, no layout engine | No |
"free" |
Items of various widths scroll freely, no snap | No |
"static" |
Items use their native size, no layout engine | No |
Presets #
Presets are named SlotConfigResolver functions stored in a global registry. Built-in variants (full, hero, hero-center, multi, uncontained) are pre-registered — you can add your own or override existing ones.
import { registerPreset, getPreset, resolvePreset } from "vlist";
| Function | Description |
|---|---|
registerPreset(name, resolver) |
Register a named preset. Overwrites any existing preset with the same name. |
getPreset(name) |
Get a resolver by name, or undefined if not registered. |
resolvePreset(name, containerSize, peek) |
Resolve a name to a SlotConfig. Returns null if the name is unknown or the resolver returns null. |
type TextFade = "role" | "viewport" | "size";
interface SlotConfig {
slots: number[];
focalSlot: number;
textFade?: TextFade; // default: "role"
}
type SlotConfigResolver = (containerSize: number, peek: number) => SlotConfig | null;
A resolver returning null means "no layout engine" — the plugin falls back to variable-width item rendering (used by multi-aspect and static variants, which are not registered as presets).
Registering a custom preset #
import { registerPreset, full } from "vlist";
// A new preset with custom slot proportions
registerPreset("panorama", (containerSize, peek) => ({
slots: [0.8, 0.1, 0.1],
focalSlot: 0,
}));
// An alias for an existing preset
registerPreset("full-h", full);
// Use it by name
carousel({ variant: "panorama" });
Built-in preset exports #
Each built-in preset is also exported as a named SlotConfigResolver function for direct use:
import { full, hero, heroCenter, multi, uncontained } from "vlist";
const config = hero(800, 56);
// { slots: [0.93, 0.07], focalSlot: 0 }
Methods #
| Method | Description |
|---|---|
next(step?, options?) |
Advance by step items (default 1). Smooth by default; pass { behavior: "auto" } for instant. |
prev(step?, options?) |
Go back by step items (default 1). Smooth by default; pass { behavior: "auto" } for instant. |
goTo(index, options?) |
Navigate to a specific item. Instant by default; pass { behavior: "smooth" } to animate. Options: { direction, behavior, duration } |
getCarouselState() |
Returns { index, scrollPosition } |
goTo direction #
| Direction | Behavior |
|---|---|
"auto" |
Shortest path (default) |
"forward" |
Always scroll forward, wrapping if needed |
"backward" |
Always scroll backward, wrapping if needed |
Events #
| Event | Payload |
|---|---|
carousel:change |
{ index, scrollPosition } — emitted when the focal item changes |
CSS variables #
Updated per rendered element on every scroll frame:
| Variable | Type | Description |
|---|---|---|
--vlist-carousel-progress |
0–1 | Distance from focal center |
--vlist-carousel-offset |
integer | Signed item distance from focal |
--vlist-carousel-role |
string | "large", "medium", or "small" |
--vlist-carousel-role-weight |
0–1 | Text overlay visibility weight — driven by the preset's textFade mode (see below) |
--vlist-carousel-width |
px | Dynamic item width |
--vlist-carousel-focal-width |
px | Focal slot width (constant per preset) — use to stabilize media sizing |
--vlist-carousel-radius |
px | Read by vlist-carousel.css for slide border-radius |
`textFade` #
The textFade option on SlotConfig controls how --vlist-carousel-role-weight is computed:
| Mode | Behavior | Used by |
|---|---|---|
"role" (default) |
1 - progress for large items, 0 for medium/small. Text is visible on the focal item and fades during transitions. |
hero, hero-center, multi, full |
"viewport" |
Visibility ratio — how much of the item is inside the viewport. Text fades in as an item enters and out as it leaves. | multi-aspect (no-engine) |
"size" |
Ratio of the item's rendered size to the focal slot's size. Text fades when items shrink at the edges and appears when they grow. | uncontained |
Use the variable in CSS to drive overlay opacity:
.slide__overlay {
opacity: var(--vlist-carousel-role-weight, 0);
}
Use these variables in your CSS for scroll-driven effects:
.slide {
opacity: calc(1 - var(--vlist-carousel-progress) * 0.4);
filter: grayscale(var(--vlist-carousel-progress));
}
Stylesheet #
Optional structural styles for carousel slides. Handles media stabilization (no image "breathing" during transitions), overlay visibility, and text truncation:
import "vlist/styles/carousel";
Add the vlist-carousel-slide classes to your template:
<div class="vlist-carousel-slide">
<img class="vlist-carousel-slide__media" src="..." />
<div class="vlist-carousel-slide__overlay">
<span class="vlist-carousel-slide__title">Title</span>
<span class="vlist-carousel-slide__subtitle">Subtitle</span>
</div>
</div>
Set --vlist-carousel-radius on the slide to control border-radius:
.my-slide { --vlist-carousel-radius: 28px; }
How it works #
The plugin creates a bounded virtual scroll window — the content is repeated across multiple cycles with items mapped via modulo. Scrolling past the last item seamlessly continues to the first.
Internally, the carousel delegates to the bounded scroll handler (RFC-012) via ctx.setBoundedWrap. When the scroll position drifts too far from the middle cycle, the handler folds the logical position back by whole laps — the user sees continuous forward or backward motion with no visual discontinuity.
Compatibility #
| Plugin | Status |
|---|---|
selection() |
Compatible — logical indices, ARIA stays at real count |
a11y() |
Compatible |
scrollbar() |
Compatible (lap progress indicator) |
autosize() |
Compatible |
scale() |
Not compatible — both own virtual scroll space |
groups() |
Not compatible — infinite wrap doesn't map to grouped sections |
Accessibility #
- Tab focuses the first carousel item
- Arrow keys navigate between items
- Container has
role="region", items labeled "item X of N" prefers-reduced-motiondisables item size transitions- RTL horizontal layout swaps arrow key directions
Notes #
list.total, click events, selection, and ARIA always report the real item count — the virtual cycles are strictly internal- Empty lists render nothing; all methods no-op
- Single-item lists render one item;
next/prevno-op getScrollPosition()returns a normalized offset within one lap
Examples #
- Carousel — MD3-aligned photo carousel with variant layouts and real photos
- Plugin Wizard — carousel-powered plugin explorer with dots, prev/next, and orientation toggle