Web3: Wallet & Identityv0.2.0
NFT Card
Tilt and glare, a rarity badge, price, and traits on hover.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/nft-card.jsonManual
Install the dependencies
npm install clsx tailwind-merge viemAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/web3/format.tsimport { formatUnits, getAddress, isAddress, parseUnits } from "viem"; /** * Formatting for on-chain values. Amounts are always `bigint` plus `decimals`: nothing here * converts a balance to a float, so large or precise values never lose digits. */ /** Decimals used for USD values and prices across the web3 components (like Chainlink feeds). */ export const USD_DECIMALS = 8; const COMPACT_UNITS = [ { suffix: "T", exponent: 12 }, { suffix: "B", exponent: 9 }, { suffix: "M", exponent: 6 }, { suffix: "K", exponent: 3 }, ] as const; function groupThousands(integer: string) { return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ","); } function compact(value: bigint, decimals: number): string | null { const base = BigInt(10) ** BigInt(decimals); for (const { suffix, exponent } of COMPACT_UNITS) { const unit = base * BigInt(10) ** BigInt(exponent); if (value < unit) continue; const tenths = (value * BigInt(10)) / unit; const whole = tenths / BigInt(10); const fraction = tenths % BigInt(10); return `${groupThousands(whole.toString())}${fraction && whole < BigInt(100) ? `.${fraction}` : ""}${suffix}`; } return null; } export interface FormatAmountOptions { /** Most fraction digits shown; extra digits are cut, never rounded up. @default 4 */ maxFractionDigits?: number; /** Fraction digits always shown (padded with zeros). @default 0 */ minFractionDigits?: number; /** Abbreviate values of 1,000 and up: 1.2K, 3.4M, 5B. @default false */ compact?: boolean; /** Prefix positive values with "+". @default false */ signed?: boolean; } /** * Formats a token amount: `formatAmount(parseUnits("1.23456789", 18), 18)` → "1.2345". * Digits past `maxFractionDigits` are truncated so a balance is never overstated, and a tiny * non-zero amount shows as "<0.0001" instead of "0". */ export function formatAmount(value: bigint, decimals: number, options: FormatAmountOptions = {}) { const { maxFractionDigits = 4, minFractionDigits = 0, compact: useCompact = false, signed = false } = options; const negative = value < BigInt(0); const abs = negative ? -value : value; const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : ""; const short = useCompact ? compact(abs, decimals) : null; if (short) return sign + short; const [integer, fraction = ""] = formatUnits(abs, decimals).split("."); let digits = fraction.slice(0, maxFractionDigits).replace(/0+$/, ""); if (digits.length < minFractionDigits) digits = digits.padEnd(minFractionDigits, "0"); if (abs > BigInt(0) && integer === "0" && !/[1-9]/.test(digits)) { return `${sign}<0.${"0".repeat(Math.max(0, maxFractionDigits - 1))}1`; } return `${sign}${groupThousands(integer)}${digits ? `.${digits}` : ""}`; } /** USD value of a token amount, as a bigint with `USD_DECIMALS` decimals. `price` uses the same decimals. */ export function toUsd(amount: bigint, tokenDecimals: number, price: bigint) { return (amount * price) / BigInt(10) ** BigInt(tokenDecimals); } /** Formats a USD bigint (default `USD_DECIMALS`): "$1,234.56", or "$1.2M" with `compact`. */ export function formatUsd(value: bigint, decimals = USD_DECIMALS, options: Omit<FormatAmountOptions, "maxFractionDigits"> = {}) { const { compact: useCompact = false, signed = false } = options; const negative = value < BigInt(0); const abs = negative ? -value : value; const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : ""; // Under a cent, keep up to 6 significant fraction digits so token prices like $0.000042 stay readable. const tiny = abs > BigInt(0) && abs < BigInt(10) ** BigInt(Math.max(0, decimals - 2)); const body = formatAmount(abs, decimals, { compact: useCompact, maxFractionDigits: tiny ? Math.min(decimals, 8) : 2, minFractionDigits: tiny ? 0 : 2, }); return `${sign}$${body}`; } /** Formats a ratio as a percentage: `formatPercent(0.0123)` → "1.23%". */ export function formatPercent(ratio: number, options: { fractionDigits?: number; signed?: boolean } = {}) { const { fractionDigits = 2, signed = false } = options; if (!Number.isFinite(ratio)) return "—"; const text = `${(Math.abs(ratio) * 100).toFixed(fractionDigits)}%`; if (ratio < 0) return `-${text}`; return signed && ratio > 0 ? `+${text}` : text; } /** Formats basis points (1 bp = 0.01%): `formatBps(BigInt(50))` → "0.5%". */ export function formatBps(bps: bigint | number) { const value = BigInt(bps); const abs = value < BigInt(0) ? -value : value; const fraction = abs % BigInt(100); return `${value < BigInt(0) ? "-" : ""}${abs / BigInt(100)}${fraction ? `.${fraction.toString().padStart(2, "0").replace(/0$/, "")}` : ""}%`; } /** Compact display for plain counts and non-token numbers: 1200 → "1.2K". */ export function compactNumber(value: number | bigint) { return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value); } /** EIP-55 checksummed address, or the input unchanged when it isn't a valid address. */ export function checksumAddress(address: string) { return isAddress(address, { strict: false }) ? getAddress(address) : address; } /** "0x1234…abcd": checksummed, with `start` characters (including 0x) and `end` characters kept. */ export function truncateAddress(address: string, { start = 6, end = 4 }: { start?: number; end?: number } = {}) { const value = checksumAddress(address); if (value.length <= start + end + 1) return value; return `${value.slice(0, start)}…${value.slice(-end)}`; } /** * Parses user input into a bigint amount. Accepts digits with one "." (commas are ignored), * cuts fraction digits past `decimals`, and returns null for anything that isn't a number. */ export function parseAmount(input: string, decimals: number): bigint | null { const text = input.replace(/,/g, "").trim(); if (!/^\d*\.?\d*$/.test(text) || text === "" || text === ".") return null; const [integer, fraction = ""] = text.split("."); return parseUnits(`${integer || "0"}.${fraction.slice(0, decimals) || "0"}`, decimals); } /** A percentage of an amount, in whole percent: `percentOf(balance, 25)`. Rounds down. */ export function percentOf(amount: bigint, percent: number) { return (amount * BigInt(Math.round(percent * 100))) / BigInt(10_000); }Copy the source
components/ui/nft-card/nft-card.tsx"use client"; import { useId, useState, type ComponentProps, type ReactNode } from "react"; import { LikeButton } from "@/components/ui/like-button/like-button"; import { TiltCard } from "@/components/ui/tilt-card/tilt-card"; import { cn } from "@/lib/utils"; import { formatAmount } from "@/lib/web3/format"; export type NftRarity = "common" | "rare" | "epic" | "legendary"; export interface NftTrait { type: string; value: string; /** Share of the collection with this trait, 0–1. */ rarity?: number; } export interface NftCardProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** The artwork: an <img>, <video>, SVG or any element. It fills a square frame. */ image: ReactNode; /** Token name, e.g. "Lumen Orb #0427". */ name: string; /** Collection name shown above the token name. */ collection?: string; /** Rarity tier shown as a badge on the artwork. */ rarity?: NftRarity; /** Listing price in the smallest unit. Omit for "Not listed". */ price?: bigint; /** Decimals of `price`. @default 18 */ priceDecimals?: number; /** Currency symbol of `price`. @default "ETH" */ priceSymbol?: string; /** Traits revealed over the artwork on hover, focus or the Traits button. */ traits?: NftTrait[]; /** Controlled like state. */ liked?: boolean; /** Initial like state when uncontrolled. @default false */ defaultLiked?: boolean; /** Called when the like state changes. */ onLikedChange?: (liked: boolean) => void; /** Like count shown next to the heart. */ likes?: number; /** Link for the token name, e.g. its detail page. */ href?: string; /** Tilt and glare on hover. @default true */ tilt?: boolean; /** Show a skeleton card. @default false */ loading?: boolean; } const RARITY: Record<NftRarity, { label: string; dot: string }> = { common: { label: "Common", dot: "bg-zinc-400" }, rare: { label: "Rare", dot: "bg-primary" }, epic: { label: "Epic", dot: "bg-violet-500" }, legendary: { label: "Legendary", dot: "bg-gradient-to-br from-amber-300 to-orange-500" }, }; export function NftCard({ image, name, collection, rarity, price, priceDecimals = 18, priceSymbol = "ETH", traits = [], liked, defaultLiked, onLikedChange, likes, href, tilt = true, loading = false, className, ...props }: NftCardProps) { const [traitsOpen, setTraitsOpen] = useState(false); const traitsId = useId(); const frame = "w-72 rounded-2xl border border-border bg-background p-2 shadow-sm"; if (loading) { return ( <div role="status" aria-label="Loading NFT" className={cn(frame, className)} {...props}> <div className="aspect-square animate-pulse rounded-xl bg-muted motion-reduce:animate-none" /> <div className="space-y-2 px-2 pt-3 pb-2"> <div className="h-2.5 w-20 animate-pulse rounded bg-muted motion-reduce:animate-none" /> <div className="h-3.5 w-36 animate-pulse rounded bg-muted motion-reduce:animate-none" /> <div className="h-3 w-16 animate-pulse rounded bg-muted motion-reduce:animate-none" /> </div> </div> ); } const content = ( <> <div className="relative aspect-square overflow-hidden rounded-xl bg-muted [&>img]:size-full [&>img]:object-cover [&>svg]:size-full"> {image} {rarity ? ( <span className="absolute top-2 left-2 inline-flex items-center gap-1.5 rounded-full bg-background/90 px-2 py-1 text-[11px] font-medium text-foreground shadow-xs backdrop-blur"> <span className={cn("size-2 rounded-full", RARITY[rarity].dot)} aria-hidden="true" /> {RARITY[rarity].label} </span> ) : null} {traits.length > 0 ? ( <div id={traitsId} data-open={traitsOpen || undefined} className={cn( "absolute inset-x-2 bottom-2 grid grid-cols-2 gap-1.5 rounded-xl border border-border/60 bg-background/88 p-2 shadow-sm backdrop-blur-md", "translate-y-[calc(100%+12px)] opacity-0 transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]", "group-hover/nft:translate-y-0 group-hover/nft:opacity-100 group-focus-within/nft:translate-y-0 group-focus-within/nft:opacity-100", "data-[open]:translate-y-0 data-[open]:opacity-100 motion-reduce:translate-y-0", )} > {traits.slice(0, 4).map((trait) => ( <div key={trait.type} className="min-w-0 rounded-lg bg-surface px-2 py-1.5"> <p className="truncate text-[10px] font-medium tracking-wide text-muted-foreground uppercase">{trait.type}</p> <p className="flex items-baseline justify-between gap-1 text-xs font-medium text-foreground"> <span className="truncate">{trait.value}</span> {trait.rarity !== undefined ? ( <span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">{Math.round(trait.rarity * 1000) / 10}%</span> ) : null} </p> </div> ))} </div> ) : null} </div> <div className="px-2 pt-3"> {collection ? <p className="truncate text-xs text-muted-foreground">{collection}</p> : null} <p className="truncate text-sm font-semibold text-foreground"> {href ? ( <a href={href} className="rounded-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"> {name} </a> ) : ( name )} </p> </div> <div className="flex items-end justify-between gap-2 px-2 pt-2 pb-1"> <div> <p className="text-[11px] text-muted-foreground">{price === undefined ? "Status" : "Price"}</p> <p className="font-mono text-sm font-medium text-foreground tabular-nums"> {price === undefined ? "Not listed" : `${formatAmount(price, priceDecimals)} ${priceSymbol}`} </p> </div> <div className="flex items-center gap-0.5"> {traits.length > 0 ? ( <button type="button" aria-expanded={traitsOpen} aria-controls={traitsId} onClick={() => setTraitsOpen(!traitsOpen)} className="h-8 cursor-pointer rounded-md px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > Traits </button> ) : null} <LikeButton variant="ghost" size="sm" liked={liked} defaultLiked={defaultLiked} onLikedChange={onLikedChange} count={likes} label={`Like ${name}`} /> </div> </div> </> ); return tilt ? ( <TiltCard maxTilt={7} glare glareOpacity={0.25} className={cn("group/nft", frame, className)} {...props}> {content} </TiltCard> ) : ( <div className={cn("group/nft", frame, className)} {...props}> {content} </div> ); }
Usage
import { NftCard } from "@/components/ui/nft-card/nft-card";
<NftCard
image={<img src={metadata.image} alt="" />}
name="Lumen Orb #0427"
collection="Lumen Orbs"
rarity="legendary"
price={listing.price} // bigint in wei
traits={[{ type: "Core", value: "Glass orb", rarity: 0.031 }]}
likes={248}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
image* | ReactNode | – | The artwork: an <img>, <video>, SVG or any element. It fills a square frame. |
name* | string | – | Token name, e.g. "Lumen Orb #0427". |
collection | string | – | Collection name shown above the token name. |
rarity | NftRarity | – | Rarity tier shown as a badge on the artwork. |
price | bigint | – | Listing price in the smallest unit. Omit for "Not listed". |
priceDecimals | number | 18 | Decimals of `price`. |
priceSymbol | string | "ETH" | Currency symbol of `price`. |
traits | NftTrait[] | – | Traits revealed over the artwork on hover, focus or the Traits button. |
liked | boolean | – | Controlled like state. |
defaultLiked | boolean | false | Initial like state when uncontrolled. |
onLikedChange | (liked: boolean) => void | – | Called when the like state changes. |
likes | number | – | Like count shown next to the heart. |
href | string | – | Link for the token name, e.g. its detail page. |
tilt | boolean | true | Tilt and glare on hover. |
loading | boolean | false | Show a skeleton card. |
Also accepts every native <div> attribute, including ref and className.
- Reuses Tilt Card for tilt and glare and Like Button for the heart, so they install as dependencies.
- Pass an <img> with an empty alt when the name is shown below it; the artwork fills a square frame with object-cover.
Accessibility
- Traits reveal on hover and keyboard focus, and the Traits button (aria-expanded) toggles them for touch and keyboard users.
- The rarity badge and trait shares are text, so nothing depends on color. The like button is labelled "Like {name}".
- Tilt follows a mouse only, so touch scrolling is unaffected. Reduced motion turns off the tilt and glare and fades the traits panel without sliding it.