Web3: DeFi & Tradingv0.2.0
Staking Card
APY badge, stake and unstake tabs, live rewards and a claim burst.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/staking-card.jsonManual
Install the dependencies
npm install clsx lucide-react motion 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/color.tsexport type Vec3 = [r: number, g: number, b: number]; /** * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels. * Anything else falls back to black, so a typo never crashes a shader. */ export function hexToRgb(hex: string): Vec3 { let value = hex.trim().replace(/^#/, ""); if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join(""); if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0]; const n = Number.parseInt(value.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function hexToVec3(hex: string): Vec3 { const [r, g, b] = hexToRgb(hex); return [r / 255, g / 255, b / 255]; } /** * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result * instead of calling this every frame. Browser only; unreadable values fall back to black. */ export function cssColorToRgb(color: string, element: Element): Vec3 { const value = color.trim(); if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value); // Children of a canvas still get computed styles, but a regular parent is the safer host. const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element; const probe = document.createElement("span"); probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden"; probe.style.color = value; host.appendChild(probe); const computed = getComputedStyle(probe).color; probe.remove(); // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes. const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!ctx) return [0, 0, 0]; ctx.fillStyle = "#000"; ctx.fillStyle = computed; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return [r, g, b]; } /** * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme. * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe. */ export function observeTheme(onChange: () => void): () => void { const observer = new MutationObserver(onChange); const options = { attributes: true, attributeFilter: ["class", "data-theme"] }; observer.observe(document.documentElement, options); if (document.body) observer.observe(document.body, options); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); scheme.addEventListener("change", onChange); return () => { observer.disconnect(); scheme.removeEventListener("change", onChange); }; } /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function cssColorToVec3(color: string, element: Element): Vec3 { const [r, g, b] = cssColorToRgb(color, element); return [r / 255, g / 255, b / 255]; } const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */ export function rgbToOklch([r, g, b]: Vec3): Vec3 { const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255)); const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return [L, Math.hypot(A, B), Math.atan2(B, A)]; } /** OKLCH back to 0–255 sRGB, clamped into gamut. */ export function oklchToRgb([L, C, h]: Vec3): Vec3 { const A = C * Math.cos(h); const B = C * Math.sin(h); const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3; const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3; const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3; const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]; return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3; } /** * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep * their chroma instead of turning muddy the way an RGB mix does. */ export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 { const [L1, C1, h1] = rgbToOklch(a); const [L2, C2, h2] = rgbToOklch(b); // A near-gray end has no meaningful hue: borrow the other end's. const ha = C1 < 0.02 ? h2 : h1; const hb = C2 < 0.02 ? h1 : h2; let dh = hb - ha; if (dh > Math.PI) dh -= Math.PI * 2; if (dh < -Math.PI) dh += Math.PI * 2; return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]); }lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }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); }lib/web3/icons.tsx"use client"; import { useId, type ComponentProps } from "react"; import { hexToRgb } from "@/lib/color"; import { cn } from "@/lib/utils"; /** * Generated avatars for tokens, chains and accounts. No third-party logos are bundled: * pass `iconUrl` (tokens, chains) or `src` (accounts) to show your own artwork. */ const PALETTE = ["#6366F1", "#2563EB", "#0EA5E9", "#14B8A6", "#16A34A", "#F59E0B", "#EF4444", "#EC4899", "#8B5CF6", "#64748B"]; function hashString(value: string) { let hash = 0x811c9dc5; for (const char of value) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193); return hash >>> 0; } /** Text color with enough contrast on a fill (relative luminance threshold). */ function inkFor(fill: string) { const [r, g, b] = hexToRgb(fill).map((c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? "#09090B" : "#FFFFFF"; } function Monogram({ text, fill, size, shape, className }: { text: string; fill: string; size: number; shape: "circle" | "squircle"; className?: string }) { const letters = text.slice(0, 4); const fontSize = letters.length <= 2 ? 13 : letters.length === 3 ? 10 : 8.5; return ( <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0", className)}> {shape === "circle" ? <circle cx="16" cy="16" r="16" fill={fill} /> : <rect width="32" height="32" rx="9" fill={fill} />} <text x="16" y="16" dy="0.35em" textAnchor="middle" fontSize={fontSize} fontWeight={700} fill={inkFor(fill)} style={{ fontFamily: "inherit", letterSpacing: letters.length > 2 ? "-0.02em" : undefined }} > {letters} </text> </svg> ); } function ImageIcon({ src, size, rounded, className }: { src: string; size: number; rounded: string; className?: string }) { // eslint-disable-next-line @next/next/no-img-element -- consumers pass arbitrary logo URLs return <img src={src} alt="" width={size} height={size} className={cn("shrink-0 object-cover", rounded, className)} />; } export interface TokenIconProps { token: { symbol: string; iconUrl?: string; color?: string }; /** Pixel size. @default 24 */ size?: number; className?: string; } /** A token's logo, or a colored circle with its symbol. Decorative: pair it with visible text. */ export function TokenIcon({ token, size = 24, className }: TokenIconProps) { if (token.iconUrl) return <ImageIcon src={token.iconUrl} size={size} rounded="rounded-full" className={className} />; const fill = token.color ?? PALETTE[hashString(token.symbol) % PALETTE.length]; return <Monogram text={token.symbol} fill={fill} size={size} shape="circle" className={className} />; } export interface ChainIconProps { chain: { name: string; shortName?: string; iconUrl?: string; color?: string }; /** Pixel size. @default 20 */ size?: number; className?: string; } /** A chain's logo, or a rounded square with its initial. Decorative: pair it with visible text. */ export function ChainIcon({ chain, size = 20, className }: ChainIconProps) { if (chain.iconUrl) return <ImageIcon src={chain.iconUrl} size={size} rounded="rounded-[28%]" className={className} />; const fill = chain.color ?? PALETTE[hashString(chain.name) % PALETTE.length]; return <Monogram text={(chain.shortName ?? chain.name).charAt(0)} fill={fill} size={size} shape="squircle" className={className} />; } export interface WalletIconProps { wallet: { name: string; iconUrl?: string; color?: string }; /** Pixel size. @default 32 */ size?: number; className?: string; } /** A wallet's logo, or a rounded square with its initial. Decorative: pair it with visible text. */ export function WalletIcon({ wallet, size = 32, className }: WalletIconProps) { if (wallet.iconUrl) return <ImageIcon src={wallet.iconUrl} size={size} rounded="rounded-[28%]" className={className} />; const fill = wallet.color ?? PALETTE[hashString(wallet.name) % PALETTE.length]; return <Monogram text={wallet.name.charAt(0)} fill={fill} size={size} shape="squircle" className={className} />; } export interface AddressAvatarProps extends Omit<ComponentProps<"svg">, "children"> { address: string; /** Pixel size. @default 24 */ size?: number; } /** A deterministic gradient avatar for an address: same address, same avatar, on every device. */ export function AddressAvatar({ address, size = 24, className, ...props }: AddressAvatarProps) { const id = useId(); const hex = address.toLowerCase().replace(/^0x/, "").padEnd(40, "0"); const byte = (i: number) => Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) || 0; const hue = Math.round((byte(0) / 255) * 360); const hue2 = (hue + 40 + Math.round((byte(1) / 255) * 120)) % 360; const angle = Math.round((byte(2) / 255) * 360); return ( <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0 rounded-full", className)} {...props}> <defs> <linearGradient id={`${id}g`} gradientTransform={`rotate(${angle} 0.5 0.5)`}> <stop offset="0" stopColor={`hsl(${hue} 85% 64%)`} /> <stop offset="1" stopColor={`hsl(${hue2} 80% 52%)`} /> </linearGradient> <clipPath id={`${id}c`}> <circle cx="16" cy="16" r="16" /> </clipPath> </defs> <g clipPath={`url(#${id}c)`}> <rect width="32" height="32" fill={`url(#${id}g)`} /> <circle cx={6 + (byte(3) % 20)} cy={6 + (byte(4) % 20)} r={6 + (byte(5) % 6)} fill="#fff" opacity="0.32" /> <circle cx={6 + (byte(6) % 20)} cy={6 + (byte(7) % 20)} r={3 + (byte(8) % 5)} fill="#fff" opacity="0.22" /> </g> </svg> ); }lib/web3/rolling-value.tsx"use client"; import { useEffect, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; const REEL = { type: "spring", stiffness: 160, damping: 22, mass: 0.9 } as const; const MASK = "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)"; function Reel({ digit, reduceMotion }: { digit: number; reduceMotion: boolean }) { return ( <span className="relative inline-block [clip-path:inset(0)]" style={{ maskImage: MASK, WebkitMaskImage: MASK }}> <span className="invisible">0</span> <motion.span className="absolute inset-x-0 top-0 flex flex-col items-center" initial={false} animate={{ y: `${-digit * 10}%` }} transition={reduceMotion ? { duration: 0 } : REEL} > {DIGITS.map((n) => ( <span key={n}>{n}</span> ))} </motion.span> </span> ); } export interface RollingValueProps { /** Pre-formatted text, e.g. "$1,234.56" or "2.4815". Digits roll; everything else stays put. */ value: string; /** * The raw value behind the text (a bigint amount or a number). When it changes, the text * flashes green for an increase and red for a decrease. */ trend?: bigint | number; /** Flash on increases and decreases. @default true */ flash?: boolean; className?: string; } type Flash = { direction: "up" | "down"; key: number }; /** * Rolling digits for pre-formatted strings, so bigint amounts animate without ever becoming * floats. Uses tabular figures; screen readers get the plain text. */ export function RollingValue({ value, trend, flash = true, className }: RollingValueProps) { const reduceMotion = useReducedMotion() ?? false; const [previous, setPrevious] = useState(trend); const [pulse, setPulse] = useState<Flash | null>(null); if (trend !== previous) { setPrevious(trend); if (flash && trend !== undefined && previous !== undefined) { setPulse({ direction: trend > previous ? "up" : "down", key: (pulse?.key ?? 0) + 1 }); } } useEffect(() => { if (!pulse) return; const timer = window.setTimeout(() => setPulse(null), 900); return () => window.clearTimeout(timer); }, [pulse]); // Key characters by their distance from the end, so a given place value keeps its reel. const chars = [...value]; return ( <span className={cn( "relative inline-flex tabular-nums transition-colors duration-300", pulse?.direction === "up" && "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]", pulse?.direction === "down" && "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]", className, )} > <span className="sr-only">{value}</span> <AnimatePresence> {pulse && !reduceMotion ? ( <motion.span key={pulse.key} aria-hidden="true" className={cn( "pointer-events-none absolute -inset-x-1 inset-y-0 rounded-[4px]", pulse.direction === "up" ? "bg-success/12" : "bg-destructive/12", )} initial={{ opacity: 1 }} animate={{ opacity: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.9, ease: "easeOut" }} /> ) : null} </AnimatePresence> <span aria-hidden="true" className="relative inline-flex select-none"> {chars.map((char, index) => { const key = chars.length - index; return /\d/.test(char) ? <Reel key={key} digit={Number(char)} reduceMotion={reduceMotion} /> : <span key={`${key}${char}`}>{char}</span>; })} </span> </span> ); }lib/web3/types.ts/** Shapes the web3 components accept. Your own token and chain objects only need these fields. */ export interface Token { symbol: string; name?: string; /** Token decimals: 18 for ETH, 6 for USDC. Amounts are bigint in these units. */ decimals: number; address?: string; /** Logo URL. Without one, a generated avatar (colored circle and symbol) is shown. */ iconUrl?: string; /** Avatar color for the generated icon. Picked from the symbol when omitted. */ color?: string; /** USD price as a bigint with USD_DECIMALS (8) decimals. */ price?: bigint; } export interface Chain { id: number; name: string; shortName?: string; iconUrl?: string; color?: string; /** Block explorer base URL without a trailing slash, e.g. https://etherscan.io */ explorerUrl?: string; nativeSymbol?: string; } export interface WalletOption { id: string; name: string; /** Logo URL. Without one, a generated squircle with the wallet's initial is shown. */ iconUrl?: string; color?: string; /** Shows a "Recent" badge. */ recent?: boolean; /** The wallet is detected in this browser. */ installed?: boolean; /** Connects by scanning a QR code with a phone instead of a browser extension. */ qr?: boolean; /** Short helper line under the name. */ description?: string; } /** Lifecycle of anything the user signs: every state gets its own visual. */ export type TxStatus = "idle" | "awaiting-signature" | "pending" | "confirmed" | "failed"; /** Explorer URL for an address or transaction, or undefined without an explorer. */ export function explorerLink(explorerUrl: string | undefined, kind: "address" | "tx", value: string) { return explorerUrl ? `${explorerUrl.replace(/\/$/, "")}/${kind}/${value}` : undefined; }Add the theme tokens
This component uses --success and --success-foreground, which shadcn does not define by default. Add them to your global CSS:
app/globals.css:root { --success: #15803d; --success-foreground: #ffffff; } .dark { --success: #4ade80; --success-foreground: #052e16; } @theme inline { --color-success: var(--success); --color-success-foreground: var(--success-foreground); }Copy the source
components/ui/staking-card/staking-card.tsx"use client"; import { useEffect, useId, useState, type ComponentProps } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { Check, TrendingUp } from "lucide-react"; import { RangeSlider } from "@/components/ui/range-slider/range-slider"; import { cn } from "@/lib/utils"; import { duration, ease, spring } from "@/lib/motion"; import { formatAmount, formatBps, formatUsd, percentOf, toUsd } from "@/lib/web3/format"; import { TokenIcon } from "@/lib/web3/icons"; import { RollingValue } from "@/lib/web3/rolling-value"; import type { Token } from "@/lib/web3/types"; export type StakingStatus = "idle" | "staking" | "unstaking" | "claiming" | "claimed"; export interface StakingCardProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Token being staked. Its price enables USD values. */ token: Token; /** Annual yield in basis points (840 = 8.4%). */ apyBps: number; /** Amount staked, in the token's smallest unit. */ staked: bigint; /** Wallet balance available to stake. */ balance: bigint; /** Pending rewards when this prop was last updated; the counter ticks up from here. */ rewards: bigint; /** Reward token, when different from the staked token. */ rewardToken?: Token; /** Lock terms shown under the title. @default "Unstake anytime" */ lockLabel?: string; /** Action in progress. "claimed" plays the success burst. @default "idle" */ status?: StakingStatus; /** Called with the amount to stake. */ onStake?: (amount: bigint) => void; /** Called with the amount to unstake. */ onUnstake?: (amount: bigint) => void; /** Called from the Claim button. */ onClaim?: () => void; } const SECONDS_PER_YEAR = BigInt(31_536_000); const PARTICLES = Array.from({ length: 10 }, (_, i) => (i / 10) * Math.PI * 2); const Spinner = () => ( <svg className="size-4 animate-spin motion-reduce:animate-pulse" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2.5" /> <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> </svg> ); export function StakingCard({ token, apyBps, staked, balance, rewards, rewardToken = token, lockLabel = "Unstake anytime", status = "idle", onStake, onUnstake, onClaim, className, ...props }: StakingCardProps) { const reduceMotion = useReducedMotion() ?? false; const id = useId(); const [mode, setMode] = useState<"stake" | "unstake">("stake"); const [percent, setPercent] = useState(50); const [elapsed, setElapsed] = useState(0); const [baseline, setBaseline] = useState(rewards); if (rewards !== baseline) { setBaseline(rewards); setElapsed(0); } useEffect(() => { if (staked === BigInt(0)) return; const timer = window.setInterval(() => setElapsed((s) => s + 1), 1000); return () => window.clearInterval(timer); }, [staked]); // Rewards accrue per second with bigint math: staked × APY ÷ seconds in a year. const perSecond = (staked * BigInt(apyBps)) / (BigInt(10000) * SECONDS_PER_YEAR); const liveRewards = rewards + perSecond * BigInt(elapsed); const max = mode === "stake" ? balance : staked; const amount = percent >= 100 ? max : percentOf(max, percent); const busy = status === "staking" || status === "unstaking"; const usd = (value: bigint, t: Token) => (t.price !== undefined ? formatUsd(toUsd(value, t.decimals, t.price)) : null); return ( <div className={cn("w-full max-w-sm rounded-3xl border border-border bg-background p-5 shadow-md", className)} {...props}> <div className="flex items-start justify-between gap-3"> <div className="flex items-center gap-3"> <TokenIcon token={token} size={40} /> <div> <p className="text-sm font-semibold text-foreground">Stake {token.symbol}</p> <p className="text-xs text-muted-foreground">{lockLabel}</p> </div> </div> <span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2.5 py-1 font-mono text-xs font-semibold text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] tabular-nums"> <TrendingUp className="size-3" aria-hidden="true" /> {formatBps(apyBps)} APY </span> </div> <dl className="mt-5 grid grid-cols-2 gap-2"> {[ { label: "Staked", value: staked }, { label: "In wallet", value: balance }, ].map((item) => ( <div key={item.label} className="rounded-xl bg-surface p-3"> <dt className="text-xs text-muted-foreground">{item.label}</dt> <dd className="mt-0.5 font-mono text-sm font-medium text-foreground tabular-nums"> {formatAmount(item.value, token.decimals, { maxFractionDigits: 2 })} {token.symbol} </dd> </div> ))} </dl> <div className="mt-3 flex items-center justify-between gap-3 rounded-xl border border-border p-3"> <div className="min-w-0"> <p className="text-xs text-muted-foreground">Rewards</p> <p className="font-mono text-base font-semibold text-foreground tabular-nums"> <RollingValue value={formatAmount(liveRewards, rewardToken.decimals, { maxFractionDigits: 4, minFractionDigits: 4 })} flash={false} />{" "} <span className="font-sans text-xs font-medium text-muted-foreground">{rewardToken.symbol}</span> </p> </div> <div className="relative"> <AnimatePresence> {status === "claimed" && !reduceMotion ? PARTICLES.map((angle, i) => ( <motion.span key={`p${i}`} aria-hidden="true" className={cn("pointer-events-none absolute top-1/2 left-1/2 size-1.5 rounded-full", i % 2 ? "bg-success" : "bg-primary")} initial={{ x: 0, y: 0, opacity: 1, scale: 1 }} animate={{ x: Math.cos(angle) * 34, y: Math.sin(angle) * 26, opacity: 0, scale: 0.4 }} transition={{ duration: 0.7, ease: ease.out }} /> )) : null} </AnimatePresence> <button type="button" onClick={onClaim} disabled={status === "claiming" || status === "claimed" || liveRewards === BigInt(0)} className={cn( "relative inline-flex h-10 min-w-24 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full px-4 text-[13px] font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default", status === "claimed" ? "bg-success text-success-foreground" : "bg-foreground text-background enabled:hover:bg-foreground/90 disabled:opacity-60", )} > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={status === "claimed" ? "done" : status === "claiming" ? "busy" : "idle"} className="flex items-center gap-1.5" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(3px)" }} transition={{ duration: duration.quick, ease: ease.out }} > {status === "claimed" ? ( <> <Check className="size-3.5" strokeWidth={3} aria-hidden="true" /> Claimed </> ) : status === "claiming" ? ( <> <Spinner /> Claiming </> ) : ( "Claim" )} </motion.span> </AnimatePresence> </button> </div> </div> <LayoutGroup id={id}> <div role="tablist" aria-label="Action" className="mt-5 flex gap-1 rounded-xl bg-muted p-1"> {(["stake", "unstake"] as const).map((option) => ( <button key={option} type="button" role="tab" aria-selected={mode === option} aria-controls={`${id}-panel`} onClick={() => setMode(option)} className={cn( "relative after:absolute after:inset-x-0 after:-inset-y-0.5 h-9 flex-1 cursor-pointer rounded-lg text-[13px] font-medium capitalize outline-none focus-visible:ring-2 focus-visible:ring-ring", mode === option ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} > {mode === option ? ( <motion.span layoutId="stake-tab" transition={reduceMotion ? { duration: 0 } : spring.snappy} className="absolute inset-0 rounded-lg bg-background shadow-xs" /> ) : null} <span className="relative">{option}</span> </button> ))} </div> </LayoutGroup> <div id={`${id}-panel`} role="tabpanel" aria-label={mode === "stake" ? "Stake" : "Unstake"} className="mt-4"> <div className="flex items-baseline justify-between gap-3"> <p className="font-mono text-2xl font-semibold tracking-tight text-foreground tabular-nums"> {formatAmount(amount, token.decimals, { maxFractionDigits: 2 })} <span className="ml-1.5 font-sans text-sm font-medium text-muted-foreground">{token.symbol}</span> </p> <p className="font-mono text-xs text-muted-foreground tabular-nums">{usd(amount, token) ?? ""}</p> </div> <RangeSlider className="mt-3" value={[percent]} onValueChange={([v]) => setPercent(v)} min={0} max={100} step={1} ticks={false} formatValue={(v) => `${v}%`} thumbLabels={[mode === "stake" ? "Share of wallet balance to stake" : "Share of stake to withdraw"]} disabled={max === BigInt(0) || busy} /> <button type="button" disabled={amount === BigInt(0) || busy} aria-busy={busy || undefined} onClick={() => (mode === "stake" ? onStake?.(amount) : onUnstake?.(amount))} className="mt-5 flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl bg-foreground text-sm font-semibold text-background outline-none transition-colors enabled:hover:bg-foreground/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground" > {busy ? <Spinner /> : null} {status === "staking" ? "Staking…" : status === "unstaking" ? "Unstaking…" : amount === BigInt(0) ? max === BigInt(0) ? mode === "stake" ? `No ${token.symbol} to stake` : "Nothing staked" : "Choose an amount" : `${mode === "stake" ? "Stake" : "Unstake"} ${formatAmount(amount, token.decimals, { maxFractionDigits: 2 })} ${token.symbol}`} </button> </div> <span className="sr-only" role="status" aria-live="polite"> {status === "claimed" ? "Rewards claimed" : busy ? (status === "staking" ? "Staking" : "Unstaking") : ""} </span> </div> ); }
Usage
import { StakingCard } from "@/components/ui/staking-card/staking-card";
<StakingCard
token={{ symbol: "USDC", decimals: 6, price: usdcPrice }}
apyBps={840}
staked={position.staked}
balance={walletBalance}
rewards={position.pendingRewards} // the counter ticks up from here
status={status} // "idle" | "staking" | "unstaking" | "claiming" | "claimed"
onStake={(amount) => stake(amount)}
onUnstake={(amount) => unstake(amount)}
onClaim={claim}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
token* | Token | – | Token being staked. Its price enables USD values. |
apyBps* | number | – | Annual yield in basis points (840 = 8.4%). |
staked* | bigint | – | Amount staked, in the token's smallest unit. |
balance* | bigint | – | Wallet balance available to stake. |
rewards* | bigint | – | Pending rewards when this prop was last updated; the counter ticks up from here. |
rewardToken | Token | – | Reward token, when different from the staked token. |
lockLabel | string | "Unstake anytime" | Lock terms shown under the title. |
status | StakingStatus | "idle" | Action in progress. "claimed" plays the success burst. |
onStake | (amount: bigint) => void | – | Called with the amount to stake. |
onUnstake | (amount: bigint) => void | – | Called with the amount to unstake. |
onClaim | () => void | – | Called from the Claim button. |
Also accepts every native <div> attribute, including ref and className.
- Rewards accrue client-side as staked × APY ÷ seconds per year, all in bigint. Pass fresh rewards from your contract to resync; the counter restarts from the new value.
- Reuses Range Slider for the amount, so it installs with it.
Accessibility
- Stake and Unstake are tabs with a sliding indicator; the amount slider is Range Slider, so it works with arrow keys and has a named thumb.
- The action button always says what it will do ("Stake 920.26 USDC"), and busy and claimed states are announced through a live region.
- The live rewards counter rolls its digits without color flashes so it doesn't distract; screen readers get plain text.
- Reduced motion skips the claim burst, moves the tab indicator instantly and stops the digit reels.