UI Components Hard
Windowed Card Grid
A virtual card grid computes the visible slice from scroll position and column count.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* Windowed Card Grid */
*, *::before, *::after { box-sizing: border-box; }
:root {
--bg: #0b1020;
--panel: #18233b;
--panel-2: #121a2d;
--line: #2b3d62;
--ink: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--card-h: 128px;
--gap: 14px;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: clamp(1rem, 4vw, 3rem);
-webkit-font-smoothing: antialiased;
}
button, input { font: inherit; }
.demo {
width: min(980px, 100%);
margin: auto;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0004;
}
.grid-head {
display: flex; flex-wrap: wrap; gap: 16px;
align-items: flex-end; justify-content: space-between;
margin-bottom: 18px;
}
h1 { margin: 0 0 4px; font-size: 1.3rem; letter-spacing: -0.02em; }
.sub { margin: 0; color: var(--muted); font-size: .9rem; }
.sub strong { color: var(--ink); font-variant-numeric: tabular-nums; }
.stats { display: flex; gap: 10px; margin: 0; }
.stats div {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px 12px;
min-width: 84px;
}
.stats dt { font-size: .64rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.stats dd { margin: 2px 0 0; font-size: 1rem; font-variant-numeric: tabular-nums; }
.controls {
display: flex; align-items: center; gap: 8px;
margin-bottom: 12px; font-size: .85rem; color: var(--muted);
}
.controls input {
width: 100px; background: var(--panel); border: 1px solid var(--line);
color: var(--ink); border-radius: 8px; padding: 7px 9px;
}
.controls button {
background: var(--accent); color: #08101f; border: 0;
border-radius: 8px; padding: 7px 15px; font-weight: 650; cursor: pointer;
}
.controls input:focus-visible,
.controls button:focus-visible,
.viewport:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.viewport {
height: 440px;
overflow-y: auto;
overscroll-behavior: contain;
background: #0d1426;
border: 1px solid var(--line);
border-radius: 16px;
padding: var(--gap);
scrollbar-color: #3a4b73 transparent;
}
.sizer { position: relative; width: 100%; }
.window {
position: absolute;
inset-inline: 0;
top: 0;
display: grid;
gap: var(--gap);
grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
}
.card {
height: var(--card-h);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
display: flex; flex-direction: column; gap: 8px;
animation: pop 170ms ease-out;
}
.card-top { display: flex; align-items: center; gap: 9px; }
.avatar {
width: 30px; height: 30px; border-radius: 9px;
display: grid; place-items: center;
font-size: .7rem; font-weight: 700; color: #0b1020;
background: hsl(var(--hue, 215) 80% 68%);
}
.card-id { font-size: .7rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.card-title { font-size: .92rem; font-weight: 600; }
.card-meta { margin-top: auto; display: flex; align-items: center; gap: 10px; font-size: .72rem; color: var(--muted); }
.bar { flex: 1; height: 4px; border-radius: 3px; background: #253355; overflow: hidden; }
.bar > i { display: block; height: 100%; background: hsl(var(--hue, 215) 80% 64%); }
.hint { margin: 12px 0 0; font-size: .8rem; color: var(--muted); }
@keyframes pop { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
}
@media (max-width: 600px) {
.demo { border-radius: 16px; }
.stats { flex-wrap: wrap; }
}/* Windowed Card Grid — manual virtualization over a responsive column grid. */
(() => {
const TOTAL = 5000;
const MIN_CARD_W = 210;
const OVERSCAN = 2; // extra rows above and below the viewport
const viewport = document.getElementById("viewport");
const sizer = document.getElementById("sizer");
const win = document.getElementById("window");
const statRendered = document.getElementById("statRendered");
const statCols = document.getElementById("statCols");
const statRows = document.getElementById("statRows");
const jump = document.getElementById("jump");
const jumpBtn = document.getElementById("jumpBtn");
document.getElementById("totalCount").textContent = TOTAL.toLocaleString();
jump.max = String(TOTAL);
const px = (name) =>
parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name)) || 0;
// Deterministic pseudo-random data so cards look stable while scrolling.
const NOUNS = ["Report", "Cluster", "Segment", "Cohort", "Pipeline", "Snapshot", "Batch", "Signal"];
const record = (i) => {
const h = (i * 2654435761) % 4294967296;
return {
id: i,
hue: h % 360,
name: `${NOUNS[h % NOUNS.length]} ${String(i + 1).padStart(4, "0")}`,
pct: 5 + (h % 95),
};
};
let cols = 0;
let rows = 0;
let rowH = 0;
let firstRow = -1;
let lastRow = -1;
const pool = new Map(); // index -> element (reused across renders)
function buildCard(i) {
const d = record(i);
const el = document.createElement("article");
el.className = "card";
el.style.setProperty("--hue", d.hue);
el.setAttribute("aria-posinset", i + 1);
el.setAttribute("aria-setsize", TOTAL);
el.innerHTML =
'<div class="card-top">' +
`<span class="avatar" aria-hidden="true">${d.name.slice(0, 2).toUpperCase()}</span>` +
`<span><span class="card-title">${d.name}</span><br><span class="card-id">#${i + 1}</span></span>` +
"</div>" +
'<div class="card-meta">' +
`<span class="bar"><i style="width:${d.pct}%"></i></span><span>${d.pct}%</span>` +
"</div>";
return el;
}
function measure() {
const gap = px("--gap");
const cardH = px("--card-h");
const inner = viewport.clientWidth - 2 * gap;
const nextCols = Math.max(1, Math.floor((inner + gap) / (MIN_CARD_W + gap)));
rowH = cardH + gap;
if (nextCols !== cols) {
cols = nextCols;
win.style.setProperty("--cols", cols);
rows = Math.ceil(TOTAL / cols);
sizer.style.height = `${rows * rowH - gap}px`;
firstRow = lastRow = -1; // force a full re-render
pool.forEach((el) => el.remove());
pool.clear();
}
}
function render() {
const viewRows = Math.ceil(viewport.clientHeight / rowH);
const start = Math.max(0, Math.floor(viewport.scrollTop / rowH) - OVERSCAN);
const end = Math.min(rows - 1, start + viewRows + OVERSCAN * 2);
if (start === firstRow && end === lastRow) return;
firstRow = start;
lastRow = end;
const from = start * cols;
const to = Math.min(TOTAL - 1, (end + 1) * cols - 1);
// Recycle: keep nodes still in range, build only the newly visible ones,
// then re-append in index order (append moves existing nodes, no rebuild).
pool.forEach((el, i) => {
if (i < from || i > to) {
el.remove();
pool.delete(i);
}
});
const frag = document.createDocumentFragment();
for (let i = from; i <= to; i++) {
let el = pool.get(i);
if (!el) {
el = buildCard(i);
pool.set(i, el);
}
frag.append(el);
}
win.append(frag);
win.style.transform = `translateY(${start * rowH}px)`;
statRendered.textContent = pool.size;
statCols.textContent = cols;
statRows.textContent = `${start + 1}–${end + 1} / ${rows}`;
}
let ticking = false;
const schedule = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
render();
});
};
viewport.addEventListener("scroll", schedule, { passive: true });
new ResizeObserver(() => {
measure();
render();
}).observe(viewport);
function scrollToIndex(i) {
const clamped = Math.min(TOTAL - 1, Math.max(0, i));
viewport.scrollTop = Math.floor(clamped / cols) * rowH;
schedule();
}
jumpBtn.addEventListener("click", () => scrollToIndex((parseInt(jump.value, 10) || 1) - 1));
jump.addEventListener("keydown", (e) => {
if (e.key === "Enter") jumpBtn.click();
});
viewport.addEventListener("keydown", (e) => {
const page = Math.max(1, Math.floor(viewport.clientHeight / rowH) - 1);
const row = Math.round(viewport.scrollTop / rowH);
const go = (r) => {
viewport.scrollTop = Math.max(0, Math.min(rows - 1, r)) * rowH;
schedule();
e.preventDefault();
};
if (e.key === "ArrowDown") go(row + 1);
else if (e.key === "ArrowUp") go(row - 1);
else if (e.key === "PageDown") go(row + page);
else if (e.key === "PageUp") go(row - page);
else if (e.key === "Home") go(0);
else if (e.key === "End") go(rows - 1);
});
measure();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Windowed Card Grid</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Windowed Card Grid">
<header class="grid-head">
<div>
<h1>Windowed card grid</h1>
<p class="sub">Only the visible slice of <strong id="totalCount">5,000</strong> cards exists in the DOM.</p>
</div>
<dl class="stats" aria-live="polite">
<div><dt>Rendered</dt><dd id="statRendered">0</dd></div>
<div><dt>Columns</dt><dd id="statCols">0</dd></div>
<div><dt>Row range</dt><dd id="statRows">0</dd></div>
</dl>
</header>
<div class="controls">
<label for="jump">Jump to card</label>
<input id="jump" type="number" min="1" max="5000" step="1" value="1" />
<button id="jumpBtn" type="button">Go</button>
</div>
<div id="viewport" class="viewport" tabindex="0" role="region"
aria-label="Virtualized card grid, scrollable list of 5000 cards">
<div id="sizer" class="sizer">
<div id="window" class="window"></div>
</div>
</div>
<p class="hint">Scroll the grid, or focus it and use Arrow keys, PageUp/PageDown, Home/End.</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useMemo, useState } from "react";
export function VirtualScrollCardGrid() {
const [offset, setOffset] = useState(0);
const rows = useMemo(() => Array.from({ length: 20 }, (_, index) => index + offset), [offset]);
return (
<section className="demo">
<h2>Windowed Card Grid</h2>
<div
style={{ height: 260, overflow: "auto" }}
onScroll={(event) => setOffset(Math.floor(event.currentTarget.scrollTop / 42))}
>
{rows.map((row) => (
<div key={row} style={{ height: 42, padding: 10, borderBottom: "1px solid #2b3d62" }}>
Row {row + 1}
</div>
))}
</div>
</section>
);
}Windowed Card Grid
A virtual card grid computes the visible slice from scroll position and column count.
Support notes
Manual windowing keeps DOM work proportional to the viewport. IntersectionObserver is used for the feed sentinel; row heights are intentionally explicit so the math stays predictable.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.