React Native
Add a full image and design editor to a React Native or Expo app. Embed Polotno in react-native-webview, load and save designs, and export PNG or PDF.
How to add an image editor to a React Native app?
Polotno is a React-based web editor. In a React Native app you run it inside react-native-webview: your users get the full design editor with text, images, templates, layers and multi-page documents, and your React Native code decides where designs and exports go.
The same setup works in bare React Native and in Expo, on iOS and Android.
React Native canvas vs a full design editor
Libraries such as react-native-canvas and react-native-skia give you a drawing surface. You still build selection, transform handles, text editing, undo, layers, fonts, templates and export yourself. That is a reasonable path for a signature pad or a sketch feature.
If your users need to edit designs, such as social posts, flyers, cards or print products, Polotno is that editor already built. You embed it and talk to it through a small message bridge.
Architecture overview
- Native layer: a React Native screen that renders a
WebViewand owns storage, sharing and navigation. - Web layer: a React + Vite app that runs the Polotno editor, built into one HTML file that ships inside your app.
React Native calls the editor with injectJavaScript(). The editor answers with window.ReactNativeWebView.postMessage(), which arrives in the WebView's onMessage handler.
Prerequisites
- Expo SDK 58 or React Native 0.88 (the versions the demo is tested with)
- Node.js 20+ (for building the web editor)
- A Polotno API key from the Polotno cabinet
1) Build the web editor
Create a React + Vite app for the editor inside your project and install Polotno and the single-file plugin:
npm create vite@latest web-editor -- --template react
cd web-editor
npm install polotno
npm install -D vite-plugin-singlefileAdd viteSingleFile() to the plugins in vite.config.js. The build then inlines all JavaScript and CSS into one dist/index.html.
The bridge exposes window.polotnoNative for React Native to call and posts every answer back as a JSON string:
// web-editor/src/nativeBridge.js
const post = (message) => window.ReactNativeWebView?.postMessage(JSON.stringify(message));
const base64Of = (dataUrl) => dataUrl.split(',')[1];
// The app waits for a reply to every request, so failures are reported too.
const replying = (task) => async (...args) => {
try {
await task(...args);
} catch (error) {
post({ type: 'error', message: String(error?.message || error) });
}
};
export const connectNativeBridge = (store) => {
window.polotnoNative = {
load: (json) => store.loadJSON(json),
save: replying(async () => {
const preview = await store.toDataURL({ pixelRatio: 0.5 });
post({
type: 'save',
docJson: JSON.stringify(store.toJSON()),
previewBase64: base64Of(preview),
});
}),
export: replying(async (format) => {
const dataUrl =
format === 'pdf'
? await store.toPDFDataURL()
: await store.toDataURL({ pageId: store.activePage?.id });
post({ type: 'export', format, base64: base64Of(dataUrl) });
}),
};
// Ask React Native for the design now that `load` exists.
post({ type: 'ready' });
};Connect it in the editor component:
// web-editor/src/App.jsx
import { createStore } from 'polotno/model/store';
import { PolotnoContainer, SidePanelWrap, WorkspaceWrap } from 'polotno';
import { Workspace } from 'polotno/canvas/workspace';
import { Toolbar } from 'polotno/toolbar/toolbar';
import { ZoomButtons } from 'polotno/toolbar/zoom-buttons';
import { PagesTimeline } from 'polotno/pages-timeline';
import { SidePanel } from 'polotno/side-panel';
import { connectNativeBridge } from './nativeBridge';
const store = createStore({ key: import.meta.env.VITE_POLOTNO_KEY, showCredit: true });
const inApp = !!window.ReactNativeWebView;
if (inApp) {
connectNativeBridge(store);
} else {
// Plain browser (npm run dev): start with an empty page.
store.addPage();
}
export default function App() {
return (
<PolotnoContainer style={{ width: '100%', height: '100%' }}>
<SidePanelWrap>
<SidePanel store={store} />
</SidePanelWrap>
<WorkspaceWrap>
{/* A browser download does nothing in a WebView. */}
<Toolbar store={store} downloadButtonEnabled={!inApp} />
<Workspace store={store} />
<ZoomButtons store={store} />
<PagesTimeline store={store} />
</WorkspaceWrap>
</PolotnoContainer>
);
}Import polotno/ui.css in main.jsx, and give html, body and #root a height of 100% in index.html.
2) Ship the editor inside the app
After vite build, turn dist/index.html into a module the app imports, so the editor ships in your JavaScript bundle and starts with no network request:
// web-editor/scripts/write-editor-module.mjs
import { readFileSync, writeFileSync } from 'node:fs';
// Inlined scripts don't need CORS mode; drop the attribute Vite adds.
const html = readFileSync('dist/index.html', 'utf8').replaceAll(' crossorigin', '');
writeFileSync('../src/editorHtml.ts', `export default ${JSON.stringify(html)};\n`);Run vite build && node scripts/write-editor-module.mjs after every editor change. The demo wires that up as npm run build:rn.
3) Render the editor in React Native
Install the WebView, plus the file and sharing modules the demo uses for exports:
# Expo
npx expo install react-native-webview expo-file-system expo-sharing
# Bare React Native
npm install react-native-webview
cd ios && pod installLoad the editor from the bundled string and handle its messages:
// src/EditorScreen.tsx
import { useRef } from 'react';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import editorHtml from './editorHtml';
// The page's origin. Add it to your API key's allowed domains.
const EDITOR_ORIGIN = 'http://localhost/';
export function EditorScreen({ design }: { design: string }) {
const webView = useRef<WebView<object>>(null);
// injectJavaScript strings must end with `true;` on iOS.
const call = (code: string) => webView.current?.injectJavaScript(`${code}; true;`);
const onMessage = (event: WebViewMessageEvent) => {
const message = JSON.parse(event.nativeEvent.data);
switch (message.type) {
case 'ready':
// A JSON document is a valid JavaScript object literal.
call(`window.polotnoNative.load(${design})`);
break;
case 'save':
// message.docJson: the design. message.previewBase64: a PNG preview.
break;
case 'export':
// message.base64: the PNG or PDF, ready to write to a file.
break;
case 'error':
console.warn(message.message);
break;
}
};
return (
<WebView<object>
ref={webView}
source={{ html: editorHtml, baseUrl: EDITOR_ORIGIN }}
originWhitelist={['*']}
onMessage={onMessage}
webviewDebuggingEnabled={__DEV__}
style={{ flex: 1 }}
/>
);
}baseUrl gives the page a regular http origin, and that origin is what Polotno checks against your key. In the Polotno cabinet, add localhost to the key's allowed domains, or set EDITOR_ORIGIN to a domain of your own and add that.
Pass an explicit type argument to WebView, as above. With the default one, the component's props collapse to never under TypeScript 6.
Trigger save and export from native buttons with call('window.polotnoNative.save()') and call("window.polotnoNative.export('pdf')"). The demo puts Close, PNG, PDF and Save in a native bar above the WebView, which leaves the Polotno toolbar its full width on a phone.
Core concepts
Saving designs
store.toJSON() returns the whole design as plain JSON: pages, elements, fonts and sizes. The demo writes it with expo-file-system, shows the preview PNG on its home screen, and passes the JSON back to load() the next time the editor opens. See the Store API.
Exporting images and PDF
Exports cross the bridge as base64. Write them to a file with expo-file-system (file.write(base64, { encoding: 'base64' })) and hand the file to expo-sharing, or upload the base64 directly.
Image uploads
Polotno's Upload panel uses a regular file input. react-native-webview opens the system picker for it on iOS and Android.
Polotno stores uploaded images in the design JSON as data URLs. For production, upload the file to your own storage and put its URL in the design instead.
FAQ
Does it work with Expo?
Yes. react-native-webview, expo-file-system and expo-sharing are all in Expo Go, so the demo runs there without a development build.
Can I build the editor with React Native components instead of a WebView?
No. Polotno renders to an HTML canvas and its UI is built for the DOM, so it needs a web view on mobile. The WebView approach is what gives you the full editor rather than a reduced mobile version.
Does the editor work with touch?
Yes. Selection, dragging, resizing, rotation and pinch-to-zoom work with touch. See Mobile support for layout tips on small screens.
Can the editor work offline?
The editor code ships inside the app, but Polotno loads stock photos, templates, fonts and the PDF export library from the network. The device must be online for these features.
Enterprise clients: Full offline support (bundling all assets and fonts) is available with enterprise licenses. Contact support for details.
What are the memory considerations?
Exports cross the bridge as base64 strings, so large multi-page PDFs or high pixel ratios use a lot of memory on low-end devices. Use lower pixel ratios for previews and render print-size exports on the server.
How do I customize the editor UI?
Edit web-editor/src/App.jsx and rebuild the bundle. That is where you add side panels, change the default sections or restyle the toolbar. See Customizations.
How do I debug the editor?
Set webviewDebuggingEnabled on the WebView in development builds. Then inspect the page from Safari's Develop menu on iOS, or chrome://inspect in desktop Chrome on Android.
Demo repository
See a complete working example with source code: https://github.com/polotno-project/polotno-react-native
The repository is an Expo app with a home screen that shows the last saved design, the editor screen with a native bar, and the React + Vite web editor that runs inside the WebView.