web3icons

Rendering thousands of icons in one page

How to keep a page with thousands of logos fast, covering DOM size, image decoding, paging versus virtualization, content-visibility and lazy loading.

5 min read

An icon library, a token list with every asset on a chain, a portfolio for a wallet that holds hundreds of tokens: sooner or later you need thousands of logos on one page. Rendered naively, 2,000 icons mean tens of thousands of DOM nodes, hundreds of image requests and scrolling that stutters on a mid-range phone. This guide explains where the cost comes from and which techniques keep a large grid responsive.

Where the cost comes from

Three separate costs add up.

DOM size. Every element costs memory, style calculation and layout. A grid cell is rarely one element: a button, an image, a label, perhaps a badge. Four or five elements per cell across 2,000 cells is 8,000 to 10,000 elements before the rest of the page.

Image work. Each icon is a request, unless it's sprited or inlined, followed by parsing and rasterizing into pixels. SVGs are rasterized at the size they're drawn, so detailed logos with gradients and many paths cost more than simple ones.

Script work. In a component framework, each cell is a component with props and maybe state and handlers. Re-rendering 2,000 components on every keystroke in a search box is often the real bottleneck, more than anything the browser does.

Measure before you optimize. In the console, document.querySelectorAll('*').length gives the element count. The Performance panel in Chrome DevTools shows whether time goes to scripting, style and layout, or painting. Lighthouse also flags large DOM sizes; its thresholds have changed between versions, so treat it as a signal rather than a target.

Use img, not inline SVG, for big grids

Inline SVG puts every path of every logo into the DOM. A detailed logo can be 5 to 30 elements, so across 2,000 icons that's another 10,000 to 60,000 nodes taking part in style calculation. Inline SVGs also share the document's ID namespace, so a logo with a gradient can break when the same icon appears twice.

An <img> pointing at an SVG file is a single element. The browser handles the SVG document internally, caches the file, and can reuse it when the same icon appears again. For a grid that only displays icons, <img> is almost always the better choice; keep inline SVG for the few places where CSS needs to reach inside the artwork. The guide on inline SVG vs img compares the options in detail.

function IconCell({ icon }: { icon: { name: string; src: string } }) {
  return (
    <button type="button" className="cell" data-key={icon.name}>
      <img src={icon.src} alt="" width={32} height={32} loading="lazy" decoding="async" />
      <span>{icon.name}</span>
    </button>
  )
}

The width and height attributes reserve space, so nothing shifts as images arrive. loading="lazy" defers images that are far off screen, and decoding="async" tells the browser it needn't hold up rendering while it decodes. The alt text is empty because the visible label already names the icon. More on these attributes in lazy loading icons.

Three ways to limit what's rendered

Approach DOM size Find in page Screen readers Complexity
Incremental rendering Grows as you scroll Rendered items only Works normally Low
Virtualization Constant Breaks Needs extra work High
content-visibility: auto Full Works Works normally Low

Incremental rendering

Render the first batch, then add the next when the user nears the end, using an IntersectionObserver on a sentinel element, with a "Show more" button as a fallback. The DOM only grows as far as someone actually scrolls, and most people searching an icon library never go past the first few batches.

Choose a batch size that fills whole rows at every breakpoint. 96 is a good example: it divides evenly into 2, 3, 4, 6, 8 and 12 columns, so at any of those column counts the last row is never half empty.

import { useEffect, useRef, useState } from 'react'

const PER_PAGE = 96

export function useIncremental(total: number) {
  const [count, setCount] = useState(PER_PAGE)
  const sentinel = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const el = sentinel.current
    if (el === null || count >= total) return
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) setCount((c) => Math.min(c + PER_PAGE, total))
      },
      { rootMargin: '800px 0px' },
    )
    observer.observe(el)
    return () => observer.disconnect()
  }, [count, total])

  return { count: Math.min(count, total), sentinel, reset: () => setCount(PER_PAGE) }
}

Call reset when the search query or filter changes, so a new search starts from the first batch. The trade-offs of this pattern, including the footer and the Back button, are covered in infinite scroll vs pagination.

Virtualization

Virtualization, or windowing, keeps only the visible rows plus a small buffer in the DOM, positioned inside a tall spacer. DOM size stays constant however many icons there are. The costs: find-in-page can't see items that aren't mounted, screen readers only perceive what's rendered, and keyboard focus can be lost when the focused cell scrolls out and unmounts. Grids add the work of computing the column count from the container width and keeping every row the same height. It's the right tool for tens of thousands of rows; for a few thousand icons, the simpler options are usually enough.

content-visibility

content-visibility: auto lets the browser skip layout and painting for content that's off screen while keeping it in the DOM, so find-in-page, tab order and the accessibility tree still work. Pair it with contain-intrinsic-size so the browser knows how much space to reserve for content it hasn't laid out:

.grid-chunk {
  display: grid;
  grid-template-columns: repeat(var(--columns), 1fr);
  content-visibility: auto;
  contain-intrinsic-size: auto 1200px;
}

The auto keyword makes the browser remember a chunk's real size once it has been rendered. Apply the property to chunks of rows, for example one wrapper per batch of 96, each its own grid with the same columns, rather than to every cell. It's supported in current versions of Chrome, Edge, Firefox and Safari; older browsers ignore it and render everything, so it's safe to add. It doesn't reduce the number of elements or the cost of creating them, so combine it with incremental rendering if element count is the problem.

Keep scrolling and typing smooth

  • One listener, not 2,000. Handle clicks and hover with event delegation on the grid container, reading a data- attribute from the target, instead of attaching handlers to each cell.
  • Keep cells cheap. Memoize cell components and pass a simple selected boolean, so changing the selection re-renders two cells, the old and the new, instead of all of them.
  • Let the input stay ahead of the grid. Filtering a few thousand entries is fast; re-rendering the results is the slow part. React's useDeferredValue lets the input update immediately while the grid catches up.
  • Animate sparingly. A staggered entrance on the first batch is fine; animating every cell on every filter change isn't. Animate only transform and opacity, and respect prefers-reduced-motion.
  • Don't measure in loops. Calling getBoundingClientRect() on many cells during scroll forces layout again and again. Let IntersectionObserver do the measuring.

With <img> cells, lazy loading, batches of 96 and cheap components, a grid of a few thousand logos stays smooth on ordinary hardware. Reach for virtualization only if measurements show you still need it.

More in Performance

All guides