@remotedraw/react-native
v0.5.0
Published
RemoteDraw capture components and receiver for React Native and Expo.
Readme
@remotedraw/react-native
No component has run on a simulator or a device yet (see "What is tested" below).
Status: In progress on iOS. Tested in Node on macOS and Linux; not yet run in an iOS app. The API may still change. Desktop (Windows, macOS) is a spike (docs/desktop-spike.md). See the status legend.
Two layers:
- Capture components (capture contract v1,
docs/sdk/capture-contract.md):RemoteDrawCapture,RemoteDrawButton,PairingCode,SessionStatus,RemoteDrawSurface. The same names, inputs, states and events as the React, SwiftUI, Compose and Flutter kits, drawn from the generated design foundations (src/generated/design.ts) with react-native-svg. - Receiver primitives (0.1.0-dev.1, unchanged):
createNativeReceiver,RemoteDrawReceiver,RemoteDrawPairing,RemoteDrawQRCode, lifecycle helpers.
Install
npm install @remotedraw/react-native
npm install react-native-svg@^15.15.4 # Expo: npx expo install react-native-svg
npm install --save-dev @babel/[email protected]With pnpm, Yarn or bun: pnpm add / yarn add / bun add the same
packages (-D for the Babel plugin). To use an unreleased build from a
RemoteDraw checkout instead, see
Working from a checkout.
Peers: react 19, react-native 0.81–0.87 (new architecture), react-native-svg 15.
Dependencies: @remotedraw/client (capture controller, protocol), @remotedraw/geometry,
qrcode-generator. No native module of its own, no WebView, no canvas.
Capture in 30 lines
import { useMemo } from "react";
import { View } from "react-native";
import { RemoteDrawCapture, createHttpSessionProvider, type CaptureResult } from "@remotedraw/react-native";
export function NoteDrawing({ noteId, token, save }: {
noteId: string;
token: string;
save: (png: Uint8Array, width: number, height: number) => Promise<void>;
}) {
// Your backend runs createSessionHandler (@remotedraw/server, Python, Go, .NET)
// and holds the rd_sk_ key; the app only receives rd_recv_ tokens.
const provider = useMemo(
() => createHttpSessionProvider({
endpoint: "https://example.com/api/remotedraw/session",
headers: { Authorization: `Bearer ${token}` },
}),
[token],
);
return (
<View style={{ padding: 16 }}>
<RemoteDrawCapture
surface={`note:${noteId}`} // one capture per note; reopening adopts it
provider={provider}
onImage={async (image: CaptureResult) => save(image.png, image.width, image.height)}
copy={{ "button.label": "Draw on phone" }}
/>
</View>
);
}The button opens the pass (a Modal on phones, an inline popover on tablets
and desktop); when the phone joins, the pass folds home and the live board
appears under the button with the session island (undo, clear, insert, end).
Insert exports the board and awaits onImage; if it throws, the island shows
"Couldn't insert" with retry (the same bytes). After a successful insert the
owned session ends (closeOnInsert, default true).
RemoteDrawCapture props
| Prop | Default | |
|---|---|---|
| provider | required unless controller | (SessionRequest) => Promise<SessionGrant>; createHttpSessionProvider({ endpoint, headers }) |
| onImage | — | (CaptureResult) => void \| Promise<void>: { png, width, height, crop, revision, submissionId?, sessionId } |
| surface | "default" | logical surface key: one controller per key per app (adoption) |
| export | contract defaults | { crop: "ink" \| "board", padding, minPaddingPx, square, background, scale, maxPixels } |
| closeOnInsert | true | end the owned session after a delivered insert |
| closeOnDismiss | true | contract §5 dismiss rule; accepted for parity, see Dismissal |
| label | copy button.label | button label (wins over copy["button.label"]) |
| appearance | "label" | "label" or "icon" (36 pt mark, labelled for screen readers) |
| afterConnect | "presence" | "presence" (green dot), "hide", "keep" |
| actions | all four | subset of ["undo", "clear", "insert", "end"] |
| theme | "auto" | "auto" (system), "light", "dark" |
| accent | contract teal | colour |
| background | "#ffffff" | board ground: a colour or your own view (under the ink) |
| boardAspect | 4/3 | width / height until the session states its coordinate space |
| controller | — | an external CaptureController (createCaptureController) |
| presentation | "auto" | "modal", "popover" |
| rasterize | null (server export) | local SVG → PNG, e.g. examples/skiaRasterize.ts |
| copy | — | per-key copy overrides for this capture (see Copy) |
| fontFamily | platform UI font | a bundled family (e.g. "Inter") |
| reduceMotion | system | force reduced motion |
| onPhaseChange, onEnded, onError, onOpenChange | — | events (onEnded only on real server termination) |
| onCopy | — | ready-plate press with the join URL (wire expo-clipboard or @react-native-clipboard/clipboard) |
| pauseInBackground | true | detach while the app is in the background |
The pieces are public too: RemoteDrawButton (provider or controller),
PairingCode (controller or joinUrl + status), SessionStatus
(controller, actions, alwaysOpen), RemoteDrawSurface (controller or
drawings, background, boardAspect), RemoteDrawThemeProvider (theme,
accent, copy, font for a subtree), RemoteDrawCopyProvider (copy only),
useCaptureController, useCaptureState, and the framework-free
createCaptureBinding / attachCaptureLifecycle.
Copy
Every visible and assistive string is a copy key (capture contract §7,
appendix A; English in src/generated/design.ts COPY): labels, hints,
accessible names, announcements, busy labels and error lines. Override any of
them with RemoteDrawCapture's copy prop, or around standalone components
with RemoteDrawCopyProvider (React's name; RemoteDrawThemeProvider takes
copy too). Nested providers merge, the inner one winning per key; a
component's own copy prop wins over providers; English fills the rest. An
explicit input wins over the map for its string (label,
accessibilityLabel). Placeholders are {name}.
<RemoteDrawCopyProvider copy={{ "button.label": "Teken op je telefoon", "error.generic": "Er ging iets mis." }}>
<RemoteDrawCapture provider={provider} onImage={attach} copy={{ "status.insert": "Invoegen" }} />
</RemoteDrawCopyProvider>Error lines show error.<code> for a code in CAPTURE_ERROR_CODES and
error.generic otherwise (captureErrorCopyKey, re-exported here): the
island tells a failed action as status.failedBecause ("Undo failed · …")
and a failed insert as status.insertFailedBecause; the sheet's retry line
shows the error's copy. The server's raw message never shows; it stays on
CaptureError.message (onError). Only a host callback's own uncoded Error
keeps its words.
Dismissal
closeOnDismiss (default true) is the contract §5 dismiss rule: when the
user dismisses a presentation that holds the live board, an owned session is
closed and a borrowed one detaches; false only detaches. This kit keeps the
live board inline under the button, outside the pass's Modal (phones) or
popover (tablets, desktop), as the React reference does. So dismissing the
pass (backdrop tap, the button) never touches the session; a
pass reopened to pair another phone closes over a live board. The prop is
accepted for parity with the kits whose sheets hold the board. Unmount and the
app going to the background only detach (below).
Lifecycle
- One controller per
surfaceper JS realm (acquireCaptureController); every view of a capture binds to it, nothing polls twice. - App to the background →
detach()(polling stops, the session is kept; the server's 120 s receiver lease ends an abandoned owned session). Back to the foreground →attach(). - Unmount → the view's lease is released; the last release detaches a live
session so a remount with the same
surfaceadopts it. Unmounting never ends a session: onlyclose()(the island's End, orcontroller.close()) does. - An external
controllerfollows the same rule: the last view showing it detaches it on unmount (or when the prop moves to another controller), and a view mounting it again re-attaches it, never a detach your code made. - Across process restarts the backend's logical create key (user + surface) returns the same live session.
When a session pauses, is rate-limited or ends
The capture controller from @remotedraw/client decides what a failed read
means; onError gets a CaptureError with its disposition, code and
message:
retry(too_many_requests,temporarily_unavailable,internal_error,request_timeout, network errors): the session and board stay, the controller polls again on its own and never before the server'sRetry-After. The phase stayspairingorconnected. Nothing to do.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): the session is kept, reading stops, the phase iserrorand the board is hidden. The pass sheet's footer shows the error line (error.<code>, elseerror.generic) and a Retry (button.launch.error) that callsopen(). Fix the cause, then callopen().terminal(invalid_receiver_token,session_not_found, 401/404/410): the session is over.onEndedfires when the server ended it (session_not_active,receiver_lease_expired,expired,ended); otherwise the phase iserror.open()starts a new session.operation(export_failed,delivery_failed,end_failed): the session and board are unaffected; the island tells it.
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, and is idempotent while a create or resume is in
flight, so it is safe to repeat. Coming back from the background re-attaches a
live capture but never lifts a pause; only open() does.
const capture = useCaptureController({
provider,
surface: `note:${noteId}`,
onError: (error) => setPaused(error.disposition === "paused" ? error.code : null),
onEnded: () => setPaused(null),
});
// …
<RemoteDrawCapture controller={capture} onImage={attach} />
{paused ? <Button title="Try again" onPress={() => { setPaused(null); void capture?.open(); }} /> : null}Export
React Native has no canvas, so by default every insert is rendered by the
server (POST /v1/receiver/export) with the same crop rules as local exports.
Pass rasterize to export on the device (examples/skiaRasterize.ts uses
@shopify/react-native-skia; it is not a dependency). A failing rasterize
falls back to the server.
Visuals, motion, accessibility
Tokens, shadows (as react-native-svg blurred shapes), glyph primitives, the
logo, pass geometry, curves and copy come from src/generated/design.ts
(bun run design:generate writes it with the other kits' foundations). The
pass's QR is module for module the web's (qrcode package segmentation and
mask choice, built with qrcode-generator, level H). Animations use
Animated with the contract's sampled curves; AccessibilityInfo's reduce
motion setting turns them into short fades. Every control has an
accessibilityRole and a label from the copy keys; island state changes are
announced. On Windows, react-native-svg has no filters, so blurred shadow
layers are left out.
Network
- iOS ATS: RemoteDraw and your session endpoint are HTTPS; no exception
needed. A plain-HTTP development backend on localhost needs
NSAllowsLocalNetworkingin the debug Info.plist only. - Kits reject
rd_sk_keys anywhere a credential is expected.
Expo
npx expo install react-native-svg, add the Babel plugin below to your Expo
Babel config (keep babel-preset-expo), and clear the cache. react-native-svg
is in Expo Go; the kit has no other native code. Expo bundling and runtime are
unverified for this preview. Setup for Expo and bare apps: examples/README.md.
What is tested
bun test (Bun, no simulator): the capture conformance traces replayed
through the controller in a React Native-shaped runtime (no DOM, no
DOMException, whatwg-fetch-style responses, server export), QR equality with
the web encoder over the fixture and 300 generated join URLs, component states
and controller wiring (light renderer over React Native / react-native-svg
shims), lifecycle (AppState detach/attach, unmount release, remount adoption),
copy (every key a view reads is in the generated table; map, provider and
explicit-input precedence; error lines from error copy, never raw messages),
the dismiss rule (pass dismissal never touches the session), generated
foundations. Parity shots render each manifest state's
react-native-svg tree to SVG (Core Text label outlines) and rasterize it with
resvg; see parity/render.ts and bun run parity. Not tested: any native
runtime (iOS, Expo, Windows, macOS), real gestures and Modal
behaviour, Skia export. Desktop: docs/desktop-spike.md.
Bare React Native Babel configuration
The shared protocol uses Zod 4's ESM namespace exports. With React Native 0.83.2's default Babel preset, Metro needs this Babel 7 transform:
npm install --save-dev @babel/[email protected]Merge this plugin into your application's existing Babel configuration; keep its other presets and plugins. A complete bare React Native example is included at examples/babel.config.cjs:
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: ['@babel/plugin-transform-export-namespace-from'],
};Restart Metro with a cleared transform cache after changing configuration: npx react-native start --reset-cache. Keep the Babel 7-compatible version above when using this preset; the plugin's newer major requires Babel 8. Expo applications should retain their Expo preset and existing configuration. Expo bundling and runtime have not been verified by this preview.
import { useMemo } from "react";
import { View } from "react-native";
import {
createNativeReceiver, RemoteDrawReceiver, RemoteDrawPairing,
useReceiverLifecycle,
} from "@remotedraw/react-native";
export function DrawingScreen({ session }) {
// session = {...response.session, receiverToken: response.receiverToken,
// joinUrl: response.joinUrl,
// joinTokenExpiresAt: response.joinTokenExpiresAt}
const receiver = useMemo(() => createNativeReceiver({
apiBaseUrl: "https://api.remotedraw.com", session,
}), [session.id, session.receiverToken]);
useReceiverLifecycle(receiver);
return <View>
<RemoteDrawPairing store={receiver} />
<RemoteDrawReceiver store={receiver} />
</View>;
}Keep the rd_sk_... API key on your backend. Pass only scoped receiver credentials to the app. Use store.setSession(session) to replace pairing metadata or change boards. store.undo(), store.clear() and store.refreshJoinToken() expose receiver controls. stop() stops listening; end the server session explicitly through your backend when the workflow finishes.
Pairing includes a scannable native SVG QR by default, using the pure JavaScript [email protected] encoder with a four-module white quiet zone. No canvas, WebView, or additional native QR dependency is needed. renderQRCode={(url) => <YourQRCode value={url} />} optionally replaces the built-in QR. Only safe HTTPS URLs are displayed; expired tokens or ended/expired sessions hide the QR and link. Refresh pairing remains available during an active session to invite another phone. Pending actions are scoped to their originating session.
Development renderer scope
Freehand pressure/tilt path geometry, basic ink layers, shapes, points, text and HTTPS image drawings are supported. Aspect ratio comes from the session coordinate space; pass aspectRatio only when the host owns that mapping. Background content must occupy the same board rectangle. Hidden drawings are omitted. Physical material simulation is explicitly unsupported; advanced material compositing shows a basic vector fallback warning. Unknown tools and anchored annotations are reported by onUnsupported and a visible warning. No map navigation, phone viewport chrome, live screen streaming, DOM anchoring, advanced texture tooth/edge filters, or blend/flatten parity is claimed. The raw shared store remains available for custom renderers.
Validation
Automated validation for 0.1.0-dev.2 (2026-10-16, macOS, no simulator or device): the workspace build and typecheck pass against React Native 0.87.1 and react-native-svg 15.15.5 types; bun test runs 114 tests (1177 assertions) across 8 files, all passing (all 47 conformance traces of file version 7 (capture contract 1.4), QR equality and independent jsQR decoding, component states, copy and dismissal, lifecycle, foundations, and the 0.1.0-dev.1 receiver tests). The parity renderer (last run 2026-10-10) passes 22 of 23 manifest states against the React reference; button/open-ready still fails the content-box check by 3 device px (the popover sheet's wide shadow halo is about 2/727 darker at 1.2σ than the reference's, which moves the box edge across a flat plateau). The dev.1 Metro bundle check (Metro 0.83.8) has not been repeated for dev.2. Linux, r17 integration (2026-10-17): bun test 114 tests, all passing. Linux, r18 integration (2026-10-17): bun test 116 tests, all passing, including all 49 conformance traces of file version 10. None of this establishes native runtime or physical-device compatibility.
Run bun run build and bun run test. Install the package in your own Expo/RN app and test on iOS first: pairing, a long stroke, undo/clear, background/resume, expired pairing refresh, session replacement, and resizing. Expo's SVG package documentation: https://docs.expo.dev/versions/latest/sdk/svg/ ; lifecycle reference: https://reactnative.dev/docs/appstate .
