TL;DR Keep bids and asks as maps of price to size, group them by tick size, sort and slice the top levels, and compute cumulative totals for the depth bars. Draw each bar as an absolutely positioned span scaled with transform, show the spread between the two sides, flash a row when its size changes, and buffer WebSocket messages so React renders at most once per animation frame.
An order book is a small component with a lot of moving parts: two sorted lists, cumulative depth, a spread in the middle, and data that can change dozens of times a second. Ask Cursor or v0 for one and you usually get a static table with hard-coded rows and no idea what to do with a WebSocket. I make animated React components at qodesign, including a trading dashboard, and this is the order book logic I keep coming back to.
Plain React and Tailwind, no library.
That is the Ledger Dashboard, a free and MIT-licensed trading dashboard on qodesign. Its order book follows the same approach as the code below.
People want to group levels by a tick size (0.5, 1, 10) to see the shape of the book. Group, sort, take the top rows, then add a running total for the depth bars:
ts
export type Level = { price: number; size: number; total: number };export function toLevels(side: Side, tick: number, dir: "bid" | "ask", rows = 12): Level[] { const grouped = new Map<number, number>(); for (const [price, size] of side) { // bids round down, asks round up, so a group never crosses the spread const key = dir === "bid" ? Math.floor(price / tick) * tick : Math.ceil(price / tick) * tick; grouped.set(key, (grouped.get(key) ?? 0) + size); } const sorted = [...grouped.entries()].sort((a, b) => (dir === "bid" ? b[0] - a[0] : a[0] - b[0])); let total = 0; return sorted.slice(0, rows).map(([price, size]) => { total += size; return { price, size, total }; });}
Floating point will bite you with ticks like 0.1. If your prices have a fixed number of decimals, work in integer units (price times 100) inside the book and format only when rendering.
Each row gets a bar sized by its cumulative total against the largest total on screen. A scaleX transform keeps the bar on the compositor, so a fast-moving book doesn't cause layout work on every update:
A busy market sends far more messages than a screen can show. If every message calls setState, React renders hundreds of times a second. Buffer the deltas and flush once per animation frame:
The maps live in refs. Only the grouped view goes into state, at most once per frame. Adapt the message parsing to your exchange's format, and resync from a REST snapshot if you detect a gap in sequence numbers.
An order book rarely lives alone. The Ledger Dashboard puts one next to candlesticks, a buy/sell ticket and a portfolio panel, and it is in the free kit with a PROMPT.md for Cursor, v0 and Lovable. If you're building the rest of the screen, the animated SVG line chart and the animated number counter posts cover the charts and the portfolio total.
Keep bids and asks as maps from price to size, apply snapshot and delta updates to them, then group by tick size, sort, slice the top levels and add a running total. Render each side as rows with the price, size and cumulative total, and put the spread between the two sides.
Put an absolutely positioned span behind each row and scale it with transform scaleX by the row's cumulative total divided by the largest total on screen. Use one shared maximum for both sides so bid and ask bars are comparable.
Buffer WebSocket messages in refs and schedule one requestAnimationFrame flush that sets state with the grouped levels. React then renders at most once per frame, however many messages arrive.
Keep the previous value in a ref, compare it in an effect, and bump a counter used as the cell's key with a class for up or down. The remount restarts a short background-color keyframe each time.