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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-market-depth.jsonPrompt
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-activedescendantwalking 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.
Ternary Plot
A four-state ternary plot in plain SVG — three-part compositions normalised to 100% on an equilateral triangle, with three gridline families, edge-rotated axis ticks, shaded classification zones and a keyboard-reachable mark per row.
Difference Area Chart
A four-state difference area: two series with the gap between them shaded, the fill and hatch flipping wherever the lead changes hands, and every crossing marked, counted and keyboard-reachable.