gridmasonryselectionscrollbar
Source
// Photo Album — React variant
// Uses useVList hook from vlist-react with declarative layout config
// Layout mode toggle: Grid ↔ Masonry
import { useState, useCallback, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { useVList, useVListEvent } from "vlist-react";
import { ITEM_COUNT, ASPECT_RATIO, items, itemTemplate } from "../shared.js";
import { createStats } from "../../stats.js";
import { createInfoUpdater } from "../../info.js";
// =============================================================================
// Stats (module-level — shared across remounts)
// =============================================================================
let statsInstance: ReturnType<typeof createStats> | null = null;
let infoUpdater: (() => void) | null = null;
// =============================================================================
// Grid Container — keyed component that remounts on config change
// =============================================================================
function GridContainer({
mode,
orientation,
columns,
gap,
onItem,
}: {
mode: "grid" | "masonry";
orientation: "vertical" | "horizontal";
columns: number;
gap: number;
onItem: (item: any) => void;
}) {
const itemConfig = getItemConfig(mode, orientation, columns, gap);
const layoutConfig =
mode === "masonry"
? { layout: "masonry" as const, masonry: { columns, gap } }
: { layout: "grid" as const, grid: { columns, gap } };
const { containerRef, instanceRef } = useVList({
ariaLabel: "Photo gallery",
orientation,
...layoutConfig,
item: itemConfig,
items,
scroll: {
scrollbar: { autoHide: true },
},
});
// Wire stats
useEffect(() => {
if (!statsInstance) {
statsInstance = createStats({
getScrollPosition: () => instanceRef.current?.getScrollPosition() ?? 0,
getTotal: () => ITEM_COUNT,
getItemSize: () => {
const el = document.getElementById("grid-container");
if (!el) return 200;
const innerWidth = el.clientWidth - 2;
const colW = (innerWidth - (columns - 1) * gap) / columns;
return mode === "masonry"
? Math.round(colW * 1.05)
: Math.round(colW * ASPECT_RATIO);
},
getColumns: () => columns,
getContainerSize: () => {
const el = document.getElementById("grid-container");
if (!el) return 0;
return orientation === "horizontal"
? el.clientWidth
: el.clientHeight;
},
});
}
if (!infoUpdater) {
infoUpdater = createInfoUpdater(statsInstance);
}
infoUpdater();
}, []);
useVListEvent(instanceRef, "scroll", () => {
infoUpdater?.();
});
useVListEvent(instanceRef, "range:change", () => {
infoUpdater?.();
});
useVListEvent(instanceRef, "velocity:change", ({ velocity }) => {
statsInstance?.onVelocity(velocity);
infoUpdater?.();
});
useVListEvent(instanceRef, "item:click", ({ item }) => {
onItem(item);
});
return <div ref={containerRef} id="grid-container" />;
}
// =============================================================================
// Item config helper
// =============================================================================
function getItemConfig(
mode: string,
orientation: string,
columns: number,
gap: number,
) {
if (mode === "masonry") {
return {
height: (_index: number, ctx: any) =>
ctx ? Math.round(ctx.columnWidth * items[_index].aspectRatio) : 200,
width:
orientation === "horizontal"
? (_index: number, ctx: any) =>
ctx
? Math.round(ctx.columnWidth * items[_index].aspectRatio)
: 200
: undefined,
template: itemTemplate,
};
}
// Grid — horizontal needs fixed cross-axis height
if (orientation === "horizontal") {
return {
height: 200, // will be overridden by grid renderer
width: (_index: number, ctx: any) =>
ctx ? Math.round(ctx.columnWidth * (4 / 3)) : 200,
template: itemTemplate,
};
}
return {
height: (_index: number, ctx: any) =>
ctx ? Math.round(ctx.columnWidth * ASPECT_RATIO) : 200,
template: itemTemplate,
};
}
// =============================================================================
// App Component
// =============================================================================
function App() {
const [mode, setMode] = useState<"grid" | "masonry">("grid");
const [orientation, setOrientation] = useState<"vertical" | "horizontal">(
"vertical",
);
const [columns, setColumns] = useState(4);
const [gap, setGap] = useState(8);
const [selectedPhoto, setSelectedPhoto] = useState<any>(null);
// Key forces full remount when layout config changes
const listKey = `${mode}-${orientation}-${columns}-${gap}`;
// Update info bar context
useEffect(() => {
const infoMode = document.getElementById("info-mode");
const infoOrientation = document.getElementById("info-orientation");
if (infoMode) infoMode.textContent = mode;
if (infoOrientation) infoOrientation.textContent = orientation;
}, [mode, orientation]);
const scrollTo = useCallback((target: "first" | "middle" | "last") => {
const el = document.querySelector("#grid-container .vlist-viewport");
if (!el) return;
// Access instance via DOM — the useVList hook manages it internally
const idx =
target === "first"
? 0
: target === "middle"
? Math.floor(ITEM_COUNT / 2)
: ITEM_COUNT - 1;
// Use the instance ref from the child — we need a different approach
// Dispatch a custom event that the container can listen for
window.dispatchEvent(
new CustomEvent("photo-album:scroll-to", { detail: { index: idx } }),
);
}, []);
return (
<div className="container">
<header>
<h1>Photo Album</h1>
<p className="description">
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.
</p>
</header>
<div className="split-layout">
<div className="split-main split-main--full">
<h2 className="sr-only">Photos</h2>
<GridContainer
key={listKey}
mode={mode}
orientation={orientation}
columns={columns}
gap={gap}
onItem={setSelectedPhoto}
/>
</div>
<aside className="split-panel">
{/* Layout */}
<section className="ui-section">
<h3 className="ui-title">Layout</h3>
<div className="ui-row">
<label className="ui-label">Mode</label>
<div className="ui-segmented">
{(["grid", "masonry"] as const).map((m) => (
<button
key={m}
className={`ui-segmented__btn${m === mode ? " ui-segmented__btn--active" : ""}`}
onClick={() => setMode(m)}
>
{m === "grid" ? "Grid" : "Masonry"}
</button>
))}
</div>
</div>
<div className="ui-row">
<label className="ui-label">Orientation</label>
<div className="ui-segmented">
{(["vertical", "horizontal"] as const).map((o) => (
<button
key={o}
className={`ui-segmented__btn${o === orientation ? " ui-segmented__btn--active" : ""}`}
onClick={() => setOrientation(o)}
>
{o === "vertical" ? "Vertical" : "Horizontal"}
</button>
))}
</div>
</div>
<div className="ui-row">
<label className="ui-label">
{orientation === "horizontal" ? "Rows" : "Columns"}
</label>
<div className="ui-btn-group">
{[3, 4, 5, 6, 10].map((c) => (
<button
key={c}
className={`ui-ctrl-btn${c === columns ? " ui-ctrl-btn--active" : ""}`}
onClick={() => setColumns(c)}
>
{c}
</button>
))}
</div>
</div>
<div className="ui-row">
<label className="ui-label">Gap</label>
<div className="ui-btn-group">
{[0, 4, 8, 12, 16].map((g) => (
<button
key={g}
className={`ui-ctrl-btn${g === gap ? " ui-ctrl-btn--active" : ""}`}
onClick={() => setGap(g)}
>
{g}
</button>
))}
</div>
</div>
</section>
{/* Navigation */}
<section className="ui-section">
<h3 className="ui-title">Navigation</h3>
<div className="ui-row">
<div className="ui-btn-group">
<button
className="ui-btn ui-btn--icon"
title="First"
onClick={() => scrollTo("first")}
>
<i className="icon icon--up" />
</button>
<button
className="ui-btn ui-btn--icon"
title="Middle"
onClick={() => scrollTo("middle")}
>
<i className="icon icon--center" />
</button>
<button
className="ui-btn ui-btn--icon"
title="Last"
onClick={() => scrollTo("last")}
>
<i className="icon icon--down" />
</button>
</div>
</div>
</section>
{/* Photo Detail */}
<section className="ui-section">
<h3 className="ui-title">Last clicked</h3>
<div className="ui-detail">
{selectedPhoto ? (
<>
<img
className="detail__img"
src={`https://picsum.photos/id/${selectedPhoto.picId}/400/300`}
alt={selectedPhoto.title}
/>
<div className="detail__meta">
<strong>{selectedPhoto.title}</strong>
<span>
{selectedPhoto.category} · ♥ {selectedPhoto.likes}
</span>
</div>
</>
) : (
<span className="ui-detail__empty">
Click a photo to see details
</span>
)}
</div>
</section>
</aside>
</div>
<div className="example-info" id="example-info">
<div className="example-info__left">
<span className="example-info__stat">
<strong id="info-progress">0%</strong>
</span>
<span className="example-info__stat">
<span id="info-velocity">0.00</span> /{" "}
<strong id="info-velocity-avg">0.00</strong>
<span className="example-info__unit">px/ms</span>
</span>
<span className="example-info__stat">
<span id="info-dom">0</span> / <strong id="info-total">0</strong>
<span className="example-info__unit">items</span>
</span>
</div>
<div className="example-info__right">
<span className="example-info__stat">
<strong id="info-mode">grid</strong>
</span>
<span className="example-info__stat">
<strong id="info-orientation">vertical</strong>
</span>
</div>
</div>
</div>
);
}
// =============================================================================
// Mount
// =============================================================================
createRoot(document.getElementById("react-root")!).render(<App />);
<div id="react-root"></div>
// Photo Album — Shared data, constants, template, and state
// Imported by all framework implementations to avoid duplication
// =============================================================================
// Constants
// =============================================================================
export const ITEM_COUNT = 900;
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];
// =============================================================================
// Valid Picsum IDs — excludes first 8 and all known 404s
// =============================================================================
const DEAD_IDS = new Set([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 86, 97, 105, 138, 148, 150, 205,
207, 224, 226, 245, 246, 262, 285, 286, 298, 303, 332, 333, 346, 359, 394,
414, 422, 438, 462, 463, 470, 489, 540, 561, 578, 587, 589, 592, 595, 597,
601, 624, 632, 636, 644, 647, 673, 697, 706, 707, 708, 709, 710, 711, 712,
713, 714, 720, 725, 734, 745, 746, 747, 748, 749, 750, 751, 752, 753, 754,
759, 761, 762, 763, 771, 792, 801, 812, 843, 850, 854, 895, 897, 899, 917,
920, 934, 956, 963, 968, 1007, 1017, 1030, 1034, 1046,
]);
const VALID_IDS = [];
for (let id = 0; id <= 1084; id++) {
if (!DEAD_IDS.has(id)) VALID_IDS.push(id);
}
// =============================================================================
// Data
// =============================================================================
export const items = Array.from({ length: ITEM_COUNT }, (_, i) => {
const picId = VALID_IDS[i % VALID_IDS.length];
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"
data-t="${performance.now()}"
onload="if(performance.now()-this.dataset.t<100){this.style.transition='none';this.offsetHeight}this.classList.add('card__img--loaded')"
onerror="this.style.transition='none';this.classList.add('card__img--loaded')"
/>
<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.
UI components (.split-layout, .split-panel, .ui-*)
is also provided by example/example.css. */
.split-main {
background-color: transparent !important;
}
/* Grid container */
#grid-container {
height: 600px;
margin: 0 auto;
}
#grid-container .vlist {
border: none;
background-color: transparent;
}
/* 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 {
/* surface via .ui-card .ui-card--compact */
margin-bottom: 16px;
font-size: 13px;
color: var(--text-muted);
}
.grid-info strong {
color: var(--text);
}
/* ============================================================================
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;
opacity: 0;
transition:
opacity 0.4s ease,
transform 0.25s ease;
}
.card__img.card__img--loaded {
opacity: 1;
}
.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;
}
#grid-container .vlist-item.vlist-item--focused {
outline-offset: 0;
border-radius: 10px;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 820px) {
#grid-container {
height: 400px;
}
#grid-container.vlist--horizontal {
height: 250px;
}
}