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.

Published September 18, 2026 Updated September 18, 2026 7 min read
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

Popup open time by list size and techniqueTime from click to first paint for a popup rendering 100, 1,000 and 5,000 rows fully, and 5,000 rows with content-visibility and with windowed rendering.100 rows, full render28 ms to firs…1,000 rows, full render190 ms to fir…5,000 rows, full render940 ms to fir…5,000 rows, content-visibility310 ms to fir…5,000 rows, windowed34 ms to firs…~30 rows in the DOM
Full rendering scales linearly with row count; windowing keeps open time flat regardless of list length.

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.

Which technique for which list sizeA decision tree mapping list size to a first-screenful render, content-visibility, or full windowing.How many rows can the list have?Under ~300Render first screen, append restrequestIdleCallbackNothing else neededfind-in-page works~300 to ~1,500+ content-visibility: autoskip off-screen paintSet contain-intrinsic-sizestable scrollbarThousandsWindowed rendering~30 rows in the DOMOwn search + aria-posinsetfind-in-page cannot see it
Stop at the first branch that keeps open time under about 50 ms — each step down adds complexity.

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.

Opening a popup with a 2,000-item listThe popup reads a thirty-item head from session storage and paints it, then asks the worker for the full list and switches to windowed rendering when it arrives.Popupstorage.sessionService workerIndexedDBget listHead (30 rows)paint 30 rowslist:allcursor over 2,000 itemsfull listswitch to windowed render
The user sees real rows on the first frame; the full list arrives before they scroll.

Cross-browser variation

  • Chrome / Edge: content-visibility and contain-intrinsic-size: auto are supported; requestIdleCallback is available in the popup. Popups are capped at 800 × 600 pixels.
  • Firefox: supports content-visibility from Firefox 125 and requestIdleCallback. Popup size limits are similar; Firefox may show a scrollbar on the popup itself if the body exceeds the cap.
  • Safari: content-visibility from Safari 18; no requestIdleCallback. 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

  1. 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.

  1. Count DOM rows while scrolling a windowed list: container.querySelectorAll(".row").length should stay near 30 regardless of position.
  2. Arrow-key from the top to row 500 and confirm focus follows and the screen reader announces “500 of 2,000”.
  3. 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.

Other UI/UX Patterns & Interactive Components Resources