@keyframerdev/inspector
v0.1.0-beta.5
Published
Live motion inspector for React Native Reanimated apps: see every animation as it runs, tune it from the Keyframer editor, write the numbers back to your source.
Maintainers
Readme
@keyframerdev/inspector
A motion debugger for React Native. A development-only Babel plugin tags every Reanimated animation, shared value, gesture callback and handler with where it is written; the app then reports what moved, what it cost and why, to a panel you scrub like a video, to a coding agent over MCP, and to CI.
- Record and scrub: every shared value, both threads' frame rates, every animation with its source line and stack, React commits, touches, screen changes and logs. Scrubbing moves the real app.
- Problems, not drops: each long frame traced to the handler, render or worklet that caused it, ranked, with where it is written and what to change.
- Tracks: every animation as a segment on the value it moves; click one to tune its numbers live and write them back to your source.
- Agents and CI: an MCP server, a fix loop that checks every fix by measuring again, and a
cicommand that fails a pull request that drops frames. - For testers: a record button on the device, one file (or a link) to send, opened and scrubbed like a live session.
Development builds only (or internal preview builds you opt in). With nothing connected, the instrumented calls are a straight pass-through.
Quick start (Expo dev tools, no account)
Install it as a dev dependency:
npm i -D @keyframerdev/inspectorAdd the Babel plugin, in development only. It must run before
react-native-worklets/plugin.Expo (
babel-preset-expoadds the worklets plugin itself, after yours):// babel.config.js module.exports = function (api) { api.cache.using(() => process.env.NODE_ENV) const dev = process.env.NODE_ENV !== 'production' return { presets: ['babel-preset-expo'], plugins: dev ? ['@keyframerdev/inspector/babel'] : [], } }A new Expo app has no
babel.config.js, andbabel-preset-expois only installed insideexpo. Install it at the top level when you add one, or Metro fails to start (Cannot read properties of undefined (reading 'transformFile')):npx expo install -- --save-dev babel-preset-expoBare React Native (list the worklets plugin yourself, last):
module.exports = function (api) { const dev = api.env('development') return { presets: ['module:@react-native/babel-preset'], plugins: [...(dev ? ['@keyframerdev/inspector/babel'] : []), 'react-native-worklets/plugin'], } }Call site ids are file paths relative to Babel's
cwd(the project root). Pass['@keyframerdev/inspector/babel', { root: '/abs/path' }]to use another root.Connect, in development only (for example in your root layout):
if (__DEV__) { const { connectDevTools, registerReplay } = require('@keyframerdev/inspector/devtools') connectDevTools({ app: 'MyApp' }) // Optional: what Replay runs (re-mount a screen, re-fire an entrance). registerReplay(() => setReplayKey((k) => k + 1)) }Start Metro, press shift+m and open @keyframerdev/inspector. Expo finds the panel through the package's
expo-module.config.json; restart Metro after installing.
It talks over Metro's own connection: nothing leaves your machine, there is no code to pair, and it
works in Expo Go (JavaScript only, no native module, no config plugin). Node 22 or later for the
keyframer-inspect command.
The panel
- Header: the connected app (with its framework and OS), Record with a running timer, Replay, Run scenario (a split button: the last one, or pick), the session picker, and Open, Compare, Save, Share, Export and Clear.
- Timeline: one playhead over the whole recording, with frame drops per thread, animations, touches, screen changes and React commits. Drag to scrub: the app follows, back to the screen that was open. Shift-drag selects a part (what Export renders).
- Values: every shared value with a sparkline and its value at the playhead. Instances of one call
site (
enter ×6in a list) fold into one row; values that never moved hide behind "Show N unchanged". The ticked ones are what Record records (all by default). - Tracks: every animation as a segment on the value it moves, grouped by component:
withDelay(withSequence(...))drawn as its delay and steps, CSS and layout animations included, and a value that started again cutting off what it replaced. Click a segment to tune it. - Tune and Apply to code: a call's numbers (to value, duration, damping, stiffness, delay…) with sliders; the next run of that line uses them, and Apply to code rewrites those number literals in your source (formatting and comments stay; files outside the project are refused).
- Trace: every step in time order (animations with their stack, commits with why each component rendered, drops with what ran in them, gestures, logs) with search and filters by kind, thread and file. Every step with a location opens in your editor; so does ⌘-click on a timeline dot.
- Problems: the recording read as what to fix (below).
- Compare: two sessions side by side, before and after a change: frame rates, frames lost, render and task time, and the problems fixed, still there and new.
- Export: an MP4 or GIF, each frame replayed in the app and captured, with the touches drawn on it and the Keyframer mark. Share: a link instead of a file (below).
Drop a .kfrec.json anywhere on the panel to open it.
Screen capture
Export, captured frames and the device recorder's screenshots use what the app has installed, without
being told: react-native-view-shot (it ships in Expo Go) through its native module, else Skia
(@shopify/react-native-skia in a development build) drawing the view inside <MotionScan>. Pass
captureScreen to connectDevTools / connectInspector to choose one, or captureScreen: null to
turn capture off.
Recording on the device (for testers)
A tester who sees a glitch should not have to describe it, or screen-record it for the developer to step through frame by frame. Give them a record button: they tap it, use the app, tap again, and send one file (or a link). The developer opens it in the panel and scrubs it like a live recording, with the screenshots it took and the touches drawn over them.
import { isInstrumented, MotionScan } from '@keyframerdev/inspector/scan'
import { shareRecording } from '@keyframerdev/inspector/expo-share' // npx expo install expo-file-system expo-sharing
<MotionScan enabled={isInstrumented()} recorder={{ app: 'MyApp', share: shareRecording }}>
<App />
</MotionScan>While Metro runs (development, or expo start --tunnel on a network that keeps devices apart), a
copy is saved in the project under .keyframer/recordings/ (git-ignored by default: recordings carry
screenshots), and the panel's Open menu lists them. shareOnlyWithoutMetro: true skips the share
sheet in that case, so it only shows for testers. Motion Scan's pill sits top right; corner="bottom-left"
(or any corner) moves it off a navigation header.
Touches. With Motion Scan around the app, every touch is recorded. A gesture handler that takes a
touch over (a pan once it activates) cancels it for React Native, so the Babel plugin also wraps the
inline callbacks of Gesture.Pan(), Gesture.Tap() and the rest: each one reports where the finger
is and which gesture ran, and the trace joins them into one drag ("Pan in SwipeCard"). Exports draw the
fingers on the video, the way iOS shows touches in a screen recording (a finger held still shows a
ring and a count-up timer: a long press). Shared values are read on the UI thread every frame, so a
recording plays back exactly as it looked.
A recording (.kfrec.json) holds every shared value, both threads' frame rates and long frames,
every animation with its source line and JS stack, React commits, touches, screen changes, app state,
the keyboard, warnings and errors, and a screenshot every 1.5 s (drops a screenshot caused are marked
as the recorder's own). With the panel open, a recording made on the device also lands there directly.
No Motion Scan? The same from code: startRecording(), stopRecording(note?), configureRecorder()
from @keyframerdev/inspector/record. Screen changes come from your navigation container:
import { trackNavigation } from '@keyframerdev/inspector/record'
trackNavigation(navigationRef) // React Navigation's ref, or expo-router's useNavigationContainerRef()With navigation tracked, playback, scrubbing and export also take the app back to the screen that was
open at that moment. A shared value created in a list row records once per row (appear, appear #2,
…) and each row replays its own. Lists are recorded by scroll offset, whether a
useAnimatedScrollHandler or a plain JS onScroll drives them, and replay scrolls the real list.
It needs the Babel plugin in the build the tester runs: a development build, or an internal preview build with the plugin turned on. The source lines of animations come from the plugin and always match; JS stacks are symbolicated by Metro, so they read best when the developer runs the same code. The panel scrubs the recording in the connected app when it is the same build, and shows the screenshots either way.
Preview builds (testers without Metro)
Keep the plugin in an internal build with an environment variable, and let Motion Scan follow it:
// babel.config.js
module.exports = function (api) {
api.cache.using(() => `${process.env.NODE_ENV}:${process.env.KEYFRAMER_INSPECTOR}`)
const inspect = process.env.NODE_ENV !== 'production' || process.env.KEYFRAMER_INSPECTOR === '1'
return { presets: ['babel-preset-expo'], plugins: inspect ? ['@keyframerdev/inspector/babel'] : [] }
}// eas.json: only the preview profile carries it, so store builds never do
{ "build": { "preview": { "distribution": "internal", "env": { "KEYFRAMER_INSPECTOR": "1" } } } }import { isInstrumented, MotionScan } from '@keyframerdev/inspector/scan'
<MotionScan enabled={isInstrumented()} recorder={recorder}>{children}</MotionScan>isInstrumented() is true only when the plugin is in the build, so one switch in the Babel config
decides. Animation call sites come from the plugin and need nothing else. A recording made without
Metro keeps its JS stacks raw (bundle positions); map them with the source map of that exact build:
npx keyframer-inspect symbolicate glitch.kfrec.json --map index.android.bundle.map
# → glitch.symbolicated.kfrec.json, with files and lines in every stackThe MCP server's open_recording takes the same map as sourceMap. With Hermes, use the map the
native build composes (the one a crash reporter uploads): on Android
android/app/build/generated/sourcemaps/react/release/index.android.bundle.map, on iOS the file
SOURCEMAP_FILE names in the "Bundle React Native code and images" phase. A build that ships plain JS
can use npx expo export --source-maps --no-bytecode from the same commit and environment.
Problems, not drops
A recording is read as the few things to fix, most costly first, in the panel's Problems tab and in every MCP report. Each long frame is traced to what ran in it, and each problem says where it is written and what to change:
- JS work: the event handler (
onPress in Glitches, app/glitches.tsx:86, ran 299 ms), effect or timer that blocked the JS thread. The Babel plugin times handlers and effects written in the app, and handlers that arrive from elsewhere (a prop, a hook's result, an import) where they are attached, named after the function (onPress (submit)); timers carry the stack that scheduled them. - Rendering: React commits charge library components (View, Text, navigators) to the app
component that rendered them, and say why it rendered (
props: tick,state,parent,mount). A component rendering in commit after commit is a render storm. - UI thread: the animated style, derived value, reaction, scroll handler, gesture callback or
scheduleOnUIworklet that took the frame, timed by the Babel plugin. - Also: a screen whose opening dropped frames, layout properties (width, height, margins) animated every frame, animations started while JS was blocked, warnings and errors.
Scenarios
A scenario is an interaction the app can replay on demand, so a problem can be reproduced and a fix
checked. It opens its screen and presses the same buttons a person would (found by testID), so
the same handlers run:
import { press, registerScenario, waitForTestId } from '@keyframerdev/inspector/devtools'
import { router } from 'expo-router'
registerScenario('glitch lab: block the JS thread', async () => {
router.navigate('/glitches')
await waitForTestId('glitch-block-js')
press('glitch-block-js')
})fire(testID, 'onValueChange', true) calls any other handler. The panel's Run scenario menu
records while one runs.
MCP server
A coding agent gets the same inspector through MCP: it records, runs scenarios, reads the problems with where they are and how to fix them, compares before and after, scrubs and captures frames, and runs the fix loop below.
With the desktop app (Keyframer Inspector, for React Native, Flutter, iOS and Android apps alike): turn it on in Settings, MCP Server (or from the menu bar), then:
claude mcp add --transport http keyframer-inspector http://127.0.0.1:8789/mcpSettings shows the same for Cursor, VS Code, Codex, Windsurf and Zed. Clients that launch a command
(Claude Desktop) run the app over stdio: "/Applications/Keyframer Inspector.app/Contents/MacOS/keyframer-inspector" --mcp-stdio.
Without it, the command in this package (React Native on Metro: it finds the Metro the app is on, 8081 to 8090, by itself):
claude mcp add keyframer-inspector -- npx keyframer-inspect mcp
claude mcp add keyframer-inspector -- npx keyframer-inspect mcp --metro http://localhost:8082--relay ws://127.0.0.1:8790 joins the desktop app's relay instead, and --http <port> serves the
Streamable HTTP transport on http://127.0.0.1:<port>/mcp (this machine only; requests from web pages
are refused) instead of stdio. Through the relay, select_app picks which connected app the tools
inspect.
The loop it is built for: run_scenario (or record while someone uses the app) reports the problems;
the agent changes the code; run_scenario again; compare says what got better, what is gone and what
is still there. Or hand it the whole loop: check and recheck, or the fix-motion-problems prompt.
| Tool | Does |
| --- | --- |
| inspector_status | The connected app, its scenarios, the recorded sessions and its shared values |
| select_app | Through the desktop relay: which connected app the tools inspect |
| record | Records everything for durationMs (optionally running the app's replay) and reports the problems |
| run_scenario | Records while one of the app's scenarios runs, and reports the problems |
| compare | Two sessions side by side: frame rates, frames lost, rendering, JS and worklet time, problems gone, still there, new |
| get_trace | A session as a timeline: drops, JS tasks, worklet frames, commits with why, animations with stacks, logs |
| scrub, play | Show a moment of the recording in the app, or play it back |
| capture_frame | A screenshot of the app, optionally at a moment of the recording |
| export_recording | GIF, or MP4 with ffmpeg on the PATH |
| replay | Runs the app's registered replay |
| list_recordings | The recordings saved in the project (or the desktop app's library) |
| open_recording, save_recording | Read a recording file from a tester (then every tool works on it; sourceMap for a preview build), or save one |
| get_screenshot | A screenshot a device recording took |
| check, recheck | The fix loop's two steps (below) |
Fix loop
The inspector can drive a coding agent through fixing what it finds, and check every fix by
measuring again. Three ways in: the MCP prompt, the two MCP tools it uses, and keyframer-inspect fix.
The MCP prompt. fix-motion-problems (Claude Code lists it as
/mcp__keyframer-inspector__fix-motion-problems) walks the agent through the loop: check, take the
top problem, open its where, change the code, let Fast Refresh (or hot reload, or a rebuild) apply
it, recheck, repeat. It stops when recheck says done, after 2 rounds without progress, or after
maxRounds (5). Its rules: never delete or disable an animation to make a problem go away, and never
touch the inspector's own setup. Arguments: scenarios, recording, maxRounds.
The tools.
checkmeasures the app and keeps the result as the loop's baseline: the app's scenarios (all, orscenarios), or a recording (recording: a.kfrec.json, orcurrentfor the last one;sourceMapfor a preview build). It returns the problems ranked, each with a ref (P1…),where, evidence, the fix, how it will be verified, and the motion that must keep working.recheckmeasures the same thing the same way and returns Fixed, Still there, New, Removed and Not verifiable, the numbers before and after, anddone: true|falsewith the reason.
The command. keyframer-inspect fix runs the whole loop with Claude Code or Codex, without a
person at the keyboard:
npx keyframer-inspect fix --agent claude --scenario "glitch lab: block the JS thread" --max-rounds 3
npx keyframer-inspect fix --agent codex --recording .keyframer/recordings/x.kfrec.json --map main.map
npx keyframer-inspect fix --agent claude --relay ws://127.0.0.1:8790 --rebuild "flutter build ios --simulator && ..."Each round it checks, hands the agent a prompt with the problems (location, evidence, fix, the app's
framework and thread names), waits (--wait, 3 s, for Fast Refresh; or runs --rebuild and waits for
the app to reconnect), and rechecks. It ends with a summary (rounds, Fixed, Still there, New, Removed,
Not verifiable) and git diff --stat. Exit codes: 0 done, 1 not done, 2 a setup error.
| Flag | Default | |
| --- | --- | --- |
| --agent claude\|codex | claude | Claude Code runs as claude -p with --tools and --allowedTools set to Read, Edit, Write, Glob and Grep (no shell unless --allow-bash) and --permission-mode acceptEdits. Codex runs as codex exec --sandbox workspace-write -C <root> -: it has a shell, inside its sandbox (the project only, no network) |
| --agent-cmd "<cmd>" | | Any other agent: a shell command that gets the prompt on stdin and in $KEYFRAMER_FIX_PROMPT_FILE |
| --scenario <name> | all | One name as written (repeat for more); --scenarios a,b takes a list |
| --recording <file>, --map <file> | | Check a recording instead; --save-scenario <file> writes a scenario stub made from it |
| --max-rounds N | 5 | |
| --runs N, --warmup N | 3, 1 | Measured runs (the median counts) and runs thrown away first |
| --no-reload | | Do not reload the app before each scenario |
| --rebuild "<cmd>", --wait MS | , 3000 | How a change reaches the app |
| --metro <url> or --relay <url>, --root <dir> | | As for mcp and ci |
Metro must watch files for a change to reach the app: CI=1 npx expo start turns watching off, so a
fix loop against it measures the old code forever.
Recording mode. A recording's problems are read straight from the file (raw stacks mapped with
--map / sourceMap). Each is then verified the best way there is, and never claimed further:
- a registered scenario that reproduces it (one probe run of each: the same problem, on the same screen) is measured and re-run by recheck;
- otherwise, for UI-thread, rendering and layout problems, the recording is played back into the app
while recording again (
playbackplusrecord; recorded values are matched to the app's current ones by file, component and name, since lines move as code is edited). Playback writes values and presses nothing, so a handler that blocked the app thread does not run again: those are not verified this way; - otherwise it is Not verifiable, with why. A loop can be done with problems left Not verifiable: it says so, and they are for a person to check.
When problems have no scenario, check offers a stub (scenarioStub / --save-scenario): the screen
the recording opened on and each gesture with the handler it ran, in order, with the waits between
them. A recording does not know testIDs: the TODOs say which views need one.
Guards, so the loop cannot fool itself:
- Removed, not fixed. A problem counts as Fixed only when the animations it belongs to (the ones
that started while it happened, matched by file, component and what they move, without line numbers)
still start in the recheck, and its values still move. If they stopped, it is Removed, which keeps
donefalse and goes back to the agent first ("put it back and fix the cost instead"). Any animation or value that stopped anywhere in a measured scenario keepsdonefalse too. - Noise. Every measurement is ci's: the median of N runs, problems seen in at least half the runs,
ci's tolerances for new problems and numbers, plus one warm-up run thrown away. Each scenario starts
from a clean state: the
reloadmessage (React Native: Expo'sreloadAppAsync, elseDevSettings.reload(), development builds only; the app advertises thereloadcapability and says hello again) or, for an app without it, its replay. Two rounds without progress stop the loop. - Build type. The app's
buildis reported, and a debug build's frame slack is doubled (see CI). - Its own setup.
fixstops when the agent changes the inspector's setup (its imports,connectDevTools,registerScenario, the Babel config,.keyframer/). - Review.
fixnever commits, pushes or branches. It prints the diff stat and says to review it.
CI
keyframer-inspect ci runs the app's scenarios with nobody watching, compares them with a baseline
committed next to the code, and fails a pull request that adds a problem or loses more frames:
npx keyframer-inspect ci --update-baseline # records .keyframer/baseline.json: commit it
npx keyframer-inspect ci # compares; exit 1 on a regressionIt waits for the app to connect through Metro (--timeout, 120 s), runs each scenario --runs
times (3) and keeps the median of each number, so one noisy run does not fail a build. Per scenario
the baseline keeps the frames lost on each of the app's threads, the longest frame and the problems
(id and severity). A comparison says, per scenario, what was fixed, what is still there, what is new
and how each number moved. Problems are matched by id, then by id without line numbers, so code moving
above a problem does not make it new. A new problem that lost no more frames than the slack (a screen
opening that drops a frame one run and not the next) is listed but not counted, unless it is high
severity. The defaults come from running unchanged code: frames lost moved by up to 20% between runs.
| Flag | Default | |
| --- | --- | --- |
| --metro <url> | 8081-8090 | The Metro the app is on |
| --root <dir> | . | The app's project root |
| --scenarios a,b | all | Which scenarios to run |
| --runs N | 3 | Runs per scenario; the median counts |
| --baseline <file> | <root>/.keyframer/baseline.json | |
| --update-baseline | | Record instead of compare (scenarios not run keep their entries) |
| --max-new-problems N | 0 | New problems allowed |
| --min-severity low\|medium\|high | low | Only new problems this severe count |
| --frames-lost-tolerance 25%, --frames-lost-slack 3 | | Frames lost may grow by 25% or 3 frames, whichever is more |
| --longest-frame-tolerance 25%, --longest-frame-slack 50 | | The longest frame may grow by 25% or 50 ms |
| --format text\|json\|github\|junit | text | github adds ::error annotations at each new problem's line and a job summary |
| --out <file> | stdout | Where the report goes |
| --warmup N | 1 | Runs per scenario thrown away before the measured ones |
| --no-reload | | Do not reload the app before each scenario. By default it does, so every scenario starts clean (an app that cannot reload runs its replay) |
Apps say how they were built (build in their hello: React Native sends debug when __DEV__ is
on). For a debug build the frame slack (--frames-lost-slack, --longest-frame-slack) is doubled
(DEBUG_BUILD_FACTOR, a conservative allowance for dev-mode JS and unoptimized code) and the report
says the numbers are inflated: the problems are real, the frame times are not a release build's.
Screens a stack keeps mounted under the one on top keep running: a loop or a component that renders
every frame there shows up in every later scenario. Run loops only while their screen is focused
(useIsFocused or useFocusEffect from expo-router or React Navigation); ci also reloads the app
before each scenario and throws away one warm-up run by default. On the
playground, unchanged code compared with its baseline three times: 7 of 11 scenarios flagged each
time with a glitch-lab bar that re-rendered every frame behind the other screens; 1, 1 and 2 once it
ran only while focused; none with the reload and the warm-up as well. A baseline recorded before they
became the default (--no-reload --warmup 0) should be recorded again.
Exit codes: 0 no regression, 1 a regression or a scenario that failed, 2 a setup error (no app, no baseline, an unknown scenario). Record the baseline on the same kind of machine CI runs on: a laptop and a CI runner do not lose the same frames.
A GitHub Actions job on a macOS runner, with Expo Go on a simulator:
name: Animations
on: pull_request
jobs:
inspector:
runs-on: macos-15
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- name: Boot a simulator
run: |
UDID=$(xcrun simctl create ci "iPhone 16 Pro")
xcrun simctl boot "$UDID"
# exp:// links open Expo Go without asking.
xcrun simctl spawn "$UDID" defaults write com.apple.launchservices.schemeapproval \
"com.apple.CoreSimulator.CoreSimulatorBridge-->exp" -string host.exp.Exponent
- name: Start the app
# --ios installs Expo Go on the booted simulator and opens the app. With a development
# build: xcrun simctl install booted <App.app>, then open its URL with xcrun simctl openurl.
run: CI=1 npx expo start --ios --port 8081 > metro.log 2>&1 &
- name: Check the scenarios
run: npx keyframer-inspect ci --metro http://localhost:8081 --format github --out inspector.txt
- if: always()
uses: actions/upload-artifact@v4
with:
name: inspector
path: |
inspector.txt
metro.logShare links
Send a link instead of a file: the panel's Share uploads the recording to your Keyframer account
and gives you keyframer.dev/r/<id>, which opens it read-only in the same panel, in the browser.
Before anything leaves your machine it is redacted: log messages masked (emails, long numbers, tokens,
secrets in URLs), navigation params dropped, screenshots downscaled, the focused text input blurred.
The dialog shows what goes, and lets you change it. Links expire after 30 days by default.
Sign in once from the dialog (it opens keyframer.dev to approve the panel), or paste a kf_ API key
(Keyframer: Profile, API keys). On the device, a tester's recorder can offer "Share link" next to
"Share file":
configureRecorder({ shareLinks: { token: () => SecureStore.getItemAsync('keyframer-token') } })token is a Keyframer session token or a kf_ key. A key built into an app is readable by anyone who
has the build and can do everything its account can: use one only in internal builds, or fetch it
from your own backend for the person who is signed in.
Keyframer editor (pairing room)
The same app can also join a pairing room on the Keyframer API, for the Keyframer editor's Inspector
panel: tune motion there with a visual preview, open a screen as nodes, and write the numbers back with
keyframer-inspect next to your source.
if (__DEV__) {
const { connectInspector, registerReplay } = require('@keyframerdev/inspector/native')
connectInspector({ apiUrl: 'https://api.keyframer.dev', code: 'ABC234', app: 'MyApp' })
registerReplay(() => setReplayKey((k) => k + 1))
}code is the 6-character code the editor's Inspector panel shows (it gives you this snippet with the
code filled in). Against a local Keyframer API, use your machine's LAN IP for apiUrl on a device
(http://192.168.1.10:8787). Then, from the project root:
npx keyframer-inspect --code ABC234 --root .It talks to https://api.keyframer.dev by default (--api http://localhost:8787, or
KEYFRAMER_API_URL). "Apply to code" in the editor sends the tuned values, and it rewrites only those
number literals (formatting and comments stay) and refuses paths outside --root. On "Open as nodes"
the editor asks for the source files the animations live in, and it sends them: only .ts, .tsx,
.js and .jsx files under --root (real paths, so a symlink cannot reach outside it), up to 200 KB
each, and only to the room's editor. Nothing else in the project (.env, keys) is read.
The room
GET {api}/inspector/rooms/:code/ws?role=app|editor|cli (WebSocket). Codes are 6 characters from
ABCDEFGHJKLMNPQRSTUVWXYZ23456789. Editors must be signed in, and a room belongs to the first account
that opens it as an editor (other accounts are refused with close code 4003; the claim lapses 24 h after
its owner leaves). The app and the cli need only the code.
Relay rules live in src/room.ts (routeMessage), messages in @keyframerdev/types
(InspectorAppMessage, InspectorEditorMessage, InspectorCliMessage, InspectorPresenceMessage).
What the plugin reports
- Animations: every
withTiming/withSpring/withDelay/withSequence/withRepeat/withDecay/withClampcall, with its file, line, component, target shared value and numeric parameters, including those started inside worklets (gesture callbacks, scroll handlers). - Animations from props: Reanimated 4 CSS animations and transitions (
animationName,transitionPropertyon an animated element) and layout animations (entering,exiting,layout). - Values: every
useSharedValue, sampled on the UI thread every frame while recording;playbackwrites a recording back into them, so a drag replays exactly while you tune what reacts to it. - Work: event handlers and effects (timed on the JS thread), animated styles, derived values,
reactions, scroll handlers, gesture callbacks and
scheduleOnUIworklets (timed on the UI thread), and React commits with why each component rendered. - Live tweaks:
overridesapply the next time that line runs.
Commands
| Command | Does |
| --- | --- |
| keyframer-inspect --code <code> | Joins a pairing room: writes the editor's edits to disk, sends source files for "Open as nodes" |
| keyframer-inspect mcp | The MCP server (above) |
| keyframer-inspect ci | Runs the scenarios against a baseline (below) |
| keyframer-inspect fix | The fix loop with Claude Code or Codex (below) |
| keyframer-inspect symbolicate <file> --map <map> | Maps a preview build's raw stacks to source lines |
Entries
| Import | For |
|---|---|
| @keyframerdev/inspector/babel | the Babel plugin (CJS in dist/babel.cjs) |
| @keyframerdev/inspector/devtools | connectDevTools, registerReplay, registerScenario, press, fire, waitForTestId: the Expo dev tools panel |
| @keyframerdev/inspector/native | connectInspector + registerReplay for React Native (a pairing room or the desktop relay) |
| @keyframerdev/inspector/scan | <MotionScan>: an on-device overlay outlining what animates, by the frames it cost, with an optional record button; isInstrumented() |
| @keyframerdev/inspector/record | recording on the device: configureRecorder, startRecording, stopRecording, trackNavigation, markNote, shareRecordingLink |
| @keyframerdev/inspector/expo-share | shareRecording: the share sheet for a recording file (expo-file-system + expo-sharing) |
| @keyframerdev/inspector/runtime | what instrumented code imports |
| @keyframerdev/inspector/client | the inspector without React Native (node, tests) |
| @keyframerdev/inspector/room | room codes, URLs and relay rules |
| @keyframerdev/inspector/redact, /share | redaction and share-link uploads |
React Native resolves the react-native export condition to dist/; the monorepo (Vite, vitest, the
API) uses src/.
Other platforms
Flutter, iOS (UIKit) and Android (Views) SDKs speak the same protocol, and the Keyframer Inspector desktop app hosts the relay they connect to, with this panel inside and the MCP server built in. The SDKs, and the source of everything in this package that runs in your app, are public at keyframer-dev/inspector, where issues go too.
Scripts
bun run build · bun run test · bun run typecheck
License
Two licenses, file by file (LICENSE.md):
- MIT for everything that runs in your app: the Babel plugin, the runtime, Motion Scan, the recorder and the connection. Their source is public in keyframer-dev/inspector.
- FSL-1.1-MIT for the tools: the
keyframer-inspectCLI (MCP,ci,fix), the panel and the dev tools server. Use them for anything except a competing product; each version becomes MIT two years after its release.
Versions up to 0.1.0-beta.3 were entirely MIT.
