Docs · Widget

One iframe, no backend.

The SwapsPro swap card, embeddable on any site. Visitors connect their own wallet inside the frame and sign their own transactions — you hold no keys, run no server and store nothing. There is a visual builder at /widget that writes the snippet for you; this page documents what it writes, and the two ways it can write it.

This is the widget, live#

Not a screenshot — the frame below is a real /embed instance, loaded the same way your visitors would load it. It quotes and signs for real, so treat anything you do inside it as production.

live · /embed?theme=dark&accent=%234DF98A&radius=16

Two ways in, and why there are two#

The builder has a Simple tab and an Advanced tab, and the difference is not how much you are allowed to change — it is how the change travels.

Styling used to go entirely through the query string, which capped the depth of customisation at how much fits in a URL: a theme, an accent, a radius. The ceiling was never the iframe. postMessage carries a structured object of any size, so a full palette, a font stack and two radius levels are available without extracting the application into a React component. Simple is the query string; Advanced is the object.

SimpleAdvanced
Carried byThe iframe srcA postMessage after load
DepthTheme, accent, radius, background, width, chromeAll of that plus 12 named colours, a font stack and a second radius level
Changing it laterChange the src — which reloads the widgetPost again. The card keeps its state, including a typed amount and a live quote.
Works with JavaScript offYesThe iframe still renders; it just keeps its own palette

The builder's preview is the output, not a demo

The preview iframe obeys the same height message the snippet installs and receives its style by the same postMessage, so what is on screen there is what an embedder gets — including the absence of a scrollbar. Its src is fixed at mount for exactly that reason: it used to change on every slider tick, which remounted the widget, flashed the card, discarded a typed amount and re-fetched a quote. Appearance is not a reason to reload an application.

The simple snippet#

embed.html

<iframe id="swapspro"
  src="https://www.swaps.pro/embed?theme=dark&accent=%234DF98A&radius=16&width=440"
  width="440" height="640"
  style="border:0;border-radius:16px;display:block;max-width:100%"
  title="SwapsPro widget" allow="clipboard-write"></iframe>
<script>
  // Height is reported, not guessed. This is what removes the scrollbar.
  addEventListener("message", function (e) {
    if (e.source !== document.getElementById("swapspro").contentWindow) return;
    if (e.data && e.data.type === "swapspro:height")
      document.getElementById("swapspro").style.height = e.data.height + "px";
  });
</script>

One iframe and eight lines of script. No npm package, no API key, no backend. The iframe target is /embed — a stripped swap card with no site navigation, deliberately kept out of the sitemap and disallowed in robots.txt so it can never outrank the app itself in search.

Height is measured, not guessed#

The widget measures itself with a ResizeObserver and posts its height to the parent:

what arrives

{ type: "swapspro:height", height: 612 }
  • Why it exists. A fixed iframe height is a guess about content whose height changes — a quote arriving, a recipient field appearing on a cross-chain pair, an error line wrapping. Guess low and the visitor gets a scrollbar over somebody else’s page; guess high and there is dead space under the card. The embed also used to set minHeight: 100dvh, which inside a fixed-height frame guarantees the scrollbar.
  • Listening is optional. Without the listener the message is inert and the frame keeps whatever height you set, so an embed written before this existed cannot break.
  • It only posts on real change — under two pixels is ignored, so an animation cannot make the parent do layout every frame.
  • Check the source. The example listener compares e.source against the frame’s contentWindow; any page can post a message at yours.

In React, there is a component that does both halves#

@swapspro/widget wraps the same iframe and installs the two pieces you would otherwise write yourself: the height listener, and the theme push on load and on every change. It is a wrapper, not a port — the swap card still runs in the frame, deployed by us, because shipping quoting, routing, wallet handling and fee collection into somebody else’s bundle means shipping every future bug in them into a page whose owner cannot patch it.

App.tsx

import { SwapProWidget } from "@swapspro/widget";

// The minimum is one prop, and even that is optional.
<SwapProWidget accent="#4DF98A" />;

// The same theme object the postMessage path takes, as a prop. Changing it
// re-posts rather than remounting, so a typed amount and a live quote survive.
<SwapProWidget
  appearance="light"
  chrome="bare"
  sell="base-eth"
  buy="base-usdc"
  theme={{
    colors: { canvas: "#FFFFFF", surface: "#FFFFFF", text: "#101418", accent: "#3B82F6" },
    fontFamily: '"Inter", system-ui, sans-serif',
    shape: { borderRadius: 20, borderRadiusSecondary: 8 },
  }}
  onHeightChange={(h) => console.log("card is", h, "px tall")}
