/ Examples
scroll.wheelselection

Wizard

Step-by-step recipe viewer with scroll.wheel: false — navigation via buttons and keyboard only. Uses withSelection and circular wrap mode.

Steps

0% 0.00 / 0.00 px/ms 0 / 0 items
vertical wrap on
Source
// Wizard — Step-by-step recipe viewer
// Demonstrates scroll.wheel: false, wrap, button-only navigation

import { vlist } from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
import "./controls.js";

// =============================================================================
// Data — fetched from /api/recipes
// =============================================================================

export let recipes = [];
export let TOTAL = 0;
export const ITEM_HEIGHT = 320;

async function fetchRecipes() {
  try {
    const response = await fetch("/api/recipes");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    recipes = await response.json();
    TOTAL = recipes.length;
  } catch (err) {
    console.error("Failed to fetch recipes:", err);
    recipes = [];
    TOTAL = 0;
  }
}

// =============================================================================
// State — exported so controls.js can read/write
// =============================================================================

export let currentOrientation = "vertical"; // "vertical" | "horizontal"
export let currentWrap = true;
export let currentIndex = 0;
export let list = null;

export function setCurrentOrientation(v) {
  currentOrientation = v;
}
export function setCurrentWrap(v) {
  currentWrap = v;
}

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

const itemTemplate = (item) => `
  <div class="recipe-card">
    <div class="recipe-header">
      <span class="recipe-emoji">${item.emoji}</span>
      <div class="recipe-meta">
        <span class="ui-badge ui-badge--pill meta-time">⏱ ${item.time}</span>
        <span class="ui-badge ui-badge--pill meta-difficulty">${item.difficulty}</span>
      </div>
    </div>
    <h2 class="recipe-title">${item.title}</h2>
    <p class="recipe-origin">${item.origin}</p>
    <div class="recipe-section">
      <h3>Ingredients</h3>
      <p>${item.ingredients}</p>
    </div>
    <div class="recipe-tip">
      <span class="tip-icon">💡</span>
      <p>${item.tip}</p>
    </div>
  </div>
`;

// =============================================================================
// Stats — shared footer (progress, velocity, visible/total)
// =============================================================================

export const stats = createStats({
  getScrollPosition: () => list?.getScrollPosition() ?? 0,
  getTotal: () => TOTAL,
  getItemSize: () => ITEM_HEIGHT,
  getContainerSize: () =>
    document.querySelector("#list-container")?.clientHeight ?? 0,
});

const updateInfo = createInfoUpdater(stats);

// =============================================================================
// Create / Recreate list
// =============================================================================

export function createList() {
  if (list) {
    list.destroy();
    list = null;
  }

  const container = document.getElementById("list-container");
  container.innerHTML = "";

  // Toggle horizontal class on wizard wrapper
  const wizardEl = document.querySelector(".wizard");
  const isH = currentOrientation === "horizontal";
  wizardEl.classList.toggle("wizard--horizontal", isH);

  // In horizontal mode, item width = container width so one card fills the view
  const containerWidth = isH ? container.clientWidth : undefined;

  const builder = vlist({
    container: "#list-container",
    orientation: currentOrientation,
    scroll: { wheel: false, scrollbar: "none", wrap: currentWrap },
    ariaLabel: "Recipe wizard",
    item: {
      height: ITEM_HEIGHT,
      width: isH ? containerWidth : undefined,
      template: itemTemplate,
    },
    items: recipes,
  });

  list = builder.build();

  list.on("scroll", updateInfo);
  list.on("range:change", updateInfo);
  list.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });

  list.on("item:click", ({ index }) => {
    goTo(index);
  });

  updateInfo();
  updateContext();

  // Restore current index (instant — no animation after rebuild)
  goTo(currentIndex, true);
}

// =============================================================================
// Navigation — go to a specific recipe
// =============================================================================

export function goTo(index, instant = false) {
  // scroll.wrap handles modulo internally — pass the raw index
  list.scrollToIndex(index, {
    align: "start",
    behavior: instant ? "auto" : "smooth",
    duration: instant ? 0 : 350,
  });

  // Resolve index for UI (dots, recipe info panel)
  currentIndex =
    currentWrap && TOTAL > 0
      ? ((index % TOTAL) + TOTAL) % TOTAL
      : Math.max(0, Math.min(index, TOTAL - 1));

  updateCurrentInfo();
  updateDots();
  updateInfo();
}

