Documentation

UI components.

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

Everything on this page is live

The components below are running against a simulated venue that speaks the published feed protocol into the published client: snapshot, absolute size deltas, one sequence per market shared across book and trades. It is deterministic, on a fixed seed, so every reader sees the same market. The controls under Loss and resync make it misbehave, so the recovery paths are visible rather than promised.

npm install @clobber/ui

import { useMarketFeed, MarketSummary, OrderBook } from "@clobber/ui";
import "@clobber/ui/clobber-ui.css";

const feed = useMarketFeed({
  url: "wss://feed-sandbox.clobberhq.com",
  credential: { publishableKey: "pk_live_..." },
  market: "WILL-IT-RAIN-BA",
});

<OrderBook feed={feed} depth={12} />
The vanilla tab is a plain page

One script tag, no build, no imports: everything it loads lives on the ClobberUI global, the way Bootstrap or jQuery arrive. Paste those lines into an HTML file and open it. If you do have a bundler, npm install @clobber/ui and import { mountOrderBook } from "@clobber/ui/vanilla" gives you the same functions as ES modules with their types.

The version is pinned and the tags carry an integrity hash, so the browser refuses the file if a byte of it ever changes. Dropping the version (@clobber/ui with no @) always serves the newest release, and no integrity hash can travel with it, because the bytes behind that URL change by design. The same file and the same hash are served by unpkg, if you would rather point there.

@clobber/ui is on npm, Apache 2.0. What it needs from you is a publishable key, which the sandbox hands out with your environment.

The package root is the React entry, so importing it needs React. @clobber/ui/vanilla is the mount functions and @clobber/ui/core is the feed client, and neither needs a framework: React is an optional peer, and a page that never imports the root never installs it.

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

MountReactWhat it draws
mountOrderBook<OrderBook>the ladder, cumulative totals, click a level for its price
mountDepthChart<DepthChart>cumulative depth as a shape, both sides meeting at the spread
mountCandleChart<MarketChart>candles and volume, the forming bar updating in place
mountTradeTape<TradeTape>public trades, coloured by the side that took liquidity
mountMarketSummary<MarketSummary>last, the move, the touch, 24h volume, lifecycle badge
mountTickerBanner<TickerBanner>the venue strip, every market of an environment
mountOrderTicket<OrderTicket>the ticket, collateral priced per market kind
mountMarketthe hooksthe whole page on one connection

A market page

Start here: one call mounts summary, chart, book, depth, tape and ticket over a single connection, and the sections after this one take it apart. One connection matters: six components on six sockets would be six snapshots, six sequence chains and six resyncs out of step with each other. Painting is coalesced into one animation frame, so a burst of deltas costs one layout.

panels decides which of the six are drawn, and the subscription follows it: a page with no chart never asks for a candles channel. setPanels changes the set while it runs, and reconnects only when the channels the new set needs are not the ones already subscribed.

Nothing to build first: a market as a link is this component hosted, pointed at an environment by the publishable key in the fragment. Paste a pk_, pick a market, share the address bar.

"use client";
import {
  useMarketFeed, MarketSummary, MarketChart, OrderBook, DepthChart, TradeTape, OrderTicket,
} 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: "SOL-USDC",
    interval: "1m",
  });
  return (
    <div data-clobber-skin="terminal" style={{ display: "grid", gap: 12 }}>
      <MarketSummary feed={feed} market="SOL-USDC" reference={open24h} />
      <MarketChart feed={feed} />
      <OrderBook feed={feed} />
      <DepthChart feed={feed} />
      <TradeTape feed={feed} />
      <OrderTicket market={market} submit={place} />
    </div>
  );
}

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.

import { useMarketFeed, OrderBook } from "@clobber/ui";

const feed = useMarketFeed({ url, credential, market: "WILL-IT-RAIN-BA" });

<OrderBook
  feed={feed}
  depth={12}
  onPrice={(level) => setPrice(level.price)}
/>

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.

import { useMarketFeed, DepthChart } from "@clobber/ui";

const feed = useMarketFeed({ url, credential, market: "WILL-IT-RAIN-BA" });

<DepthChart feed={feed} />

Candles

Candles and volume on TradingView's lightweight-charts. 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 yours to fetch

The feed publishes the forming bucket and nothing older, and a publishable key opens the feed and nothing else. Past candles come from GET /v1/markets/{symbol}/candles through your own backend and reach the chart through the history callback. On this page that callback is answered by the simulation.

import { useMarketFeed, MarketChart } from "@clobber/ui";

// the hook resubscribes when the interval changes
const [interval, setInterval] = useState("1m");
const feed = useMarketFeed({ url, credential, market, interval });

<MarketChart
  feed={feed}
  height={320}
  intervals={["1m", "5m", "1h", "1d"]}
  interval={interval}
  onInterval={setInterval}
/>

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.

import { useMarketFeed, TradeTape } from "@clobber/ui";

const feed = useMarketFeed({ url, credential, market, tapeDepth: 40 });

<TradeTape feed={feed} rows={40} />

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 under Loss and resync and watch it. The change is measured from a reference price you supply, and the label always names what it is measured from.

import { useMarketFeed, MarketSummary } from "@clobber/ui";

