The human body, as a chart.
An interactive SVG body map. Give it a state object — intensity and selection per region — and it draws the chart, handles keyboard navigation and tooltips, and labels every region. Any framework, no dependencies.
- anatomical regions
- 89
- views, front and back
- 2
- canonical groups
- 8
- intensity levels
- 0–10
- runtime dependencies
- 0
Playground
Region
Click a region, or focus one and press Enter.
Selected · 0
Nothing selected.
What you pass in
01 · Start
From install to a working chart
Install
The package ships ES modules, CommonJS, and a browser global build. TypeScript users need TypeScript 5.0 or later; the JavaScript runtime has no such requirement.
npm install @emmorts/body-musclespnpm add @emmorts/body-musclesyarn add @emmorts/body-musclesbun add @emmorts/body-muscles<script src="https://unpkg.com/@emmorts/body-muscles/dist/umd/body-muscles.umd.min.js"></script>
<script>
const { BodyChart, ViewSide } = BodyMuscles; // browser global
</script>
No bundler needed; works from file://.
<script type="module">
import { BodyChart, ViewSide } from "https://esm.sh/@emmorts/body-muscles";
</script>
esm.sh serves npm packages as native ES modules.
Your first chart
-
Mount it
Pass a container element and an initial state. The chart fills the container's width.
import { BodyChart, ViewSide } from "@emmorts/body-muscles"; const container = document.querySelector<HTMLElement>("#body-map"); if (!container) throw new Error("Missing #body-map"); const chart = new BodyChart(container, { view: ViewSide.FRONT, bodyState: {}, onMuscleClick: (id, name) => console.log(id, name), }); -
Render your data
State maps region identifiers to an intensity from 0 to 10 and a selected flag. Each update replaces the whole mapping.
chart.update({ bodyState: { "biceps-left": { intensity: 7, selected: true }, "quads-right": { intensity: 3, selected: false }, }, }); -
Clean up
Change views in place, and destroy the chart when its widget is removed. Leave it mounted across page navigation so the back/forward cache can restore it.
chart.update({ view: ViewSide.BOTH }); chart.destroy(); // removes the DOM and every listener
Runnable vanilla TypeScript and React projects, including a server-rendered React path, live in
examples/.
02 · Guides
How the chart thinks
State
You own the state; the chart keeps none of its own and never mutates what you pass.
update() merges options shallowly and ignores undefined values, but
bodyState is replaced as a whole: a region missing from the new mapping
returns to intensity 0, unselected. Use spreads to keep or remove entries — there is deliberately no
separate patch method with different merge rules.
// 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 });
Changing view or interactive rebuilds the chart. Every other update applies in
place, preserving 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 nothing. Use isValidIntensity() to check input first.
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 } } });
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.
Group & bilateral selection
Two pure helpers return a new state for a whole group or for a region and its opposite side. The
playground's group chips use setGroupSelection; its “Select both sides” button uses
setBilateralSelection.
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; central regions such asspineact alone. - Existing intensities and unrelated entries are kept. Newly selected regions start at intensity 0.
- Inputs are never mutated; a no-op returns the same mapping, and unchanged entries are shared.
Intensity & colour
Region fill comes from the intensityColor resolver, which defaults to the
INTENSITY_COLORS palette. Build a resolver from your own stops with
createIntensityColorScale; missing levels fall back to the default palette and intensities
are rounded and clamped into 0–10.
const intensityColor = createIntensityColorScale({
0: "var(--region-rest, #94a3b8)",
5: "#f59e0b",
10: "#dc2626",
});
new BodyChart(container, { view: ViewSide.FRONT, bodyState, intensityColor });
Resolvers may return CSS custom-property expressions. The browser resolves them on each region, so they
follow theme changes without an update() — this site's resting colour works that way.
Theming
Every chart style reads a --bm-* custom property with the built-in value as its fallback.
Set them on the container or any ancestor — this site's light and dark themes are nothing more than
two sets of these. Selection draws its own outline and glow, and keyboard focus a separate halo, so
both stay distinguishable from any intensity palette.
.body-map {
--bm-region-stroke: #4a4238;
--bm-region-stroke-selected: #1c1915;
--bm-region-stroke-focus: #1d56c9;
--bm-tooltip-bg: #1c1915;
--bm-svg-shadow: none;
}
Transitions are disabled whenever the system requests reduced motion, regardless of
enableTransitions. All properties are listed in the
reference.
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. Like bodyState,
labels is replaced as a whole and applied in place.
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 / Posterior body map view |
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 |
Region and tooltip callbacks receive undefined for regions missing from state, and the
supplied object otherwise — including an explicit intensity 0. Explicitly passing labels
refreshes every label, even when you mutate and resubmit the same object.
Accessibility
An interactive chart is one composite widget with a single tab stop. Each region is a
toggle button whose pressed state follows selected.
| Key | Action |
|---|---|
| Tab | Enter or leave the chart in one step |
| → ↓ / ← ↑ | Next / previous region |
| Home / End | First / last region |
| Enter Space | Activate the focused region |
| Esc | Dismiss the tooltip |
Set interactive: false for a display-only chart announced as one labelled image.
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.
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.
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.
<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>
<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} />
<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>
03 · Anatomy
The regions and their data
Regions & sides
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. Read side and group from the metadata rather
than parsing identifiers.
MUSCLE_METADATA["biceps-left"];
// { id: "biceps-left", name: "Left Biceps", view: "FRONT", side: "left", group: "Arms" }
getMuscleMetadata(input); // MuscleMetadata | undefined, for untrusted strings
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, and
/data.json serves the same geometry to non-JavaScript consumers.
import { MUSCLE_MAP, MUSCLE_METADATA, INTENSITY_COLORS } from "@emmorts/body-muscles/data";
const { MUSCLE_MAP } = require("@emmorts/body-muscles/data"); // CommonJS
The JSON document has a schemaVersion, intensityColors (11 colours, index
0–10), and frontMuscles / backMuscles entries shaped like
MuscleDef. Additions keep the version; only a breaking change bumps it. Importing JSON with
import attributes needs Node 20.10+; the module subpaths work in Node 16+.
Region catalog
Every region in the installed version, generated from the library's own data. Select a row to copy its identifier.
04 · Reference
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, name) => void | no-op | A region was activated by pointer or keyboard |
onMuscleHover | (id | null) => void | no-op | Pointer entered a region, or left the chart |
interactive | boolean | true | false renders a static labelled image |
intensityColor | (intensity) => 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 requested |
className | string | "" | Extra class on the chart wrapper |
Methods
| Method | Description |
|---|---|
update(options) | Merge partial options; undefined is ignored. view and interactive rebuild; everything else applies in place. |
destroy() | Remove the chart and all of its listeners. |
Types
MuscleId is derived from the dataset, so a misspelled identifier is a compile error.
Narrow strings that only exist at runtime with isMuscleId(value), or look them up with
getMuscleDef(id), which returns undefined when unknown.
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 | Every region definition, 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 | 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 |
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 | 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 | Two-tone drop-shadow() |
--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 |