Prediction Marketsv0.3.0
Outcome Ladder
Outcomes re-sort by probability as the odds move.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/outcome-ladder.jsonManual
Install the dependencies
npm install clsx lucide-react 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/avatar.tsximport { cn } from "@/lib/utils"; /** A generated avatar: a gradient from a hash of the name, with initials. No images needed. */ export function MarketAvatar({ name, className }: { name: string; className?: string }) { let hash = 0; for (let i = 0; i < name.length; i++) hash = (Math.imul(hash, 31) + name.charCodeAt(i)) | 0; const hue = Math.abs(hash) % 360; const initials = name .replace(/^@/, "") .split(/[\s._-]+/) .map((part) => part[0]) .join("") .slice(0, 2) .toUpperCase(); return ( <span aria-hidden="true" className={cn("grid size-7 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white ring-2 ring-background", className)} style={{ background: `linear-gradient(135deg, hsl(${hue} 70% 55%), hsl(${(hue + 40) % 360} 70% 40%))` }} > {initials} </span> ); } /** A small "Simulated" badge for demos that use mock data. */ export function SimulatedBadge({ className }: { className?: string }) { return ( <span className={cn("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))]", className)}> <span aria-hidden="true" className="size-1.5 rounded-full bg-warning" /> Simulated </span> ); }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 and --warning, which shadcn does not define by default. Add them to your global CSS:
app/globals.css:root { --success: #15803d; --warning: #b45309; } .dark { --success: #4ade80; --warning: #fbbf24; } @theme inline { --color-success: var(--success); --color-warning: var(--warning); }Copy the source
components/ui/outcome-ladder/outcome-ladder.tsx"use client"; import { useEffect, useRef, useState } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarketAvatar } from "@/lib/markets/avatar"; import { formatPrice, formatProbability, formatUsd } from "@/lib/markets/format"; import { FULL, complement } from "@/lib/markets/math"; import type { Market } from "@/lib/markets/types"; import { RollingValue } from "@/lib/web3/rolling-value"; export interface OutcomeLadderProps { /** A multi-outcome market (binary markets show their two outcomes). */ market: Market; /** Fires when Yes or No is pressed on an outcome. */ onTrade?: (outcomeId: string, side: "yes" | "no") => void; /** Classes for the list. */ className?: string; } function Spark({ values }: { values: number[] }) { if (values.length < 2) return null; const min = Math.min(...values); const max = Math.max(...values); const d = values.map((v, i) => `${i ? "L" : "M"}${((i / (values.length - 1)) * 200).toFixed(1)} ${(36 - ((v - min) / Math.max(1, max - min)) * 32).toFixed(1)}`).join(" "); return ( <svg viewBox="0 0 200 40" className="h-10 w-full" aria-hidden="true" preserveAspectRatio="none"> <path d={d} fill="none" stroke="var(--primary)" strokeWidth="1.6" vectorEffect="non-scaling-stroke" /> </svg> ); } /** * Outcomes as a ladder sorted by probability. When the odds change, rows re-sort with a layout * animation and the row that moved flashes; each row expands to a mini chart and its volume. */ export function OutcomeLadder({ market, onTrade, className }: OutcomeLadderProps) { const reduceMotion = useReducedMotion(); const [open, setOpen] = useState<string | null>(null); const [moved, setMoved] = useState<string | null>(null); const previous = useRef<string[]>([]); const sorted = [...market.outcomes].sort((a, b) => b.price - a.price); const order = sorted.map((o) => o.id).join("|"); // Highlight the outcome whose rank changed (from a timer callback, never during render). useEffect(() => { const ids = order.split("|"); const before = previous.current; previous.current = ids; if (!before.length) return; const changed = ids.find((id, i) => before[i] !== id && before.indexOf(id) > i); if (!changed) return; const show = window.setTimeout(() => setMoved(changed), 0); const hide = window.setTimeout(() => setMoved(null), 1200); return () => { window.clearTimeout(show); window.clearTimeout(hide); }; }, [order]); return ( <LayoutGroup> <ul aria-label={`Outcomes for ${market.question}`} className={cn("w-full divide-y divide-border rounded-2xl border border-border bg-background", className)}> {sorted.map((outcome, rank) => { const index = market.outcomes.indexOf(outcome); const expanded = open === outcome.id; const spark = (market.history ?? []).slice(-30).map((p) => p.prices[index] ?? 0); return ( <motion.li key={outcome.id} layout={reduceMotion ? false : "position"} transition={{ type: "spring", stiffness: 380, damping: 34 }} className={cn("relative transition-colors duration-700", moved === outcome.id && "bg-primary/6")}> <div className="flex items-center gap-3 px-3 py-2.5"> <span className="w-4 text-[12px] text-muted-foreground tabular-nums">{rank + 1}</span> <MarketAvatar name={outcome.name} className="size-8" /> <button type="button" aria-expanded={expanded} onClick={() => setOpen(expanded ? null : outcome.id)} className="flex min-w-0 flex-1 cursor-pointer flex-col items-start gap-1 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring" > <span className="flex w-full items-center gap-1.5"> <span className="truncate text-sm font-medium text-foreground">{outcome.name}</span> <ChevronDown className={cn("size-3.5 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")} aria-hidden="true" /> </span> <span className="relative h-1.5 w-full overflow-hidden rounded-full bg-surface-2"> <motion.span className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-primary" initial={false} animate={{ scaleX: outcome.price / FULL }} transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 160, damping: 24 }} /> </span> </button> <span className="w-12 text-right text-sm font-semibold text-foreground tabular-nums"> <RollingValue value={formatProbability(outcome.price)} trend={outcome.price} /> </span> <span className="hidden gap-1.5 sm:flex"> <button type="button" aria-label={`Yes ${formatPrice(outcome.price)} on ${outcome.name}`} onClick={() => onTrade?.(outcome.id, "yes")} className="h-8 w-[4.25rem] cursor-pointer rounded-lg bg-success/12 text-[12px] font-medium tabular-nums text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] outline-none hover:bg-success/18 focus-visible:ring-2 focus-visible:ring-ring"> Yes {formatPrice(outcome.price)} </button> <button type="button" aria-label={`No ${formatPrice(complement(outcome.price))} on ${outcome.name}`} onClick={() => onTrade?.(outcome.id, "no")} className="h-8 w-[4.25rem] cursor-pointer rounded-lg bg-destructive/10 text-[12px] font-medium tabular-nums text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))] outline-none hover:bg-destructive/15 focus-visible:ring-2 focus-visible:ring-ring"> No {formatPrice(complement(outcome.price))} </button> </span> </div> <AnimatePresence initial={false}> {expanded ? ( <motion.div initial={reduceMotion ? { opacity: 0 } : { opacity: 0, gridTemplateRows: "0fr" }} animate={reduceMotion ? { opacity: 1 } : { opacity: 1, gridTemplateRows: "1fr" }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, gridTemplateRows: "0fr" }} transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }} className="grid" > <div className="overflow-hidden"> <div className="flex items-center gap-4 px-3 pb-3 pl-[3.75rem]"> <div className="flex-1"> <Spark values={spark} /> </div> <dl className="text-right text-[12px]"> <dt className="text-muted-foreground">Volume</dt> <dd className="font-medium text-foreground tabular-nums">{formatUsd(outcome.volume ?? 0, true)}</dd> </dl> </div> <div className="flex gap-1.5 px-3 pb-3 pl-[3.75rem] sm:hidden"> <button type="button" aria-label={`Yes ${formatPrice(outcome.price)} on ${outcome.name}`} onClick={() => onTrade?.(outcome.id, "yes")} className="h-10 flex-1 cursor-pointer rounded-lg bg-success/12 text-[12px] font-medium text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] outline-none hover:bg-success/18 focus-visible:ring-2 focus-visible:ring-ring"> Yes {formatPrice(outcome.price)} </button> <button type="button" aria-label={`No ${formatPrice(complement(outcome.price))} on ${outcome.name}`} onClick={() => onTrade?.(outcome.id, "no")} className="h-10 flex-1 cursor-pointer rounded-lg bg-destructive/10 text-[12px] font-medium text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))] outline-none hover:bg-destructive/15 focus-visible:ring-2 focus-visible:ring-ring"> No {formatPrice(complement(outcome.price))} </button> </div> </div> </motion.div> ) : null} </AnimatePresence> </motion.li> ); })} </ul> </LayoutGroup> ); }
Usage
import { OutcomeLadder } from "@/components/ui/outcome-ladder/outcome-ladder";
<OutcomeLadder
market={market} // type: "multi", outcomes with price in basis points
onTrade={(outcomeId, side) => openTicket(outcomeId, side)}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
market* | Market | – | A multi-outcome market (binary markets show their two outcomes). |
onTrade | (outcomeId: string, side: "yes" | "no") => void | – | Fires when Yes or No is pressed on an outcome. |
className | string | – | Classes for the list. |
- Rows sort by price on every render, so a live feed is all it takes to re-order them.
- Expanding a row shows a sparkline of the last 30 points and the outcome's volume, using the grid-rows trick for height.
Accessibility
- The ladder is a list in rank order; each row's expand button reports aria-expanded and the Yes/No buttons are labelled with the side, price and outcome ("Yes 42¢ on Northwind FC").
- Rank changes move rows with a layout animation and briefly tint the row that climbed; the new rank number is also in the text.
- Price changes use rolling digits with ▲/▼ arrows, so direction never depends on color.
- With reduced motion rows jump to their new place and the tint is skipped.