{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"order-book","type":"registry:component","title":"Order Book","description":"Animated order book with bid and ask depth, spread, and volume balance.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/charts/order-book.tsx","type":"registry:component","target":"@components/charts/order-book.tsx","content":"\"use client\";\n// beui.dev/charts/order-book\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, type ReactNode } from \"react\";\nimport { SPRING_PANEL } from \"@/lib/ease\";\nimport { OrderBookDepthRow } from \"./order-book/depth-row\";\nimport { cn } from \"@/lib/utils\";\nimport { buildOrderBook } from \"./order-book/model\";\n\nexport type OrderBookLevel = { price: number; size: number };\n\nexport interface OrderBookProps {\n  /** Snapshot of buy levels. Equal prices are combined; invalid and zero sizes are omitted. */\n  bids: readonly OrderBookLevel[];\n  /** Snapshot of sell levels. Supply new arrays when the book changes. */\n  asks: readonly OrderBookLevel[];\n  /** Number of nearest levels to show on each side. */\n  levels?: number;\n  /** Last traded price. Omit to show the midpoint of the best bid and ask. */\n  lastPrice?: number;\n  label?: string;\n  baseSymbol?: string;\n  quoteSymbol?: string;\n  formatPrice?: (value: number) => string;\n  formatSize?: (value: number) => string;\n  children?: ReactNode;\n  className?: string;\n}\n\nconst priceFormatter = new Intl.NumberFormat(\"en-US\", {\n  minimumFractionDigits: 2,\n  maximumFractionDigits: 2,\n});\nconst sizeFormatter = new Intl.NumberFormat(\"en-US\", {\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n});\nconst defaultPrice = (value: number) => priceFormatter.format(value);\nconst defaultSize = (value: number) => sizeFormatter.format(value);\n\nfunction useOrderBookModel({\n  bids,\n  asks,\n  levels = 9,\n  lastPrice,\n  baseSymbol = \"\",\n  quoteSymbol = \"USD\",\n  formatPrice = defaultPrice,\n  formatSize = defaultSize,\n}: OrderBookProps) {\n  const book = buildOrderBook(bids, asks, levels);\n  return {\n    ...book,\n    price:\n      lastPrice != null && Number.isFinite(lastPrice) && lastPrice > 0 ? lastPrice : book.midpoint,\n    baseSymbol,\n    quoteSymbol,\n    formatPrice,\n    formatSize,\n    reduce: useReducedMotion(),\n  };\n}\nconst OrderBookContext = createContext<ReturnType<typeof useOrderBookModel> | null>(null);\n\nexport function useOrderBook() {\n  const context = useContext(OrderBookContext);\n  if (!context) throw new Error(\"Order book parts must be rendered inside OrderBook.\");\n  return context;\n}\n\n/** A snapshot-driven depth ladder. No timers or invented market data live in the chart. */\nexport function OrderBook({ children, className, label = \"Order book\", ...props }: OrderBookProps) {\n  const model = useOrderBookModel(props);\n  return (\n    <OrderBookContext.Provider value={model}>\n      <section\n        aria-label={label}\n        className={cn(\n          \"w-full max-w-[560px] overflow-hidden rounded-2xl border border-border bg-background font-mono text-xs tabular-nums\",\n          className,\n        )}\n      >\n        {children === undefined ? (\n          <>\n            <OrderBookHeader />\n            <OrderBookSide side=\"ask\" />\n            <OrderBookSpread />\n            <OrderBookSide side=\"bid\" />\n            <OrderBookBalance />\n          </>\n        ) : (\n          children\n        )}\n      </section>\n    </OrderBookContext.Provider>\n  );\n}\n\nexport function OrderBookHeader({ className }: { className?: string }) {\n  const { baseSymbol, quoteSymbol } = useOrderBook();\n  return (\n    <div\n      aria-hidden=\"true\"\n      className={cn(\n        \"grid grid-cols-3 py-3 text-[10px] uppercase tracking-wider text-muted-foreground\",\n        className,\n      )}\n    >\n      <span className=\"px-4\">Price{quoteSymbol && ` (${quoteSymbol})`}</span>\n      <span className=\"px-4 text-right\">Size{baseSymbol && ` (${baseSymbol})`}</span>\n      <span className=\"px-4 text-right\">Total</span>\n    </div>\n  );\n}\n\nexport function OrderBookSide({ side, className }: { side: \"bid\" | \"ask\"; className?: string }) {\n  const { bids, asks, maxTotal, formatPrice, formatSize, reduce, baseSymbol, quoteSymbol } =\n    useOrderBook();\n  const rows = side === \"ask\" ? [...asks].reverse() : bids;\n  const color =\n    side === \"ask\" ? \"text-rose-600 dark:text-rose-400\" : \"text-emerald-700 dark:text-emerald-400\";\n  return (\n    <table\n      aria-label={side === \"ask\" ? \"Asks · sell orders\" : \"Bids · buy orders\"}\n      className={cn(\"w-full table-fixed border-separate border-spacing-0\", className)}\n    >\n      <thead className=\"sr-only\">\n        <tr>\n          <th scope=\"col\">Price {quoteSymbol}</th>\n          <th scope=\"col\">Size {baseSymbol}</th>\n          <th scope=\"col\">Cumulative total {baseSymbol}</th>\n        </tr>\n      </thead>\n      <tbody>\n        {rows.length === 0 ? (\n          <tr>\n            <td colSpan={3} className=\"px-4 py-6 text-center text-muted-foreground\">\n              No {side === \"ask\" ? \"asks\" : \"bids\"}\n            </td>\n          </tr>\n        ) : (\n          rows.map((row, index) => (\n            <OrderBookDepthRow\n              key={row.price}\n              row={row}\n              fraction={maxTotal ? row.total / maxTotal : 0}\n              entranceIndex={side === \"ask\" ? rows.length - 1 - index : index}\n              color={color}\n              formatPrice={formatPrice}\n              formatSize={formatSize}\n              reduce={!!reduce}\n            />\n          ))\n        )}\n      </tbody>\n    </table>\n  );\n}\n\nexport function OrderBookSpread({ className }: { className?: string }) {\n  const { price, spread, midpoint, formatPrice } = useOrderBook();\n  return (\n    <div\n      className={cn(\n        \"my-1 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-y border-border bg-muted/40 px-4 py-3\",\n        className,\n      )}\n    >\n      <span className=\"text-base font-medium tracking-tight\">\n        <span className=\"sr-only\">Reference price: </span>\n        {price == null ? \"—\" : formatPrice(price)}\n      </span>\n      <span className=\"text-[10px] text-muted-foreground\">\n        {spread == null || midpoint == null\n          ? \"Spread unavailable\"\n          : spread < 0\n            ? \"Crossed book\"\n            : `Spread ${formatPrice(spread)} · ${((spread / midpoint) * 100).toFixed(3)}%`}\n      </span>\n    </div>\n  );\n}\n\n/** Balance of the displayed depth, not the entire exchange order book. */\nexport function OrderBookBalance({ className }: { className?: string }) {\n  const { bidTotal, askTotal, reduce } = useOrderBook();\n  const total = bidTotal + askTotal;\n  const ratio = total ? bidTotal / total : 0.5;\n  return (\n    <div className={cn(\"space-y-2 border-t border-border px-4 pb-3 pt-3\", className)}>\n      <div className=\"flex justify-between text-[10px]\">\n        <span className=\"text-emerald-700 dark:text-emerald-400\">\n          Bids {total ? `${(ratio * 100).toFixed(1)}%` : \"—\"}\n        </span>\n        <span className=\"text-muted-foreground\">Visible depth</span>\n        <span className=\"text-rose-600 dark:text-rose-400\">\n          Asks {total ? `${((1 - ratio) * 100).toFixed(1)}%` : \"—\"}\n        </span>\n      </div>\n      <div aria-hidden=\"true\" className=\"relative h-1 overflow-hidden rounded-full bg-rose-500/25\">\n        <motion.div\n          initial={false}\n          animate={{ transform: `scaleX(${ratio})` }}\n          transition={reduce ? { duration: 0 } : SPRING_PANEL}\n          className=\"absolute inset-0 origin-left bg-emerald-500/60\"\n        />\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/charts/order-book/depth-row.tsx","type":"registry:component","target":"@components/charts/order-book/depth-row.tsx","content":"\"use client\";\n\nimport { animate, motion, useMotionValue } from \"motion/react\";\nimport { useEffect, useRef } from \"react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport type { DepthLevel } from \"./model\";\n\nexport function OrderBookDepthRow({\n  row,\n  fraction,\n  entranceIndex,\n  color,\n  formatPrice,\n  formatSize,\n  reduce,\n}: {\n  row: DepthLevel;\n  fraction: number;\n  entranceIndex: number;\n  color: string;\n  formatPrice: (value: number) => string;\n  formatSize: (value: number) => string;\n  reduce: boolean;\n}) {\n  const highlight = useMotionValue(0);\n  const previousSize = useRef(row.size);\n  const entered = useRef(false);\n  useEffect(() => {\n    entered.current = true;\n  }, []);\n\n  useEffect(() => {\n    const changed = previousSize.current !== row.size;\n    previousSize.current = row.size;\n    if (!changed || reduce) {\n      highlight.set(0);\n      return;\n    }\n\n    // A soft full-row tint identifies changed quantities. Retarget from\n    // the current opacity when another snapshot interrupts the fade.\n    let fade: ReturnType<typeof animate> | undefined;\n    const rise = animate(highlight, 0.1, {\n      duration: 0.08,\n      ease: EASE_OUT,\n      onComplete: () => {\n        fade = animate(highlight, 0, { duration: 0.2, ease: EASE_OUT });\n      },\n    });\n    return () => {\n      rise.stop();\n      fade?.stop();\n    };\n  }, [row.size, reduce, highlight]);\n\n  return (\n    <motion.tr\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      transition={{\n        duration: 0.18,\n        ease: EASE_OUT,\n        delay: reduce || entered.current ? 0 : Math.min(entranceIndex, 8) * 0.025,\n      }}\n      className={cn(\"group\", color)}\n    >\n      <td className=\"relative h-8 px-4 py-0\">\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-1 inset-y-px w-[calc(300%-8px)] overflow-hidden rounded-sm\"\n        >\n          <motion.div\n            initial={reduce ? false : { transform: `scaleX(${fraction * 0.85})`, opacity: 0 }}\n            animate={{ transform: `scaleX(${fraction})`, opacity: 1 }}\n            transition={\n              reduce\n                ? { duration: 0 }\n                : {\n                    ...SPRING_PANEL,\n                    delay: entered.current ? 0 : Math.min(entranceIndex, 8) * 0.025,\n                  }\n            }\n            className=\"absolute inset-0 origin-right overflow-hidden rounded-sm\"\n          >\n            <div className=\"absolute inset-0 bg-current opacity-[0.12]\" />\n          </motion.div>\n          <motion.div style={{ opacity: highlight }} className=\"absolute inset-0 bg-current\" />\n          <div className=\"absolute inset-0 bg-current opacity-0 transition-opacity duration-150 group-hover:opacity-[0.05]\" />\n        </div>\n        <span className=\"relative\">{formatPrice(row.price)}</span>\n      </td>\n      <td className=\"relative px-4 py-0 text-right text-foreground\">\n        {formatSize(row.size)}\n      </td>\n      <td className=\"relative px-4 py-0 text-right text-muted-foreground\">\n        {formatSize(row.total)}\n      </td>\n    </motion.tr>\n  );\n}\n"},{"path":"components/charts/order-book/model.ts","type":"registry:component","target":"@components/charts/order-book/model.ts","content":"export type DepthLevel = { price: number; size: number; total: number };\ntype Level = { price: number; size: number };\n\nfunction prepare(levels: readonly Level[], side: \"bid\" | \"ask\", limit: number): DepthLevel[] {\n  const prices = new Map<number, number>();\n  for (const { price, size } of levels) {\n    if (!Number.isFinite(price) || !Number.isFinite(size) || price <= 0 || size <= 0) continue;\n    prices.set(price, (prices.get(price) ?? 0) + size);\n  }\n  let total = 0;\n  return [...prices]\n    .sort(([a], [b]) => (side === \"bid\" ? b - a : a - b))\n    .slice(0, limit)\n    .map(([price, size]) => {\n      total += size;\n      return { price, size, total };\n    });\n}\n\n/** Accumulate from the best quote outward before reversing asks for display. */\nexport function buildOrderBook(bids: readonly Level[], asks: readonly Level[], levels: number) {\n  const limit = Number.isFinite(levels) ? Math.max(0, Math.floor(levels)) : 9;\n  const buy = prepare(bids, \"bid\", limit);\n  const sell = prepare(asks, \"ask\", limit);\n  const bidTotal = buy.at(-1)?.total ?? 0;\n  const askTotal = sell.at(-1)?.total ?? 0;\n  const bestBid = buy[0]?.price;\n  const bestAsk = sell[0]?.price;\n  const midpoint = bestBid == null || bestAsk == null ? null : (bestBid + bestAsk) / 2;\n  const spread = bestBid == null || bestAsk == null ? null : bestAsk - bestBid;\n  return {\n    bids: buy,\n    asks: sell,\n    bidTotal,\n    askTotal,\n    maxTotal: Math.max(bidTotal, askTotal),\n    midpoint,\n    spread,\n  };\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}