Skip to content

Mobile Trade Bar

A sticky bar that expands into a trade sheet.

Installation

CLI

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

Manual

  1. Install the dependencies

    npm install clsx 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>
      );
    }
  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/mobile-trade-bar/mobile-trade-bar.tsx
    "use client";
    
    import { useEffect, useRef, useState, type RefObject } from "react";
    import { AnimatePresence, LayoutGroup, motion, useDragControls, useReducedMotion, type PanInfo } from "motion/react";
    import { cn } from "@/lib/utils";
    import { TradeTicket } from "@/components/ui/trade-ticket/trade-ticket";
    import { formatPrice, formatProbability } from "@/lib/markets/format";
    import { complement } from "@/lib/markets/math";
    import type { Market } from "@/lib/markets/types";
    import { RollingValue } from "@/lib/web3/rolling-value";
    
    export interface MobileTradeBarProps {
      /** A binary market (multi-outcome markets trade their leading outcome). */
      market: Market;
      /** Scrolling element that hides and shows the bar; defaults to the window. */
      scrollContainer?: RefObject<HTMLElement | null>;
      /** Pin to the nearest positioned ancestor instead of the viewport (for previews and phone frames). @default false */
      contained?: boolean;
      /** Simulated balance passed to the ticket, in cents. @default 50000 */
      balance?: number;
      /** Classes for the bar. */
      className?: string;
    }
    
    const layoutKey = (id: string, side: "yes" | "no") => `mobile-bar-${id}-${side}`;
    
    /**
     * A sticky bottom bar for phones with the market's odds and Yes/No buttons. It hides while you
     * scroll down and returns when you scroll up; a button expands into a bottom sheet with the
     * Trade Ticket, which snaps between two heights and drags down to dismiss.
     */
    export function MobileTradeBar({ market, scrollContainer, contained = false, balance = 50000, className }: MobileTradeBarProps) {
      const reduceMotion = useReducedMotion();
      const [hidden, setHidden] = useState(false);
      const [sheet, setSheet] = useState<"yes" | "no" | null>(null);
      const [tall, setTall] = useState(false);
      const dragControls = useDragControls();
      // The opener unmounts while the sheet is open (its layoutId morphs into the sheet), so focus is
      // returned to whichever button is mounted for that side after closing.
      const buttons = useRef<Partial<Record<"yes" | "no", HTMLButtonElement | null>>>({});
      const lastSide = useRef<"yes" | "no">("yes");
      const sheetRef = useRef<HTMLDivElement>(null);
      const leader = [...market.outcomes].sort((a, b) => b.price - a.price)[0];
      const yesPrice = market.type === "binary" ? market.outcomes[0].price : leader.price;
    
      // Hide on scroll down, show on scroll up.
      useEffect(() => {
        const target: HTMLElement | Window = scrollContainer?.current ?? window;
        let last = "scrollY" in target ? target.scrollY : target.scrollTop;
        const onScroll = () => {
          const y = "scrollY" in target ? target.scrollY : target.scrollTop;
          if (Math.abs(y - last) < 6) return;
          setHidden(y > last && y > 40);
          last = y;
        };
        target.addEventListener("scroll", onScroll, { passive: true });
        return () => target.removeEventListener("scroll", onScroll);
      }, [scrollContainer]);
    
      useEffect(() => {
        if (!sheet) return;
        sheetRef.current?.focus();
        const onKey = (event: KeyboardEvent) => {
          if (event.key !== "Escape") return;
          setSheet(null);
          setTall(false);
          requestAnimationFrame(() => buttons.current[lastSide.current]?.focus());
        };
        window.addEventListener("keydown", onKey);
        return () => window.removeEventListener("keydown", onKey);
      }, [sheet]);
    
      const close = () => {
        setSheet(null);
        setTall(false);
        requestAnimationFrame(() => buttons.current[lastSide.current]?.focus());
      };
      const dragged = useRef(false);
      const onDragEnd = (_: unknown, info: PanInfo) => {
        dragged.current = Math.abs(info.offset.y) > 4;
        if (info.offset.y > 120 || info.velocity.y > 600) close();
        else if (info.offset.y < -60 || info.velocity.y < -500) setTall(true);
        else if (info.offset.y > 60) setTall(false);
      };
    
      const position = contained ? "absolute" : "fixed";
      return (
        <LayoutGroup>
          <motion.div
            animate={{ y: hidden && !sheet ? "130%" : "0%" }}
            transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 36 }}
            className={cn(position, "inset-x-3 bottom-3 z-30 flex items-center gap-2 rounded-2xl border border-border bg-background/95 p-2 pl-3 shadow-[0_10px_30px_-12px_rgb(0_0_0/0.35)] backdrop-blur", className)}
          >
            <div className="min-w-0 flex-1">
              <p className="truncate text-[12px] text-muted-foreground">{market.question}</p>
              <p className="text-sm font-semibold text-foreground tabular-nums">
                <RollingValue value={formatProbability(yesPrice)} trend={yesPrice} /> <span className="text-[12px] font-normal text-muted-foreground">{market.type === "binary" ? "chance" : leader.name}</span>
              </p>
            </div>
            {(["yes", "no"] as const).map((side) =>
              sheet === side ? (
                <span key={side} className="h-10 w-[4.5rem]" />
              ) : (
                <motion.button
                  key={side}
                  ref={(node: HTMLButtonElement | null) => {
                    buttons.current[side] = node;
                  }}
                  layoutId={layoutKey(market.id, side)}
                  type="button"
                  onClick={() => {
                    lastSide.current = side;
                    setSheet(side);
                  }}
                  className={cn("h-10 w-[4.5rem] shrink-0 cursor-pointer rounded-xl text-[13px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", side === "yes" ? "bg-success/12 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]" : "bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))]")}
                >
                  {side === "yes" ? "Yes" : "No"} {formatPrice(side === "yes" ? yesPrice : complement(yesPrice))}
                </motion.button>
              ),
            )}
          </motion.div>
          <AnimatePresence>
            {sheet ? (
              <>
                <motion.div key="backdrop" aria-hidden="true" className={cn(position, "inset-0 z-40 bg-black/30")} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={close} />
                <motion.div
                  key="sheet"
                  ref={sheetRef}
                  role="dialog"
                  aria-modal="true"
                  aria-label="Trade"
                  tabIndex={-1}
                  drag="y"
                  dragControls={dragControls}
                  dragListener={false}
                  dragConstraints={{ top: 0, bottom: 0 }}
                  dragElastic={{ top: 0.1, bottom: 0.8 }}
                  onDragEnd={onDragEnd}
                  initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
                  animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
                  exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
                  transition={{ type: "spring", stiffness: 340, damping: 36 }}
                  className={cn(position, "inset-x-0 bottom-0 z-50 flex flex-col rounded-t-3xl bg-background shadow-2xl outline-none", tall ? "h-[92%]" : "h-[68%]")}
                >
                  <button
                    type="button"
                    aria-label={tall ? "Shrink sheet (or drag down to close)" : "Expand sheet (or drag up)"}
                    onPointerDown={(event) => dragControls.start(event)}
                    onClick={() => {
                      // A drag that ends on the handle also fires click; only a tap toggles.
                      if (dragged.current) dragged.current = false;
                      else setTall((t) => !t);
                    }}
                    className="flex h-10 w-full shrink-0 cursor-grab touch-none items-center justify-center rounded-t-3xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset active:cursor-grabbing"
                  >
                    <span className="h-1.5 w-10 rounded-full bg-foreground/20" />
                  </button>
                  <div className="min-h-0 flex-1 overflow-y-auto px-3 pb-4">
                    <TradeTicket market={market} outcomeId={market.type === "binary" ? market.outcomes[0].id : leader.id} choice={sheet} balance={balance} layoutId={layoutKey(market.id, sheet)} onClose={close} className="mx-auto max-w-none border-0 shadow-none" />
                  </div>
                </motion.div>
              </>
            ) : null}
          </AnimatePresence>
        </LayoutGroup>
      );
    }

