Skip to content

Embed Widget

The embed widget puts the Atom Circuit swap UI on your own site behind an iframe. Every swap routed through the widget carries a referralId so the affiliate fee from the swap is converted to ATOM and delegated to the validator that the referralId resolves to. The SDK is open source at github.com/cosmosrescue/atom-circuit-embed-sdk and published on npm as @atom-circuit/embed-sdk.

Two audiences:

  • Validators embed the widget on their own page and pass their own referralId. The affiliate fee from every swap stakes back to them.
  • Non-validator sites (community sites, ecosystem aggregators, content creators) can omit referralId entirely - the SDK defaults to 'general' and splits the fee across all participating validators. See Using the general referral.

Quick Start

Pick the stack you ship with, copy the snippet, replace YOUR_REFERRAL_ID with the value from your validator profile (or the literal string general, see below). A container plus referralId is all you need - the end user connects their wallet inside the widget, and nothing else is required.

referralId is the only option that matters for a basic embed, and even it is optional (omit it to default to 'general'). Everything else - theme, chrome, maxWidth, allowReferralChoice, the callbacks, and the parent-wallet bridge - is optional and additive. The parent-wallet bridge in particular is an advanced opt-in; it is documented last, after the basic and theming sections, and you never need it for a working embed.

Validators find their referral ID on their validator page on atomcircuit.net, next to the referral link with a Copy button. referralId also accepts your registered validator slug or the literal string general (for non-validator sites); all three resolve correctly on the dapp side.

Vanilla HTML

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Atom Circuit embed</title>
  </head>
  <body>
    <div id="atom-circuit-widget"></div>

    <script src="https://unpkg.com/@atom-circuit/embed-sdk@2.3.1/dist/atom-circuit.iife.js"></script>
    <script>
      AtomCircuit.mount(document.getElementById('atom-circuit-widget'), {
        referralId: 'YOUR_REFERRAL_ID',
      });
    </script>
  </body>
</html>

Full example: examples/vanilla/full.html.

React

Install the package:

npm install @atom-circuit/embed-sdk

Mount the component:

import { AtomCircuitSwap } from '@atom-circuit/embed-sdk/react';

export default function SwapPanel() {
  return <AtomCircuitSwap referralId="YOUR_REFERRAL_ID" />;
}

Full example: examples/typescript/react/examples/full.tsx.

Next.js

Install the package:

npm install @atom-circuit/embed-sdk

Dynamic-import the component with ssr: false so the iframe-only code stays out of the server bundle:

'use client';

import dynamic from 'next/dynamic';

const AtomCircuitSwap = dynamic(
  () => import('@atom-circuit/embed-sdk/react').then((m) => m.AtomCircuitSwap),
  {
    ssr: false,
    loading: () => <div style={{ minHeight: 520 }} />,
  }
);

export default function Page() {
  return <AtomCircuitSwap referralId="YOUR_REFERRAL_ID" />;
}

Full example: examples/typescript/nextjs/examples/full.tsx.

Supported wallets

Which wallets the end user can connect with depends on which of the two connect modes you use. Both are fully supported; the difference is only where the wallet lives.

Built-in in-widget connect (the default)

A plain embed (no wallet option, or wallet.mode: 'iframe') has the user connect their wallet inside the widget. The built-in set is fixed:

  • Cosmos: Keplr and Cosmostation. Each works as a desktop browser extension and on mobile via WalletConnect.
  • EVM: any injected browser wallet (MetaMask, Rabby, or anything that exposes window.ethereum), plus WalletConnect.

Parent-wallet reuse (advanced, opt-in)

