Drop-in dialogs
The fastest path to a working deposit or withdraw flow: mount one component per direction, drive it from a hook, ship.
import { StridgeProvider, useDeposit } from "@stridge/kit"
import { DepositDialog } from "@stridge/kit/deposit/dialog"
function DepositButton() {
const { open } = useDeposit()
return <button onClick={() => open()}>Deposit</button>
}
export function App({ userWallet }: { userWallet: `0x${string}` }) {
return (
<StridgeProvider
gatewayKey={process.env.NEXT_PUBLIC_STRIDGE_GATEWAY_KEY!}
asset={{ networkId: "9001", symbol: "USDC" }} // 9001 = Arbitrum One
flows={{ deposit: { destination: { address: userWallet } } }}
>
<DepositButton />
<DepositDialog />
</StridgeProvider>
)
}Mount the dialog once anywhere inside the provider. The dialog renders nothing until the controller transitions out of "closed", so it's safe to mount at the layout root.
How dialogs are opened and closed
Open state lives on the FSM controller, not on the dialog. Dialogs do not accept open, defaultOpen, onOpenChange, or trigger props.
| Action | API |
|---|---|
| Open | useDeposit().open() / useWithdraw().open() / useActivity().open() |
| Close | useDeposit().close() / useWithdraw().close() / useActivity().close(), or user dismisses (escape / overlay click / close button) |
| Open at a specific step | useDeposit().open({ method: "wallet" }) — jumps past the picker; useActivity().open({ settlementId }) — opens a receipt directly |
| Read open state | useDepositState().state.name !== "closed" |
Because state is controller-driven, you can open the dialog from anywhere in the provider subtree without lifting any open-state to the host. Lifecycle events (open, picker selection, quote resolved, settlement confirmed, error, …) are delivered through the typed event bus, not via dialog callbacks.
<DepositDialog />
import { DepositDialog } from "@stridge/kit/deposit/dialog"
<DepositDialog />That's it for the default case. All three props are optional.
interface DepositDialog.Props {
container?: HTMLElement | null
metadata?: Record<string, unknown>
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
presentation?: Presentation
}| Prop | Effect |
|---|---|
container | Element the dialog portals into. Defaults to document.body. Pass a transformed ancestor with overflow: clip — e.g. a mobile-mockup frame — to scope the dialog's width, height, and backdrop to that rect. Also the box measured for "auto" presentation. |
metadata | Consumer-attached metadata rides on every bus event from this session. Snapshotted at deposit.opened and frozen for the session; mid-flow changes are ignored to keep correlation consistent. The Kit does not validate the shape. |
onError | Forwarded to the dialog's internal <Deposit.Boundary> for crash reporting. The boundary's onReset is wired automatically through useDeposit().close(). |
presentation | Per-dialog surface override — "auto" (default) / "dialog" / "drawer", or { mode, breakpoint }. Wins over the provider's appearance.presentation. See Responsive presentation. |
Internally the dialog is the canonical compound composition — <Deposit.Boundary> → <Deposit.Guards> → <Deposit.Steps> with one <Deposit.Step> per FSM screen, plus the inline <DepositStatusBanner />. See Compound components if you want to author that composition yourself.
<WithdrawDialog />
Withdraw is the inverse direction. The user picks a destination chain and token; the Kit hands your host a fresh UDA target; your backend broadcasts the source-chain transfer to that UDA; the Kit watches for settlement and renders the final state.
import { WithdrawDialog } from "@stridge/kit/withdraw/dialog"
<WithdrawDialog
balance={marginBalance}
onSubmit={async (input, actions) => {
try {
const { hash } = await yourBackend.broadcast(input.depositTarget)
actions.beginProcessing({ hash })
} catch (err) {
actions.fail({
reason: err instanceof Error ? err.message : String(err),
})
}
}}
/>interface WithdrawDialog.Props {
container?: HTMLElement | null
metadata?: Record<string, unknown>
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
balance?: WithdrawBalanceInput
onSubmit?: WithdrawSubmitCallback
suggestedRecipient?: WithdrawSuggestedRecipient
presentation?: Presentation
}| Prop | Notes |
|---|---|
container | Same as deposit. Also the box measured for "auto" presentation. |
metadata | Same as deposit. Frozen at withdraw.opened. |
onError | Same as deposit. |
balance | Withdrawable balance for the form to display. Either a bare amount in display units (Kit short-circuits known stablecoins at $1) or { amount, amountUsd? }. Pass undefined while loading — the form renders a skeleton. Reactive — the form re-renders when the value changes. |
onSubmit | Required when you mount the dialog. The Kit hands you a (input, actions) pair; your code broadcasts the source-chain transfer to input.depositTarget and calls one of actions.beginProcessing, actions.setTxHash, actions.succeed, actions.fail, actions.decline to advance the FSM. Missing the callback lands the user on the error screen instead of an infinite spinner. |
suggestedRecipient | Trusted recipient address surfaced as a one-click prefill chip next to the recipient input. Omit to hide the chip. |
presentation | Same as deposit — per-dialog surface override. See Responsive presentation. |
The withdraw submit contract
onSubmit is the only place the Kit hands work back to your host — the rest is automatic.
type WithdrawSubmitCallback = (
input: WithdrawSubmitInput,
actions: WithdrawSubmitActions,
) => Promise<void> | void
interface WithdrawSubmitInput {
depositTarget: {
address: `0x${string}` | string
networkId: string
symbol: string
}
amount: { value: number; formatted: string }
correlation: { owner: `0x${string}` /* …other ids… */ }
recipient: { address: `0x${string}` | string }
}
interface WithdrawSubmitActions {
beginProcessing(tx?: { hash?: string }): void
setTxHash(tx: { hash: string }): void
succeed(): void
fail(failure: { reason: string }): void
decline(failure?: { reason?: string }): void
}Action call order. Pick one, in this order, depending on what you know:
| When | Call |
|---|---|
| Your backend accepted the request, broadcast pending | actions.beginProcessing() — moves the user to the processing screen. |
| Broadcast resolved and you have a tx hash | actions.beginProcessing({ hash }) — upgrades the watcher from best-match to tx-anchored. |
| You learn the hash later (e.g. wallet signs after a delay) | actions.setTxHash({ hash }) — upgrades the watcher mid-processing. |
| Stridge confirms settlement (default) | Do nothing — the dialog resolves itself. |
| You want to short-circuit to success | actions.succeed() — optional terminal success. |
| Broadcast failed or was rejected | actions.fail({ reason }) — terminal failure with a reason string. |
| User cancelled at the wallet | actions.decline() — soft-fail; the dialog offers retry. |
The onSubmit contract is distinct from the event bus. Bus events are for analytics ("the user reached confirm"); onSubmit's return is what drives the FSM. Don't try to drive the FSM from a bus subscriber — the bus fires after state transitions, not before.
<ActivityDialog />
The drop-in for the read-only activity surface — a unified settlement-history viewer (deposits + withdrawals). Mount it once inside the provider; open it with useActivity().open(), or open straight into a receipt with useActivity().open({ settlementId }) / open({ txHash }). There's no onSubmit and no money path — it only reads history.
import { ActivityDialog } from "@stridge/kit/activity/dialog"
<ActivityDialog />interface ActivityDialog.Props {
container?: HTMLElement | null
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
presentation?: Presentation
metadata?: Record<string, unknown>
}| Prop | Effect |
|---|---|
container | Same as deposit — the portal target and the box measured for "auto" presentation. |
onError | Forwarded to the dialog's internal <ActivityFlow.Boundary>; recovers via useActivity().close(). |
presentation | Per-dialog surface override — see Responsive presentation. |
metadata | Consumer-attached metadata, frozen at activity.opened and attached to every activity.* event in the session. |
For driving the surface from your own UI (FSM state, direct-entry, prefetch), see Headless → Activity.
Portal containers
By default the dialog portals into document.body, painting a full-page backdrop. To scope it to a specific region (mobile preview frame, embedded mock), pass container:
const frameRef = useRef<HTMLDivElement>(null)
<div ref={frameRef} className="phone-frame" style={{ overflow: "clip" }}>
…
<DepositDialog container={frameRef.current} />
</div>The Kit handles the rest — the dialog's width, backdrop, and animation all clip to the container.
Responsive presentation
A centred modal is the wrong shape on a phone-width surface. Below a breakpoint the dialogs render as a bottom-sheet drawer instead — the native mobile pattern — and as the centred dialog above it. You don't branch on viewport: the Kit measures the dialog's containing block (its portal container, else the viewport) and picks the surface for you.
// Global default for every gateway dialog.
<StridgeProvider appearance={{ presentation: "auto" }}>
// Per-dialog override — wins over the provider default.
<WithdrawDialog presentation="dialog" />
// Tune the breakpoint (default 600px).
<DepositDialog presentation={{ mode: "auto", breakpoint: 720 }} />| Value | Surface |
|---|---|
"auto" (default) | Dialog at/above the breakpoint, drawer below it. |
"dialog" | Always the centred dialog. |
"drawer" | Always the bottom-sheet drawer. |
{ mode, breakpoint } | Object form to tune the px threshold (default 600). |
Precedence: per-dialog presentation prop > appearance.presentation > "auto". Set it once on the provider and override a single dialog when you need to.
"auto" is reactive. Resizing across the breakpoint (rotating a device, dragging a window) live-swaps dialog↔drawer mid-flow — only the surface subtree remounts, so flow state survives because the FSM controllers sit above the surface. Focus is the one transient that re-runs on the swap.
A drawer has no close (X) button — it dismisses via the grabber, swipe-down, backdrop tap, or escape. The X is dialog-only; the Kit hides it automatically in drawer mode, so your composition needs no change. The drawer's surface and its grabber are targetable from CSS — see the drawer-content / drawer-handle slots and the --stridge-kit-drawer-* tokens.
Custom FSM screens
Both dialogs are pre-composed from the compound parts — they exist for the 80% who want zero composition work. The moment you want to swap a screen (replace the success state, redesign the asset picker, embed deposit inline rather than modal), drop down to the compound surface. The provider, the FSM, and the settlement watcher don't change.
Next
- Compound components — build your own dialog or embed parts inline.
- Events — subscribe to lifecycle for analytics or behavioural reactions.
- Headless integration — drive the flow with no Kit-rendered UI at all.