Usage

import { MobileTradeBar } from "@/components/ui/mobile-trade-bar/mobile-trade-bar";

// Fixed to the viewport on small screens; hide it from md up with className
<MobileTradeBar market={market} className="md:hidden" />

// Inside a framed preview: pass the scrolling element and use contained
<MobileTradeBar market={market} scrollContainer={scrollerRef} contained />

API reference

PropTypeDefaultDescription
market*Market–A binary market (multi-outcome markets trade their leading outcome).
scrollContainerRefObject<HTMLElement | null>–Scrolling element that hides and shows the bar; defaults to the window.
containedbooleanfalsePin to the nearest positioned ancestor instead of the viewport (for previews and phone frames).
balancenumber50000Simulated balance passed to the ticket, in cents.
classNamestring–Classes for the bar.
  • The bar hides when the page scrolls down and returns on scroll up. Pass scrollContainer when the scrolling element is not the window.
  • The sheet has two snap points (68% and 92% of the container); a fast or long downward drag closes it.
  • The sheet contains Trade Ticket, so pricing, impact and confirmation behave exactly as in #165.

Accessibility

  • The Yes and No buttons include the price. Opening one moves focus into the sheet, which is a labelled modal dialog; closing returns focus to the button that opened it.
  • Escape, the backdrop and the close button all dismiss the sheet. Dragging the handle is optional; the handle is also a button that toggles the tall snap point.
  • With reduced motion the button-to-sheet morph and spring snaps become instant.