Order Book
Animated order book with bid and ask depth, spread, and volume balance.
Preview
| Price USD | Size SOL | Cumulative total SOL |
|---|---|---|
| 782 | 8.6K | |
| 1.2K | 7.8K | |
| 1.1K | 6.6K | |
| 2.2K | 5.5K | |
| 1.8K | 3.3K | |
| 278 | 1.5K | |
| 363 | 1.2K | |
| 574 | 858 | |
| 284 | 284 |
| Price USD | Size SOL | Cumulative total SOL |
|---|---|---|
| 194 | 194 | |
| 543 | 737 | |
| 314 | 1.1K | |
| 592 | 1.6K | |
| 715 | 2.4K | |
| 744 | 3.1K | |
| 789 | 3.9K | |
| 735 | 4.6K | |
| 1.1K | 5.7K |
Hover a level to see the cost of filling up to it
Click a price to use it, for example in an order ticket.
"use client";
import {
OrderBook,
OrderBookBalance,
OrderBookDepthSummary,
OrderBookHeader,
type OrderBookLevel,
OrderBookSide,
OrderBookSpread,
} from "@/components/charts/order-book";
/** Pass fresh aggregated snapshots from your data source to animate the depth.
* Hover or focus a level to total the depth up to it; onLevelSelect makes prices clickable. */
export function OrderBookExample({
bids,
asks,
onPriceSelect,
}: {
bids: readonly OrderBookLevel[];
asks: readonly OrderBookLevel[];
onPriceSelect?: (price: number) => void;
}) {
return (
<OrderBook
bids={bids}
asks={asks}
baseSymbol="SOL"
quoteSymbol="USD"
levels={9}
onLevelSelect={onPriceSelect ? (level) => onPriceSelect(level.price) : undefined}
>
<OrderBookHeader />
<OrderBookSide side="ask" />
<OrderBookSpread />
<OrderBookSide side="bid" />
<OrderBookDepthSummary />
<OrderBookBalance />
</OrderBook>
);
}
"use client";
// beui.dev/charts/order-book
import { motion, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { OrderBookDepthRow } from "./order-book/depth-row";
import { buildOrderBook, type DepthLevel, summarizeDepth } from "./order-book/model";
export type OrderBookLevel = { price: number; size: number };
export type { DepthLevel as OrderBookDepthLevel, DepthSummary as OrderBookDepthSummaryData } from "./order-book/model";
/** A level identified by its side and price, so it survives snapshot updates. */
export type OrderBookActiveLevel = { side: "bid" | "ask"; price: number } | null;
export interface OrderBookProps {
/** Snapshot of buy levels. Equal prices are combined; invalid and zero sizes are omitted. */
bids: readonly OrderBookLevel[];
/** Snapshot of sell levels. Supply new arrays when the book changes. */
asks: readonly OrderBookLevel[];
/** Number of nearest levels to show on each side. */
levels?: number;
/** Last traded price. Omit to show the midpoint of the best bid and ask. */
lastPrice?: number;
label?: string;
baseSymbol?: string;
quoteSymbol?: string;
formatPrice?: (value: number) => string;
formatSize?: (value: number) => string;
/**
* The level whose depth is inspected: it and every better level on its side
* are highlighted, and `OrderBookDepthSummary` totals them. Controlled.
*/
activeLevel?: OrderBookActiveLevel;
defaultActiveLevel?: OrderBookActiveLevel;
onActiveLevelChange?: (level: OrderBookActiveLevel) => void;
/** Makes each price a button, for example to fill an order ticket. */
onLevelSelect?: (level: DepthLevel, side: "bid" | "ask") => void;
children?: ReactNode;
className?: string;
}
const priceFormatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const sizeFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
const defaultPrice = (value: number) => priceFormatter.format(value);
const defaultSize = (value: number) => sizeFormatter.format(value);
function useOrderBookModel({
bids,
asks,
levels = 9,
lastPrice,
baseSymbol = "",
quoteSymbol = "USD",
formatPrice = defaultPrice,
formatSize = defaultSize,
activeLevel,
defaultActiveLevel = null,
onActiveLevelChange,
onLevelSelect,
}: OrderBookProps) {
const book = buildOrderBook(bids, asks, levels);
const [internalActive, setInternalActive] = useState(defaultActiveLevel);
const rawActive = activeLevel === undefined ? internalActive : activeLevel;
// Resolve the inspected level during render by price; a level that left the
// book clears the inspection instead of pointing at a different row.
const activeIndex = rawActive
? (rawActive.side === "bid" ? book.bids : book.asks).findIndex(
(level) => level.price === rawActive.price,
)
: -1;
const active =
rawActive && activeIndex >= 0 ? { side: rawActive.side, index: activeIndex } : null;
// Stable setter: it reads the latest controlled flag and callback from refs.
const latest = useRef({ controlled: activeLevel !== undefined, onActiveLevelChange });
useLayoutEffect(() => {
latest.current = { controlled: activeLevel !== undefined, onActiveLevelChange };
});
const setActiveLevel = useCallback((level: OrderBookActiveLevel) => {
if (!latest.current.controlled) setInternalActive(level);
latest.current.onActiveLevelChange?.(level);
}, []);
return {
...book,
active,
summary: active
? summarizeDepth((active.side === "bid" ? book.bids : book.asks).slice(0, active.index + 1), active.side)
: null,
setActiveLevel,
onLevelSelect,
price:
lastPrice != null && Number.isFinite(lastPrice) && lastPrice > 0 ? lastPrice : book.midpoint,
baseSymbol,
quoteSymbol,
formatPrice,
formatSize,
reduce: useReducedMotion(),
};
}
const OrderBookContext = createContext<ReturnType<typeof useOrderBookModel> | null>(null);
export function useOrderBook() {
const context = useContext(OrderBookContext);
if (!context) throw new Error("Order book parts must be rendered inside OrderBook.");
return context;
}
/** A snapshot-driven depth ladder. No timers or invented market data live in the chart. */
export function OrderBook({ children, className, label = "Order book", ...props }: OrderBookProps) {
const model = useOrderBookModel(props);
return (
<OrderBookContext.Provider value={model}>
<section
aria-label={label}
className={cn(
"w-full max-w-[560px] overflow-hidden rounded-2xl border border-border bg-background font-mono text-xs tabular-nums",
className,
)}
>
{children === undefined ? (
<>
<OrderBookHeader />
<OrderBookSide side="ask" />
<OrderBookSpread />
<OrderBookSide side="bid" />
<OrderBookBalance />
</>
) : (
children
)}
</section>
</OrderBookContext.Provider>
);
}
export function OrderBookHeader({ className }: { className?: string }) {
const { baseSymbol, quoteSymbol } = useOrderBook();
return (
<div
aria-hidden="true"
className={cn(
"grid grid-cols-3 py-3 text-[10px] uppercase tracking-wider text-muted-foreground",
className,
)}
>
<span className="px-4">Price{quoteSymbol && ` (${quoteSymbol})`}</span>
<span className="px-4 text-right">Size{baseSymbol && ` (${baseSymbol})`}</span>
<span className="px-4 text-right">Total</span>
</div>
);
}
export function OrderBookSide({ side, className }: { side: "bid" | "ask"; className?: string }) {
const {
bids,
asks,
maxTotal,
formatPrice,
formatSize,
reduce,
baseSymbol,
quoteSymbol,
active,
setActiveLevel,
onLevelSelect,
} = useOrderBook();
const rows = side === "ask" ? [...asks].reverse() : bids;
const color =
side === "ask" ? "text-rose-600 dark:text-rose-400" : "text-emerald-700 dark:text-emerald-400";
return (
<table
aria-label={side === "ask" ? "Asks · sell orders" : "Bids · buy orders"}
className={cn("w-full table-fixed border-separate border-spacing-0", className)}
>
<thead className="sr-only">
<tr>
<th scope="col">Price {quoteSymbol}</th>
<th scope="col">Size {baseSymbol}</th>
<th scope="col">Cumulative total {baseSymbol}</th>
</tr>
</thead>
<tbody onPointerLeave={() => setActiveLevel(null)}>
{rows.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-6 text-center text-muted-foreground">
No {side === "ask" ? "asks" : "bids"}
</td>
</tr>
) : (
rows.map((row, index) => {
const depthIndex = side === "ask" ? rows.length - 1 - index : index;
return (
<OrderBookDepthRow
key={row.price}
row={row}
inRange={active?.side === side && depthIndex <= active.index}
onInspect={(inspect) =>
setActiveLevel(inspect ? { side, price: row.price } : null)
}
onSelect={onLevelSelect ? () => onLevelSelect(row, side) : undefined}
fraction={maxTotal ? row.total / maxTotal : 0}
entranceIndex={side === "ask" ? rows.length - 1 - index : index}
color={color}
formatPrice={formatPrice}
formatSize={formatSize}
reduce={!!reduce}
/>
);
})
)}
</tbody>
</table>
);
}
export function OrderBookSpread({ className }: { className?: string }) {
const { price, spread, midpoint, formatPrice } = useOrderBook();
return (
<div
className={cn(
"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",
className,
)}
>
<span className="text-base font-medium tracking-tight">
<span className="sr-only">Reference price: </span>
{price == null ? "—" : formatPrice(price)}
</span>
<span className="text-[10px] text-muted-foreground">
{spread == null || midpoint == null
? "Spread unavailable"
: spread < 0
? "Crossed book"
: `Spread ${formatPrice(spread)} · ${((spread / midpoint) * 100).toFixed(3)}%`}
</span>
</div>
);
}
/** Balance of the displayed depth, not the entire exchange order book. */
export function OrderBookBalance({ className }: { className?: string }) {
const { bidTotal, askTotal, reduce } = useOrderBook();
const total = bidTotal + askTotal;
const ratio = total ? bidTotal / total : 0.5;
return (
<div className={cn("space-y-2 border-t border-border px-4 pb-3 pt-3", className)}>
<div className="flex justify-between text-[10px]">
<span className="text-emerald-700 dark:text-emerald-400">
Bids {total ? `${(ratio * 100).toFixed(1)}%` : "—"}
</span>
<span className="text-muted-foreground">Visible depth</span>
<span className="text-rose-600 dark:text-rose-400">
Asks {total ? `${((1 - ratio) * 100).toFixed(1)}%` : "—"}
</span>
</div>
<div aria-hidden="true" className="relative h-1 overflow-hidden rounded-full bg-rose-500/25">
<motion.div
initial={false}
animate={{ transform: `scaleX(${ratio})` }}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
className="absolute inset-0 origin-left bg-emerald-500/60"
/>
</div>
</div>
);
}
/**
* Totals the inspected levels: the hovered or focused level and every better
* level on its side, as the cost of filling an order up to that price.
*/
export function OrderBookDepthSummary({
placeholder = "Hover a level to see the cost of filling up to it",
children,
className,
}: {
placeholder?: ReactNode;
/** Custom readout for the inspected levels. */
children?: (summary: NonNullable<ReturnType<typeof useOrderBook>["summary"]>) => ReactNode;
className?: string;
}) {
const { summary, formatPrice, formatSize, baseSymbol, quoteSymbol } = useOrderBook();
return (
<p
aria-live="polite"
className={cn(
"flex min-h-10 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-muted-foreground",
className,
)}
>
{!summary ? (
<span className="font-sans">{placeholder}</span>
) : children ? (
children(summary)
) : (
<>
<span>
Avg <span className="text-foreground">{formatPrice(summary.averagePrice)}</span>
</span>
<span>
<span className="text-foreground">
{formatSize(summary.size)}
{baseSymbol && ` ${baseSymbol}`}
</span>
{" · "}
{formatPrice(summary.quote)}
{quoteSymbol && ` ${quoteSymbol}`}
</span>
</>
)}
</p>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// beui.dev/charts/order-book
import { motion, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { OrderBookDepthRow } from "./order-book/depth-row";
import { buildOrderBook, type DepthLevel, summarizeDepth } from "./order-book/model";
export type OrderBookLevel = { price: number; size: number };
export type { DepthLevel as OrderBookDepthLevel, DepthSummary as OrderBookDepthSummaryData } from "./order-book/model";
/** A level identified by its side and price, so it survives snapshot updates. */
export type OrderBookActiveLevel = { side: "bid" | "ask"; price: number } | null;
export interface OrderBookProps {
/** Snapshot of buy levels. Equal prices are combined; invalid and zero sizes are omitted. */
bids: readonly OrderBookLevel[];
/** Snapshot of sell levels. Supply new arrays when the book changes. */
asks: readonly OrderBookLevel[];
/** Number of nearest levels to show on each side. */
levels?: number;
/** Last traded price. Omit to show the midpoint of the best bid and ask. */
lastPrice?: number;
label?: string;
baseSymbol?: string;
quoteSymbol?: string;
formatPrice?: (value: number) => string;
formatSize?: (value: number) => string;
/**
* The level whose depth is inspected: it and every better level on its side
* are highlighted, and `OrderBookDepthSummary` totals them. Controlled.
*/
activeLevel?: OrderBookActiveLevel;
defaultActiveLevel?: OrderBookActiveLevel;
onActiveLevelChange?: (level: OrderBookActiveLevel) => void;
/** Makes each price a button, for example to fill an order ticket. */
onLevelSelect?: (level: DepthLevel, side: "bid" | "ask") => void;
children?: ReactNode;
className?: string;
}
const priceFormatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const sizeFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
const defaultPrice = (value: number) => priceFormatter.format(value);
const defaultSize = (value: number) => sizeFormatter.format(value);
function useOrderBookModel({
bids,
asks,
levels = 9,
lastPrice,
baseSymbol = "",
quoteSymbol = "USD",
formatPrice = defaultPrice,
formatSize = defaultSize,
activeLevel,
defaultActiveLevel = null,
onActiveLevelChange,
onLevelSelect,
}: OrderBookProps) {
const book = buildOrderBook(bids, asks, levels);
const [internalActive, setInternalActive] = useState(defaultActiveLevel);
const rawActive = activeLevel === undefined ? internalActive : activeLevel;
// Resolve the inspected level during render by price; a level that left the
// book clears the inspection instead of pointing at a different row.
const activeIndex = rawActive
? (rawActive.side === "bid" ? book.bids : book.asks).findIndex(
(level) => level.price === rawActive.price,
)
: -1;
const active =
rawActive && activeIndex >= 0 ? { side: rawActive.side, index: activeIndex } : null;
// Stable setter: it reads the latest controlled flag and callback from refs.
const latest = useRef({ controlled: activeLevel !== undefined, onActiveLevelChange });
useLayoutEffect(() => {
latest.current = { controlled: activeLevel !== undefined, onActiveLevelChange };
});
const setActiveLevel = useCallback((level: OrderBookActiveLevel) => {
if (!latest.current.controlled) setInternalActive(level);
latest.current.onActiveLevelChange?.(level);
}, []);
return {
...book,
active,
summary: active
? summarizeDepth((active.side === "bid" ? book.bids : book.asks).slice(0, active.index + 1), active.side)
: null,
setActiveLevel,
onLevelSelect,
price:
lastPrice != null && Number.isFinite(lastPrice) && lastPrice > 0 ? lastPrice : book.midpoint,
baseSymbol,
quoteSymbol,
formatPrice,
formatSize,
reduce: useReducedMotion(),
};
}
const OrderBookContext = createContext<ReturnType<typeof useOrderBookModel> | null>(null);
export function useOrderBook() {
const context = useContext(OrderBookContext);
if (!context) throw new Error("Order book parts must be rendered inside OrderBook.");
return context;
}
/** A snapshot-driven depth ladder. No timers or invented market data live in the chart. */
export function OrderBook({ children, className, label = "Order book", ...props }: OrderBookProps) {
const model = useOrderBookModel(props);
return (
<OrderBookContext.Provider value={model}>
<section
aria-label={label}
className={cn(
"w-full max-w-[560px] overflow-hidden rounded-2xl border border-border bg-background font-mono text-xs tabular-nums",
className,
)}
>
{children === undefined ? (
<>
<OrderBookHeader />
<OrderBookSide side="ask" />
<OrderBookSpread />
<OrderBookSide side="bid" />
<OrderBookBalance />
</>
) : (
children
)}
</section>
</OrderBookContext.Provider>
);
}
export function OrderBookHeader({ className }: { className?: string }) {
const { baseSymbol, quoteSymbol } = useOrderBook();
return (
<div
aria-hidden="true"
className={cn(
"grid grid-cols-3 py-3 text-[10px] uppercase tracking-wider text-muted-foreground",
className,
)}
>
<span className="px-4">Price{quoteSymbol && ` (${quoteSymbol})`}</span>
<span className="px-4 text-right">Size{baseSymbol && ` (${baseSymbol})`}</span>
<span className="px-4 text-right">Total</span>
</div>
);
}
export function OrderBookSide({ side, className }: { side: "bid" | "ask"; className?: string }) {
const {
bids,
asks,
maxTotal,
formatPrice,
formatSize,
reduce,
baseSymbol,
quoteSymbol,
active,
setActiveLevel,
onLevelSelect,
} = useOrderBook();
const rows = side === "ask" ? [...asks].reverse() : bids;
const color =
side === "ask" ? "text-rose-600 dark:text-rose-400" : "text-emerald-700 dark:text-emerald-400";
return (
<table
aria-label={side === "ask" ? "Asks · sell orders" : "Bids · buy orders"}
className={cn("w-full table-fixed border-separate border-spacing-0", className)}
>
<thead className="sr-only">
<tr>
<th scope="col">Price {quoteSymbol}</th>
<th scope="col">Size {baseSymbol}</th>
<th scope="col">Cumulative total {baseSymbol}</th>
</tr>
</thead>
<tbody onPointerLeave={() => setActiveLevel(null)}>
{rows.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-6 text-center text-muted-foreground">
No {side === "ask" ? "asks" : "bids"}
</td>
</tr>
) : (
rows.map((row, index) => {
const depthIndex = side === "ask" ? rows.length - 1 - index : index;
return (
<OrderBookDepthRow
key={row.price}
row={row}
inRange={active?.side === side && depthIndex <= active.index}
onInspect={(inspect) =>
setActiveLevel(inspect ? { side, price: row.price } : null)
}
onSelect={onLevelSelect ? () => onLevelSelect(row, side) : undefined}
fraction={maxTotal ? row.total / maxTotal : 0}
entranceIndex={side === "ask" ? rows.length - 1 - index : index}
color={color}
formatPrice={formatPrice}
formatSize={formatSize}
reduce={!!reduce}
/>
);
})
)}
</tbody>
</table>
);
}
export function OrderBookSpread({ className }: { className?: string }) {
const { price, spread, midpoint, formatPrice } = useOrderBook();
return (
<div
className={cn(
"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",
className,
)}
>
<span className="text-base font-medium tracking-tight">
<span className="sr-only">Reference price: </span>
{price == null ? "—" : formatPrice(price)}
</span>
<span className="text-[10px] text-muted-foreground">
{spread == null || midpoint == null
? "Spread unavailable"
: spread < 0
? "Crossed book"
: `Spread ${formatPrice(spread)} · ${((spread / midpoint) * 100).toFixed(3)}%`}
</span>
</div>
);
}
/** Balance of the displayed depth, not the entire exchange order book. */
export function OrderBookBalance({ className }: { className?: string }) {
const { bidTotal, askTotal, reduce } = useOrderBook();
const total = bidTotal + askTotal;
const ratio = total ? bidTotal / total : 0.5;
return (
<div className={cn("space-y-2 border-t border-border px-4 pb-3 pt-3", className)}>
<div className="flex justify-between text-[10px]">
<span className="text-emerald-700 dark:text-emerald-400">
Bids {total ? `${(ratio * 100).toFixed(1)}%` : "—"}
</span>
<span className="text-muted-foreground">Visible depth</span>
<span className="text-rose-600 dark:text-rose-400">
Asks {total ? `${((1 - ratio) * 100).toFixed(1)}%` : "—"}
</span>
</div>
<div aria-hidden="true" className="relative h-1 overflow-hidden rounded-full bg-rose-500/25">
<motion.div
initial={false}
animate={{ transform: `scaleX(${ratio})` }}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
className="absolute inset-0 origin-left bg-emerald-500/60"
/>
</div>
</div>
);
}
/**
* Totals the inspected levels: the hovered or focused level and every better
* level on its side, as the cost of filling an order up to that price.
*/
export function OrderBookDepthSummary({
placeholder = "Hover a level to see the cost of filling up to it",
children,
className,
}: {
placeholder?: ReactNode;
/** Custom readout for the inspected levels. */
children?: (summary: NonNullable<ReturnType<typeof useOrderBook>["summary"]>) => ReactNode;
className?: string;
}) {
const { summary, formatPrice, formatSize, baseSymbol, quoteSymbol } = useOrderBook();
return (
<p
aria-live="polite"
className={cn(
"flex min-h-10 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-muted-foreground",
className,
)}
>
{!summary ? (
<span className="font-sans">{placeholder}</span>
) : children ? (
children(summary)
) : (
<>
<span>
Avg <span className="text-foreground">{formatPrice(summary.averagePrice)}</span>
</span>
<span>
<span className="text-foreground">
{formatSize(summary.size)}
{baseSymbol && ` ${baseSymbol}`}
</span>
{" · "}
{formatPrice(summary.quote)}
{quoteSymbol && ` ${quoteSymbol}`}
</span>
</>
)}
</p>
);
}
"use client";
import { animate, motion, useMotionValue } from "motion/react";
import { useEffect, useRef } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import type { DepthLevel } from "./model";
export function OrderBookDepthRow({
row,
fraction,
entranceIndex,
color,
formatPrice,
formatSize,
reduce,
inRange = false,
onInspect,
onSelect,
}: {
row: DepthLevel;
fraction: number;
entranceIndex: number;
color: string;
formatPrice: (value: number) => string;
formatSize: (value: number) => string;
reduce: boolean;
/** True when this level is inside the inspected depth range. */
inRange?: boolean;
/** Called with true when the row is hovered or its price focused, false on blur. */
onInspect?: (inspect: boolean) => void;
/** Renders the price as a button. */
onSelect?: () => void;
}) {
const highlight = useMotionValue(0);
const previousSize = useRef(row.size);
const entered = useRef(false);
useEffect(() => {
entered.current = true;
}, []);
useEffect(() => {
const changed = previousSize.current !== row.size;
previousSize.current = row.size;
if (!changed || reduce) {
highlight.set(0);
return;
}
// A soft full-row tint identifies changed quantities. Retarget from
// the current opacity when another snapshot interrupts the fade.
let fade: ReturnType<typeof animate> | undefined;
const rise = animate(highlight, 0.1, {
duration: 0.08,
ease: EASE_OUT,
onComplete: () => {
fade = animate(highlight, 0, { duration: 0.2, ease: EASE_OUT });
},
});
return () => {
rise.stop();
fade?.stop();
};
}, [row.size, reduce, highlight]);
return (
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.18,
ease: EASE_OUT,
delay: reduce || entered.current ? 0 : Math.min(entranceIndex, 8) * 0.025,
}}
onPointerEnter={onInspect ? () => onInspect(true) : undefined}
className={cn("group", color)}
>
<td className="relative h-8 px-4 py-0">
<div
aria-hidden="true"
className="pointer-events-none absolute left-1 inset-y-px w-[calc(300%-8px)] overflow-hidden rounded-sm"
>
<motion.div
initial={reduce ? false : { transform: `scaleX(${fraction * 0.85})`, opacity: 0 }}
animate={{ transform: `scaleX(${fraction})`, opacity: 1 }}
transition={
reduce
? { duration: 0 }
: {
...SPRING_PANEL,
delay: entered.current ? 0 : Math.min(entranceIndex, 8) * 0.025,
}
}
className="absolute inset-0 origin-right overflow-hidden rounded-sm"
>
<div className="absolute inset-0 bg-current opacity-[0.12]" />
</motion.div>
<motion.div style={{ opacity: highlight }} className="absolute inset-0 bg-current" />
<div
className={cn(
"absolute inset-0 bg-current transition-opacity duration-150",
inRange ? "opacity-[0.08]" : "opacity-0 group-hover:opacity-[0.05]",
)}
/>
</div>
{onSelect ? (
<button
type="button"
onClick={onSelect}
onFocus={() => onInspect?.(true)}
onBlur={() => onInspect?.(false)}
className="relative rounded-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{formatPrice(row.price)}
</button>
) : (
<span className="relative">{formatPrice(row.price)}</span>
)}
</td>
<td className="relative px-4 py-0 text-right text-foreground">
{formatSize(row.size)}
</td>
<td className="relative px-4 py-0 text-right text-muted-foreground">
{formatSize(row.total)}
</td>
</motion.tr>
);
}
export type DepthLevel = { price: number; size: number; total: number };
type Level = { price: number; size: number };
function prepare(levels: readonly Level[], side: "bid" | "ask", limit: number): DepthLevel[] {
const prices = new Map<number, number>();
for (const { price, size } of levels) {
if (!Number.isFinite(price) || !Number.isFinite(size) || price <= 0 || size <= 0) continue;
prices.set(price, (prices.get(price) ?? 0) + size);
}
let total = 0;
return [...prices]
.sort(([a], [b]) => (side === "bid" ? b - a : a - b))
.slice(0, limit)
.map(([price, size]) => {
total += size;
return { price, size, total };
});
}
export type DepthSummary = {
side: "bid" | "ask";
/** Number of levels from the best price out to the inspected one. */
levels: number;
/** Total base size across those levels. */
size: number;
/** Total quote value across those levels. */
quote: number;
/** Size-weighted average fill price. */
averagePrice: number;
};
/** Totals levels ordered from the best price outward. */
export function summarizeDepth(levels: readonly DepthLevel[], side: "bid" | "ask"): DepthSummary {
const size = levels.reduce((sum, level) => sum + level.size, 0);
const quote = levels.reduce((sum, level) => sum + level.size * level.price, 0);
return { side, levels: levels.length, size, quote, averagePrice: size ? quote / size : 0 };
}
/** Accumulate from the best quote outward before reversing asks for display. */
export function buildOrderBook(bids: readonly Level[], asks: readonly Level[], levels: number) {
const limit = Number.isFinite(levels) ? Math.max(0, Math.floor(levels)) : 9;
const buy = prepare(bids, "bid", limit);
const sell = prepare(asks, "ask", limit);
const bidTotal = buy.at(-1)?.total ?? 0;
const askTotal = sell.at(-1)?.total ?? 0;
const bestBid = buy[0]?.price;
const bestAsk = sell[0]?.price;
const midpoint = bestBid == null || bestAsk == null ? null : (bestBid + bestAsk) / 2;
const spread = bestBid == null || bestAsk == null ? null : bestAsk - bestBid;
return {
bids: buy,
asks: sell,
bidTotal,
askTotal,
maxTotal: Math.max(bidTotal, askTotal),
midpoint,
spread,
};
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { MetallicButtonProps } from "./metallic";
export { MetallicButton } from "./metallic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-muted/60",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/60",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { motion, useReducedMotion } from "motion/react";
import { forwardRef, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "./base";
export interface MetallicButtonProps extends Omit<
ButtonProps,
"ripple" | "variant"
> {
/** Stops the traveling reflection while preserving the chrome rim. */
paused?: boolean;
}
// The rim and highlight drift separately so the material stays quiet and reflective.
const SILVER_DRIFT = {
duration: 8,
ease: EASE_IN_OUT,
repeat: Infinity,
};
const CHROME_SHIMMER = {
duration: 2.4,
ease: EASE_IN_OUT,
};
export const MetallicButton = forwardRef<
HTMLButtonElement,
MetallicButtonProps
>(function MetallicButton(
{
size = "md",
paused = false,
className,
children,
onHoverStart,
onHoverEnd,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const still = paused || Boolean(reduce);
const [hovered, setHovered] = useState(false);
return (
<Button
ref={ref}
variant="ghost"
size={size}
onHoverStart={(event, info) => {
setHovered(true);
onHoverStart?.(event, info);
}}
onHoverEnd={(event, info) => {
setHovered(false);
onHoverEnd?.(event, info);
}}
className={cn(
"group relative isolate overflow-hidden border-0 bg-transparent text-foreground",
"hover:bg-transparent hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"shadow-[0_8px_22px_rgba(0,0,0,0.16)]",
size === "icon" && "rounded-full",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]"
animate={still ? undefined : { x: ["0%", "13%", "0%"] }}
transition={still ? undefined : SILVER_DRIFT}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen"
animate={still ? undefined : { x: hovered ? "310%" : "0%" }}
transition={still ? undefined : CHROME_SHIMMER}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]"
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</Button>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
// Own states: a label that swaps away and straight back
// re-enters mid-exit, and only owned targets are restored.
initial="initial"
animate="animate"
exit="exit"
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
OrderBook
bidsreadonly OrderBookLevel[]Snapshot of buy levels. Equal prices are combined; invalid and zero sizes are omitted.
—asksreadonly OrderBookLevel[]Snapshot of sell levels. Supply new arrays when the book changes.
—levels?numberNumber of nearest levels to show on each side.
—lastPrice?numberLast traded price. Omit to show the midpoint of the best bid and ask.
—label?stringOrder bookbaseSymbol?string—quoteSymbol?string—formatPrice?((value: number) => string)—formatSize?((value: number) => string)—activeLevel?OrderBookActiveLevelThe level whose depth is inspected: it and every better level on its side are highlighted, and `OrderBookDepthSummary` totals them. Controlled.
—defaultActiveLevel?OrderBookActiveLevel—onActiveLevelChange?((level: OrderBookActiveLevel) => void)—onLevelSelect?((level: DepthLevel, side: "bid" | "ask") => void)Makes each price a button, for example to fill an order ticket.
—className?string—OrderBookHeader
className?string—OrderBookSide
side"bid" | "ask"—className?string—OrderBookSpread
className?string—OrderBookBalance
className?string—OrderBookDepthSummary
placeholder?ReactNodeHover a level to see the cost of filling up to itchildren?((summary: DepthSummary) => ReactNode)Custom readout for the inspected levels.
—className?string—Related components
Updated