// =============================================================================
// Step indicator dots
// =============================================================================

const indicatorEl = document.getElementById("step-indicator");

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

indicatorEl.addEventListener("click", (e) => {
  const dot = e.target.closest(".dot");
  if (dot) goTo(Number(dot.dataset.index));
});

// =============================================================================
// Current recipe info (panel)
// =============================================================================

const currentNameEl = document.getElementById("current-name");
const currentDifficultyEl = document.getElementById("current-difficulty");
const currentTimeEl = document.getElementById("current-time");

function updateCurrentInfo() {
  const r = recipes[currentIndex];
  currentNameEl.textContent = `${r.emoji} ${r.title}`;
  currentDifficultyEl.textContent = r.difficulty;
  currentTimeEl.textContent = r.time;
}

// =============================================================================
// Keyboard navigation
// =============================================================================

document.addEventListener("keydown", (e) => {
  if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
    e.preventDefault();
    goTo(currentIndex - 1);
  } else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
    e.preventDefault();
    goTo(currentIndex + 1);
  } else if (e.key === "Home") {
    e.preventDefault();
    goTo(0);
  } else if (e.key === "End") {
    e.preventDefault();
    goTo(TOTAL - 1);
  }
});

// =============================================================================
// Footer — right side (contextual)
// =============================================================================

const infoOrientation = document.getElementById("info-orientation");
const infoWrap = document.getElementById("info-wrap");

export function updateContext() {
  infoOrientation.textContent = currentOrientation;
  infoWrap.textContent = currentWrap ? "on" : "off";
}

// =============================================================================
// Initialise
// =============================================================================

(async () => {
  await fetchRecipes();
  createList();
})();
/* Wizard — example styles */

/* ============================================================================
   Wizard layout — prev | cards | next
   ============================================================================ */

.wizard {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 96px 16px;
    height: 100%;
}

.wizard__center {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 12px;
}

#list-container {
    height: 320px;
    border-radius: 16px;
    width: auto;
    margin: 0;
}

/* Horizontal orientation — same arrow layout, list scrolls horizontally */
.wizard--horizontal #list-container {
    height: 320px;
}

/* Override vlist defaults */
#list-container .vlist {
    border: none;
    border-radius: 16px;
    background: transparent;
}

#list-container .vlist-item {
    padding: 0;
    border: none;
    cursor: default;
}

#list-container .vlist-item[aria-selected="true"] {
    background: transparent;
}

/* ============================================================================
   Navigation buttons (prev / next)
   ============================================================================ */

.nav-btn {
    flex-shrink: 0;
    width: 48px;
    height: 48px;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: var(--bg-card);
    color: var(--text);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.2s ease;
}

.nav-btn .icon {
    width: 18px;
    height: 18px;
}

.nav-btn:hover {
    background: var(--bg-card-hover);
    border-color: var(--border-hover);
}

.nav-btn:active {
    background: var(--border);
    transform: scale(0.95);
}

/* ============================================================================
   Step indicator dots
   ============================================================================ */

.step-indicator {
    display: flex;
    justify-content: center;
    gap: 6px;
    padding: 4px 0;
}

.dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--border);
    cursor: pointer;
    transition: all 0.25s ease;
}

.dot:hover {
    background: var(--text-dim);
    transform: scale(1.3);
}

.dot-active {
    background: var(--accent);
    transform: scale(1.3);
}

.dot-active:hover {
    background: var(--accent);
}

/* ============================================================================
   Recipe card
   ============================================================================ */

.recipe-card {
    height: 100%;
    max-width: 560px;
    margin: 0 auto;
    padding: 28px 32px;
    display: flex;
    flex-direction: column;
    gap: 12px;
    box-sizing: border-box;
}

.recipe-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
}

.recipe-emoji {
    font-size: 44px;
    line-height: 1;
}

.recipe-meta {
    display: flex;
    gap: 6px;
}

.meta-time {
    background: var(--accent-dim);
    color: var(--accent-text);
}

.meta-difficulty {
    background: rgba(16, 163, 74, 0.1);
    color: #16a34a;
}

[data-theme="dark"] .meta-difficulty {
    background: rgba(74, 222, 128, 0.1);
    color: #4ade80;
}

