@remotedraw/react
v0.5.0
Published
React components, hooks, and typed clients for RemoteDraw receivers, pairing, and senders.
Readme
@remotedraw/react
React helpers for RemoteDraw receiver and sender integrations.
For new projects, start with the CLI so the integration path, package install, env placeholders, and starter files line up:
remotedraw guide
remotedraw init --target web --sender remotedraw-ios --sdk react --preset sketchInstall it in your app:
npm install @remotedraw/react # npm
pnpm add @remotedraw/react # pnpm
yarn add @remotedraw/react # Yarn
bun add @remotedraw/react # bun@remotedraw/react depends on @remotedraw/client, @remotedraw/protocol, and @remotedraw/geometry, so nothing else needs adding.
To use an unreleased build from a RemoteDraw checkout instead, see Working from a checkout.
The RemoteDraw HTTP API does not require this package; the API reference includes raw fetch quickstarts for backend session creation, receiver polling, sender input, and session cleanup.
Backend Session Client
Use createHttpRemoteDrawApiClient only from trusted backend code. It sends the rd_sk_... API key in the Authorization header.
import { createHttpRemoteDrawApiClient } from "@remotedraw/react";
const remotedraw = createHttpRemoteDrawApiClient(process.env.REMOTEDRAW_API_BASE_URL!, {
apiKey: process.env.REMOTEDRAW_API_KEY!,
});
export async function createRemoteDrawSession() {
return await remotedraw.createSession({
markupPreset: "sketch",
senderIntegrationMode: "native",
target: {
kind: "field",
label: "Customer signature",
inputMapping: "surface",
coordinateSpace: { width: 1600, height: 500 },
},
});
}POST /v1/sessions charges the session minimum, so a retry is safe only with a requestId. When RemoteDrawProvider (or createReceiverStore) creates the session through your createSession callback, the request it hands you already carries a generated requestId. The same id comes back on a retry of the same request: after a failure, a StrictMode remount, or your next create() call. It is replaced only once a session is adopted, you select a board, or the request body changes. Nothing retries on its own. Forward the whole request to your backend and on into remotedraw.createSession. A callback that rebuilds the body and drops requestId loses the protection, and a lost response can then bill a second session. If you pass your own requestId, the store sends it unchanged.
// Browser: <RemoteDrawProvider createSession={createViaBackend} receiver={receiver} />
const createViaBackend = async (request: CreateSessionRequest) =>
(await fetch("/api/remotedraw/session", { method: "POST", body: JSON.stringify(request) })).json();
// Backend: `trustedSessionBody` is your own check of what the browser may ask for.
// Whatever it rebuilds, pass request.requestId through.
export async function POST(req: Request) {
const request = await req.json();
return Response.json(await remotedraw.createSession({ ...trustedSessionBody(request), requestId: request.requestId }));
}inputMapping: "surface" maps the complete phone pad directly onto the bounded
field, and a receiver rendered inside RemoteDrawProvider picks up the target's
coordinateSpace aspect ratio automatically.
senderIntegrationMode is optional. Omit it and every surface — map included — gets the native iOS integration; the mode is never derived from target.kind. A map board is the one surface whose native sender draws the same geography itself, through MapKit — that is true of both the RemoteDraw iOS app and RemoteDrawSenderKit in your own app, each of which renders the board's coordinateSpace.bounds from its own built-in map ground. Deriving "streaming" from the target used to send the iOS app into the hosted WebView and make that renderer unreachable. Set "streaming" explicitly when your surface needs the hosted WebView sender.
Use target.metadata.descriptor when the phone UI should adapt to your surface without receiving screen pixels or app content. The exported TargetDescriptor type covers metadata-only surface roles, input intents, normalized component bounds, and phone layout hints; do not put screenshots, DOM/HTML snapshots, raw field values, or customer text inside the descriptor.
Receiver With RemoteDraw iOS App
Use this path when the sender is the RemoteDraw iOS app. The receiver shows the HTTPS joinUrl; Universal Links can open the installed app, and the same URL falls back to the web join route.
import {
PairingCode,
RemoteDrawProvider,
RemoteDrawReceiver,
SessionStatus,
createHttpReceiverClient,
type CreateSessionResponse,
} from "@remotedraw/react";
const receiver = createHttpReceiverClient("https://api.remotedraw.com");
export function ReceiverWithIOSApp({ session }: { session: CreateSessionResponse }) {
return (
<RemoteDrawProvider
session={session.session}
receiverToken={session.receiverToken}
joinUrl={session.joinUrl}
joinUrls={session.joinUrls}
joinTokenExpiresAt={session.joinTokenExpiresAt}
receiver={receiver}
>
<PairingCode />
<RemoteDrawReceiver />
<SessionStatus metadataKeys={["orderId"]} />
</RemoteDrawProvider>
);
}QRCode (also exported as PairingCode for compatibility) renders the code from joinUrl, falling back to joinUrls.web. It is one light pass whose centre plate tells every state: the logo at rest, copy under the pointer, the phone that opened the link, the check on its screen when it joins. Single-use pairing draws the code into the plate and the pass rises away; multi-use pairing keeps the code available with a presence dot per phone. An expired link becomes a spent, punched ticket. Use PairingDevices for device discovery and RemoteDrawLaunchButton for a direct handoff to your own app.
RemoteDrawReceiver renders the board immediately and owns nothing around it: if your product should hide the board until a sender is linked, or show an empty state, express that in your own layout from useReceiverData().senders.
Non-React receivers can poll POST /v1/receiver/session, POST /v1/receiver/drawings, POST /v1/receiver/drafts, and POST /v1/receiver/senders with the created session.id and receiverToken. This is the receiver path for both the RemoteDraw iOS app sender path and developer-owned sender/receiver apps. RemoteDrawProvider avoids overlapping receiver poll batches and keeps only the latest queued refresh while a previous refresh is in flight; custom polling receivers should use the same one-in-flight behavior.
Developer-Owned Sender
The ready-made web sender is the hosted /join page — the target every QR and Universal Link already points at — so most products ship no web sender code at all. There is deliberately no sender component in this package: build your own sender only when the pad must live inside your page, and build it headless on the client primitives. joinTokenFromInput accepts a bare rd_join_... token, a join URL with ?token=rd_join_..., or the remotedraw://join?token=... native URL.
import {
createHttpSenderClient,
createPacedDraftQueue,
draftPointsForTransport,
joinTokenFromInput,
type DraftInputUpdate,
type NormalizedPoint,
} from "@remotedraw/react";
// `SenderClient` is a structural interface with every method optional, so a
// custom transport can implement only the calls it needs. The HTTP client
// implements all of them, which is why the `!`s below are safe.
const client = createHttpSenderClient("https://api.remotedraw.com");
const joined = await client.join!({
joinToken: joinTokenFromInput(joinTokenOrUrl)!,
device: { platform: "web", displayName: "Embedded sender" },
});
// Resuming a token must continue its sequence; the server drops stale ones.
let sequence = joined.lastSequence ?? 0;
// Pointer moves: throttled, latest-only previews of the whole stroke so far.
const drafts = createPacedDraftQueue<DraftInputUpdate>({
intervalMs: 32,
run: async (request) => {
await client.updateDraft!(request);
},
});
const previewDraft = (points: NormalizedPoint[]) =>
drafts.offer(() => ({
senderToken: joined.senderToken,
sequence: ++sequence,
tool: "freehand" as const,
points: draftPointsForTransport(points),
}));
// Pointer up: one durable stroke with a stable id per stroke.
const commitStroke = (points: NormalizedPoint[], clientStrokeId: string) =>
client.commitStroke!({
senderToken: joined.senderToken,
clientStrokeId,
sequence: ++sequence,
tool: "freehand",
points,
});remotedraw init --sender embedded-web --sdk react scaffolds this as a ready useRemoteDrawSender hook. Call client.submit when the sender-side workflow is complete, passing submitMetadata for app context such as issue IDs, document/page IDs, field IDs, reviewer labels, or workflow names; RemoteDraw still adds clientSubmissionId and occurredAt.
Custom senders must normalize points to 0..1, send draft updates while the pointer moves, commit one durable stroke on pointer up with a stable clientStrokeId, call submit when the sender-side workflow is complete, and respect the joined capability list. Do not POST every raw pointer event: createPacedDraftQueue provides the same 32 ms pacing and latest-only draft coalescing the hosted /join sender runs, so slow networks never keep transmitting stale in-flight previews.
The same sender flow can be implemented without this package by calling POST /v1/join, POST /v1/sender/draft, and POST /v1/sender/commit directly. Use POST /v1/sender/submit when the sender-side workflow is complete.
For direct owned-iOS mode, call connectSender only from backend code:
const connection = await remotedraw.connectSender({
sessionId: session.session.id,
device: { platform: "ios", displayName: "Owned iOS app" },
launchUrlTemplate:
"exampleapp://remotedraw/draw?senderToken={senderToken}&senderId={senderId}&sessionId={sessionId}",
});Send connection.launchUrl or the scoped connection.senderToken to your app through your own routing channel. Do not expose the API key to the iOS app.
Customer-owned iOS apps use the published Swift package https://github.com/AxioSOzo/remotedraw-swift.git (product RemoteDrawSenderKit): .remoteDrawSurface(isPresented:senderToken:) presents the first-party drawing board full screen from the token your backend minted, and RemoteDrawSenderSession is the headless core when you own the screen. See https://docs.remotedraw.com/docs/sdks#ios.
When the receiver workflow is finished, call endSession from backend code or POST /v1/sessions/end with your API key. Do not expose the API key to the receiver or sender client.
Open On My Phone
RemoteDrawButton is the one button that starts phone input. It is a flat
36 px tile with the RemoteDraw logo plate: press it and the pairing pass grows
out of it while the plate flies into the code's centre; when the phone joins,
the pass folds home and the plate lands as the logo with a presence dot.
Inside a RemoteDrawProvider the link, its status and the phones come from the
session; outside one, pass joinUrl (and phones if you track presence).
import { RemoteDrawButton } from "@remotedraw/react";
<RemoteDrawButton />; // inside a RemoteDrawProvider
<RemoteDrawButton joinUrl={joinUrl} afterConnect="hide" />;
<RemoteDrawButton methods={["qr", "devices", "launch"]} devices={devices} connect={connectSenderFromYourBackend} />;| Prop | Values | Default | Notes |
| --- | --- | --- | --- |
| afterConnect | "presence" \| "hide" \| "keep" | "presence" | what the button does once a phone is on: the logo + a presence dot (press to pair another); bow out of the toolbar and come back when the last phone leaves (single-link hosts; it stays mounted); or straight back to idle (kiosks, classrooms) |
| methods | ordered subset of "qr", "devices", "launch" | ["qr"] | the first is what a press does; the others are quiet links at the foot of the pass ("Nearby devices" opens PairingDevices in the same sheet, "This device" runs your app's launch). "devices" needs devices; "launch" needs connect |
| appearance | "label" \| "icon" | "label" | "icon" is the 36 px square, named for assistive tech |
| label | ReactNode | "Draw on phone" | a string doubles as the tooltip |
| onConnectedChange | (phones: number) => void | — | phones on changed (0 = none) |
| joinUrl, joinMode, phones | | from the provider | for hosts without one |
| open / defaultOpen / onOpenChange, placement | | uncontrolled, "bottom" | the sheet |
| devices, deviceMethod, onSelectDevice | | deviceMethod "bluetooth" | for "devices" |
| connect, launch | | — | for "launch"; launch takes the RemoteDrawLaunchButton options below |
The button never shows a name, a count or ink activity: SessionStatus tells
the session, wherever you place it. action="connect" is methods={["launch"]}
(the pass is its fallback); the nested pairing={{ … }} group still works.
RemoteDrawConnect and RemoteDrawLaunchButton are this button under their
original names.
When the phone is the user's own app rather than a scan, the pairing surface is a button, not a QR. RemoteDrawLaunchButton and useDirectSender are the browser half of the direct-sender flow above: the host's backend mints the sender, the button opens the resulting launchUrl, and the component watches the board to find out whether the app actually came back.
import { RemoteDrawLaunchButton, RemoteDrawProvider } from "@remotedraw/react";
<RemoteDrawProvider session={session.session} receiverToken={session.receiverToken} receiver={receiver}>
<RemoteDrawLaunchButton
// YOUR endpoint, which calls `connectSender` with the rd_sk_... key.
// The key never reaches the browser.
connect={() =>
fetch("/api/remotedraw/direct-sender", { method: "POST" }).then((response) =>
response.json(),
)
}
/>
</RemoteDrawProvider>;Your endpoint may return the raw connectSender response, a create-session response carrying it under senderConnection, or just { launchUrl } — all three are accepted. Registering the scheme is your app's job: iOS needs CFBundleURLTypes in Info.plist and an onOpenURL that pulls senderToken out of the incoming URL and hands it to .remoteDrawSurface(isPresented:senderToken:).
Why this is a component and not an onClick. A custom scheme that nothing has registered fails silently — no error, no navigation, no event. The only observable difference between "the app opened" and "the app is not installed" is whether a sender shows up on the board, so the button treats a handoffTimeoutMs with no sender as a failed launch and reveals the QR underneath it.
RemoteDrawLaunchButton
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| connect | () => Promise<DirectSenderResult \| null \| undefined> | — | required; your own backend call. Resolving null means "no direct sender for this user" and falls back to the QR |
| handoffTimeoutMs | number | DIRECT_SENDER_HANDOFF_TIMEOUT_MS (12_000) | how long the launched app has to produce a sender before the QR appears |
| showQrFallback | boolean | true | false leaves the failure entirely to your own UI |
| pairingProps | PairingCodeProps | the provider's link | the fallback pass's code (joinUrl, size, logo, onCopy, …) |
| labels | Partial<Record<DirectSenderStatus, ReactNode>> | see below | |
| appearance | "label" \| "icon" | "label" | |
| disabled | boolean | false | |
| autoLaunch | boolean | true | false mints without navigating; call launch() yourself |
| openUrl | (url: string) => void | window.location.href = url | override for tests or an in-app browser |
| onError | (error: RemoteDrawLaunchError) => void | — | |
| onStatusChange | (status: DirectSenderStatus) => void | — | fires on transitions only, never on mount |
| children | ReactNode \| (state: DirectSenderState) => ReactNode | the phase label | replaces the label; the button and its plate stay |
| className / style | | — | the wrapper |
| buttonClassName / buttonStyle | | — | the button |
| aria-label | string | — | |
Default labels: idle "Draw on phone", connecting "Preparing", ready "Waiting", drawing "Drawing", submitted "Done", expired "Ended", error "Retry". fallback and accentColor are accepted and ignored (the fallback is the button's own pass; teal is the focus ring and the stage lights).
The button is a real <button type="button">: aria-busy while minting, three stage lights around the plate (preparing, waiting — the second breathes — then green when the phone draws, leaving the presence dot), and an aria-live="polite" status line that speaks each step and the error text. When the launch does not land, the pairing pass grows out of the button (role="dialog", data-rd-launch-fallback="qr"), exactly as a QR press would open it.
useDirectSender(options)
The button is a thin default over this hook; reach for the hook when the trigger belongs to your own design system.
Options: connect (required), autoLaunch, openUrl, onError, onStatusChange — same meanings as above.
Returns { status, launchUrl, senderToken, senderId, connection, error, connect(), launch(), reset() }.
| status | Meaning |
| --- | --- |
| idle | nothing attempted yet, or reset() was called |
| connecting | your connect callback is in flight |
| ready | a sender was minted and the app was opened; no phone has attached yet |
| drawing | the minted sender is on the board |
| submitted | the session's submission.status is "submitted" |
| expired | the sender was revoked, or the session ended or expired |
| error | connect rejected, the response had no launchUrl, or navigation was refused |
error is a RemoteDrawLaunchError — an Error with kind: "connect-failed" \| "no-launch-url" \| "launch-blocked" and the original rejection on cause.
The phases after ready are read from RemoteDrawProvider, so the hook works without one but can never advance past ready outside it. The rules themselves live in @remotedraw/client (createDirectSenderController, resolveDirectSenderStatus, directSenderConnectionFromResult), which is what @remotedraw/svelte's createDirectSender store binds to — the two adapters cannot disagree about when a launch has failed.
When a session pauses, is rate-limited or ends
RemoteDrawCapture runs the capture controller from @remotedraw/client, and
the controller decides what a failed read means. The app only reacts, through
onError(error) (a CaptureError: disposition, code, message) and
onEnded(reason):
| error.disposition | Typical codes | What the component does | What your app does |
| --- | --- | --- | --- |
| "retry" | too_many_requests (429), temporarily_unavailable (503), internal_error, request_timeout, network errors | keeps the session and board and polls again on its own, never before the server's Retry-After; the phase stays pairing or connected | nothing |
| "paused" | billing_limit_reached, wallet_balance_exhausted, billing_not_configured, included_capacity_exhausted, capability_not_granted, insufficient_scope, beta_access_required, sdk_too_old, other 4xx | keeps the session, stops reading, phase error; the board hides and the pass reads "Code unavailable" | fix the cause, then call the controller's open() |
| "terminal" | invalid_receiver_token, session_not_found, 401/404/410 | the session is over: onEnded when the server ended it (session_not_active, receiver_lease_expired, expired, ended), otherwise phase error | open() starts a new session |
| "operation" | export_failed, delivery_failed, end_failed (a refused undo or clear only rejects that call) | session and board unaffected; a failed delivery keeps its image | nothing; the island tells it and its insert retries |
onError fires once per failure (a run of transient ones counts once) and not
again after a resume. open() is the resume: it clears the failure at the call
and reads once more, or after a terminal failure creates a new session. It is
idempotent while a create or resume is in flight, so a "Try again" button can
call it as often as it likes.
While paused, the component itself shows no error line and no Retry: the
board is hidden, the pass reads "Code unavailable", and the button keeps its
label. Pressing the button to open the pass calls open(), so it does resume,
but a pass that is already open closes on the first press. Show the reason in
your own UI: errorCopy(t, error.code) from @remotedraw/react/next gives the
error.<code> line (or error.generic), with the overrides of the nearest
RemoteDrawCopyProvider.
import { useState } from "react";
import { RemoteDrawCapture, createHttpSessionProvider, peekCaptureController, type CaptureError } from "@remotedraw/react";
import { errorCopy, useCopyText } from "@remotedraw/react/next";
const provider = createHttpSessionProvider({ endpoint: "/api/remotedraw/session" });
export function Sketch({ attach }: { attach: (png: Uint8Array) => void }) {
const t = useCopyText();
const [paused, setPaused] = useState<CaptureError | null>(null);
return (
<>
<RemoteDrawCapture
provider={provider}
onImage={({ png }) => attach(png)}
onError={(error) => setPaused(error.disposition === "paused" ? error : null)}
onEnded={() => setPaused(null)}
/>
{paused ? (
<p role="alert">
{errorCopy(t, paused.code)}{" "}
<button onClick={() => { setPaused(null); void peekCaptureController()?.open(); }}>Try again</button>
</p>
) : null}
</>
);
}Pass your own controller (from createCaptureController) instead of
provider to call open() on it directly; useCaptureState(controller)
re-renders with its phase and error.
Component Surface
RemoteDrawSurface composes the receiver, map camera, phone tracking, presence,
and optional visual-context layers in one component. Its one top-level decision
is mode — what the phone draws on. The frame around the board is
presentation (panel, fullscreen, or custom), independent of the mode,
and setup (whiteboard, signature, annotation, or rating) adds a
presentation aid. None of these change session capabilities or the coordinate
space; those still come from your session.
import { RemoteDrawSurface, SessionStatus } from "@remotedraw/react";
<RemoteDrawSurface
setup="signature"
background="paper"
tracking="fixed"
coordinateAspectRatio={16 / 5}
style={{ width: 480 }}
/>;Mode
| mode | What the phone draws on |
| --- | --- |
| native (default) | A sheet the surface paints itself — a named ground, your document, your map. The phone aims a viewport at it; with visualContext each phone is sent its own aimed region. |
| streaming | This surface, live. The whole rectangle is published through visualContext to every phone, so the person draws on what is really under the ink. |
| static | The same, as one still: visualContext with an image source (createImageViewportSource) publishes it once. |
| glass | The island. The block becomes a translucent pane over your own page, the ground is always transparent, and the whole rectangle is published (live, or a still) so the person can draw anywhere under it. |
streaming, static and glass are a different contract from native: there
is no sheet and no viewport aiming, every phone gets the rectangle. A mode that
publishes needs a visualContext; without one the surface renders and sends
nothing.
A streaming surface session needs visualContext.enabled, viewReceiverContext
and senderIntegrationMode: "streaming"; moveViewport enables the web
sender's local crop pan/zoom. The publisher gives glass neutral full-region
projections; explicit projection overrides remain available.
createImageViewportSource crops an original screenshot into a bounded raster
without a full-page canvas.
<RemoteDrawSurface mode="streaming" visualContext={{ transport }} background={<YourPage />} />
<RemoteDrawSurface mode="glass" glass={{ blur: 3 }} visualContext={{ transport }} />mode="glass" is parked: the API stays, but it is not a recommended mode.
It is a developer setting — the component is a pane over your page from mount,
no phone is drawn (every phone gets the whole pane), presence moves to the
corner roster, and nothing at runtime turns a board into glass or changes its
blur. The glass material: blur frosts the page behind (0 is a clear pane), tint
is the pane's own colour (light by default, dark for a smoked pane, auto
follows the theme, clear adds none), frost is how dense it is, and edge
is the rim — lit for the specular glassmorphic edge, hairline for one line,
none for none. presentation="glass" is the earlier spelling of
mode="glass" on a panel and still works.
Presentation
A panel is the board as a rounded block. elevation="raised" (default)
lifts it slightly off the page — a lit top edge, a bevel, a lip of thickness
and a soft shadow, all box-shadow, nothing transformed or repainted while
the board is still; elevation="flat" is a hairline edge for a board that
already sits inside your own card. compact and embedded still work as the
earlier names for a raised and a flat panel. fullscreen is CSS
position: fixed; inset: 0 (a layout, not the Fullscreen API), and custom
adds no chrome at all. A glass panel is a rounded pane; a glass fullscreen is a
pane over the whole page.
The rim is the board's one state light: a soft inner glow in the accent, asleep with no phone, lit while one is present, a third of itself while every phone has gone quiet, and a brighter flare once when a phone arrives.
The viewer's controls
What the surface is — mode, ground, glass material, elevation — is yours to choose at integration. The viewer only ever gets size controls, and only the ones you turn on:
<RemoteDrawSurface expandable onExpandedChange={(expanded) => track(expanded)} />expandable (default false) adds one enlarge button that appears in the
board's corner on hover or focus: the board grows out of the panel to the whole
window and back (Esc leaves, focus returns to the button). It is offered on a
panel only, never on glass or with annotations. resize (below) adds the
edge grabbers. With neither, a panel has no hover chrome at all.
Phones on the board
Each connected phone whose sender reports a projection is drawn where it is looking: real hardware (thin titanium band, black glass, Dynamic Island), the same for every phone in both themes. It sets down when it arrives, glides when the phone moves (a big jump snaps), dims and breathes slowly when it goes quiet, and lifts away when it leaves. Its ink dries in place: a committed stroke settles from its live preview instead of drawing itself again.
presenceBadge says who each phone is. "island" (default): the Dynamic
Island grows into a black pill on hover, focus or tap, with a name the host's
sender supplied, else the model (iPhone17,1 → "iPhone 16 Pro"), else the kind
("iPhone", "Phone") — plus "Away" when stale. A phone too small for three
letters slides the pill out as a tab under its top edge. "frame" pins that
tab open on every phone (a TV, a kiosk); "corner" is a roster of black pills
top-right (the default when no phone is drawn: tracking="fixed", glass);
"none" says nothing. senderIdentity(device) and deviceModelName(id) from
@remotedraw/client give the same words for your own UI.
Use map={{ bounds, projectLngLat }} with your map node as background.
video forwards received-stream options to VisualContextVideoLayer;
visualContext forwards publisher options, including the existing rendered-area
source. Rendered-area sources still use the WebRTC publisher: they change how
pixels are produced, not the transport. Phone tracking is independent of both.
HTML header, footer, and overlay slots support custom controls; SVG
children retain the underlying receiver's meaning.
Resizing
The board resizes by its own edges — no wrapper, no control strip. Omit
resize to keep the board at the size your layout gives it.
// One shape, scaled uniformly from any edge or corner. The board's own shape
// by default; nothing is sent anywhere.
<RemoteDrawSurface resize="ratio" />
<RemoteDrawSurface resize={{ mode: "ratio", aspectRatio: 4 / 5, min: { width: 240 } }} />
// Every edge on its own. Inside a RemoteDrawProvider whose receiver client
// implements updateSurface, a finished gesture commits the new presentation
// extent to the session; without a provider the canonical board is contained
// in the new box locally.
<RemoteDrawSurface resize={{ mode: "free", edges: ["e", "s", "se"] }} />Handles appear on hover, focus and touch; each is a button that also answers
arrow keys (Shift for larger steps, Escape to reset). edges, min, max,
initial, compactBelow and onChange are the options; commit: "local"
keeps a free resize on this receiver even inside a provider. A committed
resize keeps canonical drawing coordinates unchanged: a new shape adds board
rather than stretching the scene. A provider that cannot commit (no
updateSurface, an ended session) disables the handles and names the reason
in their tooltip.
The /studio Surface entry exposes these options. Its free-resize preview is a
local simulation for inspecting the component; it does not establish live API
or physical-phone correctness.
annotations={...} (page-anchored annotations, part of Glass Overlay) is
parked and unavailable for release. It renders only a parked status notice in
place of the board and does not publish visualContext. See
Glass Overlay — parked, release blocked.
Plain mode="glass" is unaffected.
SessionStatus is the session in one small island. Inline by default, or
placed in a corner of a positioned host such as the surface's overlay slot
(right corners mirror it, so its light stays in the corner):
<RemoteDrawSurface
overlay={
<SessionStatus
placement="top-left"
metadataKeys={["orderId"]}
onUndone={(event) => console.log("undid", event.drawingId)}
/>
}
/>;The light tells the session: an amber ring breathing while it waits, a green
dot when a phone is here, a teal halo per real ink update, amber again while a
quiet phone reconnects. Undo · clear emerge on hover, keyboard focus or a tap
(defaultOpen starts it open; actions={[]} for status only); Clear arms
first and a ring drains over the 4 s confirm window. Two or more phones stack
their device art and open into "who's here". On submit the island prints a
receipt with the metadataKeys you allow (receipt="pinned" keeps it open).
onUndone / onCleared — or useSessionActionEvents() anywhere under the
provider — hand the board the stroke an undo took and the ink a clear swept, so
it can retrace and sweep them. For a fully custom rendering pass a children
function and read useSessionStatus().
RemoteDrawReceiver is the receiver foundation: one component that paints
committed ink, live drafts, and each sender's pen point over a configurable background,
and prescribes nothing around it. Specialized surfaces (signature fields,
annotators, review panes) are compositions on top of it — see the recipes
below.
Everything here provides pairing, receiver ink, sender input, state, and hooks without prescribing the surrounding product UI.
State and data primitives:
RemoteDrawProvider: receiver session state, polling, pairing metadata, and receiver actions.useRemoteDraw,useRemoteDrawSession,useReceiverData,usePairingUrl,useRemoteDrawPointers,useReceiverStrokes: receiver hooks for fully custom layouts underRemoteDrawProvider.RemoteDrawPenPoint: the pen point the receiver draws where a phone's finger or pen touches the board (the nib in the ink's colour, sized by pressure like the stroke). Tune it withpenPoint, replace it withrenderPenPoint, turn it off withpointers={false}, or draw it yourself fromuseRemoteDrawPointers().createHttpRemoteDrawApiClient,createHttpReceiverClient,createHttpSenderClient: typed HTTP clients for the backend, receiver, and sender sides. These live in@remotedraw/client(framework-free TypeScript) and are re-exported here;createReceiverStorefrom the same package is the headless receiver state machine thatRemoteDrawProviderbinds to React.@remotedraw/sveltewraps the same store in the Svelte store contract.
Pairing primitives:
PairingCode: the pairing component. The scannable code with live status and subtle RemoteDraw branding. Hovering or focusing the code swaps the plate to a copy-link action, so no buttons crowd it by default. Useplacement="top-right"(or any corner) to dock the card inside aposition: relativesurface such as a screenshot pane, PDF page, or map. Passbarewithsize="fill"to drop the code into a plate your own design supplies. Full props below.PairingDevices: the connection component for pairing methods that resolve to a device —bluetooth,localNetwork,accountPresence, anddirect. PassdevicesandonSelectDevicewhen your own app/backend supplies device presence. Each row's avatar is the device itself (frommodelIdentifier/platform): its screen wakes when chosen, shows the phone's prompt ("Check phone") while a person confirms, and draws the check when it joins. While discovery is empty it shows one searching row; the search ends aftersearchTimeoutMs(7 s, orsearching={false}) and "Scan a QR" becomes the primary row, growing into the pairing pass. RejectonSelectDevicewith an error whosecodeis"declined"when a person said no: the row shows an amber refusal that lets go by itself.RemoteDrawButton: the one button that starts phone input — the pass (or the device list, or your app's launch) grows out of it and folds home when the phone joins. Use it where the user needs to draw or sign: a field, a margin, a toolbar.afterConnectchooses the ending,methodsthe ways in. Dismisses on Escape and outside click. See Open On My Phone.RemoteDrawLaunchButton/useDirectSender: "open on my phone" for a user whose own app is the sender — the same button withmethods={["launch"]}: mints a scoped sender through your backend, opens the deep link, and grows the pass when the app never comes back.RemoteDrawConnect: the same button under its original name (icon-only unlessshowTriggerLabel; a devicemethodroutes to the list).
Receiver:
RemoteDrawReceiver: the receiver foundation. It fills the box you give it (defaulting its CSSaspect-ratioto the coordinate space) and takesbackgroundas"whiteboard"(default),"transparent"(annotate host-rendered content), any CSS background string, or any ReactNode (photo, PDF page, map,VisualContextVideoLayerstream). Live sender cursors are built in —pointers={false}hides them, an explicit array replaces them. Add SVGchildrenfor guides (signature baselines, field outlines); they render in the ink's coordinate space. Data comes from props or falls back toRemoteDrawProvider, which also supplies the coordinate aspect ratio from the session target.SessionStatus: the one session-state surface — a small island that tells the session's story (waiting for a phone, the phone's device name, the ink light while it draws, reconnecting, submitted) and carries undo/clear with a two-step destructive clear (confirmClear). Once submitted it prints a receipt with curated submission facts viametadataKeys— keys render humanized ("External ID"), never raw.RemoteDrawSessionControlsis its deprecated alias.
AI results:
AiImage: a generated picture. Pass theAiAction; the box is reserved at the result's shape (its dimensions, else square, oraspectRatio) so nothing on your page moves. The wait is built only from what exists: the PNG the model was sent (result.sourceRenderUrl, or your own board snapshot viasourcebefore it exists) lies on the plate as graphite, three stage lamps light on the real server events (queued, running, sketch sent), the paper develops on the clock, and "Still working" appears past 1.5x the tier's typical time (AI_TYPICAL_MS, override withtypicalMs). The picture then develops through the sketch; a result that was already finished on mount renders still. The corner mark opens the actions on hover, focus or tap:standardActions(["download", "copy"]),onRegenerate,onPlaceandcustomActions. Copy puts the image itself on the clipboard (re-encoded to PNG, the item built inside the gesture) and says so when it fails; download goes through a blob so the cross-origin file really saves. Failed is coral and offersonRetry; canceled is amber and never does; the refund is stated in the tooltip and live region.onGenerateadds the idle call to action.AiText: what the model wrote about the sketch, on a reading sheet. While it works the sheet holds the answer's shape and the footer names the real event beside a thumbnail of the sketch that was sent; the answer arrives line by line into the same rows (never faked streaming), long answers clamp atmaxLines(5) with a real More button, and copy sweeps the text it copied (or says "Not copied"). SameonRetry/onRegenerate/customActions/labelsasAiImage. A schema run renders nothing at all:result.generatedDatais for your code.- Both take
customActions— your own buttons, in the same cluster as the standard ones, each handed the action, the result, and ablob()for the bytes. Neither ever shows the model, the tier, the latency, the billing cost, or the provider's own error string: these render to your user, not to you. Use the action object directly for anything you need to show a developer.
Sender primitives (developer-owned):
- Web sending ships no component: the hosted
/joinpage is the web sender (the QR / Universal Link target), and custom in-page senders build headless oncreateHttpSenderClient,joinTokenFromInput, andcreatePacedDraftQueue(see Developer-Owned Sender). iOS senders are native — the RemoteDraw app, or your own app through theRemoteDrawSenderKitSwift package. There is no native Android SDK: on Android the hosted/joinpage is the sender, and it is full-featured.
Pairing placement:
RemoteDrawButton: opens on press, supports controlledopenstate andplacement, and takesbuttonClassName/buttonStyle,pairing.popoverClassName/popoverStyleand a custom label (children,pairing.trigger) for custom UI. The plate stays: it is the button's identity.- The receiver never renders pairing UI. Place
PairingCodewherever your app wants the code and placeRemoteDrawReceiverwherever the board belongs. PairingCodeprops, read from source (src/Pairing.tsx) rather than from memory:
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| joinUrl | string | provider's joinUrl, else joinUrls.web | |
| size | number \| "fill" | 220 | the code's edge in px; the pass adds 14 px a side. "fill" sizes from the container |
| logo | ReactNode \| string \| false | the RemoteDraw mark | the centre plate at rest; a string is an image URL, false an empty plate. The plate is reserved either way (level H) |
| joinMode | "single-use" \| "singleUse" \| "multi-use" \| "multiUse" | the session's joinTokenUse | single-use: the code is drawn into the plate when a phone joins and the pass rises away; multi-use: the code stays, one presence dot per phone (up to 3) |
| copyAction | "hover" \| "always" \| false | "hover" | "hover" swaps the plate to the copy glyph under the pointer or keyboard focus and a click on the code copies; "always" shows the copy glyph at rest; false offers none. Replaces the @deprecated copyOnHover (false maps to copyAction={false}) |
| onCopy | (joinUrl: string) => void \| Promise<void> | clipboard write (async API, then the selection fallback) | |
| onCopyError | (error: Error, joinUrl: string) => void | — | called when a copy could not complete, after the coral exclamation lands and the failure is announced |
| onConnected / onConnectedDismiss | () => void | — | fired when a phone joins / when a single-use pass has risen away |
| labels | PairingCodeLabels | | strings: what the live region speaks, plus the only two words the pass ever prints ("Code expired", "Code unavailable") |
| alt | string | "Scan to join this drawing session" | |
| bare | boolean | false | no sheet: your surface is the sheet. With size="fill" the code fills a plate your design supplies (tile={false} is the old spelling) |
| theme | "auto" \| "light" \| "dark" | "auto" | the pass is a light sheet in both themes so a camera reads it; the theme only changes how it is lit |
| placement | "inline" \| "top-left" \| "top-right" \| "bottom-left" \| "bottom-right" | "inline" | docked placements position inside the nearest positioned ancestor |
| position / offset / zIndex | "absolute" \| "fixed" \| "sticky" / CSS length / number | "absolute" / 12 / 20 | position="fixed" pins to the viewport |
| className / style | | — | the outer element |
| codeClassName / codeStyle | | — | the pass itself (the sheet, or the bare code box) |
The pass has one design. card, ink, direction, treatment, accentColor, inset, quietZone, logoPlacement, title, description, showStatus, showJoinMode and showLink are retired: accepted for source compatibility and ignored. Every state lives on the centre plate — the mark at rest, copy under the pointer, the phone that opened the link, the check on its screen when it joins, an amber clock on a spent, punched pass — so put any instruction text beside the code in your own layout.
Status is inferred, never passed: connected when a sender is linked, error when the code cannot be encoded, expired from the session's joinTokenExpiresAt/expiresAt or an ended/expired status, then ready, then idle. Expiry arms its own timer, so a code that dies while the page sits idle stops claiming it is scannable. (previewStatus forces a status for docs and studio previews and is @internal.)
The code always encodes the HTTPS joinUrl: on an iPhone with the RemoteDraw app installed the same link opens the app through Universal Links, and the web sender is the fallback for everyone else including Android — so no separate open-in-app button is rendered. Host apps that already know the app is installed can reach the custom-scheme URL with nativeJoinUrlFromSession(joinUrls) and route it themselves. Code contrast is deliberately fixed dark-on-white in every theme so it survives a camera; the surrounding chrome themes through the --rd-pairing-* CSS custom properties (--rd-pairing-ink, -muted, -border, -surface, -paper).
The animated optical field that variant="aurora" used to select is now the separate, EXPERIMENTAL AuroraPairingField export. With presence (the sequence watchAuroraPresence returns once a phone claims an armed QR) it plays the camera check of a verified QR pairing; without it, it carries the join token in monochrome dust density. Both are read only by the RemoteDraw app, never a generic phone camera.
Recipes (compose, don't import):
Target-specific surfaces are one-liners over the receiver foundation, so the package no longer ships named wrappers for them.
// Signature field: bounded canvas plus a baseline in coordinate space.
<RemoteDrawReceiver
coordinateAspectRatio={16 / 5}
strokeWidth={7}
style={{ border: "1px solid #ddd8cf", borderRadius: 12 }}
>
<line x1="140" y1="760" x2="3060" y2="760" stroke="#d8d8d8" strokeWidth="4" />
</RemoteDrawReceiver>;
// Photo / screenshot / PDF-page / map annotation: app-owned content behind the ink.
<RemoteDrawReceiver
coordinateAspectRatio={16 / 9}
background={<img src="/screenshot.png" alt="" style={{ width: "100%", height: "100%", objectFit: "contain" }} />}
/>;
// Annotation overlay: transparent receiver stacked over content the host renders itself.
<div style={{ position: "relative" }}>
<YourDocumentView />
<RemoteDrawReceiver
background="transparent"
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
/>
</div>;
// Review pane with a docked QR in the corner.
<div style={{ position: "relative" }}>
<RemoteDrawReceiver background={appOwnedContent} coordinateAspectRatio={16 / 10} />
<PairingCode placement="top-right" size={112} />
</div>;Native component use cases
AnnotationGlass (movable website annotation glass) is parked; see
Glass Overlay — parked, release blocked.
Native component use cases:
- Form signing:
RemoteDrawButtonbeside the field (afterConnect="hide"for a single signature),RemoteDrawReceiverwith a baseline. - Approval and review flows:
RemoteDrawReceiver,SessionStatus. - Screenshots, PDFs, maps, design review:
RemoteDrawReceiverwithbackground, plus a dockedPairingCode. - Developer-owned sender apps: the hosted
/joinpage, the headless sender client (createHttpSenderClient,joinTokenFromInput,createPacedDraftQueue), or a backendconnectSenderdirect signal for owned iOS apps.
Capability handling:
draw: enable freehand, line, arrow, rectangle, and ellipse tools.point: enable point-style input.undo: enable sender or receiver undo controls.clear: enable sender or receiver clear controls.moveViewport: enable sender projection and pointer overlay movement.viewExisting: enable sender-side reads of committed drawings.viewReceiverContext: enable opt-in visual context streaming behind the web sender pad.
The hosted /join sender respects the joined capability list, offers Submit only when the session's target descriptor sets phoneHints.primaryActionLabel, and submits with clientSubmissionId, occurredAt, and optional submitMetadata. On the receiver side, SessionStatus under RemoteDrawProvider flips to its submitted state when the sender finishes (or pass it session.submission directly) and surfaces curated submission facts via metadataKeys — keys render humanized, never raw. Custom sender UIs should do the same, disable unavailable tools, and call submit when the sender-side workflow is complete.
The hosted /join sender renders the receiver's visual-context stream behind the pad automatically when the session enables it; a custom sender can render VisualContextVideoLayer behind its own canvas when your app provides a WebRTC MediaStream. RemoteDraw's hosted service stores only signaling metadata; it does not persist or relay video frames.
The publishing half is useVisualContextPublisher (experimental) on the receiver: hand it transport, a source, coordinateSize, and senders — the receiver's own sender list, passed straight through. Projections are keyed by senderId (the snd_… value), never by the sender record's id; a projections map keyed by the record id type-checks and then matches no stream, so the receiver silently never offers. Build one yourself only if you must, with phoneProjectionsBySenderId(senders).
If you draw your own backdrop while the stream connects — a local map, a still of the receiver — hand over on onLiveChange(true), not on having a MediaStream. RTCPeerConnection.ontrack fires when the offer is answered, seconds before the first video packet; swapping there leaves a frameless <video> on screen (blank on Chromium, a grey ▶ start-playback placeholder on WebKit). The layer keeps its poster fully opaque over the element until a frame is actually painted and cross-fades on the hand-off (fadeMs, default VISUAL_CONTEXT_FADE_MS).
RemoteDraw transports markup/input. It does not provide app-owned authentication, billing UI, file storage, document rendering, legal e-signature ceremony, identity proofing, or tamper-evident audit packages.
Host map integration review checklist
Keep the receiver overlay aligned to the map canvas in CSS pixels. Use a fixed
geographic fence and independently normalized x/y coordinates. The receiver
camera callback must change identity on map move and resize. Bounds-based
video crops require a flat north-up Mercator map; projectLngLat on the receiver
does not correct a globe-based source crop.
For moving WebGL maps, use createMapCanvasVisualContextSource from
@remotedraw/client to capture pixels and source geometry in one map render.
Mount the capture in an effect and dispose it when the map or session changes.
Do not combine a live mutable canvas with geometry memoized from a previous
React camera frame. See the API map contract.
A host's Draw with phone button creates the receiver session on explicit
intent. RemoteDrawLaunchButton has a different protocol job: mint a direct
sender and open the customer's installed phone app. Reuse the host's existing
button style for session creation rather than changing the flow to reuse a
similarly named component.
Separate the pairing panel from compact connected controls. Pass the host's
resolved theme when available, keep QR contrast and copy access usable on
touchscreens, and test theme changes while a session is open. Keep the receiver
credentials scoped to the current authenticated organization. Dispose the old
receiver/publisher immediately on account or organization change. Retain a
failed explicit end as a retryable state; do not silently discard the token.
Use the original session's ReceiverClient.end credential for abandoned-session
cleanup after a tenant change, including a pending host End that later fails.
The SDK toolbar catches failed actions and exposes readable error feedback;
the host should also keep recovery actions available. A detail label set to
null suppresses it: for example, suppress liveViewLive while the host's
publisher is unavailable instead of showing stale connected metadata.
On affine maps the complete material footprint follows the geography under zoom. Non-affine projections preserve exact point placement with a receiver-unit nib; they do not use a drifting affine approximation. On a streamed sender, use the video's contain-fit content rectangle for input and local ink during resize, and ignore gestures starting in letterbox bars.
Glass Overlay — parked, release blocked
Glass Overlay is parked and blocked from release (13 September 2026). It is experimental engineering work, not an available experimental feature. Do not enable it for launch. See resumption requirements.
AnnotationGlass and RemoteDrawSurface annotations={...} render only a parked status notice. They mount no overlay, write no annotation input and publish no visualContext. The ordinary mode="glass" material is not part of this block.
Experimental Blender phone receiver
Status: experimental. Developers can keep the standard 2D receiver or opt into
the Blender GLB phone overlay. The drawing and session contracts are the same.
Install three@^0.180.0 alongside this package only when using 3D, and import the
lazy adapter from its dedicated subpath:
import { ExperimentalPhoneLayer } from "@remotedraw/react/experimental/phone";
import { createThreePhoneRenderer } from "@remotedraw/react/experimental/phone/three";
<div style={{ position: "relative", width, height }}>
{/* Your receiver board, using the same viewBox and projection layouts.
Set RemoteDrawReceiver's phones={false} when supplying this overlay. */}
<ExperimentalPhoneLayer
layouts={layouts}
space={{ width, height }}
asset={phoneAsset}
mode={use3D ? "live3d" : "svg"}
quality="balanced"
createRenderer={createThreePhoneRenderer}
/>
</div>phoneAsset describes the authored family, screen rectangle, rendered-image bounds and URL, and GLB URLs for performance, balanced, and detailed. Host those assets with your application. The normal SDK entry point does not import Three.js; the adapter loads it only when a live renderer is requested. Balanced is the default quality; the normal phone layer remains SVG until mode="live3d" is selected.
The model follows existing projection positions, in-plane rotation, orientation, scale, and viewport transforms. It uses a single canvas with instanced geometry and renders only when its scene changes. Receiver ink remains on the board beneath the model's open screen aperture. Incompatible silhouettes, failed loading, render errors, and lost WebGL contexts fall back to SVG. Optional phoneProjection.tiltXDegrees and tiltYDegrees carry presentation-only pitch/roll, each bounded to -45…45 degrees; absent values keep the phone flat. Native and browser senders can opt into calibrated motion aiming. Tilt rotates the hardware without reprojecting board ink.
Pass the same space.viewport and space.transform as the underlying receiver.
Set preserveAspectRatio="none" when the board SVG stretches to its container;
the overlay otherwise uses "xMidYMid meet". Each distinct asset needs its own
layer; instances sharing an asset share geometry. The app supplies three
profiles (iPhone 17 Pro/compatible 16 Pro, iPhone 15, iPhone 15 Pro); unsupported
silhouettes retain 2D. Deployable examples live in apps/web/src/assets/phone.
Keep model URLs in your app's deployed assets, not an excluded tools directory.
3D adds a lazy renderer download, one selected GLB per active profile, and local
GPU work/memory. performance uses the smallest model and lowest pixel budget;
balanced is the default. Stationary phones do not continuously redraw. Motion
is a separate sender opt-in: the supplied controls publish at most 10 updates per
second while moving (up to 36,000 projection requests per moving hour/sender,
before transport coalescing), suppress stationary noise, and pause during
drawing/backgrounding. These updates can add database writes and receiver
subscription traffic. Rendering alone adds no server mutations or AI charges;
this feature introduces no new billing surcharge. Profile real devices before
choosing 3D or enabling sustained motion in a battery-sensitive integration.
