Skip to content

Trade Ticket

Buy and sell outcome shares with a live payout summary.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/trade-ticket.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  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/markets/format.ts
    import { FULL } from "@/lib/markets/math";
    
    const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
    // minimumFractionDigits: 0 matters: currency defaults to 2, and Node and browsers clamp it differently ("$412.0K" vs "$412K").
    const usdCompact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 });
    
    /** "64%" (or "64.2%" with decimals). */
    export const formatProbability = (bps: number, decimals = 0) => `${(bps / 100).toFixed(decimals)}%`;
    
    /** "64¢". */
    export const formatPrice = (bps: number) => `${Math.round(bps / 100)}¢`;
    
    /** "$1,234.56", or "$1.2M" compact. Input in cents. */
    export const formatUsd = (cents: number, compact = false) => (compact ? usdCompact.format(cents / 100) : usd.format(cents / 100));
    
    /** Shares from hundredths: "156.25". */
    export const formatShares = (hundredths: number) => (hundredths / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    
    /** "▲ 4%" / "▼ 2.5%" from a basis-point change (never color alone: the arrow carries the direction). */
    export function formatChange(bps: number) {
      const value = Math.abs(bps) / 100;
      return `${bps >= 0 ? "▲" : "▼"} ${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}%`;
    }
    
    /** Signed percent from basis points: "+56%". */
    export const formatReturn = (bps: number) => `${bps >= 0 ? "+" : "−"}${(Math.abs(bps) / 100).toFixed(bps % 100 === 0 ? 0 : 1)}%`;
    
    /** "Ends in 3d 4h", "Ends in 42m" or "Ended". */
    export function timeLeft(endsAt: number, now: number) {
      const ms = endsAt - now;
      if (ms <= 0) return "Ended";
      const minutes = Math.floor(ms / 60000);
      const days = Math.floor(minutes / 1440);
      const hours = Math.floor((minutes % 1440) / 60);
      if (days > 0) return `Ends in ${days}d ${hours}h`;
      if (hours > 0) return `Ends in ${hours}h ${minutes % 60}m`;
      return `Ends in ${Math.max(1, minutes)}m`;
    }
    
    /** A fixed, locale-stable date: "Mar 14, 2027". */
    export const formatDate = (ms: number) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(ms);
    
    export { FULL };
    lib/markets/math.ts
    /**
     * Prediction market math on integers only: prices in basis points (10000 = $1.00 = 100%), money in
     * cents and shares in hundredths. A share pays $1 (100 cents) if its outcome wins.
     */
    export const FULL = 10000;
    
    const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    
    /** The other side of a binary price: Yes + No = 100¢. */
    export const complement = (price: number) => FULL - price;
    
    /** Shares (in hundredths) that `amount` cents buys at `price` basis points. */
    export const sharesFor = (amount: number, price: number) => (price > 0 ? Math.floor((amount * FULL) / price) : 0);
    
    /** Payout in cents if those shares win: each share pays 100 cents, so hundredths of a share map 1:1 to cents. */
    export const payoutFor = (shares: number) => shares;
    
    /** Value in cents of shares (hundredths) at a price. */
    export const valueOf = (shares: number, price: number) => Math.floor((shares * price) / FULL);
    
    export interface BuyQuote {
      /** Average fill price, basis points. */
      averagePrice: number;
      /** Price after the trade, basis points. */
      newPrice: number;
      /** Price impact, basis points (newPrice − price). */
      impact: number;
      /** Shares received, hundredths. */
      shares: number;
      /** Payout if it wins, cents. */
      payout: number;
      /** Return if it wins, basis points of the amount (5600 = +56%). */
      returnBps: number;
    }
    
    /**
     * A simulated buy against a constant-product style curve: the more of the pool you take, the further
     * the price moves toward $1. Good enough to show impact and average price in a demo.
     */
    export function quoteBuy(price: number, amount: number, liquidity: number): BuyQuote {
      const p = clamp(Math.round(price), 1, FULL - 1);
      if (amount <= 0) return { averagePrice: p, newPrice: p, impact: 0, shares: 0, payout: 0, returnBps: 0 };
      const take = Math.floor((amount * FULL) / (amount + Math.max(1, liquidity)));
      const newPrice = clamp(p + Math.floor(((FULL - p) * take) / FULL), 1, FULL - 1);
      const averagePrice = Math.floor((p + newPrice) / 2);
      const shares = sharesFor(amount, averagePrice);
      const payout = payoutFor(shares);
      return { averagePrice, newPrice, impact: newPrice - p, shares, payout, returnBps: Math.floor(((payout - amount) * FULL) / amount) };
    }
    
    /** A simulated limit buy: fills entirely at the limit price, no impact. */
    export function quoteLimit(limitPrice: number, amount: number): BuyQuote {
      const p = clamp(Math.round(limitPrice), 1, FULL - 1);
      const shares = sharesFor(Math.max(0, amount), p);
      return { averagePrice: p, newPrice: p, impact: 0, shares, payout: shares, returnBps: amount > 0 ? Math.floor(((shares - amount) * FULL) / amount) : 0 };
    }
    
    /** Proceeds in cents from selling shares (hundredths), with the price sliding toward 0 by the same curve. */
    export function quoteSell(price: number, shares: number, liquidity: number) {
      const p = clamp(Math.round(price), 1, FULL - 1);
      const gross = valueOf(shares, p);
      const take = Math.floor((gross * FULL) / (gross + Math.max(1, liquidity)));
      const newPrice = clamp(p - Math.floor((p * take) / FULL), 1, FULL - 1);
      const averagePrice = Math.floor((p + newPrice) / 2);
      return { proceeds: valueOf(shares, averagePrice), averagePrice, newPrice, impact: newPrice - p };
    }
    
    /** Profit and loss in cents for shares bought at an average price, marked at the current price. */
    export function profitAndLoss(shares: number, averagePrice: number, currentPrice: number) {
      const cost = valueOf(shares, averagePrice);
      const value = valueOf(shares, currentPrice);
      return { cost, value, pnl: value - cost, pnlBps: cost ? Math.floor(((value - cost) * FULL) / cost) : 0 };
    }
    
    /** Scales positive weights so they sum to exactly 10000 (rounding error goes to the largest). */
    export function normalize(prices: number[]) {
      const total = prices.reduce((sum, p) => sum + Math.max(0, p), 0) || 1;
      const scaled = prices.map((p) => Math.floor((Math.max(0, p) * FULL) / total));
      const rest = FULL - scaled.reduce((sum, p) => sum + p, 0);
      const largest = scaled.indexOf(Math.max(...scaled));
      if (largest >= 0) scaled[largest] += rest;
      return scaled;
    }
    lib/markets/types.ts
    /** Prices and probabilities are integer basis points (0–10000: 6400 = 64% = 64¢), money is integer cents. */
    export type MarketStatus = "open" | "closing" | "resolved";
    
    export interface MarketOutcome {
      id: string;
      name: string;
      /** Price in basis points (0–10000). Binary markets: Yes + No always sum to 10000. */
      price: number;
      /** 24h change in basis points (positive = up). */
      change24h?: number;
      /** Avatar or icon image for the outcome. */
      image?: string;
      /** Volume traded on this outcome, in cents. */
      volume?: number;
    }
    
    export interface PricePoint {
      /** Timestamp, ms. */
      t: number;
      /** Price per outcome, in basis points, same order as outcomes. */
      prices: number[];
    }
    
    export interface Market {
      id: string;
      question: string;
      /** Topic label, e.g. "Tech". */
      topic: string;
      /** Market image URL; a generated icon is used without one. */
      image?: string;
      type: "binary" | "multi";
      /** Binary markets: [Yes, No]. Multi-outcome: probabilities normalized to 10000. */
      outcomes: MarketOutcome[];
      /** Total volume, cents. */
      volume: number;
      /** Simulated liquidity used for price impact, cents. */
      liquidity: number;
      traders: number;
      /** Names of a few traders, for the avatar group. */
      traderNames?: string[];
      /** End time, ms since epoch. */
      endsAt: number;
      status: MarketStatus;
      /** Winning outcome once resolved. */
      resolvedOutcomeId?: string;
      comments?: number;
      history?: PricePoint[];
      rules?: string;
      resolutionSource?: { label: string; href: string };
    }
    
    export type TradeSide = "buy" | "sell";
    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;
    }
  3. Add the theme tokens

    This component uses --success, --success-foreground, and --warning, which shadcn does not define by default. Add them to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
      --success-foreground: #ffffff;
      --warning: #b45309;
    }
    
    .dark {
      --success: #4ade80;
      --success-foreground: #052e16;
      --warning: #fbbf24;
    }
    
    @theme inline {
      --color-success: var(--success);
      --color-success-foreground: var(--success-foreground);
      --color-warning: var(--warning);
    }
  4. Copy the source

    components/ui/trade-ticket/trade-ticket.tsx
    "use client";
    
    import { useEffect, useId, useState } from "react";
    import { AnimatePresence, LayoutGroup, motion, useAnimate, useReducedMotion } from "motion/react";
    import { Minus, Plus, TriangleAlert, X } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { TokenAmountInput } from "@/components/ui/token-amount-input/token-amount-input";
    import { TransactionToastStack, type TransactionToastItem } from "@/components/ui/transaction-toast/transaction-toast";
    import { formatPrice, formatReturn, formatShares, formatUsd } from "@/lib/markets/format";
    import { FULL, complement, quoteBuy, quoteLimit, quoteSell } from "@/lib/markets/math";
    import type { Market, TradeSide } from "@/lib/markets/types";
    import { RollingValue } from "@/lib/web3/rolling-value";
    import type { Token } from "@/lib/web3/types";
    
    export interface TradeTicketProps {
      /** The market to trade. Everything is simulated. */
      market: Market;
      /** Outcome to start on. @default the first outcome */
      outcomeId?: string;
      /** For binary markets: buy Yes or No. @default "yes" */
      choice?: "yes" | "no";
      /** Buy or Sell tab to start on. @default "buy" */
      defaultSide?: TradeSide;
      /** Simulated USDC balance, in cents. @default 50000 */
      balance?: number;
      /** Shares held of the selected outcome (hundredths), for Sell. @default 0 */
      position?: number;
      /** Shared layout id, so a pressed Market Card button morphs into the ticket. */
      layoutId?: string;
      /** Simulate a failed order (the button shakes and an error shows). @default false */
      simulateFailure?: boolean;
      /** Shows a close button. */
      onClose?: () => void;
      /** Fires when a simulated order succeeds. */
      onTrade?: (trade: { side: TradeSide; outcomeId: string; choice: "yes" | "no"; amount: number; shares: number; price: number }) => void;
      /** Classes for the ticket. */
      className?: string;
    }
    
    const USDC: Token = { symbol: "USDC", name: "USD Coin (simulated)", decimals: 6, color: "#2563EB" };
    const IMPACT_WARNING = 500;
    type Status = "idle" | "confirming" | "success" | "error";
    
    const toCents = (amount: bigint | null) => (amount === null ? 0 : Number(amount / BigInt(10000)));
    
    /**
     * A simulated order ticket: Buy/Sell, Yes/No (or an outcome), an amount with quick chips, a live
     * summary (average price, shares, payout, return, price impact) and an optional limit price.
     * Integer math only; nothing is sent anywhere.
     */
    export function TradeTicket({
      market,
      outcomeId,
      choice: initialChoice = "yes",
      defaultSide = "buy",
      balance = 50000,
      position = 0,
      layoutId,
      simulateFailure = false,
      onClose,
      onTrade,
      className,
    }: TradeTicketProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const binary = market.type === "binary";
      const [side, setSide] = useState<TradeSide>(defaultSide);
      const [choice, setChoice] = useState<"yes" | "no">(initialChoice);
      const [outcome, setOutcome] = useState(outcomeId ?? market.outcomes[0].id);
      const [value, setValue] = useState("");
      const [amount, setAmount] = useState(0);
      const [limitMode, setLimitMode] = useState(false);
      const [status, setStatus] = useState<Status>("idle");
      const [toasts, setToasts] = useState<TransactionToastItem[]>([]);
      const [scope, animate] = useAnimate();
    
      const selected = market.outcomes.find((o) => o.id === outcome) ?? market.outcomes[0];
      const basePrice = binary ? (choice === "yes" ? market.outcomes[0].price : complement(market.outcomes[0].price)) : selected.price;
      const [limitPrice, setLimitPrice] = useState(Math.round(basePrice / 100) * 100);
      const label = binary ? (choice === "yes" ? "Yes" : "No") : selected.name;
    
      // Buy: amount is dollars (cents); Sell: amount is shares (hundredths).
      const buyQuote = limitMode ? quoteLimit(limitPrice, amount) : quoteBuy(basePrice, amount, market.liquidity);
      const sellQuote = quoteSell(basePrice, amount, market.liquidity);
      const impact = side === "buy" ? buyQuote.impact : -sellQuote.impact;
      const over = side === "buy" ? amount > balance : amount > position;
    
      useEffect(() => {
        if (status !== "confirming") return;
        const timer = window.setTimeout(() => {
          if (simulateFailure) {
            setStatus("error");
            if (!reduceMotion && scope.current) animate(scope.current, { x: [0, -8, 8, -6, 6, -2, 0] }, { duration: 0.42 });
            setToasts((list) => [...list, { id: `${Date.now()}`, title: `Order for ${label} failed`, description: "Price moved past your limit (simulated)", status: "failed" }]);
            return;
          }
          setStatus("success");
          const shares = side === "buy" ? buyQuote.shares : amount;
          onTrade?.({ side, outcomeId: selected.id, choice, amount: side === "buy" ? amount : sellQuote.proceeds, shares, price: side === "buy" ? buyQuote.averagePrice : sellQuote.averagePrice });
          setToasts((list) => [
            ...list,
            {
              id: `${Date.now()}`,
              title: side === "buy" ? `Bought ${formatShares(shares)} ${label}` : `Sold ${formatShares(shares)} ${label}`,
              description: side === "buy" ? `${formatUsd(amount)} at ${formatPrice(buyQuote.averagePrice)} avg (simulated)` : `Received ${formatUsd(sellQuote.proceeds)} (simulated)`,
              status: "confirmed",
            },
          ]);
        }, 1400);
        return () => window.clearTimeout(timer);
        // The order is captured when it starts confirming.
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [status]);
    
      const setAmountValue = (next: string, parsed: bigint | null) => {
        setValue(next);
        setAmount(side === "buy" ? toCents(parsed) : parsed === null ? 0 : Number(parsed));
        if (status !== "confirming") setStatus("idle");
      };
      const addDollars = (dollars: number | "max") => {
        const cents = dollars === "max" ? balance : amount + dollars * 100;
        const capped = Math.min(cents, balance);
        setValue((capped / 100).toFixed(2).replace(/\.00$/, ""));
        setAmount(capped);
        setStatus("idle");
      };
      const switchSide = (next: TradeSide) => {
        setSide(next);
        setValue("");
        setAmount(0);
        setStatus("idle");
      };
    
      const action = status === "confirming" ? "Confirming…" : status === "success" ? "Done" : amount <= 0 ? "Enter amount" : over ? (side === "buy" ? "Insufficient balance" : "Not enough shares") : `${side === "buy" ? "Buy" : "Sell"} ${label}`;
      const disabled = status === "confirming" || (status !== "success" && (amount <= 0 || over));
      const submit = () => {
        if (status === "success") {
          setStatus("idle");
          setValue("");
          setAmount(0);
          return;
        }
        if (!disabled) setStatus("confirming");
      };
    
      const shareToken: Token = { symbol: label.slice(0, 8), name: `${label} shares`, decimals: 2, color: "#16A34A" };
    
      return (
        <motion.section
          layoutId={layoutId}
          aria-labelledby={`${id}-title`}
          className={cn("relative w-full max-w-sm rounded-2xl border border-border bg-background p-4 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_16px_40px_-20px_rgb(0_0_0/0.3)]", className)}
        >
          <header className="flex items-start justify-between gap-3">
            <div className="min-w-0">
              <p className="flex items-center gap-2 text-[12px] text-muted-foreground">
                Trade
                <span className="inline-flex h-5 items-center gap-1 rounded-full border border-warning/30 bg-warning/10 px-2 text-[11px] font-medium text-[color-mix(in_oklab,var(--warning)_75%,var(--foreground))]">
                  <span aria-hidden="true" className="size-1.5 rounded-full bg-warning" />
                  Simulated
                </span>
              </p>
              <p id={`${id}-title`} className="mt-0.5 line-clamp-2 text-sm font-medium text-foreground">
                {market.question}
              </p>
            </div>
            {onClose ? (
              <button type="button" aria-label="Close trade ticket" onClick={onClose} className="relative after:absolute after:-inset-0.5 -mt-1 -mr-1 grid size-9 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring">
                <X className="size-4" aria-hidden="true" />
              </button>
            ) : null}
          </header>
    
          <LayoutGroup id={`${id}-side`}>
            <div role="tablist" aria-label="Order side" className="mt-3 grid grid-cols-2 gap-0.5 rounded-full bg-surface-2 p-0.5">
              {(["buy", "sell"] as const).map((s) => (
                <button key={s} type="button" role="tab" aria-selected={side === s} onClick={() => switchSide(s)} className={cn("relative h-8 cursor-pointer rounded-full text-[13px] font-medium capitalize outline-none focus-visible:ring-2 focus-visible:ring-ring", side === s ? "text-foreground" : "text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))]")}>
                  {side === s ? <motion.span layoutId="ticket-side" transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }} className="absolute inset-0 rounded-full bg-background shadow-sm" /> : null}
                  <span className="relative">{s}</span>
                </button>
              ))}
            </div>
          </LayoutGroup>
    
          {binary ? (
            <div role="radiogroup" aria-label="Outcome" className="mt-3 grid grid-cols-2 gap-2">
              {(["yes", "no"] as const).map((c) => {
                const price = c === "yes" ? market.outcomes[0].price : complement(market.outcomes[0].price);
                const active = choice === c;
                return (
                  <button
                    key={c}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    onClick={() => setChoice(c)}
                    className={cn(
                      "flex h-11 cursor-pointer items-center justify-between rounded-xl border px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                      active ? (c === "yes" ? "border-success bg-success/10 text-foreground" : "border-destructive bg-destructive/8 text-foreground") : "border-border text-muted-foreground hover:text-foreground",
                    )}
                  >
                    <span>{c === "yes" ? "Yes" : "No"}</span>
                    <span className="tabular-nums">
                      <RollingValue value={formatPrice(price)} trend={price} />
                    </span>
                  </button>
                );
              })}
            </div>
          ) : (
            <label className="mt-3 block">
              <span className="sr-only">Outcome</span>
              <select value={outcome} onChange={(event) => setOutcome(event.target.value)} className="h-11 w-full cursor-pointer rounded-xl border border-border bg-background px-3 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
                {market.outcomes.map((o) => (
                  <option key={o.id} value={o.id}>
                    {o.name} · {formatPrice(o.price)}
                  </option>
                ))}
              </select>
            </label>
          )}
    
          <div className="mt-3">
            <TokenAmountInput
              token={side === "buy" ? USDC : shareToken}
              value={value}
              onValueChange={setAmountValue}
              balance={side === "buy" ? BigInt(balance) * BigInt(10000) : BigInt(position)}
              label={side === "buy" ? "Amount" : "Shares"}
              percentages={side === "buy" ? [] : [25, 50, 100]}
              error={over ? (side === "buy" ? "More than your balance" : "More than you hold") : undefined}
            />
            {side === "buy" ? (
              <div className="mt-2 flex gap-1.5">
                {([1, 10, 100, "max"] as const).map((chip) => (
                  <button key={chip} type="button" onClick={() => addDollars(chip)} className="h-8 flex-1 cursor-pointer rounded-full border border-border text-[12px] font-medium text-foreground outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring">
                    {chip === "max" ? "Max" : `+$${chip}`}
                  </button>
                ))}
              </div>
            ) : null}
          </div>
    
          {side === "buy" ? (
            <div className="mt-3 flex items-center justify-between">
              <label className="flex cursor-pointer items-center gap-2 text-[13px] text-foreground">
                <input type="checkbox" checked={limitMode} onChange={(event) => setLimitMode(event.target.checked)} className="size-4 accent-[var(--primary)]" />
                Limit price
              </label>
              {limitMode ? (
                <div className="flex items-center gap-1" role="group" aria-label="Limit price">
                  <button type="button" aria-label="Lower limit by 1 cent" onClick={() => setLimitPrice((p) => Math.max(100, p - 100))} className="relative after:absolute after:-inset-1 grid size-8 cursor-pointer place-items-center rounded-full border border-border outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring">
                    <Minus className="size-3.5" aria-hidden="true" />
                  </button>
                  <span aria-live="polite" className="w-12 text-center text-sm font-medium tabular-nums">
                    {formatPrice(limitPrice)}
                  </span>
                  <button type="button" aria-label="Raise limit by 1 cent" onClick={() => setLimitPrice((p) => Math.min(FULL - 100, p + 100))} className="relative after:absolute after:-inset-1 grid size-8 cursor-pointer place-items-center rounded-full border border-border outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring">
                    <Plus className="size-3.5" aria-hidden="true" />
                  </button>
                </div>
              ) : null}
            </div>
          ) : null}
    
          <dl className="mt-3 space-y-1.5 rounded-xl bg-surface p-3 text-[13px]">
            {side === "buy" ? (
              <>
                <div className="flex justify-between">
                  <dt className="text-muted-foreground">Avg price</dt>
                  <dd className="font-medium tabular-nums">
                    <RollingValue value={`${(buyQuote.averagePrice / 100).toFixed(1)}¢`} flash={false} />
                  </dd>
                </div>
                <div className="flex justify-between">
                  <dt className="text-muted-foreground">Est. shares</dt>
                  <dd className="font-medium tabular-nums">
                    <RollingValue value={formatShares(buyQuote.shares)} flash={false} />
                  </dd>
                </div>
                <div className="flex justify-between">
                  <dt className="text-muted-foreground">Potential payout</dt>
                  <dd className="font-medium tabular-nums">
                    <RollingValue value={formatUsd(buyQuote.payout)} flash={false} />
                    {amount > 0 ? <span className="ml-1.5 text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]">{formatReturn(buyQuote.returnBps)}</span> : null}
                  </dd>
                </div>
              </>
            ) : (
              <div className="flex justify-between">
                <dt className="text-muted-foreground">You receive</dt>
                <dd className="font-medium tabular-nums">
                  <RollingValue value={formatUsd(sellQuote.proceeds)} flash={false} />
                </dd>
              </div>
            )}
            <div className="flex justify-between">
              <dt className="text-muted-foreground">Price impact</dt>
              <dd className={cn("inline-flex items-center gap-1 font-medium tabular-nums", Math.abs(impact) > IMPACT_WARNING && "text-[color-mix(in_oklab,var(--warning)_80%,var(--foreground))]")}>
                {Math.abs(impact) > IMPACT_WARNING ? <TriangleAlert className="size-3.5" aria-hidden="true" /> : null}
                {(Math.abs(impact) / 100).toFixed(1)}%
                {Math.abs(impact) > IMPACT_WARNING ? <span className="sr-only"> high price impact</span> : null}
              </dd>
            </div>
          </dl>
    
          <div ref={scope} className="relative mt-3">
            <button
              type="button"
              onClick={submit}
              disabled={disabled}
              className={cn(
                "relative flex h-11 w-full cursor-pointer items-center justify-center gap-2 overflow-hidden rounded-xl text-sm font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed",
                status === "success" ? "bg-[color-mix(in_oklab,var(--success)_78%,var(--foreground))] text-success-foreground" : disabled ? "bg-surface-2 text-muted-foreground" : side === "buy" && (!binary || choice === "yes") ? "bg-foreground text-background" : "bg-foreground text-background",
              )}
            >
              {status === "confirming" ? <span aria-hidden="true" className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none" /> : null}
              {status === "success" ? (
                <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduceMotion ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.35, ease: "easeOut" }} />
                </svg>
              ) : null}
              <span aria-live="polite">{action}</span>
            </button>
            {/* A small confetti burst on success. */}
            <AnimatePresence>
              {status === "success" && !reduceMotion ? (
                <span aria-hidden="true" className="pointer-events-none absolute inset-0">
                  {Array.from({ length: 14 }, (_, i) => {
                    const angle = (i / 14) * Math.PI * 2;
                    return (
                      <motion.span
                        key={i}
                        className="absolute top-1/2 left-1/2 size-1.5 rounded-[2px]"
                        style={{ background: ["var(--success)", "var(--primary)", "var(--warning)"][i % 3] }}
                        initial={{ x: 0, y: 0, opacity: 1, rotate: 0 }}
                        animate={{ x: Math.cos(angle) * (40 + (i % 3) * 18), y: Math.sin(angle) * (22 + (i % 4) * 8) - 10, opacity: 0, rotate: 180 }}
                        transition={{ duration: 0.9, ease: "easeOut" }}
                      />
                    );
                  })}
                </span>
              ) : null}
            </AnimatePresence>
          </div>
          {status === "error" ? (
            <p role="alert" className="mt-2 text-[13px] text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]">
              Order rejected: the price moved past your limit. Try again.
            </p>
          ) : null}
          <TransactionToastStack toasts={toasts} onDismiss={(toastId) => setToasts((list) => list.filter((t) => t.id !== toastId))} dismissAfter={5000} />
        </motion.section>
      );
    }

