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.

npm i @emmorts/body-muscles Get started API reference
anatomical regions
89
views, front and back
2
canonical groups
8
intensity levels
0–10
runtime dependencies
0

Playground

View
Toggle group
Fig. 1 — Anterior and posterior views. Tab into the chart, move with ← →, select with Enter. 0 10 intensity

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-muscles
    pnpm add @emmorts/body-muscles
    yarn add @emmorts/body-muscles
    bun 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

    1. 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),
      });
    2. 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 },
        },
      });
    3. 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 as spine act 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` } });
    Label members
    MemberRendersDefault
    chart(view)Accessible name of the chartAnterior / Posterior body map view
    regionName(muscle)Base name used by labels, tooltip, and onMuscleClickmuscle.name
    region(muscle, state)Complete accessible name of a regionName, plus “- intensity N” above 0
    intensity(value)How an intensity is written“intensity 7”
    tooltip(muscle, state)Tooltip contentName, plus “- intensity N” when state exists
    viewLabel(view)Overlay label for one sideAnterior 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.

    Keyboard interaction
    KeyAction
    TabEnter or leave the chart in one step
    → ↓ / ← ↑Next / previous region
    Home / EndFirst / last region
    Enter SpaceActivate the focused region
    EscDismiss 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.

    Anterior
    Posterior

      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.

      BodyChartOptions
      OptionTypeDefaultDescription
      viewViewSiderequiredFRONT, BACK, or BOTH
      bodyStateBodyStaterequiredIntensity and selection per region
      onMuscleClick(id, name) => voidno-opA region was activated by pointer or keyboard
      onMuscleHover(id | null) => voidno-opPointer entered a region, or left the chart
      interactivebooleantruefalse renders a static labelled image
      intensityColor(intensity) => stringdefault paletteFill colour for an intensity
      labelsChartLabels{}Localized strings; see Localization
      ariaLabelstringper viewAccessible name of the chart
      showTooltipbooleantrueTooltip on hover and keyboard focus
      tooltipFormatter(muscle, state) => stringname and intensityTooltip content
      showViewLabelbooleanfalseAnterior / Posterior overlay labels
      enableTransitionsbooleantrueAnimate state changes, unless reduced motion is requested
      classNamestring""Extra class on the chart wrapper

      Methods

      Instance methods
      MethodDescription
      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.

      Data export contents
      ExportContents
      MUSCLE_MAPEvery region definition, front then back
      FRONT_MUSCLES, BACK_MUSCLESDefinitions for one view
      MUSCLE_DEFSDefinitions keyed by identifier
      MUSCLE_METADATAName, view, side, and group keyed by identifier
      MUSCLE_GROUPSThe eight canonical groups; each region belongs to exactly one
      MUSCLE_PAIRSLeft/right counterparts within the same view
      INTENSITY_COLORSDefault palette, levels 0–10

      Utilities

      Utility functions
      FunctionDescription
      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

      Chart CSS custom properties
      PropertyApplies toDefault
      --bm-paddingWrapper padding1rem
      --bm-max-heightChart height limit70vh
      --bm-max-widthWidth limit, single view400px
      --bm-max-width-bothWidth limit, BOTH760px
      --bm-svg-shadowChart drop shadowdrop-shadow(0 4px 20px rgba(0, 0, 0, 0.3))
      --bm-transition-durationTransition timing200ms
      --bm-region-strokeRegion outline#1e293b
      --bm-region-stroke-widthRegion outline weight0.1
      --bm-region-stroke-selectedSelected outline#ffffff
      --bm-region-stroke-width-selectedSelected outline weight0.3
      --bm-region-stroke-focusKeyboard-focus outline#1d4ed8
      --bm-region-stroke-width-focusKeyboard-focus outline weight0.5
      --bm-region-focus-shadowKeyboard-focus haloTwo-tone drop-shadow()
      --bm-region-active-shadowSelected or hovered filterurl(#glow)
      --bm-region-inactive-opacityOpacity of untouched regions0.6
      --bm-background-fillSilhouette fill#cbd5e1
      --bm-background-opacitySilhouette opacity0.1
      --bm-tooltip-bgTooltip backgroundrgba(15, 23, 42, 0.92)
      --bm-tooltip-colorTooltip text#f8fafc
      --bm-tooltip-paddingTooltip padding0.35rem 0.65rem
      --bm-tooltip-radiusTooltip corner radius0.5rem
      --bm-tooltip-font-sizeTooltip font size0.75rem
      --bm-tooltip-font-weightTooltip font weight500
      --bm-tooltip-line-heightTooltip line height1.2
      --bm-tooltip-shadowTooltip shadowTwo-layer box-shadow
      --bm-tooltip-borderTooltip border1px solid rgba(255, 255, 255, 0.15)
      --bm-tooltip-backdrop-filterTooltip backdropblur(8px)
      --bm-view-label-colorView label text#64748b
      --bm-view-label-bgView label backgroundrgba(15, 23, 42, 0.5)
      --bm-view-label-paddingView label padding0.25rem 0.75rem
      --bm-view-label-radiusView label corner radius9999px
      --bm-view-label-font-sizeView label font size0.875rem