react-native-body-parts-anatomy
v1.2.0
Published
Interactive React Native body diagram — 317 individually tappable muscle fragments across 23 muscle groups, male/female, front/back. Pinch-zoom, pan, whole-muscle mode, fully themeable, zero native code.
Maintainers
Readme
react-native-body-parts-anatomy
An interactive human body diagram for React Native where every muscle is individually addressable — 23 muscle groups split into 317 separately tappable SVG fragments, across male/female and front/back. Zero native code.
▶ Try it live — one click opens a Snack running the full demo, with the dependencies and the iOS preview already set up. Nothing to install, nothing to paste.
Use the iOS or Android tab, not Web. Snack opens on Web by default, and selection does not work there — see Platform support.
In the picker above, two separate abdominal fragments are selected — not the whole abdomen. That per-fragment granularity is the point of this library.
Why this library
Most React Native body maps expose one tappable path per muscle group. That is the right model for "which muscle did you train?" and the wrong one as soon as you need to know where within a muscle something happened — which is exactly what pain tracking, physiotherapy, injury logs, and symptom diaries are made of.
| Capability | What you get |
|---|---|
| Tappable targets | 317 fragments across 23 muscle groups — abs alone is 8 separate targets per gender on the front view |
| Two selection models | Fragment mode (default) addresses every piece individually; selectByGroup swaps in whole-muscle slugs so one tap selects the entire muscle |
| Sub-muscle location | Every fragment carries a derived side (left/right) and axis (upper/middle/lower/inner/center/outer), so you can label "lower-left abs" without a lookup table of your own |
| Views | Male/female × front/back — 4 combinations |
| Zoom & pan | Pinch, drag-while-zoomed, and step +/− buttons, built in |
| Selection accuracy | Selection commits on a tap, so a drag or scroll starting over a muscle never records it — see Interaction model |
| Theming | Every colour is a prop. No bundled theme system, no i18n framework assumed |
| Dependencies | Zero native code — pure JS/TSX over react-native-svg, react-native-gesture-handler, and react-native-reanimated |
| Accessibility | Every muscle is individually reachable and selectable with VoiceOver/TalkBack, announced by name and selected state. Honors OS Reduce Motion. See Accessibility |
When not to use it: if you only need "which muscle group", a simpler body map is a lighter dependency. Reach for this one when the sub-region actually matters.
Installation
npm install react-native-body-parts-anatomy
npx expo install react-native-svg react-native-gesture-handler react-native-reanimatedRequired setup
Wrap your app root in GestureHandlerRootView. Both pinch/pan and tap selection run
through a GestureDetector, which throws at runtime without it:
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* ...your app... */}
</GestureHandlerRootView>
);
}You can skip this only if every diagram you render is read-only — that is, you never pass
onFragmentPress. In that case no gesture detector is mounted at all. Note this depends on
onFragmentPress, not on zoomable: a zoomable={false} diagram that is still tappable
needs GestureHandlerRootView.
On the bare React Native CLI (not Expo), also follow each peer's own native setup —
notably react-native-reanimated's Babel plugin. Those are the peers' setup steps, not this
package's.
Reanimated v4 users: Reanimated 4 moved worklets into a separate
react-native-workletspackage and requires the New Architecture. This package never importsreact-native-workletsdirectly, so it isn't declared as a peer here — but you must satisfy Reanimated's own peer requirement, and keep a singlereact-native-workletsversion in your tree. A mismatch between the installed JS version and the Babel plugin version fails at startup with[Worklets] Mismatch between JavaScript code version and Worklets Babel plugin version.
Interaction model
Selection is committed by a tap gesture, not by the raw touch-down on an SVG path. This matters because a body diagram is usually both a picker and something you drag:
- Tap (finger travels <10pt, released within 1.5s) →
onFragmentPress(slug). - Drag starting on a fragment → pans the zoomed diagram, or scrolls the parent list, and selects nothing.
- Tap on empty space between fragments → selects nothing.
- At 1× the pan gesture never activates, so the diagram does not block a surrounding
ScrollView. It claims drags only once zoomed in.
Committing on touch-down instead (the obvious approach, and what most SVG body maps do) means
every attempt to pan or scroll silently records a body part the user never chose. Committing on
React Native's onPress is not an option either — Fabric cancels it on the slightest finger
movement, so real taps stop registering.
Quick start
Interactive picker
import { useState } from 'react';
import { BodySilhouette } from 'react-native-body-parts-anatomy';
function PainAreaPicker() {
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
const toggleFragment = (slug: string) => {
setSelectedSlugs((current) =>
current.includes(slug) ? current.filter((item) => item !== slug) : [...current, slug]
);
};
return (
<BodySilhouette
gender="male"
view="front"
selectedSlugs={selectedSlugs}
onFragmentPress={toggleFragment}
/>
);
}Read-only diagram
colorForSlug is called for every fragment, so a severity map needs no
selection state at all — return undefined for anything you have no colour for:
import { BodySilhouette } from 'react-native-body-parts-anatomy';
function SymptomDiagram({ colorBySlug }: { colorBySlug: Map<string, string> }) {
return (
<BodySilhouette
gender="female"
view="back"
zoomable={false}
selectedSlugs={[]}
colorForSlug={(slug) => colorBySlug.get(slug)}
/>
);
}Whole-muscle picker
Set selectByGroup when per-piece granularity is more than you need. Selection identifiers
become muscle-group slugs ('abs', 'chest') instead of fragment slugs, and every piece of a
muscle commits the same identifier — so toggling works no matter which piece was tapped:
function MusclePicker() {
const [groups, setGroups] = useState<string[]>([]);
return (
<BodySilhouette
gender="male"
view="front"
selectByGroup
selectedSlugs={groups}
onFragmentPress={(groupSlug) => {
setGroups((current) =>
current.includes(groupSlug)
? current.filter((item) => item !== groupSlug)
: [...current, groupSlug]
);
}}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| gender | 'male' \| 'female' | — (required) | |
| view | 'front' \| 'back' | — (required) | |
| selectedSlugs | readonly BodySlugInput[] | — (required) | Identifiers currently highlighted — fragment slugs by default, muscle-group slugs under selectByGroup. Plain string[] is accepted. |
| selectByGroup | boolean | false | Select whole muscles: onFragmentPress reports the tapped fragment's parent group. |
| colorForSlug | (slug: BodySlug) => string \| undefined | — | Fill for any fragment, selected or not. Receives the identifier matching the active mode — the group slug under selectByGroup, the fragment slug otherwise. Falls back to selectedFragmentColor / unselectedFragmentColor when it returns undefined. |
| onFragmentPress | (slug: string) => void | — | Omit for a read-only diagram. |
| zoomable | boolean | true | Enables pinch/pan/step-zoom and the floating +/− buttons. |
| style | StyleProp<ViewStyle> | — | Applied to the outer container after the built-in width/aspect-ratio rules, so you can set margins, a background, or borderRadius without a wrapper View. |
| testID | string | — | Also derives per-fragment IDs (${testID}-abs-male-front-1) and ${testID}-zoom-in / -zoom-out, so Detox/Maestro can target individual muscles. |
| accessibilityMode | 'auto' \| 'fragments' \| 'groups' \| 'diagram' \| 'none' | 'auto' | How the diagram presents itself to VoiceOver/TalkBack — see Accessibility. |
| accessibilityLabel | string | derived | Overrides the spoken description of the diagram as a whole. |
| fragmentAccessibilityHint | string | — | Spoken after each muscle's name, e.g. 'Double tap to select'. |
| labels | Partial<Record<GroupSlug, string>> | — | Localized muscle-group names, used for spoken labels. |
| hitTolerance | number | 0 | How far, in points, a tap may miss every fragment and still select the nearest one. Off by default; 12 suits the small fragments this library is made of. |
| onPressMiss | () => void | — | Called when a tap selected nothing, even after hitTolerance. |
| onZoomChange | (scale: number) => void | — | Called whenever the zoom level settles on a new value. |
| resetZoomOnViewChange | boolean | true | Reset zoom and pan when gender or view changes. |
| outlineColor | string | '#333333' | Skin-outline stroke color. |
| unselectedFragmentColor | string | '#D6D6D6' | Fill for fragments not in selectedSlugs. |
| selectedFragmentColor | string | '#2563EB' | Fallback fill when colorForSlug is omitted or returns undefined. |
| zoomButtonBackgroundColor | string | '#FFFFFF' | |
| zoomButtonBorderColor | string | '#D6D6D6' | |
| zoomButtonIconColor | string | '#333333' | |
| zoomInAccessibilityLabel | string | 'Zoom in' | |
| zoomOutAccessibilityLabel | string | 'Zoom out' | |
| minScale | number | 1 | |
| maxScale | number | 3 | |
| zoomStep | number | 0.5 | Scale delta per tap of a zoom button. |
| zoomAnimationDurationMs | number | 200 | |
Labels
Every slug has a display name, so you do not have to maintain a name table of your own:
import {
MUSCLE_GROUPS,
labelForSlug,
labelForFragment,
describeFragment,
} from 'react-native-body-parts-anatomy';
labelForFragment('abs-male-front-1'); // 'Upper left abdominals'
labelForFragment('chest-male-front-1'); // 'Left chest'
labelForFragment('head-male-front-1'); // 'Head'
// Handles either selection mode without being told which one produced the slug.
labelForSlug('abs'); // 'Abdominals'
labelForSlug('abs-male-front-1'); // 'Upper left abdominals'
// The pieces separately, when you want to build your own string.
describeFragment('abs-male-front-1');
// { group: 'abs', groupLabel: 'Abdominals', side: 'left', axis: 'upper',
// label: 'Upper left abdominals' }
// Everything you need for a legend, a picker, or a settings list.
MUSCLE_GROUPS; // [{ slug: 'abs', label: 'Abdominals', fragmentCount: 16 }, ...]To localize, pass an override map — you only supply what you translate:
const labels = { 'abs': 'Abdominaux', 'chest': 'Poitrine' };
labelForFragment('abs-male-front-1', labels); // 'Upper left abdominaux'Exported data & types
import {
BODY_REGIONS,
FRAGMENTS_BY_PARENT,
FRAGMENT_BY_SLUG,
GROUP_SLUGS,
fragmentSlugsForGroup,
selectionKeyForFragment,
isFragmentSelected,
} from 'react-native-body-parts-anatomy';
import type {
BodyFragment,
BodyRegionSet,
BodySlug,
FragmentSlug,
GroupSlug,
} from 'react-native-body-parts-anatomy';
// Every muscle-group slug, sorted — the valid keys under `selectByGroup`.
GROUP_SLUGS; // ['abs', 'adductors', 'ankles', ... 23 total]
// The full fragment tree, if you need to iterate it yourself.
BODY_REGIONS.male.front.fragments; // BodyFragment[]
// Reverse lookup: parent muscle name -> every fragment slug under it, across
// all four gender x view sets (e.g. legacy-data migration). For one view only,
// use `fragmentSlugsForGroup` below.
FRAGMENTS_BY_PARENT['abs']; // ["abs-male-front-1", "abs-female-front-1", ...]
// O(1) lookup by fragment slug.
const fragment = FRAGMENT_BY_SLUG['abs-male-front-3'];
// { slug, parentSlug: 'abs', side: 'left', axis: 'upper', pathData }
// Group helpers backing `selectByGroup`, exported for custom logic too:
fragmentSlugsForGroup('abs', 'male', 'front'); // ["abs-male-front-1", ..., "abs-male-front-8"]
selectionKeyForFragment('abs-male-front-3', true); // 'abs'
isFragmentSelected(fragment, ['abs'], true); // trueGroupSlug (23 members), FragmentSlug (317), and BodySlug (either) are
literal-union types, so your editor autocompletes slugs and a typo in a
switch or comparison is a compile error. Props still accept plain string,
so existing useState<string[]> code keeps working — and in development, a slug
that can never match is reported with a console warning naming the exact
problem.
Controlling zoom, and forgiving taps
Many fragments are only a few points across, so a near miss is the common case
rather than a deliberate tap on the background. hitTolerance snaps such a tap to
the nearest fragment; onPressMiss tells you when even that found nothing:
<BodySilhouette
gender="male"
view="front"
selectedSlugs={selected}
onFragmentPress={toggle}
hitTolerance={12}
onPressMiss={() => setHint('Try zooming in to pick a smaller area')}
/>It is off by default because "a tap on empty space selects nothing" is documented
behaviour that existing apps may rely on — see Interaction model.
Opt in and the guarantee becomes "a tap more than hitTolerance from any fragment
selects nothing".
Zoom is drivable from the outside through a ref:
import { useRef } from 'react';
import { BodySilhouette, type BodySilhouetteHandle } from 'react-native-body-parts-anatomy';
const diagram = useRef<BodySilhouetteHandle>(null);
<BodySilhouette ref={diagram} {...props} onZoomChange={setScale} />;
diagram.current?.resetZoom();
diagram.current?.zoomTo(2);
diagram.current?.zoomToGroup('chest'); // frames the muscle, within maxScale
diagram.current?.getScale();Every method takes an optional { animated } (default true, and always instant
under the OS Reduce Motion setting). zoomTo clamps to minScale/maxScale, and
zoomToGroup ignores a group that has no fragments in the current view rather than
throwing.
Accessibility
Every muscle is individually reachable and selectable with VoiceOver and TalkBack.
Announced names come from the same label helpers documented above, so a
localized labels map localizes the screen reader too.
<BodySilhouette
gender="male"
view="front"
selectedSlugs={selected}
onFragmentPress={toggle}
fragmentAccessibilityHint="Double tap to select"
/>accessibilityMode controls what a screen reader sees:
| Mode | Behaviour |
|---|---|
| auto (default) | groups under selectByGroup, fragments when tappable, diagram when read-only |
| fragments | Every individual piece is focusable and selectable |
| groups | One entry per muscle per side — "Left abdominals", "Right abdominals" — each selecting the whole muscle |
| diagram | A single element describing the whole picture. Right for read-only diagrams, where hundreds of stops would be noise |
| none | Hidden from assistive tech entirely |
How it works, and why it matters
react-native-svg's Path accepts only accessible, accessibilityLabel, and
testID — a role, a selected state, and an activation handler are all dropped. So a
screen reader could hear a muscle's name but never its state, and could never select
it.
This library therefore renders an overlay of real, positioned views carrying the full accessibility contract, mounted only while a screen reader is actually running. When one is not, the overlay does not exist and gesture behaviour is byte-for-byte what it was — that guarantee is covered by tests.
In groups mode each muscle is split into a left and a right rectangle rather than
one merged box. Merging a bilateral muscle produces a band spanning the entire body
— the "forearms" rectangle would cover the abdomen — so exploring by touch would
report whatever muscle owned the widest band instead of what is under the finger.
Platform support
| Platform | Renders | Selection | Zoom & pan |
|---|---|---|---|
| iOS | yes | yes | yes |
| Android | yes | yes | yes |
| Web (react-native-web) | yes | no | pinch/pan unverified |
Web is not supported. react-native-svg drops the onPressIn handler this library
uses to detect which fragment was touched — the browser console shows
Unknown event handler property 'onPressIn'. It will be ignored. — so a web build renders
the diagram correctly but is effectively read-only. This is a deliberate scope decision,
not a pending fix: use iOS or Android for interactive diagrams.
Regenerating the data (maintainers only)
src/data/bodyRegions.generated.ts is generated, not hand-written, by
scripts/generate-body-regions.mjs, which reads react-native-body-highlighter's raw SVG path
data. This script is repo-only — it is not part of the published package (see the files
allowlist in package.json) and pulls in svgpath as a devDependency only it uses.
npm run generate-dataThis temporarily installs [email protected] (--no-save), runs the codegen,
then leaves your package.json untouched. See THIRD_PARTY_NOTICES.md for why this package
depends on that library's data at build time but never at runtime.
License & attribution
MIT — see LICENSE.
The fragment path data is derived from react-native-body-highlighter (MIT, ELABBASSI Hicham) —
see THIRD_PARTY_NOTICES.md for the full attribution.
Contributing
Made with create-react-native-library
