web3icons

Fallback icons for tokens without a logo

Build fallback icons for tokens with no logo, from neutral initials to address-based patterns, handle broken image URLs and flag unverified tokens.

6 min read

Every wallet and token list eventually meets a token with no logo: it is new or obscure, or its image URL stopped working. What you show in that slot matters. An empty circle looks like a bug, a broken-image icon looks careless, and a colorful made-up logo can look like a real brand.

A good fallback is plain, stable and honest. Here is how to build one, how to switch to it when an image fails, and how to keep "no logo" separate from "not verified."

What a fallback needs to do

  • Fill the same slot as a real logo, at the same size and shape, so rows stay aligned.
  • Look clearly generic, so nobody mistakes it for a brand mark.
  • Be stable: the same token gets the same fallback on every screen and visit.
  • Stay distinct where it matters: two different tokens both called "USDC" should not look identical.
  • Make no claim about trust. A fallback means "we have no image," nothing more.

Initials on a neutral circle

The most common fallback is the token's symbol, or its first letters, on a gray circle. It is cheap, readable, and obviously not a logo.

type FallbackProps = { symbol: string; size?: number }

function initials(symbol: string) {
  const clean = symbol.normalize('NFKC').replace(/[^A-Za-z0-9]/g, '').toUpperCase()
  return clean.slice(0, 3) || '?'
}

export function InitialsIcon({ symbol, size = 32 }: FallbackProps) {
  const text = initials(symbol)
  const fontSize = text.length > 2 ? size * 0.3 : size * 0.38

  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
      <circle cx={size / 2} cy={size / 2} r={size / 2} fill="#2a2e35" />
      <text
        x="50%"
        y="50%"
        dominantBaseline="central"
        textAnchor="middle"
        fontSize={fontSize}
        fontWeight="600"
        fill="#c9ccd3"
      >
        {text}
      </text>
    </svg>
  )
}

The initials function does more than it looks. Token symbols are arbitrary strings set by the contract's deployer and can contain spaces, emoji, invisible characters, whole URLs, or letters from other alphabets that look like Latin ones. Stripping everything except ASCII letters and digits keeps the fallback readable and stops a spam token from writing a message into your interface. Anything that cleans down to nothing gets a question mark.

Up to three characters fit at 32 px; above that, the text gets too small to read. Check the colors you pick: the initials are text, so aim for at least 4.5:1 contrast against the circle. The pair above, light gray on dark gray, is well above that. The contrast ratio guide shows how to calculate it.

React escapes text content, so rendering the symbol inside JSX is safe. If you build the SVG as a string instead, for a data URI or a server-rendered image, escape <, >, & and quotes yourself.

Patterns from the contract address

Initials have one weakness: two tokens with the same symbol get the same fallback. For a fake token copying a popular ticker, that is exactly the wrong outcome.

An identicon-style pattern derived from the contract address solves it. Run the full address through a small hash first. Reading bits straight from the address would give every vanity address that starts with a long run of zeros the same pattern, and a hash also works for non-EVM address formats.

function hash32(input: string) {
  let h = 0x811c9dc5 // FNV-1a, enough for picking a pattern
  for (const ch of input) {
    h ^= ch.charCodeAt(0)
    h = Math.imul(h, 0x01000193)
  }
  return h >>> 0
}

export function AddressPattern({ address, size = 32 }: { address: string; size?: number }) {
  const key = address.startsWith('0x') ? address.toLowerCase() : address
  const bits = hash32(key)
  const inner = size * 0.6
  const offset = (size - inner) / 2
  const cell = inner / 5
  const rects = []

  for (let row = 0; row < 5; row++) {
    for (let col = 0; col < 3; col++) {
      if ((bits >>> (row * 3 + col)) & 1) {
        const y = offset + row * cell
        rects.push(<rect key={`${row}-${col}`} x={offset + col * cell} y={y} width={cell} height={cell} />)
        if (col < 2) {
          rects.push(<rect key={`${row}-${col}m`} x={offset + (4 - col) * cell} y={y} width={cell} height={cell} />)
        }
      }
    }
  }

  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
      <circle cx={size / 2} cy={size / 2} r={size / 2} fill="#2a2e35" />
      <g fill="#8b9099">{rects}</g>
    </svg>
  )
}

The grid is five by five and mirrored left to right, which makes it read as a shape instead of noise. It covers the middle 60% of the circle, so its corners stay inside the edge without any clipping. EVM addresses are lowercased first because checksummed addresses mix cases (EIP-55), and the same address must always produce the same pattern.

Know the limit, too. Fifteen bits of pattern make accidental lookalikes unlikely, but someone determined can generate addresses until one produces a matching pattern. The pattern helps people tell tokens apart; it proves nothing about which one is genuine.

Nobody remembers a token by its pattern, though. Keep initials for everyday display and use the pattern where telling same-named tokens apart is the point, such as search results in a token picker.

Why not a random color

It is tempting to give each fallback a bright color so the list looks lively. Avoid it, for three reasons.

Bright colored circles look like real logos. A blue circle with a white "U" reads as a stablecoin at a glance. You have just designed a plausible logo for a token you know nothing about.

Colors derived from the symbol help spoofers. Every token named "USDC" gets the same color, so the fakes look consistent enough to seem legitimate.

Random colors aren't stable. A color picked at render time changes between screens and visits, which defeats recognition.

If you want variety, derive it from the contract address and keep it muted: low saturation and the same lightness for every token, so it reads as a tinted placeholder rather than a brand.

Handling broken image URLs

Logo URLs from token lists and APIs fail for ordinary reasons: a host goes down, an IPFS gateway times out, a project moves its files. The token lists and logoURI guide covers where these URLs come from. Swap to the fallback when one fails, and remember the failure so a long list doesn't retry dead URLs on every render.

import { useState } from 'react'

const failed = new Set<string>()

type Token = { address: string; symbol: string; logoURI?: string }

export function TokenIcon({ token, size = 32 }: { token: Token; size?: number }) {
  const src = token.logoURI
  const [broken, setBroken] = useState(() => (src ? failed.has(src) : true))

  if (broken || src === undefined) {
    return <InitialsIcon symbol={token.symbol} size={size} />
  }

  return (
    <img
      src={src}
      width={size}
      height={size}
      alt=""
      onError={() => {
        failed.add(src)
        setBroken(true)
      }}
    />
  )
}

Render it with key={token.address} so the state resets when the row shows a different token. If you render on the server, an image can fail before React attaches the onError handler during hydration; checking img.complete && img.naturalWidth === 0 in an effect catches those cases.

No logo is not the same as unverified

A legitimate new token often has no logo yet. A scam token often has a very convincing one. So the fallback can't double as a warning, and a logo can't count as proof.

Signal verification separately, in words: an "Unverified" label in the row, a notice on the token's detail screen, or a separate section for tokens not on any list you trust. Pair any warning color with text or an icon that carries the same meaning, since color alone misses many people; the color alone guide covers this. The fake tokens guide goes into the spoofing patterns you are guarding against.

With the two signals separated, the interface stays honest: the fallback says "no image," the label says "not verified," and each can appear without the other.

More in Using crypto logos

All guides