Prediction Marketsv0.3.0
Market Card
A prediction market with odds, a gauge and Yes/No buttons.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/market-card.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/markets/use-now.tsimport { useSyncExternalStore } from "react"; let now = 0; const listeners = new Set<() => void>(); let timer = 0; function subscribe(listener: () => void) { listeners.add(listener); // Refresh on every subscribe, so a component that mounts while the timer is already running // does not start with a timestamp up to 15 s old. now = Date.now(); if (!timer) { timer = window.setInterval(() => { now = Date.now(); listeners.forEach((l) => l()); }, 15000); } return () => { listeners.delete(listener); if (!listeners.size) { window.clearInterval(timer); timer = 0; } }; } /** * The current time, refreshed every 15 s. It is 0 on the server and during hydration, so countdowns * render a placeholder first and never mismatch. */ export function useNow() { return useSyncExternalStore( subscribe, () => now || Date.now(), () => 0, ); } const noop = () => () => {}; /** * False on the server and during hydration, true after. Gate text derived from timestamps (axis * labels, dates) with it when the data may be generated relative to page load. */ export function useHydrated() { return useSyncExternalStore( noop, () => true, () => false, ); }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/market-card/market-card.tsx"use client"; import { useState, type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; import { CloudSun, Clapperboard, Coins, Cpu, FlaskConical, MessageCircle, Star, Tag, Trophy } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarketAvatar } from "@/lib/markets/avatar"; import { formatChange, formatPrice, formatProbability, formatUsd, timeLeft } from "@/lib/markets/format"; import { FULL, quoteBuy } from "@/lib/markets/math"; import type { Market } from "@/lib/markets/types"; import { useNow } from "@/lib/markets/use-now"; import { RollingValue } from "@/lib/web3/rolling-value"; export type TradeChoice = "yes" | "no"; export interface MarketCardProps { /** The market: binary (Yes/No) or multi-outcome. Prices in basis points, money in cents. */ market: Market; /** The star is filled. */ watched?: boolean; /** Fires when the star is toggled. */ onWatchChange?: (watched: boolean) => void; /** Fires when a trade button is pressed: the outcome and whether it buys Yes or No. */ onTrade?: (market: Market, outcomeId: string, side: TradeChoice) => void; /** "compact" trims padding and shows a sparkline instead of the gauge. @default "default" */ variant?: "default" | "compact"; /** Shows a skeleton while the market loads. @default false */ loading?: boolean; /** Classes for the card. */ className?: string; } /** Shared layout id: give the Trade Ticket the same id and the pressed button morphs into it. */ export const tradeLayoutId = (marketId: string, outcomeId: string, side: TradeChoice) => `trade-${marketId}-${outcomeId}-${side}`; const TOPIC_ICONS: Record<string, ReactNode> = { tech: <Cpu />, sports: <Trophy />, culture: <Clapperboard />, science: <FlaskConical />, weather: <CloudSun />, crypto: <Coins />, }; function Gauge({ value }: { value: number }) { // Semicircle: the arc length is π·r; the filled part follows the probability. const r = 34; const length = Math.PI * r; return ( <svg viewBox="0 0 80 44" className="h-11 w-20" aria-hidden="true"> <path d={`M6 40 A${r} ${r} 0 0 1 74 40`} fill="none" stroke="var(--surface-2)" strokeWidth="7" strokeLinecap="round" /> <path d={`M6 40 A${r} ${r} 0 0 1 74 40`} fill="none" stroke="var(--primary)" strokeWidth="7" strokeLinecap="round" strokeDasharray={length} strokeDashoffset={length * (1 - value / FULL)} className="transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none" /> </svg> ); } function Sparkline({ points }: { points: number[] }) { if (points.length < 2) return null; const min = Math.min(...points); const max = Math.max(...points); const d = points.map((p, i) => `${i ? "L" : "M"}${((i / (points.length - 1)) * 80).toFixed(1)} ${(24 - ((p - min) / Math.max(1, max - min)) * 22).toFixed(1)}`).join(" "); return ( <svg viewBox="0 0 80 26" className="h-7 w-20" aria-hidden="true"> <path d={d} fill="none" stroke="var(--primary)" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" /> </svg> ); } function TradeButton({ market, outcomeId, side, price, label, onTrade, compact }: { market: Market; outcomeId: string; side: TradeChoice; price: number; label: string; onTrade?: MarketCardProps["onTrade"]; compact?: boolean }) { const reduceMotion = useReducedMotion(); const quote = quoteBuy(price, 10000, market.liquidity); return ( <span className="group/trade relative flex-1"> <motion.button layoutId={tradeLayoutId(market.id, outcomeId, side)} type="button" onClick={() => onTrade?.(market, outcomeId, side)} whileHover={reduceMotion ? undefined : { y: -2 }} whileTap={reduceMotion ? undefined : { scale: 0.97 }} className={cn( "flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-xl font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", compact ? "relative after:absolute after:inset-x-0 after:-inset-y-0.5 h-9 text-[13px]" : "h-10 text-sm", side === "yes" ? "bg-success/12 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] hover:bg-success/18" : "bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))] hover:bg-destructive/15", )} > {label} <span className="tabular-nums">{formatPrice(price)}</span> </motion.button> {/* Payout preview for $100. */} <span role="tooltip" className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 translate-y-1 rounded-md bg-foreground px-2 py-1 text-[11px] font-medium whitespace-nowrap text-background opacity-0 transition-[opacity,translate] duration-150 group-hover/trade:translate-y-0 group-hover/trade:opacity-100 group-has-[:focus-visible]/trade:translate-y-0 group-has-[:focus-visible]/trade:opacity-100" > $100 → {formatUsd(quote.payout)} </span> </span> ); } /** * The core unit of a prediction feed: topic, question, odds (gauge or outcome bars), Yes/No buttons * with a payout preview, and volume, traders and countdown. Data is passed in; nothing is traded here. */ export function MarketCard({ market, watched = false, onWatchChange, onTrade, variant = "default", loading = false, className }: MarketCardProps) { const reduceMotion = useReducedMotion(); const now = useNow(); const [pop, setPop] = useState(0); const compact = variant === "compact"; const topicKey = market.topic.toLowerCase(); if (loading) { return ( <div aria-busy="true" aria-label="Loading market" className={cn("rounded-2xl border border-border bg-background p-4", className)}> <div className="flex gap-3"> <div className="size-10 animate-pulse rounded-xl bg-surface-2 motion-reduce:animate-none" /> <div className="flex-1 space-y-2"> <div className="h-3 w-20 animate-pulse rounded bg-surface-2 motion-reduce:animate-none" /> <div className="h-4 w-full animate-pulse rounded bg-surface-2 motion-reduce:animate-none" /> </div> </div> <div className="mt-5 h-10 animate-pulse rounded-xl bg-surface-2 motion-reduce:animate-none" /> <div className="mt-4 flex gap-2"> <div className="h-10 flex-1 animate-pulse rounded-xl bg-surface-2 motion-reduce:animate-none" /> <div className="h-10 flex-1 animate-pulse rounded-xl bg-surface-2 motion-reduce:animate-none" /> </div> </div> ); } const binary = market.type === "binary"; const [yes, no] = market.outcomes; const resolved = market.status === "resolved"; const winner = market.outcomes.find((o) => o.id === market.resolvedOutcomeId); const sorted = [...market.outcomes].sort((a, b) => b.price - a.price); const spark = (market.history ?? []).slice(-24).map((point) => point.prices[0]); return ( <article className={cn("relative flex flex-col rounded-2xl border border-border bg-background shadow-[0_1px_2px_rgb(0_0_0/0.04)]", compact ? "p-3.5" : "p-4", className)}> <header className="flex items-start gap-3"> <span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-xl bg-surface-2 text-muted-foreground [&>svg]:size-5"> {market.image ? ( // eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic <img src={market.image} alt="" className="size-full object-cover" /> ) : ( (TOPIC_ICONS[topicKey] ?? <Tag />) )} </span> <div className="min-w-0 flex-1"> <p className="flex items-center gap-1.5 text-[12px] text-muted-foreground"> <span className="inline-flex items-center gap-1 [&>svg]:size-3"> {TOPIC_ICONS[topicKey] ?? <Tag />} {market.topic} </span> {market.status === "closing" ? ( <span className="inline-flex items-center gap-1 text-[color-mix(in_oklab,var(--warning)_80%,var(--foreground))]"> <span className="relative flex size-1.5"> <span className="absolute inset-0 animate-ping rounded-full bg-warning opacity-70 motion-reduce:animate-none" /> <span className="relative size-1.5 rounded-full bg-warning" /> </span> Closing soon </span> ) : null} </p> <p className={cn("mt-0.5 line-clamp-2 font-medium text-balance text-foreground", compact ? "text-sm" : "text-[15px] leading-snug")}>{market.question}</p> </div> <motion.button type="button" aria-pressed={watched} aria-label={watched ? "Remove from watchlist" : "Add to watchlist"} onClick={() => { setPop((n) => n + 1); onWatchChange?.(!watched); }} 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 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > <motion.span key={pop} initial={pop && !reduceMotion ? { scale: 0.6 } : false} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 500, damping: 15 }}> <Star className={cn("size-4", watched && "fill-warning text-warning")} aria-hidden="true" /> </motion.span> </motion.button> </header> {binary ? ( <div className={cn("flex items-end justify-between gap-3", compact ? "mt-3" : "mt-4")}> <div> <p className="flex items-baseline gap-1.5"> <span className={cn("font-semibold tracking-tight text-foreground tabular-nums", compact ? "text-2xl" : "text-3xl")}> <RollingValue value={formatProbability(resolved && winner ? (winner.id === yes.id ? FULL : 0) : yes.price)} trend={yes.price} /> </span> <span className="text-[13px] text-muted-foreground">chance</span> </p> {yes.change24h !== undefined && !resolved ? ( <p className={cn("mt-0.5 text-[12px] font-medium tabular-nums", yes.change24h >= 0 ? "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]" : "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]")}> {formatChange(yes.change24h)} <span className="font-normal text-muted-foreground">24h</span> </p> ) : null} </div> {compact ? <Sparkline points={spark} /> : <Gauge value={resolved && winner ? (winner.id === yes.id ? FULL : 0) : yes.price} />} </div> ) : ( <ul className={cn("space-y-2", compact ? "mt-3" : "mt-4")}> {sorted.slice(0, 3).map((outcome) => ( <li key={outcome.id} className="flex items-center gap-2.5"> <span className="w-28 shrink-0 truncate text-[13px] text-foreground">{outcome.name}</span> <span className="relative h-1.5 flex-1 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> <span className="w-11 text-right text-[13px] font-medium text-foreground tabular-nums"> <RollingValue value={formatProbability(outcome.price)} trend={outcome.price} /> </span> </li> ))} {sorted.length > 3 ? <li className="text-[12px] text-muted-foreground">+{sorted.length - 3} more</li> : null} </ul> )} {resolved ? ( <div className={cn("relative flex items-center justify-between rounded-xl border border-border bg-surface px-3", compact ? "mt-3 h-9" : "mt-4 h-10")}> <p className="text-[13px] text-muted-foreground">Market resolved</p> <motion.span initial={reduceMotion ? false : { scale: 1.6, rotate: -14, opacity: 0 }} animate={{ scale: 1, rotate: -6, opacity: 1 }} transition={{ type: "spring", stiffness: 380, damping: 18, delay: 0.1 }} className="rounded-md border-2 border-success px-2 py-0.5 text-[12px] font-bold tracking-wide text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))] uppercase" > Resolved: {winner?.name ?? "—"} </motion.span> </div> ) : binary ? ( <div className={cn("flex gap-2", compact ? "mt-3" : "mt-4")}> <TradeButton market={market} outcomeId={yes.id} side="yes" price={yes.price} label="Yes" onTrade={onTrade} compact={compact} /> <TradeButton market={market} outcomeId={yes.id} side="no" price={no.price} label="No" onTrade={onTrade} compact={compact} /> </div> ) : ( <div className={cn("flex gap-2", compact ? "mt-3" : "mt-4")}> <TradeButton market={market} outcomeId={sorted[0].id} side="yes" price={sorted[0].price} label={`${sorted[0].name.split(" ")[0]}`} onTrade={onTrade} compact={compact} /> <TradeButton market={market} outcomeId={sorted[1].id} side="yes" price={sorted[1].price} label={`${sorted[1].name.split(" ")[0]}`} onTrade={onTrade} compact={compact} /> </div> )} <footer className={cn("flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[12px] whitespace-nowrap text-muted-foreground", compact ? "mt-3" : "mt-4")}> <span className="tabular-nums">{formatUsd(market.volume, true)} vol</span> {!compact && market.traderNames?.length ? ( <span className="flex items-center gap-1.5"> <span className="flex -space-x-2"> {market.traderNames.slice(0, 3).map((name) => ( <MarketAvatar key={name} name={name} className="size-5 text-[8px]" /> ))} </span> <span className="tabular-nums">{market.traders.toLocaleString("en-US")}</span> </span> ) : null} <span className="tabular-nums">{now ? timeLeft(market.endsAt, now) : " "}</span> {market.comments !== undefined ? ( <span className="inline-flex items-center gap-1 tabular-nums"> <MessageCircle className="size-3.5" aria-hidden="true" /> <span className="sr-only">Comments:</span> {market.comments} </span> ) : null} </footer> </article> ); }
Usage
import { MarketCard, tradeLayoutId } from "@/components/ui/market-card/market-card";
<MarketCard
market={market}
watched={watched}
onWatchChange={setWatched}
onTrade={(market, outcomeId, side) => openTicket({ market, outcomeId, side })}
/>
// Give the ticket the same layout id and the pressed button morphs into it:
<TradeTicket market={market} layoutId={tradeLayoutId(market.id, outcomeId, side)} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
market* | Market | – | The market: binary (Yes/No) or multi-outcome. Prices in basis points, money in cents. |
watched | boolean | – | The star is filled. |
onWatchChange | (watched: boolean) => void | – | Fires when the star is toggled. |
onTrade | (market: Market, outcomeId: string, side: TradeChoice) => void | – | Fires when a trade button is pressed: the outcome and whether it buys Yes or No. |
variant | "default" | "compact" | "default" | "compact" trims padding and shows a sparkline instead of the gauge. |
loading | boolean | false | Shows a skeleton while the market loads. |
className | string | – | Classes for the card. |
- Prices are integer basis points (6400 = 64% = 64¢) and money is integer cents; binary markets always keep Yes + No = 100¢. Helpers live in lib/markets (math, format, types) and ship with the component.
- The card is presentational: pass a market and handle onTrade. Demos use lib/markets/mock (fictional markets plus a tick engine) and show a Simulated badge; never wire this to real money without your own compliance review.
- States: open, closing soon (amber pulse), resolved (an outcome stamp replaces the buttons) and a loading skeleton via loading.
Accessibility
- Yes and No are labelled buttons that show their price, so the choice never depends on color. Changes read as "▲ 4%" with an arrow, not just green or red.
- The watch star is a toggle with aria-pressed and a clear label. The payout preview appears on hover and on keyboard focus.
- Countdowns render after hydration (useNow), so server and client never disagree about the time left.
- With reduced motion the bars, gauge, stamp and button lift appear without animation; prices change color without rolling.