Widget
The framework-agnostic <ferryline-widget> custom element — a real integration example, theming, and its real limitations.
@ferryline/widget ships one thing: a real <ferryline-widget> custom element that wraps
@ferryline/sdk's quote → build → sign → track lifecycle (see Core Concepts)
in a ready-made UI. It's a plain Web Component — FerrylineWidget extends HTMLElement, defined via
defineFerrylineWidget() — with no framework runtime underneath it. There's no .tsx file
anywhere in the package and no react dependency in its package.json; it works the same way
inside a React app, a Vue app, or a static HTML page with no framework at all.
There's currently no React wrapper around it. The package's own scope note is explicit that this is a decision for this phase, not an oversight:
This package intentionally does NOT include a React wrapper, and testnet mode intentionally does NOT offer USDT0 (see above for why the latter is structural, not a policy choice). Both are explicit decisions for this phase, not oversights — see the repo's phase reports if you're deciding whether to add either downstream.
If you need the widget inside a React tree today, render the custom element directly
(<ferryline-widget> works fine in JSX) and set .request imperatively via a ref, the same way
any other custom element is used from React without a dedicated wrapper.
Want to see it running before writing any integration code at all? See Playground for a real, live instance you can try right now, real testnet transfer, real protocol inspector, no local setup.
Install and register
npm install @ferryline/widgetimport { defineFerrylineWidget } from "@ferryline/widget";
defineFerrylineWidget(); // registers <ferryline-widget>; safe to call more than onceA real integration example
Declarative attributes handle network configuration; the transfer itself is set imperatively as a
property, because a full TransferRequest (see SDK Reference) doesn't
serialize cleanly to a single HTML attribute. This is the package's own README example, verbatim:
<ferryline-widget network="testnet"></ferryline-widget>
<script type="module">
const widget = document.querySelector("ferryline-widget");
widget.request = {
asset: "USDC",
from: { chain: "stellar", address: "G..." },
to: { chain: "ethereum-sepolia", address: "0x..." },
amount: "10",
parameters: { maxFee: "0", minFinalityThreshold: 2000 },
};
</script>Setting .request kicks off a real quote() call immediately.
For a more complete picture of a host page driving this, packages/widget/e2e/harness.ts is the
real script this repo's own Playwright end-to-end run uses — it defines the element, then sets the
exact same TransferRequest shape against a real testnet outbound CCTP send (Stellar →
Ethereum Sepolia):
import { defineFerrylineWidget, type FerrylineWidget } from "../src/index.js";
import type { TransferRequest } from "@ferryline/sdk";
defineFerrylineWidget();
const SENDER = "GBBA3HN2PNOAJGR6R5VY34SQFDFTZFQIGDPYATJB34UXXFUHVR4KZRAZ";
const RECIPIENT_EVM = "0x11598bA8E624b7df301FC3d2C71165A076b7480e";
const widget = document.querySelector<FerrylineWidget>("#widget")!;
const request: TransferRequest = {
asset: "USDC",
from: { chain: "stellar", address: SENDER },
to: { chain: "ethereum-sepolia", address: RECIPIENT_EVM },
amount: "0.5",
parameters: { maxFee: "0", minFinalityThreshold: 2000 },
};
window.__ferrylineTestHooks = {
setRequest(): void {
widget.request = request;
},
};Its own doc comment is explicit that this isn't a synthetic fixture: "No mocks, no test doubles —
the only thing this file adds beyond what a real integrator's page would do is exposing a couple of
read hooks (window.__ferrylineTestHooks)" for the Playwright script to read rendered state.
Attributes
| Attribute | Values | Default | Notes |
|---|---|---|---|
network | testnet | mainnet | testnet | Changing it tears down and rebuilds the widget's internal client/wallet session. |
rpc-url | a Soroban RPC URL | network default | Override if you run your own RPC node. |
relayer-url | a Ferryline relayer base URL | — | Used for both directions: required for inbound (EVM → Stellar) CCTP tracking (see registerInboundTransfer below), and enables automatic outbound (Stellar → EVM) registration if set (see below). |
relayer-api-key | a bearer token | — | Sent as Authorization: Bearer <key> to the relayer. |
prepare-step-poll-max-attempts | a positive integer | 20 | See Polling before the deferred burn step below. Raise it if your own RPC node has worse allowance-visibility lag than this project's testing observed. |
prepare-step-poll-interval-ms | milliseconds, a positive integer | 1500 | Same section as above. A non-positive or unparseable value on either attribute falls back to its real default rather than producing a poll that never runs or runs forever. |
In network="testnet" mode, only the usdc-cctp rail is ever registered — the widget's internal
client only constructs a UsdcCctpAdapter in testnet mode, so there's no code path where a testnet
widget instance even holds a USDT0 adapter to route to (see
Core Concepts for why USDT0 has no Stellar testnet deployment
at all). In network="mainnet" mode, both rails are registered.
Registering an inbound transfer
Inbound EVM → Stellar CCTP transfers are different from outbound: the widget doesn't drive the source-chain wallet (that's whatever wallet the user already used on the EVM side), so it needs a self-hosted relayer to watch that chain and complete delivery on Stellar. Once you have a real EVM burn transaction hash, call:
await widget.registerInboundTransfer(transferId, "ethereum-sepolia", sourceTxHash);This calls the configured relayer's real POST /transfers, then polls GET /transfers/:id,
rendering live status inline (the inbound-status part below). If no relayer-url attribute or
property is set, it throws immediately rather than silently doing nothing — the exact current
source (it reads the relayer config through the widget's own SDK client, not a direct attribute
getter, since the outbound flow below needed the same config value and this was the real migration
that let both directions share it):
async registerInboundTransfer(
transferId: string,
sourceChain: ChainSlug,
sourceTxHash: string,
): Promise<void> {
const relayerUrl = this.client().ferryline.config.relayerUrl;
if (!relayerUrl) {
throw new Error("registerInboundTransfer requires a relayer-url attribute or property");
}
// ...
}Polling before the deferred burn step
For a fresh account (no prior USDC allowance to the TokenMessengerMinter), the outbound CCTP flow
is two Stellar transactions submitted back to back from the same account: approve, then the
actual deposit_for_burn. The burn's own on-chain footprint can't be simulated until the approve's
allowance genuinely exists, so it's assembled by a real, separate prepareStep call after the
approve confirms, not built up front alongside it.
This used to be a fixed-timer wait (POST_APPROVE_BUILD_DELAY_MS, 15 seconds in
@ferryline/widget@0.1.0/0.1.1), removed after an external, independent integration test caught
it failing 100% of the time on a genuinely fresh testnet account at that shipped default — the
fixed timer had, in fact, never been validated against a real, live, two-step testnet transfer
before shipping. See packages/widget/CHANGELOG.md's 0.1.2 entry for the full incident writeup.
What it does now: polls prepareStep itself, retrying only on the one specific, real, typed
error it throws exactly when the chain state it needs isn't there yet —
FerrylineError with code: "ALLOWANCE_INSUFFICIENT" (documented directly on
RailAdapter.prepareStep's own interface in @ferryline/core). Every other failure — including a
genuine STEP_NOT_READY (a real caller bug, not a timing issue) — is rethrown immediately, on the
first attempt, never retried. prepare-step-poll-max-attempts and prepare-step-poll-interval-ms
(see Attributes above) are the real, public config for this poll's bound and
interval; both are live-validated against a real testnet transfer, not just unit-tested — see
the real, dated experiment record
for the real transaction hashes and observed retry count/timing.
Outbound registration (automatic)
Unlike inbound, you don't call anything for outbound. Once the final step of an outbound
(Stellar → EVM) transfer is signed, submitted, and its hash recorded, the widget automatically
calls @ferryline/sdk's Ferryline.registerOutboundTransfer() on your behalf. The code below is
the real, current body of packages/widget/src/index.ts's own afterStepSubmitted, copied
verbatim (only re-indented; its own inline comments are trimmed here — the full version explains
the historical double-registration bug this exact isFinalStep check exists to prevent, the poll
before the deferred step described above, and the request-generation staleness guard, see that
method's own doc comment in the real file for the complete story). isFinalStep is imported from
@ferryline/sdk — the same canonical helper documented on the
SDK Reference page — this file does not hand-roll its own
version of that comparison:
if (!isFinalStep(built, stepIndex)) {
const nextIndex = stepIndex + 1;
this.setPhaseIfCurrent(
signedAwaitingNextStep({ quote, built, stepIndex: nextIndex }),
generation,
);
// [comment trimmed — see the real source for the full explanation]
const nextStep = await pollPrepareStep(
() => this.client().ferryline.prepareStep(built.transferId, nextIndex),
isAllowanceInsufficient,
this.prepareStepPollMaxAttempts,
this.prepareStepPollIntervalMs,
);
const rebuilt = {
...built,
steps: built.steps.map((s, i) => (i === nextIndex ? nextStep : s)),
};
this.setPhaseIfCurrent(buildSucceeded(quote, rebuilt, nextIndex), generation);
return;
}
// [comment trimmed — see the real source for the full explanation]
const registrationResult = await this.client().ferryline.registerOutboundTransfer(built.transferId);
if (generation === this.#requestGeneration) {
this.#outboundRegistrationResult = registrationResult;
}This is gated on relayer-url being configured, silently, the same way registerOutboundTransfer
itself is (see SDK Reference): no relayer configured
means no registration attempt, not an error. With a relayer configured, the transfer now normally
completes delivery on its own — see Known limitations below for the honest
edge of that claim.
registerOutboundTransfer() returns a real { registered: boolean, error?: string } result (not
void) specifically so the widget can tell a genuine registration success apart from a genuine
failure — a real, live 401 from a misconfigured relayer API key used to still render success text,
before this existed. The widget's own delivery-status copy reflects all three real cases, from its
own outboundDeliveryCaveat(relayerConfigured, registrationResult) helper: no relayer configured at
all ("not automatic for this rail"); relayer configured and registration genuinely succeeded ("has
been registered"); relayer configured but registration genuinely failed (or hasn't resolved yet) — a
distinct, honest "NOT currently registered" message, never the success claim in that case.
Theming
The widget's styling mechanism changed since an earlier version of this page: it's now real Tailwind v4, compiled at build time, not hand-authored CSS custom properties for every visual token. Get this right before touching your own theme, because the two mechanisms have genuinely different override surfaces.
The real current mechanism
packages/widget/src/style.css is a real Tailwind source file (@import "tailwindcss" source(none)
@config "../tailwind.config.ts"), compiled bypackages/widget/scripts/build-style.mjsinto a static string,GENERATED_STYLE, written tosrc/generated-style.tsand injected into the shadow root's<style>tag at render time —build:styleruns beforetsupin the package's ownbuildscript, and fails the build outright on any Tailwind warning.tailwind.config.tsimports its colors/radius/duration/easing directly from@ferryline/design-tokens— the same real packageapps/siteandapps/docsbuild their own Tailwind themes from (see Core Concepts for the shared-tokens story). None of this ships to consumers:tailwindcss,@tailwindcss/postcss, and@ferryline/design-tokensaredevDependenciesof the widget package, not runtime ones.
CSS custom properties still exist, but for a smaller surface than before
8 properties remain real, live, and settable from outside the shadow root — down from an earlier 12, and several defaults changed along the way:
| Property | Default | Affects |
|---|---|---|
--ferryline-font | system-ui, sans-serif | Base font family |
--ferryline-font-size | 15px | Base font size |
--ferryline-fg | #000000 | Text color |
--ferryline-bg | #ffffff | Shell background |
--ferryline-border-radius | 20px | Corner radius |
--ferryline-muted | #66696b | Secondary text |
--ferryline-danger | #dc2626 | Error text |
--ferryline-z-index | 1000 | Stacking context |
ferryline-widget {
--ferryline-border-radius: 12px;
--ferryline-font: "Inter", sans-serif;
}Four properties from the older table are dead — setting them now has zero visual effect. Disclosed plainly rather than left for you to discover by trial and error:
--ferryline-border— the shell has no border in the current design at all; it uses a drop shadow instead.--ferryline-spacing— shell padding is a hardcoded Tailwind utility (p-6) now, not a variable.--ferryline-accent— the primary call-to-action is hardcodedbg-brand(Ferryline's real indigo,#4F46E5, from@ferryline/design-tokens) with a hardcoded hover state; links usetext-brandthe same way.--ferryline-accent-fg— the CTA's text color is hardcodedtext-on-brand(#ffffff).
A real trade-off, not glossed over
This is a genuine regression in re-theming flexibility, worth stating plainly rather than
minimizing: a downstream consumer can no longer recolor the primary button, links, shell border, or
shell spacing via a CSS custom property at all. The only remaining override surface for those four
is the coarser ::part() selectors below (ferryline-widget::part(build-button) { background: ... }), which don't unify CTA + link color the way the old --ferryline-accent token did in one
place. In exchange, the widget's own visual identity now matches Ferryline's real brand exactly, by
construction, with one real source of truth (@ferryline/design-tokens) instead of a second,
independently-maintained set of hex values that could silently drift from it — which is exactly
what happened to the old defaults table above before this rewrite. Which trade-off is right depends
on whether you need to override the brand color at all; if you don't, this change is invisible to
you.
Below 480px viewport width, the widget docks to the bottom of the viewport as a mobile bottom sheet
(rounded top corners only, scrollable if content exceeds 85% of viewport height) — unchanged by the
Tailwind migration. If you're embedding it inside your own bottom-sheet container at that width,
override position / inset on the ferryline-widget element itself — the cascade allows it.
::part() selectors
Shadow-DOM part attributes are exposed for deeper styling with ::part(). There are 25 of them,
read directly from src/index.ts:
shell, network, wallet-error, inbound-status, status, quote, preview,
preview-heading, preview-summary, decoded-call, wallet-connect, wallet-module-button,
build-button, cancel-button, confirm-button, tracking-progress, spinner,
tracking-elapsed, source-tx, dest-tx, delivery-caveat, failure, reset-button,
faucets, faucet-link
tracking-progress/spinner/tracking-elapsed are the visual "still working" signal during the
tracking phase — a plain, indeterminate CSS spinner plus a live, ticking elapsed-time counter
((12s), (1m 05s)), closing a real gap: track()'s own polling loop can genuinely go minutes
between real status updates (Circle's Iris attestation alone is not instant), and without a visual
signal a user watching this has no way to tell "still working" apart from "stuck." Deliberately
indeterminate, not a real progress percentage — there is no reliable total duration for a real
delivery to measure a percentage against. Respects prefers-reduced-motion: the spinner's rotation
stops, the elapsed-time text still updates.
ferryline-widget::part(shell) {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}Wallet support
Wired against Stellar Wallets Kit's real defaultModules() bundle: Albedo, Freighter, Fordefi,
Rabet, xBull, LOBSTR, Hana, Klever, OneKey, Bitget, Cactus Link, D'CENT, Scopuly — whatever that
function returns from the installed kit version, rendered as connect buttons inside the transaction
preview step. Only one of these has actually been driven end-to-end through a real, installed
browser extension: Freighter, via the package's own Playwright E2E run (e2e/) against real Stellar
testnet infrastructure. The rest are wired identically through the same kit but have not been
independently verified the same way — the README is explicit that confirming which ones actually
work through a real installed extension is left to the repo's own phase reports, not assumed from
the kit wiring alone.
Before any wallet ever sees a signTransaction call, the widget decodes and renders the real built
step — contract/function for a Stellar invocation, the real ERC-20/TokenMessengerV2 call for an EVM
step, never raw XDR or hex — behind an explicit Confirm/Cancel. This is structural, not just a UI
convention: the internal state machine's confirmPreviewAndSign function is the only thing in the
package that can produce a signing phase, and it's called from exactly one place in src/index.ts
— the confirm-button handler, itself reachable only while the phase is preview.
Testnet mode and faucets
In network="testnet" mode, the widget surfaces two real, public testnet faucet links:
- Friendbot (
https://friendbot.stellar.org) — XLM - Circle's testnet faucet (
https://faucet.circle.com) — USDC, supports Stellar testnet directly
Known limitations
No React wrapper (v0.x). Covered above — the scope note is explicit that this is deliberate for this phase, not an oversight.
No testnet USDT0. network="testnet" only ever registers the usdc-cctp rail. This isn't a
widget-level restriction layered on top of a capable SDK — Usdt0LayerZeroAdapter's own
constructor option type only accepts { network: "mainnet" }, because USDT0 has no Stellar testnet
deployment to test against at all (see Core Concepts).
Outbound delivery isn't a guaranteed SLA, even with a relayer configured. A self-hosted
Ferryline outbound relayer now exists (see Registering an outbound transfer
above and Relayer), and the widget registers with it automatically — so outbound
delivery normally completes on its own now, closing what used to be a real, unconditional gap.
What hasn't changed: receiveMessage remains genuinely permissionless by CCTP's own design, per
Circle's own technical guide —
An API consumer must query this attestation and submits it onchain to the destination domain's MessageTransmitterV2#receiveMessage function
— so if no relayer is configured, or the configured one is unavailable, misconfigured, or has hit its own daily gas ceiling, delivery does not complete itself. The sender, recipient, or an integrator's own infrastructure can still submit it manually at any time, exactly the same real fallback that existed before the outbound relayer shipped — see the end-to-end walkthrough for that manual path, and Security & Verification for the sourced writeup behind both the original finding and its resolution.
A note on what's unverified here
This page describes real, current source, rewritten once already after an earlier version was found
to describe a pre-Tailwind-migration, pre-outbound-relayer state of this package. Two things in the
current version are still worth treating differently from the rest, disclosed rather than glossed
over: packages/widget/README.md's own "Theming" section was not updated by the Tailwind restyle
(confirmed directly — the restyle commit doesn't touch that file), so it still describes the old
12-property table; this page's theming section was rewritten from the real source
(tailwind.config.ts, style.css, build-style.mjs) rather than from that now-stale README
section, and the README itself still needs the same fix. And the wallet module list beyond
Freighter is wired but not independently confirmed against a real installed extension — take that
one at face value as "wired, not (yet) proven."
Router
The Soroban contract other contracts call to send USDC or USDT0 cross-chain in one call — its real interface, its testnet address, and the invariants that keep it honest.
Playground
A real, live <ferryline-widget> you can try right now, real testnet transfer, real protocol inspector, no local setup.