@clobber/ui

Live market components

Order book, depth, candles, tape, venue ticker and a headless order ticket for a Clobber environment. Plain DOM: no framework, no build step, no bundler. React wrappers ship in the same package and draw the same widgets.

Everything moving on this page is driven by a simulated venue that speaks the real feed protocol into the real client: snapshot, absolute-size deltas, one sequence per market shared across book and trades. The three buttons in the toolbar make it misbehave, so the recovery paths are visible rather than promised.

Script tag

<link rel="stylesheet" href="https://unpkg.com/@clobber/ui/dist/clobber-ui.css">
<script src="https://unpkg.com/@clobber/ui"></script>
<div id="market"></div>
<script>
  ClobberUI.mountMarket({
    target: "#market",
    url: "wss://feed-sandbox.clobberhq.com",
    credential: { publishableKey: "pk_live_..." },
    market: "WILL-IT-RAIN-BA",
  });
</script>

npm

npm install @clobber/ui

import { mountOrderBook } from "@clobber/ui/vanilla";
import "@clobber/ui/clobber-ui.css";

const book = mountOrderBook({ target: "#book", depth: 12 });
feed.onBook = (market, state) => book.update(state);

Every widget is the same four things: update(state) for new data, setStatus(status) for the connection light, retheme() after a skin change, destroy() to leave the page as it was. That is the entire surface.

Five skins, one structure

A skin is a set of CSS variables and nothing else. The markup, the classes and the behaviour are identical in all five, which is why a host can write a sixth in twenty lines. Set data-clobber-skin on any ancestor and everything under it follows, canvases included: the depth and candle widgets read the same variables and repaint.

Order book

The ladder, with cumulative totals summed in decimal rather than in floats, a wash behind each row for relative depth, and the spread and mid between the sides. Rows are pooled and mutated in place, so a book updating ten times a second does not repaint the column a trader is reading. Click a level and the price goes wherever you send it: on this page, into the ticket.

const book = ClobberUI.mountOrderBook({
  target: "#book",
  depth: 12,
  onPrice: (level) => ticket.setPrice(level.price),
});

book.update(state);          // BookState from the feed
book.setStatus("live");

Depth

The same book as a shape: cumulative size against price, the two sides meeting at the spread. Drawn on a canvas with no charting dependency, because a step area and two axes are a hundred lines and a library here would have to be themed through its own options object instead of through the skin. Hover for the level under the cursor.

const depth = ClobberUI.mountDepthChart({
  target: "#depth",
  depth: 50,      // levels per side folded into the curve
  band: 0.25,     // optional: clamp the axis to +/- 25% of mid
});

depth.update(state);

Candles

Candles and volume on TradingView's lightweight-charts, which the milestone names as the kit's charting base. The forming bucket arrives once a second on candles.{interval} and is written with a single series update, so the last bar grows in place instead of the series being replaced.

History is the host's to fetch. The feed publishes the forming bucket and nothing older, and a publishable key opens the feed and nothing else, so past candles come from GET /v1/markets/{symbol}/candles through your own backend and arrive through the history callback. On this page that callback is answered by the simulator.

const chart = ClobberUI.mountCandleChart({
  target: "#chart",
  intervals: ["1m", "5m", "1h", "1d"],
  interval: "1m",
  onInterval: (iv) => resubscribe(iv),
});

chart.update(candles);   // history first, then the forming bucket

Trades

The public tape, newest first, coloured by the side that took liquidity. It is handed the whole list rather than one trade at a time, because the list is what a feed client already holds and a component with its own history would drift from it after a resync.

const tape = ClobberUI.mountTradeTape({
  target: "#tape",
  rows: 40,
  compact: false,   // true abbreviates 12400 as 12.4K
});

tape.update(trades);   // newest first

Summary

Last, the move, the touch, the day's volume, and the lifecycle state told honestly: a halted, closed, resolved or voided market says so in a badge instead of rendering as a live market with stale numbers. Press halt the market in the toolbar and watch it. The change is measured from a reference price you supply, and the label always names what it is measured from.

const summary = ClobberUI.mountMarketSummary({
  target: "#summary",
  market: "WILL-IT-RAIN-BA",
  referenceLabel: "24h",
});

summary.update({ ticker, event, reference: "0.58" });

Order ticket

The ticket refuses two things. It refuses a credential: there is no key option, no header option and no base URL, and placing an order goes through an adapter function you supply, which runs on your backend with your key. An API key in a browser is every account on your platform, and the only way to make that impossible rather than discouraged is to give the types nowhere to put one.

