Render a single React Native component in isolation, in your app's real provider shell, with chosen props/state, without booting the rest of the app. Uses Storybook-compatible CSF (*.stories.tsx) with zero setup (no .rnstorybook/, no codegen manifest, no Metro wrapper), and runs on both surfaces: the iOS/Android simulator (true native) and a browser (via react-native-web).
The differentiator: your real AppShell wraps every story (single source of truth, can't drift), and CSF decorators run inside it. Edit a story's args live on-device with the built-in controls panel.
pnpm add -D component-previewer # or npm i -D / yarn add -DPeer dependencies: react, react-native.
- Expose your real provider shell as one component (theme, query client, i18n, safe-area, …):
// AppProviders.tsx export function AppProviders({ children }) { /* your real providers */ }
- Write CSF stories (
*.stories.tsx), the exact Storybook format, no import from this tool needed:// Button.stories.tsx import { Button } from './Button'; export default { title: 'Button', component: Button, args: { label: 'Press me' } }; export const Primary = { args: { label: 'Buy now' } }; export const Disabled = { args: { disabled: true } }; // optional per-story providers run INSIDE your real shell: // export const Boxed = { args: {...}, decorators: [(Story) => <View>…<Story/></View>] };
- Wire the boot flag at your app root (~10 lines). Discovery is zero-codegen:
On a pure-Vite host, use
import { Previewer, fromRequireContext } from 'component-previewer'; const ctx = require.context('./', true, /\.stories\.tsx$/); // Metro / native + Expo web const PREVIEW = process.env.EXPO_PUBLIC_PREVIEW === '1'; const Shell = ({ children }) => <AppProviders>{children}</AppProviders>; export default function App() { return PREVIEW ? <Previewer stories={fromRequireContext(ctx)} shell={Shell} /> : <YourApp />; }
fromGlob(import.meta.glob('./**/*.stories.tsx', { eager: true }))instead offromRequireContext.
Open any story and tap Controls to edit its args on-device and re-render live (the Storybook-controls equivalent). Control kinds are inferred from the arg values with zero config:
| value type | control |
|---|---|
string |
text field |
string like #rrggbb |
color swatch + hex field |
number |
numeric field |
boolean |
switch |
Functions, plain objects, arrays, null/undefined are skipped by inference (passed through untouched). Declare optional CSF argTypes to force a control — a dropdown, a range, an explicit color, or an editable object/array:
export default {
title: 'Badge',
component: Badge,
args: { tone: 'neutral', accent: '#3366ff', data: { rows: 3 } },
argTypes: {
tone: { control: 'select', options: ['neutral', 'success', 'danger'] },
size: { control: 'range', min: 16, max: 256, step: 8 }, // clamped number field
accent: { control: 'color' }, // swatch + hex (also auto-inferred for #hex values)
data: { control: 'object' }, // JSON editor (objects + arrays), commits on valid JSON
},
};A Reset button restores the story's declared args; Copy logs the current args as JSON (pass onCopyArgs to wire a real clipboard). Edits never leak between stories.
Edited args persist per story id across list ↔ stage navigation and Fast Refresh, with zero dependencies (an in-memory session store). To also survive a cold reload, pass a persistence adapter — the package never imports a storage lib itself:
import { Previewer, type ArgsPersistence } from 'component-previewer';
// any synchronous get/set/remove around AsyncStorage, MMKV, a file, etc.
const persistence: ArgsPersistence = { get, set, remove };
<Previewer stories={…} shell={Shell} persistence={persistence} />Args are per-component props. Globals are toolbar-level params that vary the
shell across every story — the canonical case is light/dark. Declare them on
<Previewer globalTypes> and read them in your shell:
const Shell: ShellComponent = ({ children, globals }) => (
<AppProviders mode={globals?.theme === 'light' ? 'light' : 'dark'}>{children}</AppProviders>
);
<Previewer
stories={…}
shell={Shell}
globalTypes={{ theme: { options: ['dark', 'light'], default: 'dark' } }}
/>A Globals section appears at the top of the Controls panel; switching theme
re-renders the current story through the same shell. This is what removes the two
classic sources of duplication:
- No forked light/dark stories. One story, toggled — not
export const Light+export const Darkwith a hand-writtendarkdecorator. - No separate "lab" component with its own switchers. Variant axes (a card's
state, a chart'sdata) belong inargs/argTypesand drive the Controls panel; theme/locale belong inglobalTypes. There's nothing left for a bespoke lab harness to do.
The differentiator is that your real AppProviders is the shell, so the
preview can't drift from the app. Globals exist specifically so you never have to
fork that shell into a lighter "preview-only" copy to vary the theme — vary it
with a global instead. The package deliberately can't auto-detect a forked
shell (it can't see your app root), so the contract is the guard: import your
real providers as shell; parameterize with globalTypes; never re-author.
The repo ships an Expo example app.
Native (simulator / device):
cd example
EXPO_PUBLIC_PREVIEW=1 npx expo start # press i, or scan with Expo GoBoots into a searchable picker of discovered stories; selecting one renders it natively inside the real shell. This is the surface that shows Skia/glass/native modules (the example's BlurCard story renders a real native blur).
Browser (no simulator):
EXPO_PUBLIC_PREVIEW=1 npx expo start --webSame picker via react-native-web. Native modules can't render here (documented cap).
Without the flag, the app boots its normal screen instead. Deep-link straight into one story with EXPO_PUBLIC_PREVIEW_STORY=button--primary.
fromRequireContext(ctx)/fromGlob(glob)→StoryEntry[]— zero-codegen discovery.<Previewer stories={…} shell={…} initialStoryId? />— the picker + stage UI;initialStoryId(orEXPO_PUBLIC_PREVIEW_STORY) deep-links straight into one story.inferControls(args, argTypes?)→Control[]— pure; the control descriptors that drive the on-device panel.composeStory(entry, Shell?, args?)—Shell(outer) > decorators (inner) > story; pure. Passargsto render an override (the live-edit path).parseCsfModule(id, module)→StoryEntry[]— parse one CSF module to entries.
Exported types: StoryEntry, Meta, Story, Args, ArgType, ArgTypes, ControlKind, Control, Decorator, ShellComponent, RequireContext, GlobModules, CsfModule.
This is a pnpm workspace: the example/ app consumes the library source via Metro (bob's metro-config + the source export condition), so editing src/ hot-reloads in the example, no rebuild or repack.
pnpm install # once — links the workspace
pnpm lint # eslint (typescript-eslint)
pnpm typecheck # tsc --noEmit
pnpm test # vitest (CSF parsing, discovery, shell compose, control inference)
pnpm build # react-native-builder-bob → lib/module (ESM) + lib/typescript (.d.ts)Build/packaging: react-native-builder-bob (ESM module + typescript targets), exports map with a source condition, MIT licensed. See CONTRIBUTING.md.
- Core: CSF parsing, zero-codegen discovery (
require.context/import.meta.glob), registry, real-shell compose. Pure and unit-tested. - Native backend: boot-flag entry swap + in-app picker + real-shell render + deep-link, verified on the iOS simulator (including a native
expo-blurstory). - Web backend: renders the same CSF stories via react-native-web (native-module cap documented).
- Live props controls: value-inferred text/number/boolean/color + CSF
argTypesselect/range/color/object, on-device, live. Edits persist per story (session + optional cold-reload adapter); Copy/Reset. - Roadmap: TS-type-driven prop controls (auto-infer from prop types), VS Code extension, more CSF decorator edge cases.