web3icons

Client-side search for icon libraries

How to search a few thousand icons in the browser with ranked exact, prefix and substring matches, aliases, responsive typing and a URL kept in sync.

5 min read

A few thousand icons is a small dataset. The whole index fits in one JSON file, a full scan takes milliseconds, and searching in the browser means results update as fast as people type, with no server and no network round trip. The hard part isn't speed. It's ranking: when someone types "eth", they expect the Ethereum token first, not whichever icon happens to contain those letters earliest in the alphabet.

What the index looks like

Keep one compact entry per icon, with only the fields search and display need:

type Entry = {
  key: string // unique across categories, e.g. 'token/ETH'
  category: 'token' | 'network' | 'wallet' | 'exchange'
  name: string // 'Ethereum'
  symbol: string // 'ETH' for tokens, '' otherwise
  aliases: string[] // testnet names, former names, short forms
  rank: number // popularity, lower is more popular
}

With a handful of short fields, a few thousand entries come to a couple of hundred kilobytes before compression, depending on how much you keep. Load the index once, on the page that searches, rather than importing it into every page of the site. Normalize the strings when you build the index, not on every keystroke.

Normalize first

Compare normalized forms of both the query and the fields:

export function normalize(text: string) {
  return text
    .normalize('NFKD')
    .replace(/\p{Diacritic}/gu, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, ' ')
    .trim()
}

This lowercases, strips accents, and turns punctuation and runs of spaces into single spaces, so "Wrapped-BTC", "wrapped btc" and " Wrapped BTC " all become the same string. If your names include non-Latin scripts, widen the character class, for example to [^\p{L}\p{N}]+ with the u flag, or those names will vanish from the index.

Rank in tiers

Score each entry by the best way it matches, then sort by that score and break ties by popularity. A tier system is easy to reason about and easy to tune:

Tier Match Example
1 Exact ticker eth finds ETH
2 Exact name ethereum finds Ethereum
3 Name or ticker starts with the query ethe finds Ethereum and Ethena
4 A later word in the name starts with the query one finds Arbitrum One
5 Exact alias sepolia finds Ethereum
6 Substring of name or ticker bitrum finds Arbitrum One
7 Alias starts with or contains the query sepo finds Ethereum

Within a tier, sort by popularity, then by shorter name (a shorter match is usually the more specific one), then alphabetically so the order is stable.

type Indexed = Entry & { n: string; s: string; words: string[]; a: string[] }

export function buildIndex(entries: Entry[]): Indexed[] {
  return entries.map((e) => {
    const n = normalize(e.name)
    return { ...e, n, s: normalize(e.symbol), words: n.split(' '), a: e.aliases.map(normalize) }
  })
}

function tier(e: Indexed, q: string): number {
  if (e.s === q) return 1
  if (e.n === q) return 2
  if (e.n.startsWith(q) || (e.s && e.s.startsWith(q))) return 3
  if (e.words.slice(1).some((w) => w.startsWith(q))) return 4
  if (e.a.includes(q)) return 5
  if (e.n.includes(q) || (e.s && e.s.includes(q))) return 6
  if (e.a.some((a) => a.includes(q))) return 7
  return 0
}

export function search(index: Indexed[], query: string): Indexed[] {
  const q = normalize(query)
  if (q === '') return index
  const hits: { e: Indexed; t: number }[] = []
  for (const e of index) {
    const t = tier(e, q)
    if (t > 0) hits.push({ e, t })
  }
  hits.sort(
    (x, y) =>
      x.t - y.t ||
      x.e.rank - y.e.rank ||
      x.e.name.length - y.e.name.length ||
      x.e.name.localeCompare(y.e.name),
  )
  return hits.map((h) => h.e)
}

A linear scan like this over a few thousand entries typically finishes in a few milliseconds or less. Measure on a slow phone to be sure, but at this size you don't need a search library or a prebuilt inverted index.

Aliases do a lot of work

Aliases catch the queries your display names don't:

  • Testnets that share a mainnet's logo, so "sepolia" finds Ethereum.
  • Former names and tickers after a rebrand, so people searching for the old name still find the logo.
  • Short forms people actually type, such as "bsc" for BNB Smart Chain.

Keep alias matches ranked below name matches. A search for "sepolia" should show Ethereum, but a search for "ethereum" shouldn't be crowded by every entry that merely mentions Ethereum in an alias. The guide on naming conventions for large icon libraries covers how to choose names and aliases in the first place.

Be careful with fuzzy matching

Typo tolerance sounds helpful, but tickers are short and often differ by a single letter. USDC and USDT are one edit apart; so are ETH and ETC. A fuzzy matcher will happily rank the wrong stablecoin second, or even first. If you add fuzzy matching, run it only when the strict tiers return nothing, and label those results clearly, for example "No exact matches. Similar names:". In a wallet, where a wrong pick has consequences, this matters even more; see designing a token selector.

Keep typing responsive

Filtering 2,000 entries is cheap. Re-rendering a grid of results on every keystroke is the expensive part. Two tools help:

  • useDeferredValue in React lets the input show each character immediately while the results re-render at a lower priority, skipping intermediate renders when someone types quickly. There's no delay to tune.
  • Debouncing, waiting something like 150 to 300 ms after the last keystroke, suits work that's costly or has side effects: network requests, analytics events, URL updates.
const index = useMemo(() => buildIndex(entries), [entries])
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const results = useMemo(() => search(index, deferredQuery), [index, deferredQuery])

For a local index this size, useDeferredValue alone is usually enough. Save debouncing for the URL.

Keep the URL in sync

Putting the search in the URL, as in ?q=usdc&type=token, makes results shareable and lets them survive a reload. Two rules:

  • Read the URL once, on mount, to set the initial query and filters.
  • Write with history.replaceState, not pushState. Pushing a history entry per keystroke fills the Back button with "u", "us", "usd".
export function writeUrl(query: string, type: string) {
  const params = new URLSearchParams(window.location.search)
  if (query) params.set('q', query)
  else params.delete('q')
  if (type === 'all') params.delete('type')
  else params.set('type', type)
  const search = params.toString()
  const url = search ? `?${search}` : window.location.pathname
  history.replaceState(history.state, '', url)
}

Passing history.state through keeps any state your router stored on the current entry. Leave parameters out when they hold default values, so the plain URL stays clean.

Details that make search feel right

  • Show the number of results, and put it in a role="status" element so screen readers announce it.
  • Let Enter or the down arrow move from the search box to the first result.
  • When a category filter hides every match, say so, and offer the matches from all categories.
  • Keep a list of queries with the result you expect at the top (eth gives ETH, usdc gives USDC, base gives the Base network) and check it whenever you change the ranking.

More in Performance

All guides