Charts

Market Depth

A four-state order-book depth chart: mirrored cumulative bid and ask staircases meeting at the spread, an exact step outline, a hover and keyboard readout of price, size and cumulative total at any level, and a refusal when the book crosses itself.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildDepthBook,
  clipSide,
  decimalsFor,
  depthAxis,
  depthDomain,
  niceTicks,
  orderByPrice,
  probeAt,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-market-depth.json

Prompt

Build a React + TypeScript + Tailwind "ChartMarketDepth" card in plain SVG with
zod. A depth chart is a mirrored pair of exact step functions on a shared price
axis, which no cartesian chart primitive draws correctly, so the book model, the
clipping and the axes are small pure functions that live beside the schema.
There is no charting dependency.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    asOf?: string; baseUnit?: string; quoteUnit?: string;
    bids: { price: number; size: number > 0 }[];
    asks: { price: number; size: number > 0 }[] }.
  size is the quantity resting AT that price, never a running total: turning
  levels into the staircase is this chart's job, and a feed that pre-accumulates
  gets summed twice.
  price may be negative on purpose — power and some commodity markets clear
  below zero, and clamping such a book at 0 moves every mark on the chart.
- asOf is a PRE-FORMATTED string, not an instant. The component never turns a
  time into text: a locale- and timezone-dependent formatter run at render time
  disagrees between server and client and the header hydrates torn. Same rule
  for any "last trade" marker you add — inject the price, never read a clock.
- superRefine: a ready book needs at least one level, and a CROSSED book (best
  bid above best ask) is rejected outright. Guard every access — sibling
  refinements all run, so a ragged payload must produce an issue rather than
  throw a TypeError out of safeParse.
- Props = z.infer of the schema plus height (default 260, clamped 160-520),
  range (a fraction of mid, e.g. 0.02 for ±2%; omitted = fit the whole book),
  measure: "size" | "notional" (default "size"), onRetry, onLevelSelect,
  className and the div's native props, forwardRef to the card.
- Export the maths beside the schema so it is testable and so this prompt can
  describe it: buildSide(), buildDepthBook(), depthDomain(), clipSide(),
  levelIndexAt(), probeAt(), orderByPrice(), niceStep(), niceTicks(),
  depthAxis(), decimalPlaces().

Behavior
- THE MODEL, in this order. Drop rows that carry no usable price or size (and
  under measure="notional", any level quoted at or below zero, because price ×
  size is then not a quantity) and COUNT them. Sum rows that repeat a price into
  one level — a per-order feed does that constantly, and two steps at one price
  would draw a vertical segment of zero width — and count those too. Sort each
  side from the spread outward (bids descending, asks ascending) on a COPY, so
  the caller's array is never mutated, then accumulate. Derive mid, spread,
  spread as a fraction of |mid|, and the bid share of all resting quantity.
- CROSSED BOOKS ARE REFUSED, not drawn. If best bid > best ask the two
  staircases overlap, the spread band has negative width and every distance
  from mid carries the wrong sign. Return { ok: false, issue } and render a card
  that names both prices. A stale or badly merged feed produces this regularly,
  and it is exactly the case where a plausible-looking picture does the damage.
- THE STEP IS EXACT, never interpolated. Depth on the bid side is "everything
  bid at this price or better", so it holds flat between two levels and jumps
  vertically at each one: every level contributes two vertices, the end of the
  flat run and the jump, and the first pair starts on the baseline, which is
  what draws the wall at the best price. A line chart through the level points
  would claim a quantity resting at prices nobody quoted.
- WHERE THE OUTLINE STOPS is a decision, not an accident. When the book ends
  inside the window the staircase stops at the last level and the area closes
  with a vertical drop: a feed sends the top of the book, so nothing is known
  past its last level and a plateau running on to the frame would draw a promise
  the snapshot never made. When the WINDOW cuts the book short instead, the run
  does continue to the frame edge at the height it had reached — those levels
  exist, they are only off-screen — and the hidden levels and the size they
  carry are counted in a note under the chart.
