@outcode/bug-reporter-native
v2.1.1
Published
In-app bug reporter for React Native / Expo: a draggable floating button that captures the screen, lets users annotate it (draw, arrow, box, blur, text), set severity/type, and file to ClickUp or any custom backend.
Maintainers
Readme
@outcode/bug-reporter-native
In-app bug reporter for React Native & Expo. A draggable floating button captures the current screen, lets users annotate it (draw, arrow, box, blur, text), set a severity and type, and file a report to ClickUp or any custom backend.
Part of the OutCode Bug Reporter monorepo. For the web (React, Angular, Vue, Svelte, Solid, vanilla), see
@outcode/bug-reporter-web.
Features
- 🐛 Draggable floating button that snaps to the screen edge
- ✏️ Annotate — pen, arrow, box, blur/redact, and text on the captured screenshot
- 🧭 Auto-captured context — platform, OS, screen, density, orientation, language, color scheme… consent-free
- 🎨 Themeable — Indigo / Noir / Mint presets or your own tokens
- 📡 Offline retry queue, breadcrumb capture, screenshot compression
- 🧩 Pluggable backends — ClickUp out of the box, or any
repository/ HTTP endpoint - 📳 Shake to report — optional, sensor-agnostic, and inert until you wire an accelerometer
- 📦 ESM + CJS, fully typed; native peers are externalized (never bundled)
Install
npm install @outcode/bug-reporter-native @outcode/bug-reporter-core
# peers (use `npx expo install …` on Expo):
npm install react-native-svg react-native-view-shot react-native-safe-area-context# Optional — only for shake to report. Pick the one that matches your setup;
# the library installs neither, and without one shake is simply inert.
npm install react-native-sensors # bare React Native
npx expo install expo-sensors # ExpoRequires a dev build / bare workflow —
react-native-view-shotships native code that is not in the Expo Go sandbox. Screen capture silently returns nothing in Expo Go; run a dev build (expo run:ios/expo run:android/ EAS Build).
Quick start
Wrap your app so the reporter can screenshot the current screen, then mount the button:
import {
BugReporterButton,
BugReporterCaptureRefProvider,
BugReporterScreenCaptureView,
} from '@outcode/bug-reporter-native';
import { OutcodeBackendBugReporterRepository } from '@outcode/bug-reporter-core';
export default function App() {
return (
<BugReporterCaptureRefProvider>
<BugReporterScreenCaptureView style={{ flex: 1 }}>
{/* …your navigator / screens… */}
</BugReporterScreenCaptureView>
<BugReporterButton
config={{
appName: 'My Mobile App',
appVersion: '1.0.0',
theme: 'indigo',
// Recommended: reports go through the OutCode bug service, so no ClickUp token ships in the client.
repository: new OutcodeBackendBugReporterRepository({
endpoint: 'https://your-bug-service.example.com/api/bugs/ingest', // your bug service URL
apiKey, // a scoped `bug:create` key
targetKey, // the project routing key from the bot's "Bug Trends" tab
}),
// Internal/trusted builds can talk to ClickUp directly instead (ships the token in the client):
// repository: new ClickUpBugReporterRepository({ apiKey, listId }),
}}
/>
</BugReporterCaptureRefProvider>
);
}Reporting a bug while a modal or dropdown is open
In-tree overlays — dropdowns, menus, popovers a library renders inside your view tree — are
handled by mounting order. The button sits at elevation: 24 (the Material ceiling that dialogs and
menus use), so mount <BugReporterButton> last at your app root, as in
the example, and it
paints above them.
A host Modal is different, and no styling fixes it. React Native presents a Modal in a
separate native window above the entire app, and modals stack by presentation order — so nothing in
your view tree can rise above one. (Hosting the button in a transparent Modal of its own doesn't
work either: RN's modal view controller installs a plain full-screen UIView with no hitTest
override, so that window swallows every touch and your app becomes untappable — pointerEvents
can't give them back. On Android a visible Modal also captures the hardware back button.)
Use the ref instead: trigger the flow from inside your own modal, and the reporter's overlays — presented after yours — land on top:
import { useRef } from 'react';
import { BugReporterButton, type BugReporterButtonHandle } from '@outcode/bug-reporter-native';
const reporter = useRef<BugReporterButtonHandle>(null);
<BugReporterButton ref={reporter} config={config} />
// inside your own modal:
<Button title="Report a bug" onPress={() => reporter.current?.open()} />Note that native capture is view-based: it snapshots the view you wrapped in
BugReporterScreenCaptureView. A Modal lives in its own native window outside that view, so the
screenshot shows the screen behind your modal, not the modal itself. Describe the dialog in the
report, or wrap the modal's content in its own capture view if you need it in the shot.
Shake to report
Shaking the device opens the reporter, so a tester doesn't have to find the floating button — and a build can hide the button entirely and still be reportable.
React Native ships no accelerometer, so rather than force a native dependency on every consumer the sensor is pluggable. Install one of the optional peers above and pass the matching adapter:
import { rnSensorsAccelerometerProvider } from '@outcode/bug-reporter-native/react-native-sensors';
// Expo: import { expoAccelerometerProvider } from '@outcode/bug-reporter-native/expo-sensors';
<BugReporterButton config={config} accelerometerProvider={rnSensorsAccelerometerProvider} />Or register one once at startup, if you mount the button in several places:
import { setDefaultAccelerometerProvider } from '@outcode/bug-reporter-native';
import { rnSensorsAccelerometerProvider } from '@outcode/bug-reporter-native/react-native-sensors';
setDefaultAccelerometerProvider(rnSensorsAccelerometerProvider);With no provider, shake does nothing and the floating button remains the way in. Nothing throws over a missing sensor.
Shake-only builds
Pair it with showButton: false to ship the reporter with no visible bug button — reachable by a
gesture briefed testers know about, invisible to everyone else:
const config = { appName: 'My App', showButton: __DEV__, /* … */ };Shake is unavailable to anyone who can't shake the device, so it should never be the only way in —
keep a settings row that calls ref.current?.open().
Bring your own motion stream
AccelerometerProvider is just a subscribe function, so any source works. Samples must be in g
(≈1.0 at rest) — divide m/s² readings by the exported GRAVITY:
import { GRAVITY, type AccelerometerProvider } from '@outcode/bug-reporter-core';
const myProvider: AccelerometerProvider = (onSample, intervalMs) => {
const sub = mySensor.subscribe(({ x, y, z }) =>
onSample({ x: x / GRAVITY, y: y / GRAVITY, z: z / GRAVITY })
);
return { remove: () => sub.unsubscribe() };
};This is also the workaround if your bundler can't resolve the adapter subpaths (see below).
Tuning
config.shake holds sensitivity knobs — it is not an on/off switch (the provider is):
| Option | Default | |
| --- | --- | --- |
| threshold | 1.2 | How far past resting gravity a reading must swing to count as a jolt, in g. |
| minDurationMs | 1000 | How long the shaking must be sustained. |
| requiredJolts | 4 | Jolts needed within one run. |
| maxGapMs | 400 | A longer pause ends the run and starts a new one. |
| cooldownMs | 3000 | Quiet period after a reported shake. |
| joltGapMs | 100 | Minimum gap between counted jolts, so one peak isn't counted twice at 60Hz. |
| intervalMs | 60 | Sampling interval requested from the provider. |
Requiring a duration rather than just a count is what separates a shake from a knock: three jolts can land in 200ms when a phone is set down hard, so asking for a second of continuous shaking means the user has to mean it. The gap rule stops that second being assembled out of unrelated bumps.
The reporter subscribes only while the app is foregrounded, ignores samples while a report is on
screen, and mutes for cooldownMs when the flow closes — so the shake that dismissed it can't
reopen it.
React Native < 0.79: Metro only honours the
exportsmap whenunstable_enablePackageExportsis on, which became the default in 0.79. On older versions the adapter subpath resolves through a root re-export shim that ships with the package, so the import above still works. If your bundler resolves neither, use a custom provider as shown above.
WiFi / cellular context (optional)
The reporter auto-captures consent-free context. To add network type on native, inject it via
collectContext (e.g. with expo-network):
import * as Network from 'expo-network';
config={{
// …
collectContext: async () => {
const n = await Network.getNetworkStateAsync();
return { Network: n.type, Online: String(n.isConnected) };
},
}}Redaction
The blur tool paints an opaque block rather than a real blur, because a blur can sometimes be inverted and a password or a customer's name deserves better than that.
It also fails closed. Annotations are flattened into the screenshot before it is submitted, and if that flatten fails the original capture — which still shows what was redacted — is not sent as a fallback. The editor stays open with an error instead, and Continue retries. Reports with no redaction still fall back to the unflattened capture, since nothing was hidden to lose.
Theming
The three presets are a starting point, not the whole menu — any colour works:
<BugReporterButton config={{ appName: 'My App', theme: 'noir' }} /> // preset
<BugReporterButton config={{ appName: 'My App', theme: { accent: '#FF5C00' } }} /> // custom accent
<BugReporterButton config={{ appName: 'My App', theme: { preset: 'noir', accent: '#FF5C00' } }} />
<BugReporterButton config={{ appName: 'My App', fabColor: '#FF5C00' }} /> // just the buttonA custom accent is enough on its own: accentPress, ring and onAccent are
derived from it, so the pressed state, selection tints and the button's bug glyph
all follow — a light brand colour gets a dark icon rather than white-on-white. Set
any of those tokens explicitly to override the derived value.
fabColor recolours the floating button and its halo only, leaving the rest of the
flow on the theme accent. Both accept any hex or rgb() string; an unparseable
value falls back to the accent instead of throwing.
Every token is overridable — see BugReporterTheme in
@outcode/bug-reporter-core
for the full list.
Device brand and model
Reports include the device's brand and model automatically, with no extra dependency:
Brand name: Samsung
Model: SM-S921BOn Android these come from Platform.constants (Build.BRAND,
Build.MANUFACTURER, Build.MODEL). The brand is title-cased; the model is the
raw SKU, kept verbatim because mapping SKUs to marketing names ("Galaxy S24")
needs a lookup table that is stale on every device launch.
On iOS there is no machine identifier available to JavaScript, so you get
Apple plus the interface idiom (iPhone / iPad). If you need the real model,
supply it yourself — a host-provided value always wins:
import DeviceInfo from 'react-native-device-info';
<BugReporterButton
config={{
appName: 'My App',
deviceInfo: { brand: DeviceInfo.getBrandSync(), model: DeviceInfo.getModelSync() },
}}
/>Props
BugReporterButton:
| Prop | Type | Notes |
| --- | --- | --- |
| config | BugReporterConfig | Required. Full reference in the root README. |
| label? | string | Accessible label for the floating button (default 'Report a bug'). |
| captureRef? | CaptureViewRef | Explicit capture target. Usually unnecessary — BugReporterCaptureRefProvider supplies it. |
| accelerometerProvider? | AccelerometerProvider | Wire up shake-to-report. See Shake to report. |
Ref handle: open() and close() — see
Reporting a bug while a modal or dropdown is open.
Configuration
Full BugReporterConfig reference lives in the
root README.
License
MIT © OutCode Software
