web3icons

Designing token pair icons for DeFi pools

How to build overlapping pair icons for liquidity pools, with the right overlap, a separating ring, a sensible token order, and legibility at small sizes.

6 min read

Every DEX and yield dashboard has a list of pools, and every row starts with the same small picture: two coins overlapping, ETH partly in front of USDC. It says "this pool holds these two tokens" in less width than two separate logos.

Getting it right at 20 to 32 px comes down to four decisions: how much the coins overlap, how you separate them, which one goes first, and what to do with three or more tokens.

How much to overlap

Think of overlap as a fraction of the coin's diameter.

Overlap What it looks like
0 Two separate coins side by side. Clear, but as wide as two icons
25 to 35 percent Reads as one unit, and both logos keep their center symbol
50 percent The back coin's center sits exactly on the front coin's edge, so its symbol is half hidden
More than 50 percent The back coin turns into a colored crescent

Most coin logos put their symbol in the middle, so stop well short of the back coin's center. A third of the diameter is a good default.

The width follows directly. With diameter D and overlap o, each extra coin adds a step of D × (1 − o), so n tokens take D + (n − 1) × step:

Coin size Step at one third overlap Pair width
20 px 13 px 33 px
24 px 16 px 40 px
32 px 21 px 53 px
40 px 27 px 67 px

Round the step to a whole pixel so both coins land on the pixel grid at 1x. Then reserve the pair width as a fixed column, so pool names line up down the list whether a row shows two tokens or three.

Separating the coins

Two overlapping discs with no gap merge into one shape, especially when both logos are blue or both are dark. A thin gap between them, 1.5 to 2 px at 24 to 32 px, fixes this.

The quick version gives the front coin box-shadow: 0 0 0 2px var(--surface), which shows as a dark halo once the row is hovered or selected. The better version cuts a real hole in the back coin with a CSS mask, centered on the front coin. For a 24 px coin with a 16 px step and a 2 px gap, the front coin's center is 4 px to the left of the back coin's box, and the hole's radius is 12 + 2 = 14 px:

.pair-back {
  mask-image: radial-gradient(circle at -4px 50%, transparent 14px, #000 14.5px);
}

In general the hole is centered at D / 2 − step from the back coin's left edge, with a radius of D / 2 + gap. Because it's a real hole, the row's background shows through in every state. The same technique separates a chain badge from a token; see network badges on token icons.

Which token goes first

The rule that matters most: the icons follow the label. If the row says ETH / USDC, ETH is the left coin. Anything else looks like a bug.

Deciding the label order is harder than it sounds. Pool contracts often store their tokens in an internal order that has nothing to do with meaning. Uniswap v2 and v3 pools, for example, sort their two tokens by contract address. Show that order raw and the same kind of pool reads USDC / WETH in one row and WBTC / USDC in the next.

A display rule fixes it. Treat stablecoins and major assets as "quote" tokens that go second, and put the other token first:

const QUOTE_PRIORITY = ['USDC', 'USDT', 'DAI', 'ETH', 'WETH', 'BTC', 'WBTC']

function quoteRank(token) {
  const i = QUOTE_PRIORITY.indexOf(token.symbol)
  return i === -1 ? Infinity : i
}

// The more quote-like token goes second; unknown pairs keep their order.
export function displayOrder([a, b]) {
  return quoteRank(a) < quoteRank(b) ? [b, a] : [a, b]
}

That gives ETH / USDC, WBTC / ETH and PEPE / ETH. Use the same order for the icons, the label and any price in the row, so "price" always means the first token in units of the second.

As for which coin sits in front, either works. Putting the first token in front matches reading order. Pick one and use it everywhere.

Three or more tokens

Stablecoin pools and weighted pools often hold three or more assets. There are three practical layouts:

Layout Footprint with 24 px coins Works for
Row with 40 to 50 percent overlap 48 to 52 px wide for three tokens Three or four tokens in a list
Triangle cluster About 40 px square, taller than the row Large sizes only; unreadable below 32 px coins
Two coins plus a "+2" chip About 56 px wide Pools with four or more tokens

The row is the most legible at list sizes. Increase the overlap to keep the width reasonable; a half-hidden symbol matters less when the label lists every token. Past four tokens every coin becomes a sliver, so show the first two and a count chip that matches the coins' height and uses your text color.

Small sizes

At 16 px, a pair with one third overlap is 27 px wide, and the back coin shows a crescent about 10 px wide. That is usually enough to recognize a color, rarely a symbol. A 1 px gap is the most you can afford there.

In practice, 20 px is the smallest size where pair icons help. Below that, let the label carry the information. Sizing icons in lists and tables covers matching icon size to row height.

Keep both coins in the same style. A full-color logo overlapping a mono one looks like a disabled state. And if one token's logo needs a light tile to show on your dark background, the tile becomes that coin's disc; the ring and mask work the same way.

A React component

This renders any number of tokens, cuts each back coin, and gives the group one accessible name:

type Token = { symbol: string; logo: string }

type PairIconProps = {
  tokens: Token[]
  size?: number
  overlap?: number
  gap?: number
}

const list = new Intl.ListFormat('en', { type: 'conjunction' })

export function PairIcon({ tokens, size = 24, overlap = 1 / 3, gap = 2 }: PairIconProps) {
  const step = Math.round(size * (1 - overlap))
  const hole = `radial-gradient(circle at ${size / 2 - step}px 50%, transparent ${size / 2 + gap}px, #000 ${size / 2 + gap + 0.5}px)`

  return (
    <span
      role="img"
      aria-label={list.format(tokens.map((t) => t.symbol))}
      style={{ position: 'relative', display: 'inline-block', width: size + step * (tokens.length - 1), height: size }}
    >
      {tokens.map((token, i) => (
        <img
          key={token.symbol}
          src={token.logo}
          alt=""
          width={size}
          height={size}
          style={{
            position: 'absolute',
            left: i * step,
            zIndex: tokens.length - i,
            borderRadius: '50%',
            maskImage: i > 0 ? hole : undefined,
          }}
        />
      ))}
    </span>
  )
}

Screen readers hear "ETH and USDC" once instead of two unlabeled images. If the row's visible text already names both tokens, remove role and aria-label and keep the empty alt attributes, as described in decorative or informative icons.

Before shipping, check a hovered row at 1x where two same-colored tokens are paired. If you can tell the coins apart there, they'll hold up everywhere.

More in Icon design

All guides