- THE PRICE WINDOW is anchor ± |anchor| × range, written that way so a
  negative-price book zooms the same way instead of turning inside out; an
  anchor of exactly 0 has no percentage window and falls back to fitting.
  Without a range, fit every level plus 4%, and that padding is load-bearing:
  the outermost level closes with a vertical drop, and a drop drawn exactly on
  the frame loses half its stroke to the clip.
- ONE-SIDED BOOKS keep working and claim nothing. With no bids there is no mid
  and no spread, so the axis anchors on the best ask, the summary says so, and
  no distance-from-mid appears anywhere on the card.
- INTERACTION. One transparent hit rect for the whole plot converts clientX
  through its own client box (so it stays correct when the SVG scales below its
  minimum width), turns it into a price and asks probeAt() what is there: a
  level, or the SPREAD — where nothing rests, and saying that is more useful
  than snapping to a level nobody is pointing at. Click reads the event, not the
  hover state, because a tap fires no pointermove first and would otherwise pin
  nothing.
  Keyboard: the plot is one role="listbox" with tabIndex 0 and
  aria-activedescendant, so there is one tab stop rather than one per level.
  Tab lands on the BEST BID, the inside of the book, because that is what a
  depth chart is read for. Left/Right walk the levels in price order, which
  means stepping right off the best bid lands on the best ask and crossing the
  spread needs no special key; Up jumps to the best price on the current side,
  Down to its outermost level, Home/End to the ends of the whole book, Enter or
  Space pins, Escape releases. preventDefault fires only for keys that were
  handled, so Tab still leaves the chart. Only levels inside the window are
  reachable, so what the keyboard can reach is exactly what is drawn.
  Precedence for the readout is hover, then the keyboard cursor of a focused
  plot, then the pin — a pin that swallowed hover would make every other level
  feel dead.
- Four first-class branches of one card: loading (two mirrored skeleton
  staircases, aria-hidden, plus one sr-only role=status line), empty (a valid
  contract with no resting orders, worded so it cannot be mistaken for a failed
  fetch), error (a Try again button only when onRetry was passed), ready — plus
  the refusal card above. A ready book with nothing drawable renders empty.
- CLEANUP AND FOCUS: one ResizeObserver, disconnected on unmount and whenever
  the node changes; no timers and no rAF. Pressing Try again sets a ref
  synchronously and, once the status changes, moves focus to the ladder if the
  vanished button dropped it on <body>.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
  the frame and gridlines, muted for the spread band and the skeleton,
  muted-foreground for axis text, ring for the focus outline, foreground for the
  crosshair and the pin marker, var(--chart-2) for bids and var(--chart-4) for
  asks. Never a chart token as text colour.
- COLOUR IS NEVER THE ONLY CHANNEL. The two sides differ by position (they meet
  at the spread), by a direct in-plot label naming the side and its total, and
  by fill: bids are a flat tint, asks are tinted AND hatched with a 6px 45°
  pattern. Define the pattern inside each SVG that uses it rather than sharing
  one id across documents.
- Axes: depth on the left, rounded outward so the top gridline is the frame top
  (an axis stopping exactly at the deepest level puts that step on the frame,
  where a reader cannot tell a full book from a clipped one); price along the
  bottom, ticks on the 1 / 2 / 2.5 / 5 × 10^n ladder, one per ~104px, explicit
  "en-US" locale, compact notation past 100,000, and the two end labels anchored
  to their ends so they cannot spill out of the frame. The left gutter is sized
  from the widest label it has to hold, so a book in satoshis and a book in
  millions both stay readable.
- PRECISION FOLLOWS THE FEED: take the decimals actually present in the level
  prices and sizes (exponent form included, capped) and format with those, so a
  readout never rounds a price away. Percentages follow their own magnitude — a
  57% imbalance next to a 0.0071% spread — because fixed precision either
  rounds the spread to "0.00%" or pads the imbalance with zeros.