.recipe-title {
    font-size: 22px;
    font-weight: 700;
    color: var(--text);
    letter-spacing: -0.3px;
}

.recipe-origin {
    font-size: 14px;
    color: var(--text-dim);
    margin-top: -4px;
}

.recipe-section h3 {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.8px;
    color: var(--text-dim);
    margin-bottom: 4px;
}

.recipe-section p {
    font-size: 14px;
    color: var(--text-muted);
    line-height: 1.5;
}

.recipe-tip {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    background: rgba(251, 191, 36, 0.08);
    border: 1px solid rgba(251, 191, 36, 0.2);
    border-radius: 10px;
    padding: 10px 14px;
    margin-top: auto;
}

.tip-icon {
    font-size: 16px;
    line-height: 1.4;
    flex-shrink: 0;
}

.recipe-tip p {
    font-size: 13px;
    color: #92400e;
    line-height: 1.5;
}

[data-theme="dark"] .recipe-tip {
    background: rgba(251, 191, 36, 0.06);
    border-color: rgba(251, 191, 36, 0.12);
}

[data-theme="dark"] .recipe-tip p {
    color: #fbbf24;
}
<div class="container">
    <header>
        <h1>Wizard</h1>
        <p class="description">
            Step-by-step recipe viewer with <code>scroll.wheel: false</code>
            — navigation via buttons and keyboard only. Uses
            <code>withSelection</code> and circular <code>wrap</code> mode.
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main split-main--full">
            <h2 class="sr-only">Steps</h2>
            <div class="wizard">
                <button class="nav-btn nav-prev" id="btn-prev">
                    <i class="icon icon--back"></i>
                </button>

                <div class="wizard__center">
                    <div id="list-container"></div>
                    <div class="step-indicator" id="step-indicator"></div>
                </div>

                <button class="nav-btn nav-next" id="btn-next">
                    <i class="icon icon--forward"></i>
                </button>
            </div>
        </div>

        <aside class="split-panel">
            <!-- Scroll -->
            <section class="ui-section">
                <h3 class="ui-title">Scroll</h3>

                <div class="ui-row">
                    <label class="ui-label">Orientation</label>
                    <div class="ui-segmented" id="orientation-mode">
                        <button
                            class="ui-segmented__btn ui-segmented__btn--active"
                            data-orientation="vertical"
                        >
                            Vertical
                        </button>
                        <button
                            class="ui-segmented__btn"
                            data-orientation="horizontal"
                        >
                            Horizontal
                        </button>
                    </div>
                </div>

                <div class="ui-row">
                    <label class="ui-label">Wrap</label>
                    <div class="ui-segmented" id="wrap-mode">
                        <button
                            class="ui-segmented__btn ui-segmented__btn--active"
                            data-wrap="true"
                        >
                            On
                        </button>
                        <button class="ui-segmented__btn" data-wrap="false">
                            Off
                        </button>
                    </div>
                </div>
            </section>

            <!-- Navigation -->
            <section class="ui-section">
                <h3 class="ui-title">Navigation</h3>
                <div class="ui-row">
                    <div class="ui-btn-group">
                        <button
                            id="btn-first"
                            class="ui-btn ui-btn--icon"
                            title="First"
                        >
                            <i class="icon icon--up"></i>
                        </button>
                        <button
                            id="btn-last"
                            class="ui-btn ui-btn--icon"
                            title="Last"
                        >
                            <i class="icon icon--down"></i>
                        </button>
                        <button
                            id="btn-random"
                            class="ui-btn ui-btn--icon"
                            title="Random"
                        >
                            <i class="icon icon--shuffle"></i>
                        </button>
                    </div>
                </div>
            </section>

            <!-- Current Recipe -->
            <section class="ui-section">
                <h3 class="ui-title">Current</h3>
                <div class="ui-row">
                    <span class="ui-label">Recipe</span>
                    <span class="ui-value" id="current-name">–</span>
                </div>
                <div class="ui-row">
                    <span class="ui-label">Difficulty</span>
                    <span class="ui-value" id="current-difficulty">–</span>
                </div>
                <div class="ui-row">
                    <span class="ui-label">Time</span>
                    <span class="ui-value" id="current-time">–</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">
                <strong id="info-orientation">vertical</strong>
            </span>

            <span class="example-info__stat">
                wrap <strong id="info-wrap">on</strong>
            </span>
        </div>
    </div>
</div>