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.
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} />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@clobber/ui@0.2.1/dist/clobber-ui.css" integrity="sha384-04rmGqePxavdA8+JD63ZNq2bMYTneQtU5zVvLi/4vGSAcUQfe5eDG8aENPTeOrCT" crossorigin="anonymous"> <script src="https://cdn.jsdelivr.net/npm/@clobber/ui@0.2.1/dist/clobber-ui.global.js" integrity="sha384-R3g/xgZQEfxoOx2fNErUWoCDXaV8Q3BFSR4/Xdu0ekBwWnSZRVAZ9eEWj7/V+C4g" crossorigin="anonymous"></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>
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.
| Mount | React | What 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 |
| mountMarket | the hooks | the 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.
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" })),
});
"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.
const book = ClobberUI.mountOrderBook({ target: "#book", depth: 12, onPrice: (level) => ticket.setPrice(level.price), }); book.update(state); // the book from the feed book.setStatus("live");
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.
const depth = ClobberUI.mountDepthChart({ target: "#depth", depth: 50, // levels per side folded into the curve band: 0.25, // optional: clamp the axis around the mid }); depth.update(state);
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.
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.
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
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.
const tape = ClobberUI.mountTradeTape({ target: "#tape", rows: 40, compact: false, // true abbreviates 12400 as 12.4K }); tape.update(trades); // newest first
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.
const summary = ClobberUI.mountMarketSummary({ target: "#summary", market: "WILL-IT-RAIN-BA", referenceLabel: "24h", }); summary.update({ ticker, event, reference: "0.58" });
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.
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() };
},
});
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.
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 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}`); });
// A tap on an inline keyboard: callback_query "buy:25:0.62". bot.on("callback_query", async (q) => { const [side, qty, price] = q.data.split(":"); const order = await clobber("/v1/orders", { account: accountFor(q.from.id), // the user is the account idempotencyKey: `tg:${q.id}`, // one tap is one order body: { market: "WILL-IT-RAIN-BA", side, type: "limit", price, qty, time_in_force: "ioc", }, }); await bot.answerCallbackQuery(q.id, { text: `${order.status} ${order.filled_qty} @ ${order.avg_fill_price}`, }); });
// WhatsApp Cloud API webhook. The shape of the SMS one, with a // reply that can carry buttons. for (const message of value.messages ?? []) { const intent = parseOrder(message.text.body); if (!intent.ok) { await reply(message.from, intent.error); continue; } const order = await clobber("/v1/orders", { account: accountFor(message.from), // the id is the account idempotencyKey: message.id, // Meta redelivers a non 200 body: { ...intent.order, time_in_force: "ioc" }, }); await reply(message.from, receipt(order)); }
// In the browser. The wallet signs the order; it never holds a key. const intent = { market, side, price, qty, nonce, expiry: now() + 60 }; const signature = await window.ethereum.request({ method: "eth_signTypedData_v4", params: [address, JSON.stringify(typedData(intent))], }); await fetch("/api/orders", { method: "POST", body: JSON.stringify({ intent, signature }), }); // On your backend. The signer is the account, the nonce is // the idempotency key. const signer = recoverTypedSignature({ data: typedData(intent), signature }); await clobber("/v1/orders", { account: accountFor(signer), idempotencyKey: `${signer}:${intent.nonce}`, body: { market: intent.market, side: intent.side, type: "limit", price: intent.price, qty: intent.qty, }, }); // Or mint that address an account key once and let the page // place directly: POST /v1/accounts/{id}/keys answers with // ck_acct_..., scoped to that one account and no other.
// The tool an agent gets: its arguments are the order fields. const placeOrder = { name: "place_order", description: "Place an order on a Clobber market.", input_schema: { type: "object", properties: { market: { type: "string" }, side: { enum: ["buy", "sell"] }, type: { enum: ["limit", "market"] }, price: { type: "string" }, // decimal, on the tick grid qty: { type: "string" }, // decimal, a multiple of the lot time_in_force: { enum: ["gtc", "ioc", "fok"] }, }, required: ["market", "side", "type", "qty"], }, }; // Executing it is one POST with an account key: it names one // account, carries no Clobber-Account header, and sees no other. await fetch(`${API}/v1/orders`, { method: "POST", headers: { authorization: `Bearer ${accountKey}`, // ck_acct_test_... "Idempotency-Key": toolUse.id, // a retry is not a second order }, body: JSON.stringify(toolUse.input), });
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.
| Channel | Who the account is | Idempotency key | Credential |
|---|---|---|---|
| SMS | the phone number | the provider's message id | trading key, on your backend |
| Telegram | the Telegram user id | the callback id | trading key, on your backend |
| the WhatsApp id | the message id | trading key, on your backend | |
| Wallet | the recovered signer | signer plus intent nonce | trading key on your backend, or an account key in the page |
| Agent | the account its key names | the tool use id | account 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.
| Hook | Gives you |
|---|---|
| useMarketFeed | status, book, trades, ticker, candles and the lifecycle event for one market |
| useOrderBook | status and the book |
| useTrades | status and the tape |
| useCandles | status and the interval's candles, the forming one live |
| useMarket | status, ticker and the lifecycle event |
| useVenueFeed | status 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.
| Event | Payload |
|---|---|
| status | connecting, live, resyncing or closed |
| book | the market and its whole book, already assembled from snapshot plus deltas |
| trade | one public trade: price, size, the side that took, its sequence |
| ticker | top of book, last price and 24h volume, throttled by the feed |
| candle | the forming bucket of the subscribed interval |
| market | a lifecycle change: halted, resumed, settled, voided |
| message | every frame as it arrived, uninterpreted, for a log or a debugger |
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]);
const feed = new ClobberUI.FeedClient({ url: "wss://feed-sandbox.clobberhq.com", credential: { publishableKey: "pk_live_..." }, channels: ["book", "trades", "ticker"], markets: ["SOL-USDC"], }); const stop = feed.on("trade", (trade) => notify(trade)); feed.on("status", (status) => light.dataset.status = status); feed.on("book", ({ market, book }) => ladder.update(book)); feed.on("message", (frame) => console.debug(frame)); feed.connect(); stop(); // on() hands back its own unsubscribe // the composed page exposes the same surface, and it survives an interval change const page = ClobberUI.mountMarket({ target: "#market", url, credential, market }); page.on("trade", (trade) => tally(trade));
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.
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.
.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().