diff --git a/examples/react-render-target/README.md b/examples/react-render-target/README.md
new file mode 100644
index 0000000..872f00e
--- /dev/null
+++ b/examples/react-render-target/README.md
@@ -0,0 +1,21 @@
+# Proton Web SDK — `renderTarget` example (React)
+
+A minimal React app that mounts the Proton Web SDK dialog inside a specific
+DOM element via the new `renderTarget` option, instead of the default
+`document.body`.
+
+## Run
+
+```bash
+pnpm install
+pnpm --filter example-react-render-target dev
+```
+
+## What it demonstrates
+
+- `uiOptions.renderTarget` passed to `ProtonWebSDK()` — the login modal is
+ appended inside the referenced element.
+- `renderer.setRenderTarget(el)` called before `session.transact(...)` — the
+ sign-request modal re-parents into the same container.
+
+The target can be either an `HTMLElement` or a CSS selector string.
diff --git a/examples/react-render-target/index.html b/examples/react-render-target/index.html
new file mode 100644
index 0000000..20e8305
--- /dev/null
+++ b/examples/react-render-target/index.html
@@ -0,0 +1,289 @@
+
+
+
+
+
+ Proton Web SDK - renderTarget example
+
+
+
+
+
+
+
diff --git a/examples/react-render-target/package.json b/examples/react-render-target/package.json
new file mode 100644
index 0000000..c7c9af7
--- /dev/null
+++ b/examples/react-render-target/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "example-react-render-target",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@proton/web-sdk": "workspace:^",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.2",
+ "@types/react-dom": "^19.1.2",
+ "@vitejs/plugin-react": "^4.4.1",
+ "typescript": "~5.8.3",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/examples/react-render-target/src/App.tsx b/examples/react-render-target/src/App.tsx
new file mode 100644
index 0000000..9e11f37
--- /dev/null
+++ b/examples/react-render-target/src/App.tsx
@@ -0,0 +1,269 @@
+import {useRef, useState} from "react";
+import ProtonWebSDK from "@proton/web-sdk";
+import type {Link, LinkSession, ProtonWebLink} from "@proton/web-sdk";
+
+const REQUEST_ACCOUNT = "taskly";
+const BURN_ACCOUNT = "eosio.null";
+const CHAIN_ID =
+ "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd";
+const ENDPOINTS = ["https://testnet.rockerone.io"];
+
+type Renderer = NonNullable<
+ Awaited>["renderer"]
+>;
+
+type Mode = "connect" | "sign" | "done";
+
+const nextFrame = () =>
+ new Promise(resolve => requestAnimationFrame(() => resolve()));
+
+export const App = () => {
+ const targetRef = useRef(null);
+ const linkRef = useRef(undefined);
+ const rendererRef = useRef(undefined);
+ const [session, setSession] = useState();
+ const [mode, setMode] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [status, setStatus] = useState("Not connected");
+ const [burned, setBurned] = useState(false);
+
+ const openWith = async (next: Mode) => {
+ setMode(next);
+ await nextFrame();
+ };
+
+ const closeModal = () => setMode(null);
+
+ const connect = async () => {
+ setBusy(true);
+ await openWith("connect");
+ if (!targetRef.current) {
+ setBusy(false);
+ return;
+ }
+ try {
+ const result = await ProtonWebSDK({
+ linkOptions: {
+ endpoints: ENDPOINTS,
+ chainId: CHAIN_ID,
+ restoreSession: false,
+ },
+ transportOptions: {
+ requestAccount: REQUEST_ACCOUNT,
+ requestStatus: false,
+ },
+ uiOptions: {
+ appInfo: {name: "renderTarget demo"},
+ renderTarget: targetRef.current,
+ },
+ });
+ linkRef.current = result.link;
+ rendererRef.current = result.renderer;
+ if (result.session) {
+ setSession(result.session);
+ setStatus(`Connected as ${result.session.auth.actor}`);
+ setMode("done");
+ } else {
+ setStatus("Login cancelled");
+ closeModal();
+ }
+ } catch (err) {
+ setStatus(`Error: ${(err as Error).message}`);
+ closeModal();
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const transfer = async (
+ to: string,
+ memo: string,
+ successLabel: string,
+ embedded: boolean,
+ ) => {
+ if (!session) return;
+ setBusy(true);
+ if (embedded) {
+ await openWith("sign");
+ rendererRef.current?.setRenderTarget(targetRef.current!);
+ } else {
+ rendererRef.current?.setRenderTarget(null);
+ }
+ try {
+ await session.transact(
+ {
+ actions: [
+ {
+ account: "eosio.token",
+ name: "transfer",
+ data: {
+ from: session.auth.actor,
+ to,
+ quantity: "0.0001 XPR",
+ memo,
+ },
+ authorization: [session.auth],
+ },
+ ],
+ },
+ {broadcast: true},
+ );
+ setStatus(successLabel);
+ if (embedded) setMode("done");
+ } catch (err) {
+ setStatus(`Error: ${(err as Error).message}`);
+ if (embedded) setMode("done");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const sendToTaskly = () =>
+ transfer(
+ REQUEST_ACCOUNT,
+ "renderTarget example",
+ "Transfer signed and broadcast",
+ false,
+ );
+
+ const burn = async () => {
+ await transfer(
+ BURN_ACCOUNT,
+ "burn",
+ "Burned 0.0001 XPR to eosio.null",
+ true,
+ );
+ setBurned(true);
+ };
+
+ const logout = async () => {
+ if (linkRef.current && session) {
+ await linkRef.current.removeSession(
+ REQUEST_ACCOUNT,
+ session.auth,
+ CHAIN_ID,
+ );
+ }
+ linkRef.current = undefined;
+ rendererRef.current = undefined;
+ setSession(undefined);
+ setStatus("Not connected");
+ setBurned(false);
+ };
+
+ return (
+ <>
+
+
Proton Web SDK — renderTarget example
+
+ Clicking Connect opens a two-pane modal: the left
+ side is your own UI, and the SDK dialog mounts into the right pane
+ using the new renderTarget option.
+
+
+
{status}
+
+ {!session ? (
+
+ {busy ? "Opening…" : "Connect wallet"}
+
+ ) : (
+
+
+ {busy ? "Signing…" : "Send 0.0001 XPR"}
+
+
+ Logout
+
+
+ )}
+
+
+ {mode && (
+ {
+ if (e.target === e.currentTarget && !busy) closeModal();
+ }}
+ >
+
+
+
+ {mode === "done" && session && (
+
+
+
+ {status.startsWith("Transfer")
+ ? "Transaction signed"
+ : "Wallet connected"}
+
+
{String(session.auth.actor)}
+
+ {status.startsWith("Transfer") ||
+ status.startsWith("Burned")
+ ? "Your transaction has been broadcast to the network."
+ : "You can now sign transactions from this app."}
+
+
+ {busy ? "Signing…" : "Burn 0.0001 XPR"}
+
+
+ )}
+
+
+
+ )}
+ >
+ );
+};
diff --git a/examples/react-render-target/src/main.tsx b/examples/react-render-target/src/main.tsx
new file mode 100644
index 0000000..607cf4b
--- /dev/null
+++ b/examples/react-render-target/src/main.tsx
@@ -0,0 +1,9 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { App } from './App'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/examples/react-render-target/tsconfig.app.json b/examples/react-render-target/tsconfig.app.json
new file mode 100644
index 0000000..b464276
--- /dev/null
+++ b/examples/react-render-target/tsconfig.app.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/react-render-target/tsconfig.json b/examples/react-render-target/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/examples/react-render-target/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/examples/react-render-target/tsconfig.node.json b/examples/react-render-target/tsconfig.node.json
new file mode 100644
index 0000000..c604786
--- /dev/null
+++ b/examples/react-render-target/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/examples/react-render-target/vite.config.ts b/examples/react-render-target/vite.config.ts
new file mode 100644
index 0000000..9ffcc67
--- /dev/null
+++ b/examples/react-render-target/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/packages/proton-web-renderer/src/index.ts b/packages/proton-web-renderer/src/index.ts
index c5f48ca..8c7a48c 100644
--- a/packages/proton-web-renderer/src/index.ts
+++ b/packages/proton-web-renderer/src/index.ts
@@ -15,6 +15,7 @@ import {
backAction,
closeAction,
demoMode,
+ embeddedMode,
enabledWallets,
error,
manualAction,
@@ -62,11 +63,19 @@ export class WebRenderer implements UIRenderer {
async selectWallet({
enabledWallets: wallets,
- }: {enabledWallets?: UIWalletType[] | string[]} = {}): Promise {
+ renderTarget,
+ }: {
+ enabledWallets?: UIWalletType[] | string[]
+ renderTarget?: HTMLElement | string | null
+ } = {}): Promise {
if (!wallets) {
wallets = ENABLED_WALLETS
}
+ if (renderTarget) {
+ this.setRenderTarget(renderTarget)
+ }
+
enabledWallets.set(new Set(wallets as UIWalletType[]))
router.push(ROUTES.WEBAUTH_CONNECT)
@@ -89,6 +98,9 @@ export class WebRenderer implements UIRenderer {
if (payload.wallet_type === SUPPORTED_WALLETS.ANCHOR) {
route = ROUTES.OTHER_ANCHOR_USE
}
+ if (payload.renderTarget) {
+ this.setRenderTarget(payload.renderTarget)
+ }
this.request(route, payload)
}
@@ -97,6 +109,9 @@ export class WebRenderer implements UIRenderer {
if (payload.wallet_type === SUPPORTED_WALLETS.ANCHOR) {
route = ROUTES.OTHER_ANCHOR_SIGN
}
+ if (payload.renderTarget) {
+ this.setRenderTarget(payload.renderTarget)
+ }
this.sign_request(route, payload)
}
@@ -105,10 +120,16 @@ export class WebRenderer implements UIRenderer {
if (payload.wallet_type === SUPPORTED_WALLETS.ANCHOR) {
route = ROUTES.OTHER_ANCHOR_SIGN_MANUAL
}
+ if (payload.renderTarget) {
+ this.setRenderTarget(payload.renderTarget)
+ }
this.request(route, payload)
}
showError(payload: UIErrorPayload): void {
+ if (payload.renderTarget) {
+ this.setRenderTarget(payload.renderTarget)
+ }
error.set(payload.data)
this.show()
}
@@ -119,6 +140,10 @@ export class WebRenderer implements UIRenderer {
route = ROUTES.OTHER_ANCHOR_SIGN
}
+ if (payload.renderTarget) {
+ this.setRenderTarget(payload.renderTarget)
+ }
+
recoverError.set(payload.data)
if (payload.onManual) {
manualAction.set(payload.onManual)
@@ -266,13 +291,42 @@ export class WebRenderer implements UIRenderer {
this.initialized = true
}
+ private resolveRenderTarget(
+ target: HTMLElement | string | null | undefined
+ ): HTMLElement {
+ if (target instanceof HTMLElement) {
+ return target
+ }
+ if (typeof target === 'string') {
+ const found = document.querySelector(target)
+ if (found) {
+ return found
+ }
+ }
+ return document.body
+ }
+
+ setRenderTarget(target: HTMLElement | string | null): void {
+ this.options.renderTarget = target
+ if (!this.initialized || !this.element) {
+ return
+ }
+ const parent = this.resolveRenderTarget(target)
+ if (this.element.parentNode !== parent) {
+ parent.append(this.element)
+ }
+ embeddedMode.set(parent !== document.body)
+ }
+
private appendDialogElement() {
const existing = document.getElementById(this.elementId)
if (!this.element || !this.shadow) {
throw new Error('The WebRenderer is not initialized. Call the initialize method first.')
}
if (!existing) {
- document.body.append(this.element)
+ const target = this.resolveRenderTarget(this.options.renderTarget)
+ target.append(this.element)
+ embeddedMode.set(target !== document.body)
this.offDOMContentLoaded()
this.app = mount(App, {
diff --git a/packages/proton-web-renderer/src/types.ts b/packages/proton-web-renderer/src/types.ts
index ae54aa1..a307c54 100644
--- a/packages/proton-web-renderer/src/types.ts
+++ b/packages/proton-web-renderer/src/types.ts
@@ -13,12 +13,14 @@ export type UISpace =
export interface UIRendererOptions extends UIProps {
id?: string
+ renderTarget?: HTMLElement | string | null
}
interface UIGenericPayload {
wallet_type: UIWalletType | string
onClose?: () => void
onBack?: () => void
+ renderTarget?: HTMLElement | string | null
}
export interface UIRequestPayload extends UIGenericPayload {
@@ -31,6 +33,7 @@ export type UISignManuallyPayload = UIRequestPayload
export interface UIErrorPayload {
wallet_type: UIWalletType | string
data: UIError
+ renderTarget?: HTMLElement | string | null
}
export interface UISignPayload extends UIGenericPayload {
@@ -51,4 +54,5 @@ export interface UIRenderer {
close(): void
destroy(): void
showLoading(): void
+ setRenderTarget(_: HTMLElement | string | null): void
}
diff --git a/packages/proton-web-renderer/src/ui/components/Countdown.svelte b/packages/proton-web-renderer/src/ui/components/Countdown.svelte
index 4c79db6..532844d 100644
--- a/packages/proton-web-renderer/src/ui/components/Countdown.svelte
+++ b/packages/proton-web-renderer/src/ui/components/Countdown.svelte
@@ -114,6 +114,7 @@
}
.content {
+ position: relative;
background: black;
border-radius: inherit;
box-sizing: border-box;
@@ -124,7 +125,7 @@
align-items: center;
border: 1px solid rgba(var(--countdown-background), 0.05);
background: rgba(var(--countdown-background), 0.05);
- z-index: -1;
+ z-index: 0;
}
.label {
diff --git a/packages/proton-web-renderer/src/ui/components/Modal.svelte b/packages/proton-web-renderer/src/ui/components/Modal.svelte
index 7b47fea..42980ab 100644
--- a/packages/proton-web-renderer/src/ui/components/Modal.svelte
+++ b/packages/proton-web-renderer/src/ui/components/Modal.svelte
@@ -1,7 +1,8 @@
-
+
{@render content?.()}
@@ -100,6 +105,19 @@
&::backdrop {
background: rgba(0, 0, 0, 0.75);
}
+
+ &[data-embedded='true'] {
+ position: absolute;
+ inset: 0;
+ margin: 0;
+ width: 100%;
+ max-width: 100%;
+ height: 100%;
+ max-height: 100%;
+ border: none;
+ border-radius: 0;
+ --max-modal-content-height: 100%;
+ }
}
.dialog-content-old {
diff --git a/packages/proton-web-renderer/src/ui/store.ts b/packages/proton-web-renderer/src/ui/store.ts
index 37137e8..757c00c 100644
--- a/packages/proton-web-renderer/src/ui/store.ts
+++ b/packages/proton-web-renderer/src/ui/store.ts
@@ -19,6 +19,9 @@ const defaultUIProps: UIProps = {}
export const app_props = writable(defaultUIProps)
+/** Whether the dialog is mounted into a custom renderTarget (non-modal mode) */
+export const embeddedMode = writable(false)
+
/** Whether or not the interface is active in the browser */
export const active = writable(false)
diff --git a/packages/proton-web-sdk/src/connect.ts b/packages/proton-web-sdk/src/connect.ts
index fdc488d..c06fdf5 100644
--- a/packages/proton-web-sdk/src/connect.ts
+++ b/packages/proton-web-sdk/src/connect.ts
@@ -53,9 +53,15 @@ export const ConnectWallet = async ({
if (!renderer) {
renderer = new WebRenderer(uiOptions)
+ } else if (uiOptions.renderTarget) {
+ renderer.setRenderTarget(uiOptions.renderTarget)
}
- return await login({selectorOptions, linkOptions, transportOptions})
+ const result = await login({selectorOptions, linkOptions, transportOptions})
+ if (result) {
+ ;(result as ConnectWalletRet).renderer = renderer
+ }
+ return result
}
const login = async (
diff --git a/packages/proton-web-sdk/src/types.ts b/packages/proton-web-sdk/src/types.ts
index 7f66f9a..138a010 100644
--- a/packages/proton-web-sdk/src/types.ts
+++ b/packages/proton-web-sdk/src/types.ts
@@ -1,7 +1,7 @@
import type {Link, LinkOptions, LinkSession, LinkStorage, LoginResult} from '@proton/link'
import type {BrowserTransportOptions} from '@proton/browser-transport'
import type {ProtonWebLink} from './links/protonWeb'
-import type {UIRendererOptions} from '@proton/web-renderer'
+import type {UIRendererOptions, WebRenderer} from '@proton/web-renderer'
type PartialBy = Omit & Partial>
@@ -31,6 +31,7 @@ export interface ConnectWalletRet {
session?: LinkSession
link?: ProtonWebLink | Link
loginResult?: LoginResult
+ renderer?: WebRenderer
error?: any
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 56c7956..af59553 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -180,6 +180,34 @@ importers:
specifier: ~5.9.2
version: 5.9.3
+ examples/react-render-target:
+ dependencies:
+ '@proton/web-sdk':
+ specifier: workspace:^
+ version: link:../../packages/proton-web-sdk
+ react:
+ specifier: ^19.1.0
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.1.0
+ version: 19.2.4(react@19.2.4)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.1.2
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.1.2
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^4.4.1
+ version: 4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.3))
+ typescript:
+ specifier: ~5.8.3
+ version: 5.8.3
+ vite:
+ specifier: ^6.3.5
+ version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.3)
+
examples/svelte:
dependencies:
'@proton/js':