When you opt into parent-wallet mode (wallet.mode: 'parent', see Reusing the parent page's wallet), the widget reuses whatever wallet is already connected on your page. This is not limited to the built-in set above; it works with any compatible wallet:

  • Cosmos: any Keplr-API-compatible injected wallet via fromInjectedCosmosWallet (any provider exposing getKey / enable / getOfflineSigner / getOfflineSignerOnlyAmino), or any connected cosmos-kit wallet client via fromCosmosKit. window.keplr and window.cosmostation.providers.keplr are examples of compatible injected providers, not the only ones; any wallet that implements the same injected API works.
  • EVM: any EIP-1193 provider via fromWagmi (window.ethereum, a wagmi connector's resolved provider, or any object with a request(...) method).

The user always signs in their own wallet UI; keys never leave their wallet. If the bridge cannot be established, the widget silently falls back to the built-in in-widget connect described above, so a swap is always completable.

Using the general referral

If you do not represent a validator, omit referralId entirely (the SDK defaults to 'general') or pass it explicitly as referralId: 'general'. The affiliate fee from every swap is then split across all participating validators - participating meaning registered Atom Circuit validators that have received at least one prior swap attribution. The bot fans the fee out equally at sweep time; rounding remainder goes to the last validator in the set.

<AtomCircuitSwap referralId="general" />

The widget shows "Fees split across participating validators" in the validator-attribution row instead of a single validator name. Everything else (theming, chrome, callbacks) works the same way.

Letting users choose the validator

By default the referralId you pass is fixed - the end user sees which validator the fee supports but cannot change it. Set allowReferralChoice: true to render a validator picker inside the widget so the user chooses which validator the affiliate fee stakes to:

<AtomCircuitSwap referralId="YOUR_REFERRAL_ID" allowReferralChoice />

Your referralId becomes the pre-selected default. The user can switch to any participating validator, or clear the selection to fan the fee out across all of them (general); their choice is remembered across reloads on your site. Omit the flag or set it to false (the default) to keep a fixed referralId the user cannot change - existing integrations are unaffected. The flag is also available on the imperative mount() call (AtomCircuit.mount(el, { referralId, allowReferralChoice: true })).

Reusing the parent page's wallet

This is an advanced, optional feature. You do not need it for a working embed. By default the widget connects its own wallet inside the iframe with no setup: the end user clicks Connect Wallet in the widget even when your page already has a wallet connected. If that is fine for you, as it is for most integrators, skip this section.

Opt in only when your site already runs a Cosmos (cosmos-kit) or EVM (wagmi) wallet and you want to reuse that connection (wallet.mode: 'parent') so the user never reconnects inside the widget. To use parent mode, set wallet.mode: 'parent' and pass at least one wallet handle. The widget trusts the page it is embedded in via the browser's unspoofable parent origin, so the bridge works directly with nothing to register. The user still signs every transaction in their own wallet UI - keys never leave their wallet, and the iframe only requests signatures.

This is opt-in and fully backward compatible. Omitting wallet, or setting wallet.mode to 'iframe' (the default), behaves exactly as before.

The explicit choice: two modes

wallet.mode is a deliberate choice you make per embed:

  • 'iframe' (default): the widget connects its own wallet inside the iframe. No bridge, no handles. Identical to the prior behaviour.
  • 'parent': the widget reuses your page's already-connected wallet over a postMessage bridge. Requires at least one wallet handle (cosmos, evm, or both). The widget trusts your page's actual embedding origin, which the browser supplies and the page cannot forge.

Cosmos

Pass wallet.cosmos. The fromCosmosKit helper adapts a connected cosmos-kit wallet client into the handle the bridge needs. In React, derive the client from useChain() and wrap it once the wallet is connected:

import { useChain } from '@cosmos-kit/react';
import { fromCosmosKit } from '@atom-circuit/embed-sdk';

const { status, chainWallet } = useChain('cosmoshub');

// Note: useChain() does not return a top-level `client`.
// The wallet client lives at chainWallet.client, and it only exists after connect.
const cosmosClient = chainWallet?.client;

const cosmos = useMemo(
  () => (status === 'Connected' && cosmosClient ? fromCosmosKit(cosmosClient) : undefined),
  [status, cosmosClient]   // key on the client identity - it appears asynchronously after connect
);

return <AtomCircuitSwap referralId="..." wallet={{ mode: 'parent', cosmos }} />;

Common mistake

Do not write const { client } = useChain(...). useChain() has no top-level client; it is always undefined, so the widget will never adopt the host wallet. The wallet client is chainWallet.client.

With the imperative mount() API the same handle is built from whatever cosmos-kit client you already have:

import { mount, fromCosmosKit } from '@atom-circuit/embed-sdk';

// `client` is your connected cosmos-kit wallet client (a ChainWallet's `.client`, populated only after connect).
mount(container, {
  referralId: 'YOUR_REFERRAL_ID',
  wallet: {
    mode: 'parent',
    cosmos: fromCosmosKit(client, { metadata: { name: 'My App' } }),
  },
});

fromCosmosKit reads the client's getOfflineSignerDirect / getOfflineSignerAmino (falling back to a unified getOfflineSigner(chainId, signerType)) and throws at wiring time if the client can produce no signer. It also synthesizes the small connect / enable shims the bridge expects but that some cosmos-kit clients (notably Cosmostation) do not expose, without overwriting ones the client already has. The Cosmos bridge delegates to @dao-dao/cosmiframe and is wallet-agnostic: whatever your cosmos-kit has connected (Keplr, Cosmostation, WalletConnect) works, and the widget needs no matching wallet package. If you are not on cosmos-kit, construct the handle directly with target, getOfflineSignerDirect, getOfflineSignerAmino, and optional metadata.

If you do not run cosmos-kit but a Keplr-API-compatible wallet is injected on your page, use fromInjectedCosmosWallet instead. It accepts a raw injected provider - window.keplr, Cosmostation via window.cosmostation.providers.keplr, and any other Keplr-compatible injected wallet:

import { mount, fromInjectedCosmosWallet } from '@atom-circuit/embed-sdk';

const provider = window.keplr; // or window.cosmostation.providers.keplr
await provider.enable('cosmoshub-4'); // connect on your page first

mount(container, {
  referralId: 'YOUR_REFERRAL_ID',
  wallet: {
    mode: 'parent',
    cosmos: fromInjectedCosmosWallet(provider, { metadata: { name: 'My App' } }),
  },
});

A raw injected provider has enable / getKey / sign* but lacks connect / getAccount / getSimpleAccount, which the bridge needs. fromInjectedCosmosWallet wraps it into a valid bridge target (no-op connect, account getters derived from getKey). Use fromCosmosKit for an actual cosmos-kit client and fromInjectedCosmosWallet for a raw injected provider. fromKeplr is a backward-compatible alias of fromInjectedCosmosWallet (it works for any Keplr-compatible wallet, including Cosmostation); prefer fromInjectedCosmosWallet in new code.

fromInjectedCosmosWallet requires every source chain a swap may use to already be added in the user's wallet. It deliberately carries no chain-registry dependency to add chains, so if a missing chain is needed it surfaces a clear, actionable error ("the wallet does not have <chainId> added. Add it in your wallet first, or use fromCosmosKit which can add chains."). If you need to swap from arbitrary source chains the user may not have added, use fromCosmosKit (which forwards the real cosmos-kit client's addChain).

EVM

Pass wallet.evm with an EIP-1193 provider. The fromWagmi helper takes an already-resolved provider:

import { mount, fromWagmi } from '@atom-circuit/embed-sdk';

// Resolve the provider from your connected wagmi connector first:
//   const provider = await getAccount(config).connector.getProvider();
// window.ethereum (or any { request, on?, removeListener? }) also works directly.
mount(container, {
  referralId: 'YOUR_REFERRAL_ID',
  wallet: {
    mode: 'parent',
    evm: fromWagmi(provider),
  },
});

Reuse one wallet for both channels: Keplr also exposes an EVM provider at window.keplr.ethereum, so a single Keplr wallet can serve both the Cosmos and EVM channels. Pass fromWagmi(window.keplr.ethereum) directly, or with wagmi target Keplr through an injected connector (Keplr announces via EIP-6963 with rdns app.keplr). A bare injected() or window.ethereum resolves to MetaMask, or whatever owns window.ethereum.

The EVM bridge relays provider.request(...) calls to your wallet and forwards its accountsChanged / chainChanged / disconnect events to the widget. You can pass both cosmos and evm; the loader wires only the side(s) you supply.

How trust works

Parent-wallet mode trusts the page it is embedded in by that page's own origin. Set wallet.mode: 'parent', pass a handle, and the bridge is established directly with nothing to register. Trust rests on the per-message origin check: each channel validates event.origin and event.source on every message, and the iframe only ever talks to its real, unspoofable parent origin (the browser supplies that origin, the page cannot forge it). The widget trusts the page it is actually embedded in - nothing else - which is why parent mode is safe to wire directly.

Connecting after mount (render now, adopt later)

You rarely have a connected wallet at the instant the widget mounts. The recommended flow is to render the widget immediately in 'parent' mode with no handles, then hand the wallet over once your user connects on your page. The widget stays visible the whole time and adopts the reused wallet with no remount and no reconnect.

Vanilla mount() returns setWallet / clearWallet for this:

const widget = mount(container, {
  referralId: 'YOUR_REFERRAL_ID',
  wallet: { mode: 'parent' }, // no handles yet
});

// once your user connects on your page:
widget.setWallet({ cosmos: fromCosmosKit(client) }); // or { cosmos, evm }

// when your user disconnects:
widget.clearWallet();            // all channels; or widget.clearWallet(['cosmos'])

setWallet (re)wires the bridge and posts the internal ready signal so the iframe auto-adopts; calling it again with a fresh handle re-adopts cleanly. clearWallet reverts the channel(s) to the in-iframe connect fallback. Both are no-ops unless the embed was mounted in 'parent' mode.

In React this is automatic: render <AtomCircuitSwap wallet={{ mode: 'parent' }} /> first, then pass wallet.cosmos / wallet.evm on a later render once they exist. The component diffs handle identity and drives setWallet / clearWallet for you - no remount. Changing wallet.mode (it bakes into the iframe URL) does remount; changing only the handles does not.

CDN / IIFE build: helpers on the global

The IIFE build exposes the same wallet helpers on the AtomCircuit global, so a CDN integrator with no bundler can still build handles. AtomCircuit.mount(...) returns the same setWallet / clearWallet, and the helpers are available as AtomCircuit.fromInjectedCosmosWallet (aliased AtomCircuit.fromKeplr), AtomCircuit.fromCosmosKit, and AtomCircuit.fromWagmi:

<script>
  var widget = AtomCircuit.mount(document.getElementById('atom-circuit-widget'), {
    referralId: 'YOUR_REFERRAL_ID',
    wallet: { mode: 'parent' }, // no handles yet
    onWalletConnectRequest: function (channel) {
      if (channel === 'cosmos') {
        // Keplr at window.keplr; Cosmostation at window.cosmostation.providers.keplr
        var provider = window.keplr;
        provider.enable(['cosmoshub-4', 'osmosis-1']).then(function () {
          widget.setWallet({
            cosmos: AtomCircuit.fromInjectedCosmosWallet(provider, { metadata: { name: 'My App' } }),
          });
        });
      } else {
        window.ethereum.request({ method: 'eth_requestAccounts' }).then(function () {
          widget.setWallet({ evm: AtomCircuit.fromWagmi(window.ethereum) });
        });
      }
    },
  });
</script>

The SDK's examples/vanilla/full.html carries the complete copy-pasteable version.

In-widget connect button

In 'parent' mode, when a channel is not yet bridged the widget shows a not-connected prompt rather than its own wallet picker (the picker belongs to your page). You choose how it behaves:

  • Supply onWalletConnectRequest(channel) and the widget renders an actionable Connect button. On click it calls your handler with the channel ('cosmos' or 'evm'); you run your own connect flow on the parent page, then call setWallet (or pass the handle in React) so the widget adopts it. One handler can service both channels; an unserviceable channel may no-op.
  • Omit onWalletConnectRequest and the widget shows a passive text prompt instead of a button. Override the text with connectPrompt (for example "Connect your wallet at the top of the page to swap"); omit it and the widget uses a friendly generic default.
mount(container, {
  referralId: 'YOUR_REFERRAL_ID',
  wallet: { mode: 'parent' },
  onWalletConnectRequest: (channel) => {
    openMyConnectModal(channel); // then widget.setWallet({ [channel]: handle })
  },
  // or, with no handler: connectPrompt: 'Connect your wallet to swap',
});

Both are only meaningful when wallet.mode === 'parent', and the widget always falls back to its own in-iframe connect when no parent wallet is available.

Graceful fallback and trust model

Reliability is preferred over convenience. In 'parent' mode, if the bridge cannot be established for any reason - no parent wallet is connected, the wallet is unsupported, or the handshake times out - the widget silently falls back to its own in-iframe connect. The user can always complete a swap.

The wallet lives on your page. The iframe builds the swap transaction and requests a signature over postMessage; your page relays it to the wallet, which shows its own confirmation UI; the iframe never holds keys or a signer. The user's own wallet confirmation is the authoritative backstop - the values the iframe shows are advisory, because a compromised parent could tamper at the relay layer, so users should verify amounts and recipients in their wallet. The front-line control is the per-message origin check: both channels validate event.origin and event.source on every message, the iframe only talks to its real parent origin (which the browser supplies and a page cannot spoof), and the Cosmos channel is never constructed with a wildcard origin. This origin boundary is the full trust model for parent mode; there is nothing to register. See the SDK's SECURITY.md for the full bridge trust model.

Where do my fees go

Every swap through the widget carries the referralId you pass at mount time. The 0.5% affiliate fee on that swap is collected, routed through Skip Go to the protocol's collector wallets, converted to ATOM on Cosmos Hub, and delegated to the validator (or split across participating validators, for general) at the next sweep cycle. The full pipeline is documented in Fee Flow.

To find your referral ID, open your validator profile on atomcircuit.net. The referral ID is shown at the top of the page next to your referral link.

Sizing

The widget renders inside a wrapper element you do not need to style. The following MountOptions control its layout:

  • width - any CSS width applied to the iframe. When omitted, the SDK does not set the width option at all; the iframe falls back to its built-in width: 100% (the iframe element is always created with width: 100%). Pass width only to override that.
  • maxWidth - any CSS max-width. Default unset. The cap applies to the swap form inside the widget, not only to the iframe element, so the form never stretches past it even when the iframe itself is wider.
  • padding - applied to the wrapper element, not the iframe (iframes ignore their own padding). Default '0'.
  • minHeight - starting iframe height before the widget reports its real content height. Default '480px'. The runtime height is managed by the SDK's resize handler and cannot be overridden.
  • autoscale - boolean, default false. When true, the widget scales its entire geometry up proportionally to fill a wide container instead of leaving the extra space empty. See Autoscale below.
  • maxScale - number, default 1.5, clamped to [1.0, 3.0]. Caps how far autoscale can grow the widget. Ignored when autoscale is false.
<AtomCircuitSwap
  referralId="YOUR_REFERRAL_ID"
  width="100%"
  maxWidth="480px"
  padding="16px"
  minHeight="520px"
/>

Autoscale

The widget is designed around a natural width of 480px. In a container wider than that, the default behaviour leaves the widget at its natural density and the extra space goes unused, so on a wide desktop layout the form can look small and lost. Set autoscale: true to make the embed scale its entire geometry up proportionally: text, buttons, icons, input fields, and spacing all grow together (via CSS zoom), so a wide container shows a correspondingly larger, more legible widget instead of a small one floating in empty space.

The scale factor is clamp(availableWidth / 480, 1.0, maxScale):

  • Below 480px of available width, autoscale does nothing. The widget falls back to its normal fluid layout and reflows to fit, so it never overflows a narrow container or a mobile viewport, and there is no horizontal scrollbar.
  • Between 480px and 480 * maxScale, the widget scales up linearly with the available width, filling it at the matching scale factor.
  • Past 480 * maxScale, the widget stops growing and sits centered at maxScale, so it never stretches beyond its intended proportions on an ultra-wide container.

maxScale (default 1.5) caps the growth and is clamped to [1.0, 3.0]; an out-of-range or non-finite value falls back to 1.5. Setting maxScale: 1.0 keeps the widget at its natural size on every container (autoscale on, but no growth). maxScale is ignored when autoscale is false.

Leaving autoscale off (the default) is fully backward compatible: no scaling and no layout change versus prior versions. The widget's reported height tracks the scaled content automatically, so the iframe still resizes to fit with no internal scrollbar.

<AtomCircuitSwap
  referralId="YOUR_REFERRAL_ID"
  width="100%"
  autoscale
  maxScale={2}
/>

Theming

The optional theme object controls the widget's color palette and typography. Every field is optional. The full token surface:

Key Type Controls
mode 'light' \| 'dark' \| 'auto' Which built-in preset to start from. 'dark' is the default; 'light' is the light preset; 'auto' follows the host's system preference. Every token below overrides whichever preset you pick.
accentColor hex string Primary buttons (Swap, Connect) and active highlights. #abc or #aabbcc.
accentForeground hex string Text/icon color rendered on top of the accent (for example, the primary-button label).
background hex string The widget's outer page background.
foreground hex string Primary text/foreground color.
card hex string Convenience bundle. Sets every surface in one shot: card / panel (--bg-card), the secondary panel (--bg-secondary), the input surface (--bg-input), the validator / picker band (--bg-deep), and a derived hover shade (--bg-card-hover). The more specific cardSecondary and input tokens override individual tiers on top of this.
cardSecondary hex string Secondary surface tier: the validator / picker band (--bg-deep) and the secondary panel (--bg-secondary). Overrides the card bundle for those two surfaces only; leaves --bg-card and --bg-input untouched.
input hex string Input surface (text / amount inputs, --bg-input). Overrides the card bundle for the input surface only.
mutedForeground hex string Muted/secondary text: labels, captions, helper text.
border hex string Border color of inputs, cards, and dividers.
borderFocus hex string Focused/secondary border (focused inputs, emphasized dividers).
warning hex string Warning notification color.
success hex string Success notification color.
error hex string Error notification color.
radius number Corner radius in px, 0-64 inclusive.
fontSize number Base font size in px, 8-32 inclusive. Applied at the iframe document root, so it scales the entire widget (every surface is authored in rem), not just one text element.
fontFamily string CSS font-family; CSS-safe subset, no <>;{}=(), no newlines, max 200 chars.

Every color value must be a hex string (#RGB or #RRGGBB); other CSS color notations (rgb(), named colors) are rejected so the wire surface stays trivial to validate and free of CSS-injection footguns.

Presets and overrides. Pick a preset with mode ('dark' default, 'light', or 'auto'), then override individual tokens on top of it. Omitting mode and every token gives the dark preset unchanged. The override model is additive: { mode: 'light', accentColor: '#7b61ff' } renders the full light preset with only the accent swapped, leaving every other token at the light-preset default.

Surface tiers (card / cardSecondary / input). card is the convenience bundle: it sets the card / panel (--bg-card), the secondary panel (--bg-secondary), the input surface (--bg-input), the validator / picker band (--bg-deep), and a derived hover shade (--bg-card-hover) in one shot. The two more specific tokens are applied after the bundle and win for their tier: cardSecondary overrides the secondary / band surfaces (--bg-secondary + --bg-deep), and input overrides the input surface (--bg-input). Use card alone for a single flat surface color, then reach for cardSecondary / input only when you want those tiers to differ. Each is independent: supplying cardSecondary or input without card overrides just that tier and leaves the rest at the dapp default.

Validation is all-or-nothing: if any single field fails its rule, the entire theme is dropped and the widget renders with its defaults. The SDK emits one console.warn describing the failure.

<AtomCircuitSwap
  referralId="YOUR_REFERRAL_ID"
  width="100%"
  maxWidth="480px"
  theme={{
    mode: 'dark',
    accentColor: '#7b61ff',
    accentForeground: '#ffffff',
    background: '#0d0f14',
    foreground: '#f5f6fa',
    card: '#161a23',
    mutedForeground: '#9aa3b2',
    border: '#1f2330',
    borderFocus: '#2c3242',
    warning: '#f5a623',
    success: '#3ecf8e',
    error: '#ef5350',
    radius: 12,
    fontSize: 14,
    fontFamily: 'Inter, system-ui, sans-serif',
  }}
/>

The widget does not load fonts itself. Use a fontFamily already available on the host page.

Chrome Toggles

The widget ships with the Atom Circuit logo, the Connect Wallet button, the "Fees stake with <moniker>" badge, and the footer visible by default. Each of these can be hidden independently through the chrome object.

<AtomCircuitSwap
  referralId="YOUR_REFERRAL_ID"
  chrome={{
    logo: false,
    wallet: true,
    validator: true,
    footer: false,
  }}
/>

A non-boolean value on any field drops the entire chrome bundle and the widget renders with all surfaces visible.

Callbacks

The widget emits six events on the iframe side and the SDK has one error callback for bring-up failures. All are optional.

Widget events (5):

  • onReady - fires once when the iframe has loaded and the SDK handshake completed; from here the widget is interactive. Payload: { protocolVersion }.
  • onResize - fires when the iframe content height changes; use it to reflow your surrounding page layout. Payload: { height } in px.
  • onSwapSubmitted - fires after the user signs and the source-chain transaction broadcasts. Payload: { txHash, route }.
  • onSwapBridging - fires while a multi-step swap's bridge leg is still settling (for example a CCTP transfer mid-attestation): the source transaction has broadcast and funds are bridging to the next chain, but the swap is not yet complete. Non-terminal - a later onSwapSuccess or onSwapError still fires for the same swap, and it may fire zero times for single-chain swaps that never bridge. Payload: { chainId, explorerLink? }, where chainId is the intermediate chain and explorerLink (when present) deep-links the in-flight bridge leg.
  • onSwapSuccess - fires once the cross-chain delivery is confirmed by the indexer. Payload: { txHash } (source-chain hash).
  • onSwapError - fires when the swap fails inside the iframe or the wallet rejects the signature. Payload: { code, message } with a stable code and a human-readable message.

SDK error callback:

  • onError - fires on widget-level bring-up problems: handshake failure, iframe load error, origin mismatch, protocol incompatibility. Separate from onSwapError, which covers in-flow swap failures. Payload: { code, message, cause }. Codes are stable strings: handshake_failed, iframe_load_failed, origin_mismatch, protocol_incompatible, unknown. If you do not supply onError, the SDK logs a single console.warn and continues. Nothing is thrown.

Persisting across route changes

React Router and most SPA routers unmount route-level components when the visitor navigates away. The default behavior is: the widget mounts on the swap page, runs through the loading spinner and handshake, then unmounts when the visitor goes to another page. Coming back remounts from scratch. The wallet session is preserved via iframe-side browser storage, but in-progress swap state (selected tokens, typed amount, fetched route) is lost.

Three patterns to handle this:

Mount <AtomCircuitSwap /> once in a top-level layout that does not unmount across route changes. Toggle CSS visibility per route:

'use client';

import { AtomCircuitSwap } from '@atom-circuit/embed-sdk/react';
import { usePathname } from 'next/navigation';

export function PersistentSwap() {
  const pathname = usePathname();
  return (
    <div style={{ display: pathname === '/swap' ? 'block' : 'none' }}>
      <AtomCircuitSwap referralId="YOUR_REFERRAL_ID" />
    </div>
  );
}

The widget stays mounted across navigations; only display toggles. Wallet and form state both preserved. Trade-off: the iframe stays in memory on every page.

Pattern 2 - imperative mount once

Use AtomCircuit.mount() directly into a persistent DOM container outside the router-managed area. Show or hide via CSS:

<div id="atom-circuit-widget" style="display: none;"></div>
<script src="https://unpkg.com/@atom-circuit/embed-sdk@2.3.1/dist/atom-circuit.iife.js"></script>
<script>
  AtomCircuit.mount(document.getElementById('atom-circuit-widget'), {
    referralId: 'YOUR_REFERRAL_ID',
  });
  function showSwap() {
    document.getElementById('atom-circuit-widget').style.display = 'block';
  }
  function hideSwap() {
    document.getElementById('atom-circuit-widget').style.display = 'none';
  }
</script>

The vanilla mount() lifecycle is not tied to React. Same trade-off as Pattern 1.

Pattern 3 - accept the reload

Zero extra code. Re-handshake on every visit takes 1-3 seconds with the loading spinner. Appropriate when the swap page is the destination rather than a sidebar - which is how Stripe Elements, Mapbox demos, and most embedded widget previews work.

Loading State

While the iframe is fetching the dapp bundle and completing the handshake (typically 1-3 seconds on a warm cache), the widget renders a centered spinner inside its wrapper. The spinner fades out on the first ready event. If the handshake fails permanently the spinner is also dismissed on onError, so the host page never shows a forever-spinning state.

You do not need to wire anything for this. The behavior is automatic.

Security Model

The widget runs inside a sandboxed iframe served from atomcircuit.net. The cross-origin browser boundary prevents the widget from reading or writing the host page's DOM, cookies, or storage. All communication between host and iframe goes over postMessage and is origin-validated on both sides.

Sandbox attributes

The iframe is rendered with:

sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms"

allow-same-origin is required so the Keplr extension can inject window.keplr. allow-popups and allow-popups-to-escape-sandbox let wallet popups (Keplr, Cosmostation) and tx success links open. allow-top-navigation is intentionally omitted to limit clickjacking surface.

DOM contract

The iframe is always wrapped in a <div data-atom-circuit-embed> element that carries position: relative. This anchors the loading overlay so it can absolutely-position over the iframe without affecting host page layout. Select the iframe with #atom-circuit-widget iframe or [data-atom-circuit-embed] iframe.

Subresource Integrity

For CDN consumers, pin the script with Subresource Integrity. Current hash:

<script
  src="https://unpkg.com/@atom-circuit/embed-sdk@2.3.1/dist/atom-circuit.iife.js"
  integrity="sha384-HupUMMdBD4mVAOQeYUZ7sf4KC7HWmOKwNC3l4kuOQ7/ATqOWx751/x4rdJQp/mly"
  crossorigin="anonymous"
></script>

Each release publishes a new hash on the SDK's GitHub release page. See the SDK's Security section for how to compute it yourself.

For the disclosure channel and supported versions, see SECURITY.md in the SDK repo.

Versioning and Compatibility

The npm package follows semver. Breaking changes to the public API of mount() and AtomCircuitSwap ship as a major version bump. Minor versions add backward-compatible options. Patch versions are fixes only. Release notes for each version live in the SDK's CHANGELOG.

The iframe protocol carries its own PROTOCOL_VERSION independent of the npm package version. The SDK and the iframe negotiate compatibility at handshake time. A protocol major-version mismatch (the SDK and the deployed iframe disagree on the wire-protocol major) emits onError with code protocol_incompatible instead of mounting in a broken state. A minor or patch difference within the same major is wire-compatible: the SDK logs a single console.warn and the widget still mounts.

Tested in Chromium 115+, Firefox 115+, and Safari 16+. The Keplr in-app browser on mobile is supported directly. Standalone mobile browsers connect through WalletConnect; cold-start signing on iOS Safari can time out after 90 seconds (see the Integration FAQ for the surfaced behavior).

Where to Go Next