---
title: "BodyMap"
description: "A neutral, non-realistic body schematic for pointing at where something is. It records region keys and interprets nothing, so the product owns every meaning."
url: "https://opsinjs.pensievelabs.org/components/body-map"
source: "https://opsinjs.pensievelabs.org/components/body-map.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-input"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["pain map", "anatomy diagram", "where does it hurt"]
governedBy: ["two-colour-axes"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="body-map"
  status="shipped"
  questions="[
  &#x22;The native toggle buttons with aria-pressed and a per-region aria-label are argued rather than verified against VoiceOver, NVDA or TalkBack.&#x22;,
  &#x22;Whether the nine-stop schematic should become a single roving toolbar is undecided, and the checkbox list is the primary keyboard path meanwhile.&#x22;,
  &#x22;The deliberately non-realistic silhouette has not been reviewed for whether it reads as neutral to every reader, and no user research backs the choice yet.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="body-map" />

## Installation [#installation]

<ComponentInstall name="body-map" unbuilt="false" importPath="@/components/ui/body-map" />

## Usage [#usage]

```tsx
import { BodyMap } from "@/components/ui/body-map"
```

```tsx
<BodyMap
  label="Where are you noticing something?"
  value={regions}
  onValueChange={setRegions}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Letting someone point at one or more general regions of the body, as structured input a product interprets on its own terms.&#x22;,
  &#x22;Capturing a coarse location beside a free-text note or a reading, where a key like left-arm is more reliable than a sentence.&#x22;,
  &#x22;Offering a faster alternative to a long checkbox list, while keeping that list available for keyboard and assistive-technology users.&#x22;,
]"
  avoid="[
  { case: &#x22;You are recording one specific measurement or reading rather than a location.&#x22;, instead: &#x22;reading-input&#x22; },
  { case: &#x22;You need a realistic figure that represents a particular body, age or condition.&#x22;, instead: &#x22;field&#x22; },
  { case: &#x22;You are asking how strong or how bad something is rather than where it is.&#x22;, instead: &#x22;scale-input&#x22; },
  { case: &#x22;The answer is really free text and a diagram would only get in the way.&#x22;, instead: &#x22;field&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** Only that the reader indicated these regions. It does not assert what
is there, whether it hurts, how strongly, since when, or what it means.

**Never read as.** A symptom checker, a triage or a diagnosis. A product that treats
a marked region as evidence of a condition has added a judgement the component does
not make, and owns it.

**Colour axis.** Neither. A selection is a muted fill, a hairline and a tick, never
a hue. A marked region carries no clinical level and names no category. See
[The two colour axes](../health/two-colour-axes.mdx).

**Thresholds.** None. The component runs no rules over a selection and reaches no
conclusion from it.

**Vocabulary.** The consuming product owns the region words and any interpretation.
The shipped labels are generic placeholders, and whether a selection triggers
anything is the product's decision.

## Anatomy [#anatomy]

<Anatomy
  name="body-map"
  parts="[
  {
    name: &#x22;BodyMap&#x22;,
    describes: &#x22;The root: a group named by label, laying out the figures. Carries neither data-status nor data-category.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;Figure&#x22;,
    describes: &#x22;One abstract silhouette per view, aria-hidden, as a group named for the view, with region buttons positioned over it.&#x22;,
    prop: &#x22;view&#x22;,
  },
  {
    name: &#x22;Region&#x22;,
    describes: &#x22;One native toggle button per region, with aria-pressed and an aria-label from the region label. Selected shows a tick.&#x22;,
    prop: &#x22;regions&#x22;,
  },
]"
/>

<CompositionTree
  name="body-map"
  tree="[
  {
    part: &#x22;BodyMap&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;data-slot=\&#x22;body-map\&#x22;, role=\&#x22;group\&#x22; named by label&#x22;,
    children: [
      {
        part: &#x22;Figure&#x22;,
        cardinality: &#x22;1 to 2&#x22;,
        note: &#x22;data-slot=\&#x22;body-map-figure\&#x22;, one per view, role=\&#x22;group\&#x22; named for the view&#x22;,
        children: [
          { part: &#x22;Region&#x22;, cardinality: &#x22;0 or more&#x22;, note: &#x22;data-slot=\&#x22;body-map-region\&#x22;, a native button with aria-pressed and aria-label&#x22; },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### Pointing at what hurts [#pointing-at-what-hurts]

The base case: a controlled multi-select over both figures, driven by `value` and
`onValueChange`. Read it in greyscale to confirm a marked region stands out without
colour.

<ComponentPreview name="body-map-pointing-at-what-hurts" kind="example" align="start" />

### Pairing the map with a checkbox list [#pairing-the-map-with-a-checkbox-list]

The map and a native checkbox list share one `value`, so a keyboard or
assistive-technology user has a robust path to the same answer. Ship both together.

<ComponentPreview name="body-map-with-a-checkbox-list" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Label the group with the question the reader is answering, and keep the region
words plain. "Head", "Left arm" and "Lower back" say where and nothing more. Do not
put a symptom or a diagnosis into a region label, and do not read one out of a
selection.

<DoDont>
  <DoDont.Do>
    **"Where are you noticing something?"** with regions named "Chest" and "Left arm" says where, and nothing more.
  </DoDont.Do>

  <DoDont.Dont>
    **A region relabelled "Chest pain" or "Fracture"** presents a symptom or a diagnosis as though the reader confirmed it.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA, in a source pass and a rendered pass.** This audit
is author-run. It is not an independent review, and a clinical review is still
pending. `pnpm run check:a11y` runs on every commit: every colour is a role token,
no type size is in `px`, and no banned word appears anywhere.

**What the audit fixed.** The region markers are `rem`-sized so they grow with the
reader's text size, but the figure box was pinned in `px`. At 200% text the markers
outgrew the fixed box and collided, which lost the ability to tap them separately
under 1.4.4 and 1.4.10. The box now scales in `rem` in lockstep with the markers, so
the spacing ratio holds at every text size and the default view is unchanged.

**What the audit confirmed.**

* A real `button` per region, named by its label, with `aria-pressed` for its
  state. The map is a group named by `label`.
* Selected is a muted fill, a hairline and a tick, and unselected shows a plus.
  The difference survives greyscale, and `aria-pressed` is the carrier.
* Each marker meets the 44 by 44 floor on both axes, carried in `rem`.
* Left and right are the subject's own sides, so the `left-arm` marker sits on the
  viewer's right, matching clinical convention.

**What a reader should still know.** The Front and Back captions render as `<p>`
rather than headings, a deliberate choice so the component does not hardcode a
heading level into a host document of unknown depth; each figure still carries a
named `role="group"`. Screen-reader output across VoiceOver, NVDA and TalkBack, the
nine-stop tab sequence, and forced colours are not gated, and the paired checkbox
list stays the primary path for readers who cannot point at a target.

<KeyboardTable
  name="body-map"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves to the next region&#x22;,
    notes: &#x22;Each region is its own stop, so the default map is nine stops.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves to the previous region&#x22;,
    notes: &#x22;The same sequence in reverse.&#x22;,
  },
  {
    keys: &#x22;Space or Enter&#x22;,
    action: &#x22;Toggles the focused region&#x22;,
    notes: &#x22;Native button activation. aria-pressed flips, and the fill and tick follow it.&#x22;,
  },
]"
/>

<ContrastReport component="body-map" />

## API reference [#api-reference]

<PropsTable name="BodyMapProps" />

`label` is required and has no default. Name it as the question the reader is
answering. A region `key` with no built-in place on the figure is warned once in
development and skipped rather than drawn. `view` defaults to `both`.

## Related [#related]

* [ReadingInput](./reading-input.mdx) records a measurement rather than a location. Reach for it when the answer is a number and a unit.
* [Field](./field.mdx) wraps a single control, and a free-text description of where something is belongs in one.
* [ScaleInput](./scale-input.mdx) records how strong something is rather than where it is.
