Prediction Marketsv0.3.0
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.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/markets/format.tsimport { 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> ); }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/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
| Prop | Type | Default | Description |
|---|---|---|---|
market* | Market | – | A binary market (multi-outcome markets trade their leading outcome). |
scrollContainer | RefObject<HTMLElement | null> | – | Scrolling element that hides and shows the bar; defaults to the window. |
contained | boolean | false | Pin to the nearest positioned ancestor instead of the viewport (for previews and phone frames). |
balance | number | 50000 | Simulated balance passed to the ticket, in cents. |
className | string | – | 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.