Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge viem
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/web3/format.ts
    import { 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);
    }
  3. 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

PropTypeDefaultDescription
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".
collectionstring–Collection name shown above the token name.
rarityNftRarity–Rarity tier shown as a badge on the artwork.
pricebigint–Listing price in the smallest unit. Omit for "Not listed".
priceDecimalsnumber18Decimals of `price`.
priceSymbolstring"ETH"Currency symbol of `price`.
traitsNftTrait[]–Traits revealed over the artwork on hover, focus or the Traits button.
likedboolean–Controlled like state.
defaultLikedbooleanfalseInitial like state when uncontrolled.
onLikedChange(liked: boolean) => void–Called when the like state changes.
likesnumber–Like count shown next to the heart.
hrefstring–Link for the token name, e.g. its detail page.
tiltbooleantrueTilt and glare on hover.
loadingbooleanfalseShow 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.