- The mid is a dashed rule with a label above the plot, clamped away from the
  frame edges; the spread is a BAND, not a line, because its width is the
  number being read. A locked book (best bid = best ask) keeps a 1px band and
  says it is locked.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
  children-presentational and would silence the focusable ladder. Use
  role="group" labelled by the card heading and described by the summary line
  (mid, spread, per-side totals and level counts, book imbalance). Every level
  is a role="option" rect whose aria-label reads side, price, size, cumulative
  depth, distance from mid and its rank on that side. The visible readout is
  aria-hidden, because the focused ladder already announces the active level
  through aria-activedescendant and a live region would say it twice; the polite
  live region is reserved for pin and release, which nothing else announces.
  Below the plot an sr-only WRAPPER DIV (never sr-only on the table itself: CSS
  width is only a lower bound for a table box, so width:1px does not hold one
  back and a narrow viewport picks up real horizontal scroll) holds every level
  with its share of that side and whether it is on the chart or outside the
  window.
- Motion: the only animation is the loading skeleton's pulse, carrying
  motion-reduce:animate-none. Nothing else moves, so nothing else has to stop.

Customization levers
- range: the zoom, and the one lever that changes what is on screen. ±0.1-0.5%
  is the market-making view, ±2-5% the risk view, omitted the whole-book view.
  Whatever it is, the hidden levels are counted out loud — never widen the
  silence instead of the window.
- measure: "notional" answers "what does it cost to lift this" instead of "how
  many units are there", which is the right question on a book whose levels
  span a wide price range. Add a third measure the same way: it is one
  accumulator in buildSide(), and every label follows the unit you pass.
- height plus the tick pitches are the density knobs; drop the depth gridlines
  and the axis for a sparkline-sized book preview and keep the readout.
- Palette: re-point the two ink constants (both sides through one token with
  color-mix if the brand demands a monochrome chart) and keep the hatch, the
  direct labels and the spread band — those are what carry the distinction when
  colour cannot.
- Markers: a last-trade or VWAP rule is one more vertical line plus a clamped
  label, and it must arrive as a prop; deriving it from a clock at render time
  is what makes a chart hydrate torn.
- Interaction: onLevelSelect carries side, price, size and cumulative depth —
  wire it to an order ticket, a "sweep to here" calculator or a linked ladder.
  The hit rect is where a drag-to-measure or a context menu goes without
  touching the geometry.

Concepts

  • Cumulative depth — the y value is never one level's size, it is everything resting at that price or better. That is what makes the picture answer "how much can I lift before the price moves", and it is why the two sides are drawn as areas growing away from the spread rather than as bars.
  • Exact step, never interpolated — the depth function is flat between levels and jumps at each one, so each level contributes two vertices and the first pair rises out of the baseline as the wall at the best price. Sloping a line between level points would claim quantity resting at prices nobody quoted.
  • Where the outline stops — a feed sends the top of the book, so past its last level nothing is known and the staircase stops there with a vertical drop. When the price window is what cut the book short the run does continue to the frame edge, because those levels exist and are merely off-screen — and both cases are stated on the card rather than left to the eye.
  • Spread band — the gap between best bid and best ask is drawn as a band, not a line, because its width is the number a trader is reading. Pointing into it reports the spread instead of snapping to a neighbouring level: nothing rests there, and saying so is the honest answer.
  • Crossed book refusal — a best bid above the best ask is a stale or badly merged snapshot, not a market. Drawn anyway it looks perfectly normal while the staircases overlap and every distance from mid carries the wrong sign, so the chart names both prices and refuses.
  • Ladder as listbox — one tab stop for the whole plot with aria-activedescendant walking the levels, ordered by price so stepping right off the best bid lands on the best ask; up and down jump to the inside and the outside of the current side. Tab lands at the best bid because the inside of the book is what a depth chart is read for.

On This Page