Skip to content

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

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion 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/motion.ts
    import 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.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);
    }
    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>
      );
    }
  3. 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);
    }
  4. 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

PropTypeDefaultDescription
options*GasOption[]–Speed options, usually slow, standard and fast.
valuestring–Controlled selected option id.
defaultValuestringthe middle optionInitial option when uncontrolled.
onValueChange(id: string) => void–Called when the selection changes.
gasUnitsbigint150000nGas the transaction uses.
nativeSymbolstring"ETH"Native token symbol.
nativePricebigint–Native token price in USD with 8 decimals, for the USD estimate.
livebooleantrueShow the pulsing live indicator (fees update in real time).
labelstring"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.