/>;
PropTypeWhat it does
appearance"dark" | "light"Defaults to dark.
accentstringShorthand for theme.colors.accent.
themeWidgetThemeColours, font stack, two radius levels. Wins over accent.
chrome"card" | "bare"Keep or drop the widget's own card — the same thing as chrome=none.
backgroundstringtransparent, solid, or any #rrggbb.
sell / buy / amountstringPreselect the pair and the amount.
proAddressstringRemoves the badge for a verified Pro Pass holder. Same on-chain check, same closed failure.
widthnumber | stringDefaults to 100%.
onHeightChange(h: number) => voidFires when the card resizes — the only event the frame emits.
originstringOnly to point at a preview deployment.

Not on npm yet

@swapspro/sdk is published; @swapspro/widget is not. Build it from packages/widget (bun run widget:build), or paste the iframe and the eight-line listener above and lose nothing but the props.

Query parameters#

Two groups, honoured by two different layers. The first are the embed's own styling; the rest are the swap card's deep-link parameters, which work on /swap too. Note that tab is not among them — the tab bar lives on /swap, not inside the frame.

Styling#

ParamValuesDefaultEffect
themedark or lightdarkStamps the theme class on the iframe document. Anything other than light is dark.
accentA six-digit hex colour, URL-encoded — %234DF98A for #4DF98Abrand greenOverrides the accent everywhere in the card, including the legacy ramp it still drives. Must match #RRGGBB exactly; three-digit shorthand, named colours and rgb() are ignored here — the Advanced palette accepts all three.
radius03216Corner radius of the card's outer surface, in pixels. Clamped to the range; the builder steps in 2s.
bgsolid, a #RRGGBB hex, or absenttransparentsolid paints the app's canvas colour behind the card and a hex paints that colour. Absent leaves it transparent so your own page shows through.
width320720480Maximum width of the card inside the frame, in pixels. Clamped. The card centres itself in whatever is left.
fontsans or monosansSwitches the whole widget to the monospace face. An arbitrary font stack is an Advanced setting, not a URL one.
chromecard or nonecardnone (the builder calls it Bare) drops the widget's own border, fill, blur and shadow so only the controls render and your page shows through behind them. It affects the card inside the frame, which is why removing the preview's own chrome never achieved it.
brand + probrand=0 with pro=0x…badge shownRemoves the ⚡ swaps.pro badge for a verified Pro Pass holder. See below — it is proved on-chain and fails closed.

Encode the hash

accent=#4DF98A does not work — everything after # is a URL fragment and never reaches the page. Write accent=%234DF98A.

Pre-filling the swap#

ParamFormatEffect
sellchain-symbolThe sell token, as a lowercase slug: eth-usdc, base-eth, btc-btc, hive-hbd. An unknown slug falls back to the default rather than erroring.
buychain-symbolThe buy token, same format.
amountdecimalSell amount, as typed. Empty, zero and non-numeric values are ignored — 0.50 round-trips as 0.50.

deep link

<!-- Land on a specific pair with an amount pre-filled.
     Slugs are chain-symbol, lowercase: eth-usdc, base-weth, btc-btc, hive-hbd. -->
<iframe
  src="https://www.swaps.pro/embed?theme=dark&sell=base-eth&buy=base-usdc&amount=0.1"
  width="440" height="640" style="border:0" title="SwapsPro widget"
></iframe>

The Advanced theme object#

Post { type: "swapspro:style", theme: {...} } at the frame and the widget restyles in place. Every key maps to a CSS variable the app already renders through — a smaller surface than arbitrary per-component overrides, and an honest one: every key below does something.

advanced.html

<iframe id="swapspro" src="https://www.swaps.pro/embed?theme=dark&chrome=none"
  width="440" height="640"
  style="border:0;border-radius:16px;display:block;max-width:100%"
  title="SwapsPro widget" allow="clipboard-write"></iframe>
<script>
  var f = document.getElementById("swapspro");
  var theme = {
    colors: {
      canvas: "#0b0f0d",
      surface: "rgb(17, 24, 20)",
      surfaceSunken: "#0e1411",
      borderSubtle: "#1e2a24",
      text: "#e8f1ec",
      textMuted: "#9fb3a8",
      accent: "#4DF98A",
      accentText: "#04120a",
      accentSoft: "#4DF98A1f"
    },
    fontFamily: '"Inter", system-ui, sans-serif',
    shape: { borderRadius: 20, borderRadiusSecondary: 10 }
  };

  // Push it once the frame has a document, and again any time you change it.
  function style() {
    f.contentWindow.postMessage({ type: "swapspro:style", theme: theme }, "*");
  }
  f.addEventListener("load", style);

  addEventListener("message", function (e) {
    if (e.source !== f.contentWindow) return;
    if (e.data && e.data.type === "swapspro:height") f.style.height = e.data.height + "px";
  });
</script>
KeyTypeWhat it does
colorsup to 12 named coloursEach drives one CSS variable. An unset key keeps the widget's own colour, which is not the same as sending one that happens to match.
fontFamilystringA font stack for the whole widget. The font must already be loaded on your page — the frame cannot fetch one.
shape.borderRadiusnumber, 0–48The outer card. Clamped.
shape.borderRadiusSecondarynumber, 0–32Buttons, inputs and inner panels. Clamped.

