Skip to content

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

PropTypeDefaultDescription
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.
classNamestring–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.