Web3: DeFi & Tradingv0.2.0
Gas Fee Selector
Slow, standard and fast cards with a live pulse and cost.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/gas-fee-selector.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/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/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> ); }Add the theme tokens
This component uses --success, which shadcn does not define by default. Add it to your global CSS:
app/globals.css:root { --success: #15803d; } .dark { --success: #4ade80; } @theme inline { --color-success: var(--success); }Copy the source
components/ui/gas-fee-selector/gas-fee-selector.tsx"use client"; import { useId, useRef, useState, type ComponentProps, type KeyboardEvent } from "react"; import { LayoutGroup, motion, useReducedMotion } from "motion/react"; import { Clock3, Gauge, Zap, type LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { spring } from "@/lib/motion"; import { formatAmount, formatUsd, toUsd } from "@/lib/web3/format"; import { RollingValue } from "@/lib/web3/rolling-value"; export interface GasOption { id: string; label: string; /** Max fee per gas, in wei. */ maxFeePerGas: bigint; /** Typical confirmation time, in seconds. */ seconds: number; } export interface GasFeeSelectorProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Speed options, usually slow, standard and fast. */ options: GasOption[]; /** Controlled selected option id. */ value?: string; /** Initial option when uncontrolled. @default the middle option */ defaultValue?: string; /** Called when the selection changes. */ onValueChange?: (id: string) => void; /** Gas the transaction uses. @default 150000n */ gasUnits?: bigint; /** Native token symbol. @default "ETH" */ nativeSymbol?: string; /** Native token price in USD with 8 decimals, for the USD estimate. */ nativePrice?: bigint; /** Show the pulsing live indicator (fees update in real time). @default true */ live?: boolean; /** Heading. @default "Network fee" */ label?: string; } const ICONS: LucideIcon[] = [Clock3, Gauge, Zap]; function formatEta(seconds: number) { if (seconds < 60) return `~${seconds}s`; const minutes = Math.round(seconds / 60); return `~${minutes} min`; } export function GasFeeSelector({ options, value, defaultValue, onValueChange, gasUnits = BigInt(150000), nativeSymbol = "ETH", nativePrice, live = true, label = "Network fee", className, ...props }: GasFeeSelectorProps) { const reduceMotion = useReducedMotion() ?? false; const id = useId(); const [internal, setInternal] = useState(defaultValue ?? options[Math.floor(options.length / 2)]?.id); const selected = value ?? internal; const buttons = useRef<(HTMLButtonElement | null)[]>([]); const select = (next: string) => { if (value === undefined) setInternal(next); onValueChange?.(next); }; function onKeyDown(event: KeyboardEvent<HTMLDivElement>) { const index = options.findIndex((o) => o.id === selected); const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; if (!step) return; event.preventDefault(); const next = (index + step + options.length) % options.length; select(options[next].id); buttons.current[next]?.focus(); } return ( <div className={cn("w-full max-w-lg", className)} {...props}> <div className="mb-2.5 flex items-center justify-between"> <p id={`${id}-label`} className="text-sm font-medium text-foreground"> {label} </p> <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="relative flex size-2" aria-hidden="true"> {live ? <span className="absolute inset-0 animate-ping rounded-full bg-success/60 motion-reduce:animate-none" /> : null} <span className={cn("relative size-2 rounded-full", live ? "bg-success" : "bg-muted-foreground/50")} /> </span> {live ? "Live" : "Paused"} </span> </div> <LayoutGroup id={id}> <div role="radiogroup" aria-labelledby={`${id}-label`} onKeyDown={onKeyDown} className="grid grid-cols-1 gap-2 sm:grid-cols-3"> {options.map((option, index) => { const active = option.id === selected; const Icon = ICONS[Math.min(index, ICONS.length - 1)]; const fee = option.maxFeePerGas * gasUnits; return ( <button key={option.id} ref={(node) => { buttons.current[index] = node; }} type="button" role="radio" aria-checked={active} tabIndex={active ? 0 : -1} onClick={() => select(option.id)} className={cn( "group relative flex cursor-pointer flex-col items-start rounded-2xl border p-3.5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", active ? "border-transparent" : "border-border bg-background hover:bg-surface", )} > {active ? ( <motion.span layoutId="gas-selected" transition={reduceMotion ? { duration: 0 } : spring.snappy} className="absolute inset-0 rounded-2xl border-2 border-foreground bg-surface" /> ) : null} <span className="relative flex w-full items-center justify-between"> <span className="flex items-center gap-1.5 text-sm font-medium text-foreground"> <Icon className="size-4 text-muted-foreground" aria-hidden="true" /> {option.label} </span> <span className="font-mono text-xs text-muted-foreground tabular-nums">{formatEta(option.seconds)}</span> </span> <span className="relative mt-3 font-mono text-base font-semibold text-foreground tabular-nums"> {nativePrice !== undefined ? <RollingValue value={formatUsd(toUsd(fee, 18, nativePrice))} trend={fee} /> : `${formatAmount(fee, 18, { maxFractionDigits: 5 })} ${nativeSymbol}`} </span> {nativePrice !== undefined ? ( <span className="relative mt-0.5 font-mono text-xs text-muted-foreground tabular-nums"> {formatAmount(fee, 18, { maxFractionDigits: 5 })} {nativeSymbol} </span> ) : null} <span className="relative font-mono text-[11px] text-muted-foreground tabular-nums"> {formatAmount(option.maxFeePerGas, 9, { maxFractionDigits: 1 })} gwei </span> </button> ); })} </div> </LayoutGroup> </div> ); }
Usage
import { GasFeeSelector } from "@/components/ui/gas-fee-selector/gas-fee-selector";
<GasFeeSelector
options={[
{ id: "slow", label: "Slow", maxFeePerGas: parseGwei("8"), seconds: 180 },
{ id: "standard", label: "Standard", maxFeePerGas: parseGwei("12"), seconds: 30 },
{ id: "fast", label: "Fast", maxFeePerGas: parseGwei("18"), seconds: 12 },
]}
gasUnits={estimatedGas}
nativePrice={ethUsdPrice} // bigint, 8 decimals
onValueChange={setSpeed}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
options* | GasOption[] | – | Speed options, usually slow, standard and fast. |
value | string | – | Controlled selected option id. |
defaultValue | string | the middle option | Initial option when uncontrolled. |
onValueChange | (id: string) => void | – | Called when the selection changes. |
gasUnits | bigint | 150000n | Gas the transaction uses. |
nativeSymbol | string | "ETH" | Native token symbol. |
nativePrice | bigint | – | Native token price in USD with 8 decimals, for the USD estimate. |
live | boolean | true | Show the pulsing live indicator (fees update in real time). |
label | string | "Network fee" | Heading. |
Also accepts every native <div> attribute, including ref and className.
- Fees are maxFeePerGas × gasUnits in wei (bigint), converted to USD with an 8-decimal price. Costs roll and flash when gas prices change.
Accessibility
- A labelled radio group with roving focus: arrow keys move and select, Tab leaves the group.
- Each option reads its speed, time estimate, cost and gwei as text; the live status is text as well as a pulsing dot.
- The selection border glides between cards. Reduced motion moves it instantly, stops the live ping and keeps cost updates to a color change.