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.
<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 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);
npm install @clobber/ui import { useMarketFeed, OrderBook } from '@clobber/ui'; import '@clobber/ui/clobber-ui.css'; const feed = useMarketFeed({ url, credential, market }); <OrderBook feed={feed} depth={12} />
@clobber/ui is on npm, Apache 2.0: one bundle for a page that has no build step,
ES modules and types for a page that does, and the stylesheet either way. What it needs from you
is a publishable key, which
the sandbox hands out with your
environment.
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 |
Four 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 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.
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() }; }} />
A market page
One call mounts summary, chart, book, depth, tape and ticket over a single connection. 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.
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> ); }
React
The React components mount the same components and draw nothing of their own. Two renderers for one component is two sets of bugs, and the promise of the package is that a React page and a script tag see the same book, the same colours and the same recovery behaviour. Every example on this page has a react tab: that is the whole API.
What is React's own is the hooks. One subscription per hook call, torn down with the component, and the per slice hooks are views of the same connection rather than new ones.
| 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 |
A getToken callback that is a new closure on every render does not reconnect
the feed: the hooks hold it behind a ref, so a fifteen minute token can be minted again on
every reconnect without the component churning.
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.
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().