It also refuses to invent a number. The collateral line appears when you tell the ticket what kind of market this is, because the hold on a binary short is (1 - price) x qty, a scalar short posts (max - price) x qty, and a pair seller posts the asset itself. Tick and lot are checked before the round trip. All of it is computed in scaled integers.

ClobberUI.mountOrderTicket({
  target: "#ticket",
  market: {
    symbol: "WILL-IT-RAIN-BA",
    kind: "binary",
    tickSize: "0.01",
    lotSize: "1",
    settlementCurrency: "USDC",
  },
  submit: async (order) => {
    const r = await fetch("/api/orders", {
      method: "POST",
      body: JSON.stringify(order),
    });
    return r.ok ? { ok: true } : { ok: false, error: await r.text() };
  },
});

The whole page

One call mounts summary, chart, book, depth, tape and ticket over a single connection. One connection matters: five widgets on five sockets would be five snapshots, five sequence chains and five resyncs out of step with each other. Painting is coalesced into one animation frame, so a burst of deltas costs one layout.

ClobberUI.mountMarket({
  target: "#market",
  url: "wss://feed-sandbox.clobberhq.com",
  credential: { publishableKey: "pk_live_..." },
  market: { symbol: "SOL-USDC", kind: "pair", tickSize: "0.01", settlementCurrency: "USDC" },
  interval: "1m",
  history: (iv) => fetch(`/api/candles?interval=${iv}`).then((r) => r.json()),
  submit: (order) => fetch("/api/orders", { method: "POST", body: JSON.stringify(order) })
    .then((r) => (r.ok ? { ok: true } : { ok: false, error: "rejected" })),
});

React

The React components mount the same widgets and draw nothing of their own. Two renderers for one component is two sets of bugs, and the promise of the kit is that a React page and a script tag see the same book, the same colours and the same recovery behaviour.

"use client";
import { useMarketFeed, MarketSummary, MarketChart, OrderBook, DepthChart, TradeTape } from "@clobber/ui";
import "@clobber/ui/clobber-ui.css";

export default function Market() {
  const feed = useMarketFeed({
    url: "wss://feed-sandbox.clobberhq.com",
    credential: { publishableKey: process.env.NEXT_PUBLIC_CLOBBER_PK },
    market: "WILL-IT-RAIN-BA",
  });
  return (
    <div data-clobber-skin="terminal" style={{ display: "grid", gap: 12 }}>
      <MarketSummary feed={feed} market="WILL-IT-RAIN-BA" />
      <MarketChart feed={feed} height={320} />
      <OrderBook feed={feed} depth={12} />
      <DepthChart feed={feed} />
      <TradeTape feed={feed} />
    </div>
  );
}

Loss and resync

Book and trade frames carry seq and prev. A frame whose prev is not the last sequence delivered for that market means something was lost, and the client's recovery is to subscribe again: the snapshot that answers re-anchors the chain at its own sequence. Derived channels (ticker, candles) carry no chain and are never gap checked, because a candle has no sequence of its own.

The toolbar drives all of it against these live widgets. Force a gap skips a sequence and you will see the status light pulse while the client re-anchors. Drop the connection closes the socket underneath, and the client reconnects on its own backoff and re-subscribes. Neither one needs anything from your code.

Theming

Declare the variables on any ancestor. Undeclared ones fall back to the dashboard skin, so a host overrides three tokens and leaves the rest.

.my-desk {
  --clobber-font: "Inter", system-ui, sans-serif;
  --clobber-mono: "Roboto Mono", monospace;
  --clobber-bg: #0b0f14;
  --clobber-panel: #121820;
  --clobber-sunken: #0b0f14;
  --clobber-fg: #e7edf5;
  --clobber-muted: #7d8b9c;
  --clobber-grid: #223040;
  --clobber-grid-soft: #18222e;
  --clobber-bid: #2bb673;
  --clobber-ask: #e05561;
  --clobber-bid-wash: rgba(43,182,115,.14);
  --clobber-ask-wash: rgba(224,85,97,.14);
  --clobber-accent: #2bb673;
  --clobber-accent-fg: #08131d;
  --clobber-badge: #f0bb48;
  --clobber-radius: 3px;
  --clobber-pad: 10px;
  --clobber-row: 20px;
}

Canvases cannot inherit a CSS variable the way markup does, so the depth and candle widgets read the values and repaint. A skin change anywhere above them is observed; a host that swaps a stylesheet wholesale calls retheme().