# React Order Book Component with Tailwind: Depth Bars, Spread and Flashing Updates

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

- URL: https://qodesign.dev/blog/react-order-book-component-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-22
- Updated: 2026-10-22
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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.

![Ledger Dashboard from qodesign: candlesticks, a flashing order book and a buy/sell ticket](/previews/ledger-dashboard.webp)

That is the [Ledger Dashboard](/components/ledger-dashboard), a free and MIT-licensed trading dashboard on qodesign. Its order book follows the same approach as the code below.

## The data shape

Exchanges send snapshots and then deltas: "this price level now has this size", where size 0 removes the level. A `Map` per side handles that directly:

```ts
export type Side = Map<number, number>; // price -> size

export function applyDelta(side: Side, updates: [number, number][]) {
  for (const [price, size] of updates) {
    if (size === 0) side.delete(price);
    else side.set(price, size);
  }
}
```

## Grouping, sorting and depth

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.

## Rendering a side with depth bars

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:

```tsx
const fmtPrice = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const fmtSize = new Intl.NumberFormat("en-US", { maximumFractionDigits: 4 });

function BookSide({ levels, dir, max }: { levels: Level[]; dir: "bid" | "ask"; max: number }) {
  const rows = dir === "ask" ? [...levels].reverse() : levels; // asks read upward toward the spread
  return (
    <div role="rowgroup">
      {rows.map((l) => (
        <div key={l.price} role="row" className="relative grid grid-cols-3 px-3 py-0.5 text-xs tabular-nums">
          <span
            aria-hidden="true"
            className={`absolute inset-y-0 right-0 w-full origin-right transition-transform duration-200 motion-reduce:transition-none ${
              dir === "bid" ? "bg-emerald-500/15" : "bg-rose-500/15"
            }`}
            style={{ transform: `scaleX(${l.total / max})` }}
          />
          <span role="cell" className={dir === "bid" ? "text-emerald-400" : "text-rose-400"}>
            {fmtPrice.format(l.price)}
          </span>
          <FlashCell value={l.size} className="text-right text-zinc-200" />
          <span role="cell" className="text-right text-zinc-500">{fmtSize.format(l.total)}</span>
        </div>
      ))}
    </div>
  );
}
```

Use the same `max` for both sides, the larger of the two final totals, so the bid and ask bars are comparable.

## The spread row

The spread is the gap between the best ask and the best bid. It sits between the two sides and is the first thing a trader reads:

```tsx
function Spread({ bestBid, bestAsk }: { bestBid?: number; bestAsk?: number }) {
  if (bestBid === undefined || bestAsk === undefined) return null;
  const spread = bestAsk - bestBid;
  const pct = (spread / bestAsk) * 100;
  return (
    <div className="flex justify-between border-y border-zinc-800 px-3 py-1.5 text-xs tabular-nums text-zinc-400">
      <span>Spread</span>
      <span>
        {fmtPrice.format(spread)} ({pct.toFixed(3)}%)
      </span>
    </div>
  );
}
```

## Flashing a size when it changes

The flash is what makes a book feel live. Compare with the previous value and replay a short keyframe on change:

```tsx
"use client";

import { useEffect, useRef, useState } from "react";

function FlashCell({ value, className }: { value: number; className?: string }) {
  const prev = useRef(value);
  const [flash, setFlash] = useState<"up" | "down" | null>(null);
  const [tick, setTick] = useState(0);

  useEffect(() => {
    if (value === prev.current) return;
    setFlash(value > prev.current ? "up" : "down");
    setTick((t) => t + 1);
    prev.current = value;
  }, [value]);

  return (
    <span
      key={tick}
      role="cell"
      className={`${className} rounded-sm ${
        flash === "up" ? "motion-safe:animate-flash-up" : flash === "down" ? "motion-safe:animate-flash-down" : ""
      }`}
    >
      {fmtSize.format(value)}
    </span>
  );
}
```

```css
@theme {
  --animate-flash-up: flash-up 600ms ease-out;
  --animate-flash-down: flash-down 600ms ease-out;
  @keyframes flash-up { from { background-color: rgb(16 185 129 / 0.35); } }
  @keyframes flash-down { from { background-color: rgb(244 63 94 / 0.35); } }
}
```

Keying on a counter remounts the span, so the animation restarts even if the size changes twice in a row.

## Batching WebSocket updates per frame

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:

```tsx
"use client";

import { useEffect, useRef, useState } from "react";

export function useOrderBook(url: string, tick: number) {
  const bids = useRef<Side>(new Map());
  const asks = useRef<Side>(new Map());
  const scheduled = useRef(false);
  const [view, setView] = useState<{ bids: Level[]; asks: Level[] }>({ bids: [], asks: [] });

  useEffect(() => {
    const ws = new WebSocket(url);
    let frame = 0;
    const flush = () => {
      scheduled.current = false;
      setView({ bids: toLevels(bids.current, tick, "bid"), asks: toLevels(asks.current, tick, "ask") });
    };
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data) as { bids: [number, number][]; asks: [number, number][] };
      applyDelta(bids.current, msg.bids);
      applyDelta(asks.current, msg.asks);
      if (!scheduled.current) {
        scheduled.current = true;
        frame = requestAnimationFrame(flush);
      }
    };
    return () => {
      cancelAnimationFrame(frame);
      ws.close();
    };
  }, [url, tick]);

  return view;
}
```

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.

## Layout details that matter

- Fixed height with scrolling inside the book. The page should never jump as levels come and go.
- `tabular-nums` everywhere, so columns don't wiggle as digits change.
- Price levels as React keys, never array indexes. With indexes, a new level at the top makes every row below it flash.
- Skip `aria-live` on the book; it would read every update aloud.

## The full dashboard

An order book rarely lives alone. The [Ledger Dashboard](/components/ledger-dashboard) puts one next to candlesticks, a buy/sell ticket and a portfolio panel, and it is in the [free kit](/free) with a PROMPT.md for Cursor, v0 and Lovable. If you're building the rest of the screen, the [animated SVG line chart](/blog/animated-svg-line-chart-react-no-library) and the [animated number counter](/blog/animated-number-counter-react-no-library) posts cover the charts and the portfolio total.

## FAQ

### How do I build an order book in React?

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.

### How do I draw depth bars in an order book with Tailwind?

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.

### How do I stop an order book from re-rendering too often?

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.

### How do I flash a cell when its value changes in React?

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.