const feed = useMarketFeed({ url, credential, market: "WILL-IT-RAIN-BA" });

<MarketSummary
  feed={feed}
  market="WILL-IT-RAIN-BA"
  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 a binary short posts (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.

import { OrderTicket } from "@clobber/ui";

<OrderTicket
  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() };
  }}
/>

Order channels

The ticket is one way to fill in an order. It is not the only one, and the seam is narrow on purpose: SubmitAdapter takes a SubmitOrder and answers whether it was placed, so anything that can produce those fields can place an order. A text message, a bot callback, a wallet signature and an agent's tool call are the same integration with a different front door.

The credential rule does not move with the door. Your backend holds the trading key and places on the sender's behalf, or the user's own account key does, which is the one secret credential in the API built to reach a client directly, because its blast radius is one account. What changes per channel is only which field names the sender and which one is safe to use as an idempotency key.

Simulated

There is no phone, no bot and no model on this page. What is real is the shape: the parser, the order fields, the venue that crosses them against the same book every component here draws, and the fill, which arrives back through the feed rather than through a return value. The wallet is real when you have one installed, and labels itself simulated when you do not.

Your backend
// Your SMS webhook. One message in, one order out.
app.post("/sms", async (req, res) => {
  const intent = parseOrder(req.body.Body);  // "buy 25 @ 0.62"
  if (!intent.ok) return res.send(intent.error);

  const r = await fetch(`${API}/v1/orders`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${TRADING_KEY}`,
      "Clobber-Account": accountFor(req.body.From),
      "Idempotency-Key": req.body.MessageSid,  // carriers retry
      "content-type": "application/json",
    },
    body: JSON.stringify({ ...intent.order, time_in_force: "ioc" }),
  });

  const order = await r.json();
  res.send(`${order.status} ${order.filled_qty} @ ${order.avg_fill_price}`);
});

The fill lands on the public feed like any other, so it reaches this page the way your customers' screens see it: through the same client, drawn by the kit.

ChannelWho the account isIdempotency keyCredential
SMSthe phone numberthe provider's message idtrading key, on your backend
Telegramthe Telegram user idthe callback idtrading key, on your backend
WhatsAppthe WhatsApp idthe message idtrading key, on your backend
Walletthe recovered signersigner plus intent noncetrading key on your backend, or an account key in the page
Agentthe account its key namesthe tool use idaccount key, one account wide

Every one of them ends in the same call, which is the reason the list can keep growing without the API learning a new trick: POST /v1/orders, an account, an idempotency key, and the order fields the ticket would have filled in.

Hooks and events

A component takes state and draws it. Where that state comes from is this section: a hook in React, an observer on the connection everywhere else. Both are the same facts, arriving from the same client, and a page can use either without the components at all.

React hooks

One subscription per hook call, torn down with the component. The per slice hooks are views of the same connection rather than new ones, so a page with a chart, a book and a tape opens one socket.

HookGives you
useMarketFeedstatus, book, trades, ticker, candles and the lifecycle event for one market
useOrderBookstatus and the book
useTradesstatus and the tape
useCandlesstatus and the interval's candles, the forming one live
useMarketstatus, ticker and the lifecycle event
useVenueFeedstatus and a row per market of the environment, for the ticker

Events

on(event, handler) returns the unsubscribe, which is the shape a teardown wants. It works on a FeedClient you opened yourself and on the page mountMarket built, where a subscription survives an interval change even though the connection underneath is rebuilt.

EventPayload
statusconnecting, live, resyncing or closed
bookthe market and its whole book, already assembled from snapshot plus deltas
tradeone public trade: price, size, the side that took, its sequence
tickertop of book, last price and 24h volume, throttled by the feed
candlethe forming bucket of the subscribed interval
marketa lifecycle change: halted, resumed, settled, voided
messageevery frame as it arrived, uninterpreted, for a log or a debugger
A listener cannot break the feed

A handler that throws is reported to the console and the others still run. Losing the book to somebody else's bug is worse than a noisy console.

import { useMarketFeed, useTrades } from "@clobber/ui";

// the whole market, for a page that draws several components
const feed = useMarketFeed({ url, credential, market: "SOL-USDC" });
feed.status;   // "connecting" | "live" | "resyncing" | "closed"
feed.book;     // { seq, bids, asks }, absolute sizes
feed.trades;   // newest first

// or one slice, same connection
const { trades } = useTrades({ url, credential, market: "SOL-USDC" });

// side effects go in an effect, as usual: the hook gives you state, not a stream
useEffect(() => {
  if (feed.trades[0]) notify(feed.trades[0]);
}, [feed.trades]);

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 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. All of that is implemented once, in the client every component here stands on, and it is the sequencing rule from the reference, not a second interpretation of it.

These three drive it against every component on the page. Force a gap skips a sequence, and the status light pulses while the client re-anchors. Drop the connection closes the socket underneath, and the client reconnects on its own backoff and subscribes again. Neither one needs anything from your code.

speed

Skins

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

Theming

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

A skin of your own
.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 components read the values and repaint. A skin change anywhere above them is observed; a host that swaps a stylesheet wholesale calls retheme().