UI Components Hard
Sticky Group Headers
Use manual windowing plus sticky group labels to keep long grouped content scannable.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--panel-2: #172239;
--line: #263555;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--row-h: 52px;
--head-h: 34px;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
-webkit-font-smoothing: antialiased;
}
.demo {
width: min(720px, 100%);
margin: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0004;
}
.demo__head h1 { margin: 0 0 .35rem; font-size: 1.35rem; letter-spacing: -0.01em; }
.demo__head p { margin: 0 0 1.1rem; font-size: .9rem; }
.muted { color: var(--muted); }
.stats { display: flex; gap: .6rem; flex-wrap: wrap; margin: 0 0 1rem; padding: 0; }
.stats div {
flex: 1 1 130px;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 12px;
padding: .5rem .75rem;
}
.stats dt { font-size: .68rem; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); }
.stats dd { margin: .15rem 0 0; font-size: 1rem; font-variant-numeric: tabular-nums; }
/* ---------- virtual list ---------- */
.vlist {
position: relative;
height: 440px;
overflow-y: auto;
overscroll-behavior: contain;
background: #101a30;
border: 1px solid var(--line);
border-radius: 16px;
scrollbar-color: #49618e transparent;
}
.vlist:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.vlist__sizer { position: relative; width: 100%; }
.vlist__slice { position: absolute; inset-inline: 0; top: 0; will-change: transform; }
.ghead,
.vlist__sticky {
height: var(--head-h);
display: flex;
align-items: center;
gap: .5rem;
padding: 0 1rem;
font-size: .68rem;
font-weight: 700;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--accent);
background: #16213a;
border-bottom: 1px solid var(--line);
}
.ghead__count { margin-left: auto; color: var(--muted); font-weight: 500; letter-spacing: .04em; }
.vlist__sticky {
position: sticky;
top: 0;
z-index: 2;
margin-bottom: calc(var(--head-h) * -1);
box-shadow: 0 8px 16px -10px #000;
}
.vlist__sticky:empty { display: none; }
/* ---------- rows ---------- */
.row {
height: var(--row-h);
display: flex;
align-items: center;
gap: .75rem;
padding: 0 1rem;
border-bottom: 1px solid #1c2740;
}
.row[aria-selected="true"] {
background: #1b2a46;
box-shadow: inset 3px 0 0 var(--accent);
}
.row__avatar {
flex: none;
width: 30px; height: 30px;
border-radius: 50%;
display: grid; place-items: center;
font-size: .72rem; font-weight: 700;
color: #0b1020;
background: var(--av, #78a9ff);
}
.row__name { font-size: .9rem; }
.row__meta { margin-left: auto; color: var(--muted); font-size: .78rem; font-variant-numeric: tabular-nums; }
.hint { margin: .9rem 0 0; color: var(--muted); font-size: .82rem; }
kbd {
background: #16213a; border: 1px solid var(--line);
border-radius: 5px; padding: 0 .3rem; font: inherit; font-size: .78rem;
}
@media (prefers-reduced-motion: reduce) {
.vlist { scroll-behavior: auto; }
* { transition: none !important; animation: none !important; }
}/* Sticky Group Headers — manual windowing over a flattened grouped list.
Zero dependencies. Heights are explicit so offset math stays exact. */
const ROW_H = 52;
const HEAD_H = 34;
const OVERSCAN = 4;
const viewport = document.querySelector("[data-viewport]");
const sizer = document.querySelector("[data-sizer]");
const slice = document.querySelector("[data-slice]");
const sticky = document.querySelector("[data-sticky]");
const stat = (k) => document.querySelector(`[data-stat="${k}"]`);
/* ---------- fake data: 26 groups x ~230 rows ---------- */
const FIRST = ["Ada", "Bo", "Cyd", "Dev", "Eli", "Fay", "Gus", "Hana", "Ivo", "Jun", "Kai", "Lena", "Mira", "Noa", "Oz", "Pia", "Quin", "Rey", "Sol", "Tao"];
const LAST = ["Aster", "Brook", "Chen", "Duarte", "Eriks", "Fontan", "Grave", "Hollis", "Imani", "Jarv", "Kessler", "Lund", "Marek", "Novak", "Ortiz", "Pike", "Rossi", "Stein", "Tamm", "Vance"];
const HUES = [212, 268, 160, 32, 340, 190];
const groups = [];
for (let g = 0; g < 26; g++) {
const letter = String.fromCharCode(65 + g);
const count = 180 + ((g * 37) % 90);
const rows = [];
for (let i = 0; i < count; i++) {
const f = FIRST[(g * 7 + i * 3) % FIRST.length];
const l = LAST[(g * 11 + i * 5) % LAST.length];
rows.push({
name: `${letter}${f.slice(1)} ${l}`,
hue: HUES[(g + i) % HUES.length],
seen: `${1 + ((i * 13) % 28)}d ago`,
});
}
groups.push({ label: `Group ${letter}`, rows });
}
/* ---------- flatten + prefix offsets ---------- */
const items = []; // { kind, groupIndex, data }
for (let g = 0; g < groups.length; g++) {
items.push({ kind: "head", groupIndex: g, data: groups[g] });
for (const r of groups[g].rows) items.push({ kind: "row", groupIndex: g, data: r });
}
const offsets = new Float64Array(items.length + 1);
for (let i = 0; i < items.length; i++) {
offsets[i + 1] = offsets[i] + (items[i].kind === "head" ? HEAD_H : ROW_H);
}
const totalHeight = offsets[items.length];
const rowCount = items.length - groups.length;
sizer.style.height = `${totalHeight}px`;
stat("total").textContent = rowCount.toLocaleString();
/** First index whose bottom edge is > y. */
function indexAt(y) {
let lo = 0;
let hi = items.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (offsets[mid + 1] <= y) lo = mid + 1;
else hi = mid;
}
return lo;
}
/* ---------- rendering ---------- */
let activeIndex = 1; // first row (index 0 is a header)
let rendered = { start: -1, end: -1 };
function buildNode(item, index) {
if (item.kind === "head") {
const h = document.createElement("div");
h.className = "ghead";
h.innerHTML = `<span></span><span class="ghead__count"></span>`;
h.firstChild.textContent = item.data.label;
h.lastChild.textContent = `${item.data.rows.length} people`;
return h;
}
const r = document.createElement("div");
r.className = "row";
r.id = `vrow-${index}`;
r.setAttribute("role", "option");
r.setAttribute("aria-selected", index === activeIndex ? "true" : "false");
r.style.setProperty("--av", `hsl(${item.data.hue} 80% 70%)`);
r.innerHTML = `<span class="row__avatar"></span><span class="row__name"></span><span class="row__meta"></span>`;
r.children[0].textContent = item.data.name[0];
r.children[1].textContent = item.data.name;
r.children[2].textContent = item.data.seen;
return r;
}
function render() {
const top = viewport.scrollTop;
const bottom = top + viewport.clientHeight;
let start = Math.max(0, indexAt(top) - OVERSCAN);
let end = Math.min(items.length - 1, indexAt(bottom) + OVERSCAN);
if (start !== rendered.start || end !== rendered.end) {
const frag = document.createDocumentFragment();
for (let i = start; i <= end; i++) frag.append(buildNode(items[i], i));
slice.replaceChildren(frag);
slice.style.transform = `translateY(${offsets[start]}px)`;
rendered = { start, end };
stat("dom").textContent = String(end - start + 1);
}
// sticky label: the group owning the row at the top edge
const g = items[indexAt(top)].groupIndex;
const label = `${groups[g].label} · ${groups[g].rows.length} people`;
if (sticky.textContent !== label) {
sticky.textContent = label;
stat("group").textContent = groups[g].label;
}
}
let ticking = false;
viewport.addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { ticking = false; render(); });
}, { passive: true });
new ResizeObserver(() => render()).observe(viewport);
/* ---------- keyboard navigation ---------- */
function setActive(index) {
const next = Math.min(items.length - 1, Math.max(0, index));
activeIndex = items[next].kind === "head" ? Math.min(items.length - 1, next + 1) : next;
const top = offsets[activeIndex];
const bottom = top + ROW_H;
if (top - HEAD_H < viewport.scrollTop) viewport.scrollTop = Math.max(0, top - HEAD_H);
else if (bottom > viewport.scrollTop + viewport.clientHeight) {
viewport.scrollTop = bottom - viewport.clientHeight;
}
render();
for (const el of slice.children) {
if (el.classList.contains("row")) {
el.setAttribute("aria-selected", el.id === `vrow-${activeIndex}` ? "true" : "false");
}
}
viewport.setAttribute("aria-activedescendant", `vrow-${activeIndex}`);
}
const page = () => Math.max(1, Math.floor(viewport.clientHeight / ROW_H) - 1);
viewport.addEventListener("keydown", (e) => {
const moves = {
ArrowDown: () => activeIndex + 1,
ArrowUp: () => activeIndex - 1,
PageDown: () => activeIndex + page(),
PageUp: () => activeIndex - page(),
Home: () => 0,
End: () => items.length - 1,
};
const fn = moves[e.key];
if (!fn) return;
e.preventDefault();
setActive(fn());
});
viewport.addEventListener("click", (e) => {
const row = e.target.closest(".row");
if (row) setActive(Number(row.id.slice(5)));
});
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sticky Group Headers</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Sticky Group Headers">
<header class="demo__head">
<h1>Sticky Group Headers</h1>
<p class="muted">Manual windowing over thousands of grouped rows — only the visible slice is in the DOM.</p>
<dl class="stats">
<div><dt>Rows total</dt><dd data-stat="total">0</dd></div>
<div><dt>Nodes in DOM</dt><dd data-stat="dom">0</dd></div>
<div><dt>Current group</dt><dd data-stat="group">—</dd></div>
</dl>
</header>
<div
class="vlist"
data-viewport
role="listbox"
tabindex="0"
aria-label="Grouped contact list, virtualized"
aria-activedescendant=""
>
<div class="vlist__sticky" data-sticky aria-hidden="true"></div>
<div class="vlist__sizer" data-sizer>
<div class="vlist__slice" data-slice></div>
</div>
</div>
<p class="hint">Scroll the list, or focus it and use <kbd>↑</kbd>/<kbd>↓</kbd>, <kbd>PageUp</kbd>/<kbd>PageDown</kbd>, <kbd>Home</kbd>/<kbd>End</kbd>.</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useMemo, useState } from "react";
export function VirtualScrollStickyGroups() {
const [offset, setOffset] = useState(0);
const rows = useMemo(() => Array.from({ length: 20 }, (_, index) => index + offset), [offset]);
return (
<section className="demo">
<h2>Sticky Group Headers</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>
);
}Sticky Group Headers
Use manual windowing plus sticky group labels to keep long grouped content scannable.
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.