The 12 colour keys, in the order you would reason about them — the surfaces first, then what sits on them, then the accent: canvas, surface, surfaceRaised, surfaceSunken, borderSubtle, borderStrong, text, textMuted, textSubtle, accent, accentText, accentSoft.

  • Send only the keys you set. An empty object means “use the widget’s own palette”.
  • theme: null clears it. An absent theme key leaves the current one alone, so a host can update the simple style and the deep theme independently in one message.
  • The variables are removed by exactly the keys that were set — never a blanket reset, which would take the app’s own variables with it.
  • The style half of the message is validated identically to the URL. Both are untrusted input and neither gets to set a value we would not accept from the other.

A colour that does not parse is refused, not sanitised

A theme arrives from another window, so every value is validated before it reaches the document. Colours must match #rgb, #rrggbb, #rrggbbaa, rgb()/rgba() or hsl()/hsla(); anything else is dropped rather than cleaned up, because a colour we cannot parse is either a mistake or an injection attempt and neither should reach the document. A font stack is stripped of anything that could close a declaration and capped at 200 characters. A host gets to restyle the widget, not to inject CSS into it.

The practical consequence: rebeccapurple, var(--brand) and color-mix(...) silently keep the existing colour. If a key appears to do nothing, check that its value parses.

Removing the badge is a Pro benefit, and it is proved#

A small ⚡ swaps.pro link sits in the bottom-right of the frame. Passing brand=0 with pro=<address> removes it — but only after the pass is read on chain, with the same tokenOf call on Base (chain 8453) the app uses for its own Pro check.

It fails closed. Until the read comes back positive the badge renders, so a network problem, an RPC outage or a made-up address all end with the attribution still on the page. The opposite design — hide first, restore if the check fails — hands the benefit to exactly the population that would arrange for the check not to complete.

The honest limit

An address is public, so this proves a Pro pass exists, not that you own it. It is an honesty lock, not a licence server. Making it unforgeable means signing something, and that is a different feature. See the Pro Pass.

Sizing#

  • Width. The card is capped by width (default 480, max 720) and centres itself in whatever box you give it, so a wider iframe just adds empty space either side. Below about 320px the amount row starts to crowd.
  • Height. Set a sensible starting value — the builder emits 640 — and let the height message correct it on first paint. That is the whole sizing story now.
  • Responsive. width: 100% on the iframe with a max-width on a wrapper, and the height listener installed. Nothing else is needed.

allow="clipboard-write"

The card offers a copy-link and copy-hash affordance. Without that permission attribute those buttons fail silently in the frame. Nothing else is requested — no camera, no microphone, no payment, no geolocation.

Widget or SDK?#

The widget is the whole SwapsPro experience in a box: you get everything, and you restyle it. The SDK is the opposite trade.

WidgetSDK
Integration costOne iframe tag, plus a height listenerA package, your own UI, your own state
Look and feelTheme, accent, background, chrome, two radius levels, a font stack and 12 coloursEntirely yours
Token selectorSwapsPro's, full universeWhatever you build
ChainsEvery chain the app supports, including non-EVMQuoting everywhere; execution helper is EVM-only
Wallet connectionHandled inside the frameYou pass an EIP-1193 provider
Limit orders and TWAPNo — the frame is the instant swap card onlyNo — quote and swap only
Partner attributionNot availableYes, via partner — see Partners
Analytics on the flowNone — it is a cross-origin frameAll of it, it is your code

What the widget still cannot do#

  • No partner id. The embed honours no attribution parameter, so an embed cannot be credited to you. If revenue share matters, use the SDK.
  • No flow events. The only message the frame emits is its height. The parent cannot observe a connect, a quote or a completed swap.
  • No control over the swap after load. Style is live; the pair and the amount are not. To change those, change the iframe's src.
  • No custom token list. The selector is SwapsPro's.
  • No per-component style overrides. The theme reaches CSS variables the app already renders through, and nothing beyond them.
  • Instant swaps only. The frame renders the swap card without the tab bar, so limit orders and TWAP ladders are not reachable from an embed. Link visitors to /swap for those.

Wallets, fees and behaviour#

Identical to swaps.pro itself. Visitors connect their own browser extension or mobile wallet inside the frame; quotes come from the same routing; the fee is the same fee, and it is on the fees page like everything else. Nothing about being embedded changes the price.

Theme isolation

The embed stamps its theme class directly on its own document rather than persisting a preference. That is deliberate: the widget and the app share an origin, so a persisted theme would leak out of the frame — loading the builder’s preview would silently flip the whole app to the widget’s theme.

Build one#

The widget builder has both tabs, a live preview on a checkerboard so you can see what is and is not painted, and a copy button on the finished snippet. It is the fastest way to land on values you like before pasting anything into your own page.