react-native-signature-ink
v1.0.0
Published
True-native signature capture for React Native. PencilKit on iOS, hand-tuned velocity-Bezier on Android. Fabric-first, zero Skia.
Maintainers
Readme
react-native-signature-ink
True-native signature capture for React Native. Zero Skia, zero JS canvas, zero WebView. Buttery-smooth strokes, instant exports, and a clean imperative API — all powered by the platform's own ink engine.
Demo
| iOS | Android |
| :---: | :---: |
|
|
|
Why this library
Most signature libraries on RN either render in JS (slow, jittery) or pull in Skia (large bundle, extra runtime, fights you on layout). This one is fully native on both sides:
- Native rendering, native feel. Strokes are drawn by the OS's own ink pipeline — pressure-aware, sub-frame smooth, identical to what users get in the system Notes app.
- Tiny footprint. No Skia, no Reanimated, no WebView, no third-party native deps. Pure Swift on iOS, pure Kotlin on Android.
- Fabric-first. Built for the New Architecture from day one: codegen specs, view recycling-safe, deterministic prop diffing.
- Real exports. PNG / JPEG / SVG, base64 / file URI / system clipboard / photo library, plus round-trippable raw stroke data with timestamps.
- Drop-in DX. One
<SignatureInk />component, a typed imperative ref, sensible defaults. No setup beyondpod install.
Features
- True native rendering on both platforms
- Built-in toolbar (undo / redo / clear / copy) with configurable layout
- PNG / JPEG / SVG export — base64, file URI, photo library, system clipboard
- Replay animation with configurable speed
- Round-trippable stroke data (
getStrokeData/setStrokeData) - Apple Pencil exclusivity (iOS) / stylus-only mode (Android)
- PencilKit system tool picker (iOS)
- Configurable baseline (solid / dashed / dotted, width, offset, color)
- Transparent canvas + dark/light theming hooks
- Fabric-correct view recycling — no state leaks across screens, modals, lists
- Density-independent units everywhere (pen widths render the same physical size on every device)
Installation
yarn add react-native-signature-ink
# or
npm install react-native-signature-inkiOS
cd ios && pod installIf you plan to use saveToPhotoLibrary, add the permission key to your host app's Info.plist:
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save your signature to your photo library.</string>Android
No extra setup needed on API 29+. To support saveToPhotoLibrary on API ≤ 28, add the legacy storage permission to your host AndroidManifest.xml:
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />The library bundles its own FileProvider for clipboard support, so you don't need to declare one yourself.
Requirements
- React Native 0.75+ (New Architecture / Fabric enabled).
- iOS 13+.
- Android API 24+.
Quick start
import React, { useRef } from 'react';
import { Button, View } from 'react-native';
import { SignatureInk, type SignatureInkHandle } from 'react-native-signature-ink';
export function MySignaturePad() {
const ref = useRef<SignatureInkHandle>(null);
return (
<View style={{ flex: 1 }}>
<SignatureInk
ref={ref}
style={{ flex: 1 }}
showBaseline
showToolbar
penColor="#111"
onEnd={() => console.log('user lifted the pen')}
/>
<Button
title="Export"
onPress={async () => {
const base64 = await ref.current?.toBase64({ format: 'png', trim: true });
console.log(base64?.slice(0, 64));
}}
/>
</View>
);
}Props
All props are optional. Defaults are documented inline in src/types.ts.
Pen
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| penColor | ColorValue | #111 | Captured literally on iOS (dark-mode auto-inversion disabled). |
| penMinWidth | number | 1 | Width at the fastest pen velocity (pt on iOS, dp on Android). |
| penMaxWidth | number | 3 | Width at the slowest pen velocity. Same units. |
| velocityFilterWeight | number | 0.7 | Android only. 0..1 smoother weight. |
Canvas & baseline
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| backgroundColor | ColorValue | transparent | Pair with a light penColor for dark themes. |
| showBaseline | boolean | false | Show the signing line. |
| baselineColor | ColorValue | system gray @ 50% | |
| baselineStyle | 'solid' \| 'dashed' \| 'dotted' | 'dashed' | |
| baselineWidth | number | 0 | 0 = per-style auto value; any positive value overrides. |
| baselineOffsetFromBottom | number | 8 (iOS) / 16 (Android) | Honoured only when the toolbar is hidden; otherwise the baseline auto-anchors to the toolbar edge. |
Input policy
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| pencilOnly | boolean | false | iOS: Apple Pencil only. Android: TOOL_TYPE_STYLUS only. |
Toolbar
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| showToolbar | boolean | false | Render the built-in native toolbar. |
| toolbarPosition | 'top' \| 'bottom' | 'bottom' | |
| toolbarButtons | ('undo' \| 'redo' \| 'clear' \| 'copy')[] | all four | Order is preserved. |
| toolbarBackgroundColor | ColorValue | transparent | |
| toolbarTintColor | ColorValue | platform accent | Tints SF Symbols (iOS) / vector drawables (Android). |
| toolbarHeight | number | 44 (iOS) / 48 (Android) | |
| toolbarIconSpacing | number | 8 | Horizontal gap between buttons. |
iOS-only
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| showToolPicker | boolean | false | Attach PencilKit's system tool picker (PKToolPicker). |
| defaultInkType | 'pen' \| 'pencil' \| 'marker' \| 'monoline' \| 'fountainPen' \| 'watercolor' \| 'crayon' | 'pen' | iOS 14+ for the last four. |
Events
| Prop | Type | Fires |
| --- | --- | --- |
| onBegin | () => void | Finger / pencil down. |
| onEnd | () => void | Finger / pencil up. |
| onChange | (e: { isEmpty, strokeCount }) => void | Any drawing change. |
| onReplayProgress | (e: { progress: number }) => void | Per-frame while replay() runs. |
| onToolbarAction | (e: { action: 'undo' \| ... }) => void | After a toolbar button is tapped. |
Imperative API
Available via ref. All async methods return a Promise.
| Method | Returns | Notes |
| --- | --- | --- |
| clear() | void | Reversible via undo(). |
| undo() / redo() | void | No-op when the respective stack is empty. |
| copyToClipboard() | void | PNG into the system clipboard. |
| isEmpty() | Promise<boolean> | |
| toBase64(opts?) | Promise<string> | Raw base64, no data: prefix. |
| toFile(opts?) | Promise<string> | file:// URI in the app temp dir. |
| toSvg() | Promise<string> | SVG document string. |
| getStrokeData() | Promise<StrokeData> | JSON-serializable strokes (with timestamps + pressure on iOS). |
| setStrokeData(data) | void | Replace canvas contents. |
| replay(opts?) | void | Animate existing strokes. |
| saveToPhotoLibrary(opts?) | Promise<{ granted, uri? }> | iOS prompts the permission UI on first use. |
opts for image methods: { format?: 'png' | 'jpeg', quality?: number, trim?: boolean }.
Guides
Exports & clipboard
// Base64 (no `data:` prefix; raw payload).
const png = await ref.current?.toBase64({ format: 'png', trim: true });
// File URI in the app's temporary directory.
const fileUri = await ref.current?.toFile({ format: 'jpeg', quality: 0.85 });
// SVG with embedded paths.
const svg = await ref.current?.toSvg();
// PNG into the system clipboard. On Android this goes through the
// library's bundled FileProvider — no setup required on the host app.
ref.current?.copyToClipboard();trim: true crops to the strokes' bounding box (plus a 2pt anti-alias inset). Defaults to false for toBase64 / toFile / toSvg, true for copyToClipboard and saveToPhotoLibrary.
Saving to the photo library
const result = await ref.current?.saveToPhotoLibrary({ format: 'png' });
if (!result.granted) {
// iOS user denied the "Add Photos" prompt.
}- iOS: prompts the system "Add Photos" permission UI the first time. The host app must declare
NSPhotoLibraryAddUsageDescriptioninInfo.plistor iOS will crash the process. - Android: writes into
Pictures/Signatures/via MediaStore. API 29+ needs no runtime permission; API ≤ 28 requiresWRITE_EXTERNAL_STORAGE. The promise resolves with the insertedcontent://URI.
Stroke data round-trip
const data = await ref.current?.getStrokeData();
// ... persist, transmit, edit ...
ref.current?.setStrokeData(data);The format is StrokePoint[][]. Every point has { x, y, t }; iOS additionally captures pressure, azimuth, altitude, and per-point size. Unknown fields are ignored on setStrokeData, so payloads round-trip cleanly across platforms.
Replay animation
ref.current?.replay({ speed: 1.5 }); // 1.5× natural paceSpeed is clamped to a minimum of 0.05. Any new stroke (or another replay() call) cancels the running animation.
Theming (dark / light)
The canvas defaults to transparent — the parent view's background shows through. For a dark theme:
<SignatureInk
backgroundColor="#0c0c0c"
penColor="#ffffff"
baselineColor="rgba(255,255,255,0.4)"
toolbarTintColor="#ffffff"
/>iOS pins the underlying PKCanvasView to a light trait collection so user-set ink colors render literally (no PencilKit dark-mode auto-inversion surprises).
Apple Pencil-only
<SignatureInk pencilOnly />On iOS, finger touches are silently dropped (PKCanvasViewDrawingPolicy.pencilOnly). On Android, only events with MotionEvent.TOOL_TYPE_STYLUS are accepted.
PencilKit tool picker (iOS)
<SignatureInk showToolPicker defaultInkType="fountainPen" />Attaches the system PKToolPicker so the user can pick ink type, color, width, and switch between pen / eraser. Silently a no-op on Android. Tool-picker state is reset on view recycling so it never leaks across screens.
Architecture
The library is split along the codegen line: a TypeScript Fabric spec, two thin host wrappers, and one self-contained native rendering surface per platform.
┌────────────────────────────────────┐
│ src/SignatureInk.tsx │
│ • Promise/request-id back-channel │
│ • Public types (./types.ts) │
└────────────────┬───────────────────┘
│
┌────────────────▼───────────────────┐
│ src/SignatureInkViewNativeComponent.ts (codegen) │
└─────────────┬──────────────────────┘
│
┌───────────────────┴────────────────────┐
▼ ▼
┌────────────────────────────────┐ ┌─────────────────────────────────┐
│ ios/SignatureInkView.mm │ │ android/.../SignatureInkView.kt │
│ (Fabric host, prop diff) │ │ (Fabric host, synchronous layout)│
└──────────────┬─────────────────┘ └──────────────┬──────────────────┘
▼ ▼
┌────────────────────────────────┐ ┌─────────────────────────────────┐
│ ios/SignatureInkSurface.swift │ │ android/.../SignatureCanvasView │
│ • PKCanvasView (PencilKit) │ │ • Velocity-Bezier ink algorithm │
│ • PKToolPicker │ │ (port of gcacace/warting) │
│ • PKDrawing.image(...) export │ │ • Offscreen Bitmap → PNG/JPEG │
└────────────────────────────────┘ └─────────────────────────────────┘iOS
- Strokes are rendered by
PKCanvasView, Apple's first-party ink engine — same one used by Notes and Markup. Pressure, tilt, and azimuth all flow through unchanged. - Exports go through
PKDrawing.image(from:scale:), forced into a light trait collection so dark-mode hosts don't auto-invert ink in the output. - The Fabric host (
ios/SignatureInkView.mm) does per-prop diffing and forwards to a Swift surface (ios/SignatureInkSurface.swift). The Obj-C++ ↔ Swift split keeps PencilKit types out of the Obj-C++ header (PencilKit isn't visible there). - View recycling is explicitly handled: every
@objc public varis reset to its declared default inprepareForReuse, thePKCanvasViewis replaced (not just.drawing = …), and thePKToolPickeris force-detached so it doesn't reappear on the next screen.
Android
- Strokes are rendered by a hand-tuned velocity-Bezier algorithm (port of gcacace via warting) drawing into an offscreen
Bitmap. Width tapers with pen speed; the bitmap doubles as the export source so PNG/JPEG/SVG are instant. - Pen widths, baseline width, and
baselineOffsetFromBottomare stored in dp internally and converted to raw pixels at every draw site — so apenMaxWidth={3}renders at the same physical thickness across 1×/2×/3× densities and matches iOS visually. - Layout is performed synchronously on prop change (
applyChildLayout()measures and positions the canvas + toolbar children directly), because Fabric on Android silently swallowsrequestLayout()calls from native descendants. - Clipboard exports go through a bundled
FileProvidersocontent://URIs can be shared cross-process withoutFileUriExposedException.
JavaScript
- The codegen spec (
src/SignatureInkViewNativeComponent.ts) is the single source of truth for props, commands, and event payload shapes — both platforms generate their Fabric glue from it. - The high-level wrapper (
src/SignatureInk.tsx) owns a request-id ⇄ Promise map: each async command stamps a unique id, the native side echoes it back on the genericonResultevent, and the wrapper resolves the matching pending Promise. One generic event channel keeps the codegen surface narrow.
For known gotchas, build commands, and contribution conventions, see AGENTS.md and CONTRIBUTING.md.
Contributing
License
MIT
