gridmasonryscrollbar
Photo Album
Virtualized 2D photo gallery with real images from Lorem Picsum. Toggle between grid and masonry layouts, adjust columns and gap — only visible rows are rendered.
Source
// Photo Album — Virtualized 2D photo gallery
// Demonstrates withGrid + withMasonry + withScrollbar plugins
// Layout mode toggle: Grid ↔ Masonry
import { vlist, withGrid, withMasonry, withScrollbar } from "vlist";
import { createStats } from "../../stats.js";
import {
ITEM_COUNT,
ASPECT_RATIO,
items,
itemTemplate,
currentMode,
currentOrientation,
currentColumns,
currentGap,
list,
setList,
setCreateView,
} from "../shared.js";
import "../controls.js";
// =============================================================================
// Stats — shared footer (progress, velocity, visible/total)
// =============================================================================
function getEffectiveItemHeight() {
const container = document.getElementById("grid-container");
if (!container || !list) return 200;
const innerWidth = container.clientWidth - 2;
const colWidth =
(innerWidth - (currentColumns - 1) * currentGap) / currentColumns;
if (currentMode === "masonry") return Math.round(colWidth * 1.05);
return Math.round(colWidth * ASPECT_RATIO);
}
export const stats = createStats({
getList: () => list,
getTotal: () => ITEM_COUNT,
getItemHeight: () => getEffectiveItemHeight(),
container: "#grid-container",
});
// =============================================================================
// Create / Recreate
// =============================================================================
let firstVisibleIndex = 0;
function createView() {
if (list) {
list.destroy();
setList(null);
}
const container = document.getElementById("grid-container");
container.innerHTML = "";
const orientation = currentOrientation;
const columns = currentColumns;
const gap = currentGap;
if (currentMode === "grid") {
createGridView(container, orientation, columns, gap);
} else {
createMasonryView(container, orientation, columns, gap);
}
// Wire events
list.on("scroll", stats.scheduleUpdate);
list.on("range:change", ({ range }) => {
firstVisibleIndex =
currentMode === "grid" ? range.start * currentColumns : range.start;
stats.scheduleUpdate();
});
list.on("velocity:change", ({ velocity }) => stats.onVelocity(velocity));
list.on("item:click", ({ item }) => {
showDetail(item);
});
// Restore scroll position to first visible item
if (firstVisibleIndex > 0) {
list.scrollToIndex(firstVisibleIndex, "start");
}
stats.update();
updateContext();
}
// Register createView so controls.js can call it
setCreateView(createView);
function createGridView(container, orientation, columns, gap) {
if (orientation === "horizontal") {
const innerHeight = container.clientHeight - 2;
const colWidth = (innerHeight - (columns - 1) * gap) / columns;
const height = Math.round(colWidth);
setList(
vlist({
container: "#grid-container",
ariaLabel: "Photo gallery",
orientation,
item: {
height,
width: (_index, ctx) =>
ctx ? Math.round(ctx.columnWidth * (4 / 3)) : 200,
template: itemTemplate,
},
items,
})
.use(withGrid({ columns, gap }))
.use(withScrollbar({ autoHide: true }))
.build(),
);
} else {
setList(
vlist({
container: "#grid-container",
ariaLabel: "Photo gallery",
orientation,
item: {
height: (_index, ctx) =>
ctx ? Math.round(ctx.columnWidth * ASPECT_RATIO) : 200,
template: itemTemplate,
},
items,
})
.use(withGrid({ columns, gap }))
.use(withScrollbar({ autoHide: true }))
.build(),
);
}
}
function createMasonryView(container, orientation, columns, gap) {
setList(
vlist({
container: "#grid-container",
ariaLabel: "Photo gallery",
orientation,
item: {
height: (_index, ctx) =>
ctx ? Math.round(ctx.columnWidth * items[_index].aspectRatio) : 200,
width:
orientation === "horizontal"
? (_index, ctx) =>
ctx
? Math.round(ctx.columnWidth * items[_index].aspectRatio)
: 200
: undefined,
template: itemTemplate,
},
items,
})
.use(withMasonry({ columns, gap }))
.use(withScrollbar({ autoHide: true }))
.build(),
);
}
// =============================================================================
// Photo detail (panel)
// =============================================================================
const detailEl = document.getElementById("photo-detail");
function showDetail(item) {
detailEl.innerHTML = `
<img
class="detail__img"
src="https://picsum.photos/id/${item.picId}/400/300"
alt="${item.title}"
/>
<div class="detail__meta">
<strong>${item.title}</strong>
<span>${item.category} · ♥ ${item.likes}</span>
</div>
`;
}
// =============================================================================
// Footer — right side (contextual)
// =============================================================================
const ftMode = document.getElementById("ft-mode");
const ftOrientation = document.getElementById("ft-orientation");
function updateContext() {
ftMode.textContent = currentMode;
ftOrientation.textContent = currentOrientation;
}
// =============================================================================
// Initialise
// =============================================================================
createView();
// Photo Album — Shared data, constants, template, and state
// Imported by all framework implementations to avoid duplication
// =============================================================================
// Constants
// =============================================================================
export const PHOTO_COUNT = 1084;
export const ITEM_COUNT = 600;
export const ASPECT_RATIO = 0.75; // 4:3 landscape
const CATEGORIES = [
"Nature",
"Urban",
"Portrait",
"Abstract",
"Travel",
"Food",
"Animals",
"Architecture",
"Art",
"Space",
];
// Variable aspect ratios for masonry mode (height/width)
const ASPECT_RATIOS = [0.75, 1.0, 1.33, 1.5, 0.66];
// =============================================================================
// Data
// =============================================================================
export const items = Array.from({ length: ITEM_COUNT }, (_, i) => {
const picId = i % PHOTO_COUNT;
const category = CATEGORIES[i % CATEGORIES.length];
return {
id: i + 1,
title: `Photo ${i + 1}`,
category,
likes: Math.floor(Math.abs(Math.sin(i * 2.1)) * 500),
picId,
aspectRatio: ASPECT_RATIOS[i % ASPECT_RATIOS.length],
};
});
// =============================================================================
// Template
// =============================================================================
export const itemTemplate = (item) => `
<div class="card">
<img
class="card__img"
src="https://picsum.photos/id/${item.picId}/300/225"
alt="${item.title}"
loading="lazy"
decoding="async"
/>
<div class="card__overlay">
<span class="card__title">${item.title}</span>
<span class="card__category">${item.category}</span>
</div>
<div class="card__likes">♥ ${item.likes}</div>
</div>
`;
// =============================================================================
// State — mutable, shared across script.js and controls.js
// =============================================================================
export let currentMode = "grid";
export let currentOrientation = "vertical";
export let currentColumns = 4;
export let currentGap = 8;
export let list = null;
export function setCurrentMode(v) {
currentMode = v;
}
export function setCurrentOrientation(v) {
currentOrientation = v;
}
export function setCurrentColumns(v) {
currentColumns = v;
}
export function setCurrentGap(v) {
currentGap = v;
}
export function setList(v) {
list = v;
}
// =============================================================================
// View lifecycle — set by each variant's script.js
// =============================================================================
let _createView = () => {};
export function createView() {
_createView();
}
export function setCreateView(fn) {
_createView = fn;
}
/* Photo Album — shared styles for all variants (javascript, react, vue, svelte)
Common styles (.container, h1, .description, .stats, footer)
are provided by example/example.css using shell.css design tokens.
Panel system (.split-layout, .split-panel, .panel-*)
is also provided by example/example.css. */
/* Grid container */
#grid-container {
height: 600px;
margin: 0 auto;
}
/* Horizontal orientation - swap dimensions */
#grid-container.vlist--horizontal {
height: 300px;
width: 100%;
}
#grid-container.vlist--horizontal .vlist-viewport {
overflow-x: auto;
overflow-y: hidden;
}
/* ============================================================================
Grid Info Bar
============================================================================ */
.grid-info {
padding: 8px 14px;
margin-bottom: 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-card);
font-size: 13px;
color: var(--text-muted);
}
.grid-info strong {
color: var(--text);
}
/* ============================================================================
Control Buttons (columns / gap)
============================================================================ */
.ctrl-btn {
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
min-width: 36px;
text-align: center;
}
.ctrl-btn:hover {
border-color: var(--accent);
color: var(--accent-text);
}
.ctrl-btn--active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* ============================================================================
Photo Card (inside grid items)
============================================================================ */
.card {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 8px;
background: var(--bg-card);
cursor: pointer;
}
.card__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.25s ease;
}
.card:hover .card__img {
transform: scale(1.05);
}
.card__overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 24px 8px 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
display: flex;
flex-direction: column;
gap: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.card:hover .card__overlay {
opacity: 1;
}
.card__title {
font-size: 12px;
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card__category {
font-size: 10px;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card__likes {
position: absolute;
top: 6px;
right: 6px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.5);
color: white;
font-size: 11px;
font-weight: 600;
opacity: 0;
transition: opacity 0.2s ease;
}
.card:hover .card__likes {
opacity: 1;
}
/* ============================================================================
Photo Detail (panel)
============================================================================ */
.detail__img {
width: 100%;
border-radius: 8px;
display: block;
margin-bottom: 8px;
}
.detail__meta {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.detail__meta strong {
font-weight: 600;
}
.detail__meta span {
font-size: 12px;
color: var(--text-muted);
}
/* ============================================================================
vlist grid overrides — remove item borders/padding for photo cards
============================================================================ */
#grid-container .vlist-item {
padding: 0;
border: none;
background: transparent;
}
#grid-container .vlist-item:hover {
background: transparent;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 820px) {
#grid-container {
height: 400px;
}
#grid-container.vlist--horizontal {
height: 250px;
}
}