Skip to main contentSkip to navigation

A token as a reader meets it — a monogram derived from what the data has, the symbol, and the name where there is room.

A venue's lists name their assets in bare text: the picker, the market table, the pool rows, the token page. Someone scanning forty rows for the one they hold is reading forty strings, and reading is slower than recognising. Coin is the shorter path to the row they came for — a mark, the symbol, and the name where there is room. It is one component because the four screens that drew a token had four spellings of it and had drifted: one showed the name first, one truncated at a different width.

Loading...

The indexer returns id, symbol, name and decimals, measured against its own schema, and no image. A component that expected a URL would draw a broken frame for every token this venue actually has. A monogram is derived from what is present instead: it always resolves, and it never asks anything for a file.

The letters

The symbol, stripped of punctuation, upper-cased, at most three characters. Two is not enough on this chain — almost every asset is L plus a name, so LUX and LUSD both reduce to LU, and a mark that collides misleads faster than no mark would. Three separates the family.

It does not separate everything: USDC and USDT still meet, and are meant to. The mark is an aid to scanning, not an identifier. The symbol is in the row beside it and the shade comes from the address, so a pair sharing letters still differ on the page.

monogram('LUX')                // 'LUX'
monogram('$LUX')               // 'LUX' — punctuation takes a slot
monogram('w-eth')              // 'WET'
monogram(undefined, '0xab12')  // 'AB'
monogram(undefined, undefined) // '??'

A token carrying no symbol at all is not rare: an indexer records a pool before it has ingested the tokens in it. The letters then come from the first two hex digits after 0x, which is what the address column shows anyway, and two such tokens still differ from each other. With neither symbol nor address the mark reads ?? rather than drawing an empty circle.

The shade

shade hashes the lowercased address with FNV-1a and returns a step from 0 to 11. A step rather than a percentage, because the two lightnesses a mark needs — its ground and its letters — are derived from that one number and so cannot drift apart: the ground is 16 + step × 2.5%, the letters 62 + step × 2.5%.

FNV-1a rather than a sum of character codes. The input is hex, so its alphabet is sixteen characters wide, and a plain sum lands every address in the same narrow band: the first version put six real tokens between 180 and 344 of 360, two of them seventeen apart. Mixing on every character spreads them.

The hash reads the address lowercased, because addresses arrive checksummed from a chain and lowercased from a wallet, and one token wearing two marks is worse than one wearing none. Same token, same shade, on every screen and across reloads — that stability is the only property that makes a mark recognisable.

Lightness, not hue

@luxfi/ui/tokens.css states the rule plainly: one achromatic ladder plus exactly two hues, reserved for error and success. A wall of tinted discs breaks it twice — it spends colour on identity, and it gives the reader a second thing that looks like state and is not. So the mark varies in lightness and the page stays monochrome. The twelve steps cross a band that stays clear of the page behind the mark and of the letters in front of it: low enough to read as an object, wide enough that two marks side by side are two objects.

The text beside the mark takes the two colours every surface here uses — paint.plain for the symbol, paint.quiet for the name — from paint, which holds this surface's colours in the design system's own names.

It takes no reading

Most of @luxfi/ui/read takes a reading rather than a value, because a component that has flattened there is nothing and we could not ask into an empty list cannot tell them apart afterwards.

type Rows<R> =
  | { at: 'reading' }
  | { at: 'read'; rows: R[] }
  | { at: 'failed'; why: string }

Coin takes fields, not a reading. It is drawn inside a row that a reading has already delivered — Table draws cells only for { at: 'read' } — so the question has been answered before Coin is called. Absence here is a different thing: a token that states no symbol is a fact in a record that did arrive, not an unanswered request, which is why the fallback draws letters from the address rather than a placeholder.

Installation

npm install @luxfi/ui @hanzo/gui @hanzo/ui @hanzo/design @tanstack/react-query
import '@luxfi/ui/tokens.css'
import '@hanzo/design/styles.css'
import { AppProvider } from '@luxfi/ui/provider'
import { Coin, Face, monogram, shade } from '@luxfi/ui/read'

AppProvider goes once at the root of the app and mounts the gui engine and a query client. The two sheets are where the names resolve: colour from @luxfi/ui/tokens.css, spacing, elevation and measure from @hanzo/design.

Usage

import { Coin, Face, monogram, shade } from '@luxfi/ui/read'

In a row, where the name has room beside the symbol:

<Coin symbol={token.symbol} name={token.name} address={token.id} />

At the head of a token page, where the same object is drawn larger:

<Coin symbol={token.symbol} name={token.name} address={token.id} size="head" />

Where the row already carries the name in a column of its own, the mark stands alone:

<Face symbol={token.symbol} address={token.id} />

monogram and shade are exported beside the components, so a surface drawing something the pair do not cover derives it from the same two functions rather than from a second opinion about what a token's letters are.

Props

Coin is a stack of @hanzo/gui primitives carrying style props — there is no className anywhere in it — and it composes a Face with the text beside it, passing size through to both.

PropTypeRequiredDefaultDescription
symbolstringno—Drawn beside the mark; an em dash where absent
namestringno—Drawn quieter, and only where it differs from symbol
addressstringno—The shade, and the letters where symbol is absent
size'row' | 'head'no'row'The mark is 22px in a row, 34px at a head

Face is the mark on its own, and takes symbol, address and size with the same meanings.

monogram(symbol?: string, address?: string): string  // one to three characters
shade(address?: string): number                      // a step, 0 to 11

Both are pure. The same arguments give the same answer, in a test as on a page.

What it does not do

Coin fetches nothing — no image, no icon set, no request of any kind — so a venue that does publish token art has to draw it somewhere else. It does not identify: a monogram collides by design, and a shade is one of twelve, so two tokens can share both and a counterfeit will sometimes wear the mark of the token it imitates. Anything that turns on telling two tokens apart has to use the address. It does not verify either: a token that names itself LUX is drawn as LUX, and neither the letters nor the shade is a claim that the asset is what it says. The band the shade moves within is fixed and dark, so the mark does not follow a light theme: the letters stay light on a dark ground wherever it is drawn. The name elides to one line rather than wrapping, and gives way before the symbol when the row is narrow. The mark is aria-hidden, because the name it abbreviates is already in the row and a screen reader should not read it twice — which also means a Face drawn without a Coin beside it announces nothing at all. And it is not a control: no press handler, no link, no focus behaviour, no state.