Rendering Long Lists in a Popup
Show hundreds or thousands of items in a 600-pixel extension popup without a slow open — windowed rendering, content-visibility, keyboard navigation and restoring scroll position.
Table of Contents
A reading list with 2,000 saved articles, a tab manager with 150 open tabs, a history of every blocked request — the data is ordinary and the surface is not. A popup is capped at roughly 800 by 600 pixels, is rebuilt from nothing on every open, and is judged by how fast it appears. Rendering all 2,000 rows into the DOM before first paint makes the popup visibly slow to open, every time. This guide is part of popup interface design.
Where the time goes
Step-by-step
1. Cap the first paint
Before any technique, the cheapest fix: render the first screenful immediately and the rest afterwards.
1const FIRST = 30;
2
3function renderList(items) {
4 list.replaceChildren(...items.slice(0, FIRST).map(row));
5 if (items.length > FIRST) {
6 requestIdleCallback(() => list.append(...items.slice(FIRST).map(row)));
7 }
8}
Execution context: the popup document. The popup paints after thirty rows, and the remainder is appended when the browser is idle. On Safari, where requestIdleCallback is missing, a setTimeout(…, 0) is an acceptable substitute because the popup owns its own event loop. This alone is enough for lists of a few hundred items.
2. Let the browser skip off-screen rows
content-visibility: auto tells the browser it may skip layout and paint for rows outside the viewport, while keeping them in the DOM for search and accessibility.
1.row {
2 content-visibility: auto;
3 contain-intrinsic-size: auto 44px; /* the row's real height, so the scrollbar is right */
4}
Execution context: the popup stylesheet. contain-intrinsic-size must match the row height or the scrollbar jumps as rows come into view. The DOM still contains every row, so creating 5,000 elements still costs — this helps layout and paint, not node creation.
3. Window the list for thousands of rows
For genuinely large lists, keep only the visible rows plus a small buffer in the DOM and move them as the user scrolls.
1const ROW = 44, BUFFER = 8;
2
3function windowed(container, items, render) {
4 const spacer = document.createElement("div");
5 spacer.style.height = `${items.length * ROW}px`;
6 spacer.style.position = "relative";
7 container.replaceChildren(spacer);
8
9 function paint() {
10 const top = container.scrollTop;
11 const start = Math.max(0, Math.floor(top / ROW) - BUFFER);
12 const end = Math.min(items.length, Math.ceil((top + container.clientHeight) / ROW) + BUFFER);
13 spacer.replaceChildren(...items.slice(start, end).map((item, i) => {
14 const el = render(item);
15 el.style.cssText = `position:absolute;top:${(start + i) * ROW}px;left:0;right:0;height:${ROW}px`;
16 el.setAttribute("aria-setsize", String(items.length));
17 el.setAttribute("aria-posinset", String(start + i + 1));
18 return el;
19 }));
20 }
21 container.addEventListener("scroll", () => requestAnimationFrame(paint), { passive: true });
22 paint();
23}
Execution context: the popup document. aria-setsize and aria-posinset tell a screen reader the row’s position in the full list (“item 412 of 2,000”) even though only thirty exist. The trade-off is that browser find-in-page cannot see rows that are not rendered — which is why the list needs its own search box.
4. Keep keyboard navigation working
Windowing breaks naive focus: the focused row can scroll out of the DOM and be destroyed. Track the active index, not the element.
1let active = 0;
2
3container.addEventListener("keydown", (e) => {
4 if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
5 e.preventDefault();
6 active = Math.max(0, Math.min(items.length - 1, active + (e.key === "ArrowDown" ? 1 : -1)));
7 const rowTop = active * ROW;
8 if (rowTop < container.scrollTop) container.scrollTop = rowTop;
9 if (rowTop + ROW > container.scrollTop + container.clientHeight) container.scrollTop = rowTop + ROW - container.clientHeight;
10 requestAnimationFrame(() => container.querySelector(`[aria-posinset="${active + 1}"]`)?.focus());
11});
Execution context: the popup document. Scrolling first and focusing after the next paint means the target row exists when focus() is called. The container should have role="listbox" or role="list" and a label; the keyboard conventions are in making popups and options keyboard navigable.
5. Restore scroll position on reopen
The popup is destroyed on every close, so a user scrolled halfway down 2,000 items loses their place every time — unless you save it.
1addEventListener("pagehide", () => {
2 chrome.storage.session.set({ listScroll: container.scrollTop, listActive: active });
3});
4
5const { listScroll = 0, listActive = 0 } = await chrome.storage.session.get(["listScroll", "listActive"]);
6container.scrollTop = listScroll;
7active = listActive;
Execution context: the popup document. Restoring before the first paint avoids a visible jump. The lifecycle this compensates for is described in why the popup closes and how to work with it.
Loading the data is the other half
A fast renderer does not help if the popup waits on a worker round trip for the data first. For long lists the data layer matters as much as the DOM.
Keep a small render-ready summary in chrome.storage.session — the first thirty items, pre-shaped for display — and render it immediately. Load the full list afterwards, from IndexedDB through a message to the worker or directly from the popup, and swap it in once it arrives. The popup opens in the time it takes to render thirty rows from memory, and the rest of the list appears before the user has finished reading the first screen.
1const { listHead = [] } = await chrome.storage.session.get("listHead");
2renderList(listHead); // frame 1: real rows, no wait
3
4const full = await chrome.runtime.sendMessage({ type: "list:all" }).catch(() => null);
5if (full) windowed(container, full, row); // replace once available
Execution context: the popup document. The worker keeps listHead current whenever the list changes. The broader pattern — render from a stored summary, correct from the worker — is in loading popup data without a flash of empty UI, and the storage choice for the full list is in choosing between chrome.storage and IndexedDB.
Cross-browser variation
- Chrome / Edge:
content-visibilityandcontain-intrinsic-size: autoare supported;requestIdleCallbackis available in the popup. Popups are capped at 800 × 600 pixels. - Firefox: supports
content-visibilityfrom Firefox 125 andrequestIdleCallback. Popup size limits are similar; Firefox may show a scrollbar on the popup itself if the body exceeds the cap. - Safari:
content-visibilityfrom Safari 18; norequestIdleCallback. Windowed rendering is the most reliable approach on Safari for large lists. - All three: a windowed list hides unrendered rows from the browser’s find-in-page. Provide a search field for any list long enough to need windowing.
Verification
- Measure open time with the full dataset:
1performance.mark("open");
2renderList(listHead);
3requestAnimationFrame(() => {
4 performance.measure("first-paint", "open");
5 console.debug(performance.getEntriesByName("first-paint")[0].duration);
6});
7// 18.2
Execution context: the popup’s DevTools console (right-click the action → Inspect popup). Anything over about 50 ms on a mid-range machine will be noticed as lag.
- Count DOM rows while scrolling a windowed list:
container.querySelectorAll(".row").lengthshould stay near 30 regardless of position. - Arrow-key from the top to row 500 and confirm focus follows and the screen reader announces “500 of 2,000”.
- Scroll halfway, close the popup, reopen it, and confirm the position is restored.
FAQ
Should I use a virtual-list library?
For a React or Svelte popup, a maintained virtual-list component saves effort. For a plain popup, the forty lines above avoid a dependency in a surface where bundle size directly affects open time.
Why does the scrollbar jump with content-visibility?
contain-intrinsic-size does not match the real row height. Measure one row and use that value.
Is a popup the right surface for thousands of items?
Often not. Show the most relevant thirty and link to an extension page in a tab for the full list — the tab has room for search, filters and bulk actions.
Related
- Loading popup data without a flash of empty UI — getting the first rows on screen instantly.
- Popup loading and empty states — what to show before and without data.
- Fixing popup size and overflow issues — the size cap this works within.
- Popup interface design — the parent guide.