@emmorts/body-muscles
| Install | |
|---|---|
npm install @emmorts/body-muscles |
|
| Latest Version: | 2.1.1 |
| License: | Apache-2.0 |
| Last Updated: | Oct 9, 2026 |
| Links: | GitHub · npm |
Body Muscles
An interactive, accessible SVG body map for the web.
You own the state — an intensity from 0 to 10 and a selected flag per region — and Body Muscles draws it, handles pointer and keyboard interaction, and labels every region. It is plain DOM, so it works with React, Vue, Svelte, Angular, or no framework at all.
- 89 anatomical regions across anterior and posterior views, with name, side, and group metadata
- Accessible by default — one tab stop, arrow-key navigation, toggle-button semantics, focus tooltips
- Typed identifiers — a misspelled region identifier is a compile error
- Selection helpers — pure functions that select a whole group or both sides of a region
- Themeable and localizable —
--bm-*CSS custom properties and replaceable labels - Data without a DOM —
/dataand/data.jsonentry points for servers and custom renderers - Zero dependencies — ESM, CommonJS, and a browser build (about 23 KB gzipped)
This is a maintained fork of vulovix/body-muscles — see Credits.
Installation
npm install @emmorts/body-muscles
pnpm add @emmorts/body-muscles
yarn add @emmorts/body-muscles
bun add @emmorts/body-muscles
[!NOTE] TypeScript consumers need TypeScript 5.0 or later: the declarations use const type parameters, which older compilers cannot parse even with
skipLibCheck. JavaScript needs no compiler, and the runtime supports Node 16+.
Without a build step
<!-- Native ES module -->
<script type="module">
import { BodyChart, ViewSide } from "https://esm.sh/@emmorts/body-muscles";
</script>
<!-- Classic script exposing a BodyMuscles global; also works from file:// -->
<script src="https://unpkg.com/@emmorts/body-muscles/dist/umd/body-muscles.umd.min.js"></script>
Quick start
import { BodyChart, ViewSide, type BodyState } from "@emmorts/body-muscles";
const container = document.querySelector<HTMLElement>("#body-map");
if (!container) throw new Error("Missing #body-map");
let state: BodyState = {
"biceps-left": { intensity: 7, selected: true },
"quads-right": { intensity: 3, selected: false },
};
const chart = new BodyChart(container, {
view: ViewSide.BOTH,
bodyState: state,
onMuscleClick(id) {
const current = state[id] ?? { intensity: 0, selected: false };
state = { ...state, [id]: { ...current, selected: !current.selected } };
chart.update({ bodyState: state });
},
});
// Later: switch views in place, and destroy the chart when its widget is removed.
chart.update({ view: ViewSide.FRONT });
chart.destroy();
The chart fills its container's width. Leave it mounted across page navigation so the browser's
back/forward cache can restore it; call destroy() only when you remove the widget.
Guides
State
update() merges options shallowly and ignores undefined values. bodyState, however, is
replaced as a whole: a region missing from the new mapping returns to intensity 0, unselected.
The chart never mutates your mapping and keeps no selection state of its own, so your application
remains the single source of truth.
// Keep every other region and change one.
chart.update({ bodyState: { ...state, "biceps-left": { intensity: 7, selected: true } } });
// Remove one region's state.
const { "biceps-left": removed, ...rest } = state;
chart.update({ bodyState: rest });
There is deliberately no separate patch method: a spread expresses both preservation and removal,
and a second entry point with different merge rules would be easy to mix up. Changing view or
interactive rebuilds the chart; every other update applies in place and keeps keyboard focus and
any open tooltip.
Intensity values
An intensity is a finite integer from 0 to 10. The constructor, update(), and
createBodyPartState() throw a descriptive error for fractions, negatives, values above 10, NaN,
or infinities, and a rejected update() applies none of the submitted options.
isValidIntensity(9); // true
isValidIntensity(9.5); // false
// Error: Invalid bodyState entry for "biceps-left": intensity 12. Expected an integer from 0 to 10.
chart.update({ bodyState: { "biceps-left": { intensity: 12, selected: true } } });
[!IMPORTANT] The chart holds your mapping by reference, so a rejected update cannot undo mutations you already made to an accepted object. Validate before mutating shared state, or submit a new mapping.
The colour helpers (resolveIntensityColor, createIntensityColorScale, getMuscleColor) never
throw: they round and clamp into 0–10, because they also render mappings mutated after the chart
accepted them.
Group and bilateral selection
Two pure helpers return a new state for a whole group, or for a region and its opposite side:
import { setBilateralSelection, setGroupSelection } from "@emmorts/body-muscles";
state = setGroupSelection(state, "Arms", "toggle");
state = setBilateralSelection(state, "biceps-right", "select");
chart.update({ bodyState: state });
- Actions are explicit:
"select","deselect", or"toggle". Toggle deselects a fully selected target and otherwise selects all of it. - Groups span both views. Pairs come from
MUSCLE_PAIRS, never identifier substitution; either side addresses the same pair, and central regions such asspineact alone. - Existing intensities and unrelated entries are kept. Selecting a missing entry creates
{ intensity: 0, selected: true }; deselecting a missing entry leaves it absent. - Inputs are never mutated. A no-op returns the same mapping, and unchanged entries are shared.
Typed identifiers
MuscleId is a union derived from the dataset, so state keys, callbacks, and helpers are checked at
compile time:
const state: BodyState = {
"biceps-left": { intensity: 7, selected: true }, // ok
"bicepz-left": { intensity: 7, selected: true }, // error: not a region identifier
};
Identifiers that only exist at runtime — URL parameters, stored state, user input — must be narrowed before they are used as keys:
const raw = new URLSearchParams(location.search).get("muscle") ?? "";
const state: BodyState = isMuscleId(raw) ? { [raw]: createBodyPartState(5) } : {};
const name = getMuscleDef(raw)?.name ?? "unknown"; // accepts any string
Identifiers follow {group}-{side} (biceps-left), {group}-{part}-{side}
(shoulder-front-left), or a single word for central regions (spine). Read side and group from
the metadata rather than parsing identifiers.
Accessibility
An interactive chart is one composite widget with a single tab stop. Each region is a toggle
button whose pressed state follows selected, so selection is never conveyed by colour alone, and
keyboard focus has its own high-contrast halo.
| Key | Action |
|---|---|
| Tab | Enter or leave the chart in one step |
| → ↓ | Next region |
| ← ↑ | Previous region |
| Home / End | First / last region |
| Enter / Space | Activate the focused region (fires onMuscleClick) |
| Esc | Dismiss the tooltip |
Set interactive: false for a display-only chart, announced as a single labelled image: regions
are not focusable or clickable, callbacks never fire, and no tooltip renders. Use ariaLabel to
name the chart in either mode.
[!NOTE] Verified with keyboard interaction, Chromium's accessibility tree, and automated audits. Screen-reader announcements and browse-mode behaviour have not been verified, and these checks do not establish full WCAG conformance.
Theming
Every chart style reads a --bm-* custom property with the built-in value as its fallback, so
nothing changes until you override one. Set them on the container or any ancestor:
.body-map {
--bm-max-height: 55vh;
--bm-region-stroke: #0f172a;
--bm-region-stroke-selected: #f8fafc;
--bm-region-inactive-opacity: 0.4;
--bm-tooltip-bg: rgb(15 23 42 / 0.95);
}
Selection draws its own outline and glow, and keyboard focus a separate dual-tone halo, so both stay distinguishable from any intensity palette.
All CSS custom properties
| Property | Applies to | Default |
|---|---|---|
--bm-padding |
Wrapper padding | 1rem |
--bm-max-height |
Chart height limit | 70vh |
--bm-max-width |
Width limit, single view | 400px |
--bm-max-width-both |
Width limit, BOTH view |
760px |
--bm-svg-shadow |
Chart drop shadow | drop-shadow(0 4px 20px rgba(0, 0, 0, 0.3)) |
--bm-transition-duration |
Transition timing | 200ms |
--bm-region-stroke |
Region outline | #1e293b |
--bm-region-stroke-width |
Region outline weight | 0.1 |
--bm-region-stroke-selected |
Selected outline | #ffffff |
--bm-region-stroke-width-selected |
Selected outline weight | 0.3 |
--bm-region-stroke-focus |
Keyboard-focus outline | #1d4ed8 |
--bm-region-stroke-width-focus |
Keyboard-focus outline weight | 0.5 |
--bm-region-focus-shadow |
Keyboard-focus halo | dual drop-shadow(…) ring |
--bm-region-active-shadow |
Selected or hovered filter | url(#glow) |
--bm-region-inactive-opacity |
Opacity of untouched regions | 0.6 |
--bm-background-fill |
Silhouette fill | #cbd5e1 |
--bm-background-opacity |
Silhouette opacity | 0.1 |
--bm-tooltip-bg |
Tooltip background | rgba(15, 23, 42, 0.92) |
--bm-tooltip-color |
Tooltip text | #f8fafc |
--bm-tooltip-padding |
Tooltip padding | 0.35rem 0.65rem |
--bm-tooltip-radius |
Tooltip corner radius | 0.5rem |
--bm-tooltip-font-size |
Tooltip font size | 0.75rem |
--bm-tooltip-font-weight |
Tooltip font weight | 500 |
--bm-tooltip-line-height |
Tooltip line height | 1.2 |
--bm-tooltip-shadow |
Tooltip shadow | two-layer box-shadow |
--bm-tooltip-border |
Tooltip border | 1px solid rgba(255, 255, 255, 0.15) |
--bm-tooltip-backdrop-filter |
Tooltip backdrop | blur(8px) |
--bm-view-label-color |
View label text | #64748b |
--bm-view-label-bg |
View label background | rgba(15, 23, 42, 0.5) |
--bm-view-label-padding |
View label padding | 0.25rem 0.75rem |
--bm-view-label-radius |
View label corner radius | 9999px |
--bm-view-label-font-size |
View label font size | 0.875rem |
Colour mapping
Region fill comes from the intensityColor resolver, which defaults to the INTENSITY_COLORS
palette (slate → yellow → orange → red). Build one from your own stops; intensities are rounded and
clamped into 0–10, and missing levels fall back to the default palette:
const chart = new BodyChart(container, {
view: ViewSide.FRONT,
bodyState: {},
intensityColor: createIntensityColorScale({ 0: "var(--app-rest, #e5e7eb)", 5: "#f59e0b", 10: "#dc2626" }),
});
A resolver may return any valid CSS colour, including var() expressions. The browser resolves them
on each region, so they follow theme changes without an update(). getAttribute("fill") returns
the expression; getComputedStyle(region).fill returns the resolved colour.
Reduced motion
Transitions are on by default. When the system requests reduced motion, chart and tooltip
transitions are disabled regardless of enableTransitions, and the preference is re-evaluated if it
changes at runtime. --bm-transition-duration changes timing but never re-enables motion.
Localization
Every rendered string comes from the optional labels members, falling back to English. A dedicated
option beats the matching member: ariaLabel over labels.chart, and tooltipFormatter over
labels.tooltip.
const labels: ChartLabels = {
chart: () => "Körperkarte",
regionName: (muscle) => GERMAN_NAMES[muscle.id] ?? muscle.name,
intensity: (value) => `Intensität ${value}`,
viewLabel: (view) => (view === ViewSide.FRONT ? "Vorderansicht" : "Rückansicht"),
};
chart.update({ labels: { ...labels, intensity: (value) => `${value} von 10` } });
| Member | Renders | Default |
|---|---|---|
chart(view) |
Accessible name of the chart | “Anterior body map view” and variants |
regionName(muscle) |
Base name used by labels, tooltip, and onMuscleClick |
muscle.name |
region(muscle, state) |
Complete accessible name of a region | name, plus “- intensity N” above 0 |
intensity(value) |
How an intensity is written | “intensity 7” |
tooltip(muscle, state) |
Tooltip content | name, plus “- intensity N” when state exists |
viewLabel(view) |
Overlay label for one side | “Anterior View” / “Posterior View” |
Like bodyState, labels is replaced as a whole and applied in place without dropping focus.
Explicitly passing labels refreshes every label, even when you mutate and resubmit the same object.
region, tooltip, and tooltipFormatter receive undefined for regions missing from state, and
the supplied object otherwise — including an explicit intensity 0.
Anatomy and terminology
The dataset describes regions, not a muscle-by-muscle inventory: alongside individual muscles
it includes areas such as head, nape, spine, and knee-left. Sides are the subject's own,
as in an anatomy atlas, so biceps-left is drawn on the viewer's right.
MUSCLE_METADATA["biceps-left"];
// { id: "biceps-left", name: "Left Biceps", view: "FRONT", side: "left", group: "Arms" }
getMuscleMetadata(input); // MuscleMetadata | undefined, for untrusted strings
MUSCLE_GROUPS is checked at compile time against the dataset and verified at build time to cover
every region exactly once. extractMuscleGroup(id) returns the identifier prefix ("biceps"), not
the display group — use MUSCLE_METADATA[id].group for that. The
region catalog lists every region with
front/back previews, generated from these exports.
Data without a DOM
Server code, native apps, and custom renderers can import the anatomy alone. The /data subpath
never loads chart code or touches browser globals:
import { MUSCLE_MAP, MUSCLE_METADATA, INTENSITY_COLORS } from "@emmorts/body-muscles/data";
const { MUSCLE_MAP } = require("@emmorts/body-muscles/data"); // CommonJS
It exports MUSCLE_MAP, MUSCLE_DEFS, MUSCLE_METADATA, MUSCLE_GROUPS, MUSCLE_PAIRS,
FRONT_MUSCLES, BACK_MUSCLES, INTENSITY_COLORS, and the getMuscleDef / getMuscleMetadata /
isMuscleId lookups. For consumers that cannot run JavaScript, the geometry and palette also ship as
JSON at @emmorts/body-muscles/data.json:
{
"schemaVersion": 1, // bumped only when the shape changes incompatibly
"intensityColors": ["#94a3b8", "…"], // 11 CSS colours, index 0–10
"frontMuscles": [{ "id": "head", "name": "Head", "view": "FRONT", "path": "M 11.639,…" }],
"backMuscles": [{ "id": "head-back", "name": "Head (Posterior)", "view": "BACK", "path": "M …" }]
}
Import it with import data from "@emmorts/body-muscles/data.json" with { type: "json" } (Node
20.10+ or Bun), require(), or read the file from any language. The module subpaths work in Node 16+.
Frameworks
The chart is plain DOM, so every framework integrates the same way: create it once the container
exists, forward state with update(), and destroy it on unmount. Runnable vanilla TypeScript and
React projects, including a server-rendered path, live in examples/.
React
import { useEffect, useRef, useState } from "react";
import { BodyChart, ViewSide } from "@emmorts/body-muscles";
export function BodyMap({ view = ViewSide.FRONT }) {
const containerRef = useRef(null);
const chartRef = useRef(null);
const [bodyState, setBodyState] = useState({});
// Read state through a ref so the instance effect depends only on `view`.
const latest = useRef(bodyState);
latest.current = bodyState;
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
const chart = new BodyChart(container, {
view,
bodyState: latest.current,
onMuscleClick(id) {
setBodyState((prev) => ({
...prev,
[id]: { intensity: prev[id]?.intensity ?? 0, selected: !prev[id]?.selected },
}));
},
});
chartRef.current = chart;
return () => {
chart.destroy();
chartRef.current = null;
};
}, [view]);
// State changes apply in place, preserving focus.
useEffect(() => {
chartRef.current?.update({ bodyState });
}, [bodyState]);
return <div ref={containerRef} />;
}
The chart is created in an effect, never during render, so the component is safe to server-render and survives StrictMode's double-invoked effects.
Vue
<script setup>
import { onMounted, onUnmounted, ref, watch } from "vue";
import { BodyChart, ViewSide } from "@emmorts/body-muscles";
const container = ref(null);
const bodyState = ref({});
let chart;
onMounted(() => {
chart = new BodyChart(container.value, {
view: ViewSide.FRONT,
bodyState: bodyState.value,
onMuscleClick(id) {
const current = bodyState.value[id] ?? { intensity: 0, selected: false };
bodyState.value = { ...bodyState.value, [id]: { ...current, selected: !current.selected } };
},
});
});
watch(bodyState, (state) => chart?.update({ bodyState: state }));
onUnmounted(() => chart?.destroy());
</script>
<template>
<div ref="container" />
</template>
Svelte
<script>
import { onDestroy, onMount } from "svelte";
import { BodyChart, ViewSide } from "@emmorts/body-muscles";
let container;
let chart;
let bodyState = {};
onMount(() => {
chart = new BodyChart(container, {
view: ViewSide.FRONT,
bodyState,
onMuscleClick(id) {
const current = bodyState[id] ?? { intensity: 0, selected: false };
bodyState = { ...bodyState, [id]: { ...current, selected: !current.selected } };
chart.update({ bodyState });
},
});
});
onDestroy(() => chart?.destroy());
</script>
<div bind:this={container} />
Plain HTML
<div id="body-map"></div>
<script src="https://unpkg.com/@emmorts/body-muscles/dist/umd/body-muscles.umd.min.js"></script>
<script>
const { BodyChart, ViewSide } = BodyMuscles;
let state = {};
const chart = new BodyChart(document.getElementById("body-map"), {
view: ViewSide.FRONT,
bodyState: state,
onMuscleClick(id) {
const current = state[id] ?? { intensity: 0, selected: false };
state = { ...state, [id]: { ...current, selected: !current.selected } };
chart.update({ bodyState: state });
},
});
</script>
API reference
new BodyChart(container, options)
Mounts a chart inside container. Options set to undefined use their defaults; an explicit
false is kept.
| Option | Type | Default | Description |
|---|---|---|---|
view |
ViewSide |
required | FRONT, BACK, or BOTH |
bodyState |
BodyState |
required | Intensity and selection per region |
onMuscleClick |
(id: MuscleId, name: string) => void |
no-op | A region was activated by pointer or keyboard |
onMuscleHover |
(id: MuscleId | null) => void |
no-op | Pointer entered a region, or left the chart |
interactive |
boolean |
true |
false renders a static labelled image |
intensityColor |
(intensity: number) => string |
default palette | Fill colour for an intensity |
labels |
ChartLabels |
{} |
Localized strings; see Localization |
ariaLabel |
string |
per view | Accessible name of the chart |
showTooltip |
boolean |
true |
Tooltip on hover and keyboard focus |
tooltipFormatter |
(muscle, state) => string |
name and intensity | Tooltip content |
showViewLabel |
boolean |
false |
Anterior / Posterior overlay labels |
enableTransitions |
boolean |
true |
Animate state changes, unless reduced motion is set |
className |
string |
"" |
Extra class on the chart wrapper |
| Method | Description |
|---|---|
update(options) |
Merge partial options; undefined is ignored. view and interactive rebuild; the rest applies in place. |
destroy() |
Remove the chart and all of its listeners. |
Types
enum ViewSide { FRONT = "FRONT", BACK = "BACK", BOTH = "BOTH" }
type MuscleId = "head" | "face" | "neck-left" | /* … 89 in total … */ "foot-back-right";
interface BodyPartState {
intensity: number; // integer 0–10
selected: boolean;
}
type BodyState = Partial<Record<MuscleId, BodyPartState>>;
type IntensityColorResolver = (intensity: number) => string;
type SelectionAction = "select" | "deselect" | "toggle";
type MuscleGroup = keyof typeof MUSCLE_GROUPS;
type MuscleSide = "left" | "right" | "central";
interface MuscleMetadata {
id: MuscleId;
name: string;
view: ViewSide;
side: MuscleSide; // the subject's own side
group: MuscleGroup;
}
Data exports
Available from the package root and from /data.
| Export | Contents |
|---|---|
MUSCLE_MAP |
All 89 region definitions, front then back |
FRONT_MUSCLES, BACK_MUSCLES |
Definitions for one view |
MUSCLE_DEFS |
Definitions keyed by identifier |
MUSCLE_METADATA |
Name, view, side, and group keyed by identifier |
MUSCLE_GROUPS |
The eight canonical groups; each region belongs to exactly one |
MUSCLE_PAIRS |
Readonly [left, right] counterparts within the same view |
INTENSITY_COLORS |
Default palette, levels 0–10 |
Utilities
| Function | Description |
|---|---|
setGroupSelection(state, group, action) |
New state with a whole group selected, deselected, or toggled |
setBilateralSelection(state, id, action) |
The same for a region and its counterpart |
createBodyPartState(intensity?, selected?) |
Validated state entry |
isValidIntensity(value) |
Whether a value is an integer from 0 to 10 |
isMuscleId(value) |
Type guard for a known region identifier |
getMuscleDef(id) |
Region definition, or undefined |
getMuscleMetadata(id) |
Region metadata, or undefined |
filterMuscles(view) |
Definitions drawn in a view |
getMuscleColor(state, isHovered, resolver?) |
Fill colour for a state entry |
resolveIntensityColor(intensity) |
The default colour resolver |
createIntensityColorScale(colors) |
Resolver from custom palette stops |
extractMuscleSide(id) |
"left", "right", or "central" |
extractMuscleGroup(id) |
Identifier prefix such as "biceps" — not the display group |
Contributing
Development needs Node 20+ (Playwright's minimum) and a downloaded Chromium; this is separate from the library's Node 16+ runtime support.
npm install
npx playwright install chromium
npm run typecheck
npm run build # ESM, CommonJS, UMD, and dist/data/body-muscles-data.json
npm run verify-build # assert the built artifacts are complete
npm run test:types # compile a consumer against the built declarations
npm run test:types:min # the same on TypeScript 5.0, the supported minimum
npm run test:selection # selection helper behaviour
npm run test:package # pack the package and import it from ESM and CommonJS
npm run test:browser # chart, docs, and accessibility tests in headless Chromium
npm run docs # assemble docs/lib and serve the site on :3000
Commands after build test the built output, and the examples install the
library from this checkout, so build first.
Repository layout
src/
BodyChart.ts the chart
types.ts public types
data/ region geometry, metadata, groups, and pairs
utils/ colour, lookup, validation, and selection helpers
docs/ documentation site: static HTML, one layered stylesheet, ES modules
examples/ runnable vanilla TypeScript and React projects
tests/ browser, selection, and consumer type tests
scripts/ build, verification, docs, and release scripts
The documentation site themes the chart only through the public --bm-* properties and is deployed
to GitHub Pages by docs.yml on every push to main that touches it.
Releasing
Releases publish to npm from GitHub Actions with provenance.
- Add a
## <version>section toCHANGELOG.md. - Run
npm run release -- <version>. It checks the tree, changelog, and tag, then bumpspackage.json, commits, tagsv<version>, and pushes. - CI verifies the build, publishes the package, and opens a GitHub release from that changelog section.
Credits
Body Muscles was created by Ivan Vulović as vulovix/body-muscles. The anatomical artwork, the region dataset, and the chart's original design and API are his work.
This fork is maintained by @emmorts and published as
@emmorts/body-muscles. It adds keyboard and
screen-reader semantics, typed identifiers and metadata, input validation, theming and localization
hooks, selection helpers, a data-only entry point, browser tests, and runnable examples; the
changelog lists every change.
License
Apache-2.0. The original copyright notice is kept in NOTICE, as the license requires.
Related Packages
Interactive SVG body map with 70+ muscles, intensity visualization, and zero dep...
Lilac - a smooth, modern WYSIWYG text editor built with pure TypeScript. Zero de...
Premium, framework-agnostic muscle map. Highlights muscles with mix-blend-mode o...
Version History
| Version | Released | License |
|---|---|---|
| 2.1.1 | Apache-2.0 | |
| 2.1.0 | Apache-2.0 | |
| 2.0.0 | Apache-2.0 | |
| 1.1.1 | Apache-2.0 | |
| 1.1.0 | Apache-2.0 |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |