Skip to content

Order Book

Bids and asks with animated depth bars and the spread.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/order-book.json

Manual

  1. Install the dependencies

    npm install clsx 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/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;
    }
  3. Add the theme tokens

    This component uses --success, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/order-book/order-book.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState } from "react";
    import { LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { formatPrice, formatShares, formatUsd } from "@/lib/markets/format";
    import { FULL, valueOf } from "@/lib/markets/math";
    
    export interface BookLevel {
      /** Price in basis points. */
      price: number;
      /** Shares at this price, in hundredths. */
      shares: number;
    }
    
    export interface OrderBookProps {
      /** Bids for Yes, best (highest) first. */
      bids: BookLevel[];
      /** Asks for Yes, best (lowest) first. */
      asks: BookLevel[];
      /** Fires when a price is clicked, e.g. to fill a Trade Ticket's limit price. */
      onPriceSelect?: (price: number, book: "yes" | "no", side: "bid" | "ask") => void;
      /** Rows per side. @default 6 */
      depth?: number;
      /** Classes for the root. */
      className?: string;
    }
    
    function Row({ level, side, max, onSelect }: { level: BookLevel & { total: number }; side: "bid" | "ask"; max: number; onSelect: () => void }) {
      const reduceMotion = useReducedMotion();
      const last = useRef(level.shares);
      const [flash, setFlash] = useState<"up" | "down" | null>(null);
      useEffect(() => {
        if (level.shares === last.current) return;
        const direction = level.shares > last.current ? "up" : "down";
        last.current = level.shares;
        const on = window.setTimeout(() => setFlash(direction), 0);
        const off = window.setTimeout(() => setFlash(null), 600);
        return () => {
          window.clearTimeout(on);
          window.clearTimeout(off);
        };
      }, [level.shares]);
      return (
        <li>
          <button
            type="button"
            onClick={onSelect}
            className={cn("relative grid w-full cursor-pointer grid-cols-3 px-3 py-1 text-[12px] tabular-nums outline-none transition-colors duration-500 hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", flash === "up" && "bg-success/10", flash === "down" && "bg-destructive/10")}
          >
            <motion.span
              aria-hidden="true"
              className={cn("absolute inset-y-0.5 right-0 w-full origin-right rounded-l-sm", side === "bid" ? "bg-success/12" : "bg-destructive/10")}
              initial={false}
              animate={{ scaleX: Math.min(1, level.total / Math.max(1, max)) }}
              transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 200, damping: 28 }}
            />
            <span className={cn("relative text-left font-medium", side === "bid" ? "text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]" : "text-[color-mix(in_oklab,var(--destructive)_75%,var(--foreground))]")}><span className="sr-only">{side === "bid" ? "Bid " : "Ask "}</span>
              {formatPrice(level.price)}
            </span>
            <span className="relative text-right text-foreground">
              {formatShares(level.shares)}
              <span className="sr-only"> shares, total</span>
            </span>
            <span className="relative text-right text-[color-mix(in_oklab,var(--muted-foreground)_70%,var(--foreground))]">
              {formatUsd(level.total, true)}
              <span className="sr-only">. Use as limit price</span>
            </span>
          </button>
        </li>
      );
    }
    
    /**
     * A prediction market order book: asks above, bids below, depth bars that grow with the running
     * total, the spread in between, a Yes/No toggle, flashing rows on size changes and click-to-fill.
     */
    export function OrderBook({ bids, asks, onPriceSelect, depth = 6, className }: OrderBookProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [book, setBook] = useState<"yes" | "no">("yes");
      // The No book mirrors Yes: a Yes bid at p is a No ask at 100 − p.
      const mirror = (levels: BookLevel[]) => levels.map((l) => ({ price: FULL - l.price, shares: l.shares }));
      const sideBids = (book === "yes" ? bids : mirror(asks)).slice(0, depth);
      const sideAsks = (book === "yes" ? asks : mirror(bids)).slice(0, depth);
      const withTotals = (levels: BookLevel[]) => {
        let running = 0;
        return levels.map((l) => {
          running += valueOf(l.shares, l.price);
          return { ...l, total: running };
        });
      };
      const bidRows = withTotals(sideBids);
      const askRows = withTotals(sideAsks);
      const max = Math.max(bidRows[bidRows.length - 1]?.total ?? 0, askRows[askRows.length - 1]?.total ?? 0);
      const spread = sideAsks[0] && sideBids[0] ? sideAsks[0].price - sideBids[0].price : 0;
    
      return (
        <section aria-label="Order book" className={cn("w-full rounded-2xl border border-border bg-background py-2", className)}>
          <div className="flex items-center justify-between px-3 pb-2">
            <p className="text-sm font-medium text-foreground">Order book</p>
            <LayoutGroup id={id}>
              <div role="radiogroup" aria-label="Book" className="flex rounded-full bg-surface-2 p-0.5">
                {(["yes", "no"] as const).map((b) => (
                  <button key={b} type="button" role="radio" aria-checked={book === b} onClick={() => setBook(b)} className={cn("relative h-7 cursor-pointer rounded-full px-3 text-[12px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", book === b ? "text-foreground" : "text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))]")}>
                    {book === b ? <motion.span layoutId="book-pill" transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }} className="absolute inset-0 rounded-full bg-background shadow-sm" /> : null}
                    <span className="relative">Trade {b === "yes" ? "Yes" : "No"}</span>
                  </button>
                ))}
              </div>
            </LayoutGroup>
          </div>
          <div className="grid grid-cols-3 px-3 pb-1 text-[11px] text-muted-foreground">
            <span>Price</span>
            <span className="text-right">Shares</span>
            <span className="text-right">Total</span>
          </div>
          <ul aria-label="Asks" className="flex flex-col-reverse">
            {askRows.map((level) => (
              <Row key={`a-${level.price}`} level={level} side="ask" max={max} onSelect={() => onPriceSelect?.(level.price, book, "ask")} />
            ))}
          </ul>
          <p className="my-1 flex items-center justify-between border-y border-border bg-surface px-3 py-1.5 text-[12px] text-muted-foreground">
            <span>Spread</span>
            <span className="font-medium text-foreground tabular-nums">{formatPrice(spread)}</span>
          </p>
          <ul aria-label="Bids">
            {bidRows.map((level) => (
              <Row key={`b-${level.price}`} level={level} side="bid" max={max} onSelect={() => onPriceSelect?.(level.price, book, "bid")} />
            ))}
          </ul>
        </section>
      );
    }

Usage

import { OrderBook } from "@/components/ui/order-book/order-book";

<OrderBook
  bids={[{ price: 6300, shares: 120000 }]} // basis points, hundredths of a share
  asks={[{ price: 6500, shares: 98000 }]}
  onPriceSelect={(price) => setLimitPrice(price)}
/>

API reference

PropTypeDefaultDescription
bids*BookLevel[]–Bids for Yes, best (highest) first.
asks*BookLevel[]–Asks for Yes, best (lowest) first.
onPriceSelect(price: number, book: "yes" | "no", side: "bid" | "ask") => void–Fires when a price is clicked, e.g. to fill a Trade Ticket's limit price.
depthnumber6Rows per side.
classNamestring–Classes for the root.
  • The No book mirrors Yes: a Yes bid at p is a No ask at 100 − p, so one set of levels drives both views.
  • Depth bars are cumulative totals drawn with scaleX on a fixed track (transform only).
  • Data is simulated. Sizes are hundredths of a share and totals are in cents.

Accessibility

  • Asks and bids are lists; each price row is a button whose label names the side, price, size and running total.
  • The Yes/No book switch is a radio group. The spread row states the spread in text.
  • Size changes flash briefly, but the new number is always in the text; the flash is skipped with reduced motion.