Usage

import { TradeTicket } from "@/components/ui/trade-ticket/trade-ticket";

<TradeTicket
  market={market}
  choice="yes"
  balance={50000} // cents, simulated
  onTrade={(trade) => console.log(trade.shares, trade.price)}
/>

API reference

PropTypeDefaultDescription
market*Market–The market to trade. Everything is simulated.
outcomeIdstringthe first outcomeOutcome to start on.
choice"yes" | "no""yes"For binary markets: buy Yes or No.
defaultSideTradeSide"buy"Buy or Sell tab to start on.
balancenumber50000Simulated USDC balance, in cents.
positionnumber0Shares held of the selected outcome (hundredths), for Sell.
layoutIdstring–Shared layout id, so a pressed Market Card button morphs into the ticket.
simulateFailurebooleanfalseSimulate a failed order (the button shakes and an error shows).
onClose() => void–Shows a close button.
onTrade(trade: { side: TradeSide; outcomeId: string; choice: "yes" | "no"; amount: number; shares: number; price: number }) => void–Fires when a simulated order succeeds.
classNamestring–Classes for the ticket.
  • Everything is simulated: a constant-product style curve gives the average price and impact, shares are amount ÷ price and pay $1 each, all in integer cents, basis points and hundredths of a share (lib/markets/math).
  • The amount field is Token Amount Input with a simulated USDC balance (bigint, 6 decimals); Sell switches it to shares with your position as the max.
  • Success draws a check, bursts a little confetti and adds a Transaction Toast. Pass layoutId to morph from a Market Card button.

Accessibility

  • Buy and Sell are tabs; Yes and No are a radio group whose labels include the price. The amount field, quick chips and limit stepper are all labelled controls.
  • The submit button names the action ("Buy Yes", "Insufficient balance", "Confirming…") and announces changes politely. A failed order shows an alert with the reason.
  • Price impact above 5% is flagged with an icon and a spoken "high price impact", not only with color.
  • With reduced motion the check, confetti and shake are skipped; the states still change.