Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/markets/avatar.tsx
    import { 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.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/markets/use-now.ts
    import { 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>
      );
    }
  3. 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);
    }
  4. 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

PropTypeDefaultDescription
market*Market–The market: binary (Yes/No) or multi-outcome. Prices in basis points, money in cents.
watchedboolean–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.
loadingbooleanfalseShows a skeleton while the market loads.
classNamestring–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.