opsinjs
ComponentsHealth input

BodyMap

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.

Preview

Theme
Density
Text
Status
body-map · base base · style base-lyraOpen under the product theme

Installation

pnpm dlx shadcn@latest add @opsinjs/body-map

The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.

Usage

import { BodyMap } from "@/components/ui/body-map"
<BodyMap
  label="Where are you noticing something?"
  value={regions}
  onValueChange={setRegions}
/>

When to use it

Use it when

  • Letting someone point at one or more general regions of the body, as structured input a product interprets on its own terms.
  • Capturing a coarse location beside a free-text note or a reading, where a key like left-arm is more reliable than a sentence.
  • Offering a faster alternative to a long checkbox list, while keeping that list available for keyboard and assistive-technology users.

Do not use it when

  • You are recording one specific measurement or reading rather than a location.Use reading-input instead.
  • You need a realistic figure that represents a particular body, age or condition.Use field instead.
  • You are asking how strong or how bad something is rather than where it is.Use scale-input instead.
  • The answer is really free text and a diagram would only get in the way.Use field instead.

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.

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

  1. BodyMapThe root: a group named by label, laying out the figures. Carries neither data-status nor data-category.Controlled by label
  2. FigureOne abstract silhouette per view, aria-hidden, as a group named for the view, with region buttons positioned over it.Controlled by view
  3. RegionOne native toggle button per region, with aria-pressed and an aria-label from the region label. Selected shows a tick.Controlled by regions
  • BodyMap 1data-slot="body-map", role="group" named by label
    • Figure 1 to 2data-slot="body-map-figure", one per view, role="group" named for the view
      • Region 0 or moredata-slot="body-map-region", a native button with aria-pressed and aria-label

Examples

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.

Theme
Density
Text
Status
body-map-pointing-at-what-hurts · base base · style base-lyraOpen under the product theme

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.

Theme
Density
Text
Status
body-map-with-a-checkbox-list · base base · style base-lyraOpen under the product theme

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.

Do

"Where are you noticing something?" with regions named "Chest" and "Left arm" says where, and nothing more.

Don’t

A region relabelled "Chest pain" or "Fracture" presents a symptom or a diagnosis as though the reader confirmed it.

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.

KeyActionNotes
TabMoves to the next regionEach region is its own stop, so the default map is nine stops.
Shift+TabMoves to the previous regionThe same sequence in reverse.
Space or EnterToggles the focused regionNative button activation. aria-pressed flips, and the fill and tick follow it.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor

These are the measured token pairs this component draws colour from, not a measurement of the component itself.

API reference

Prop

Type

Generated from BodyMapProps in registry/bases/base/body-map.tsx.

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.

  • ReadingInput records a measurement rather than a location. Reach for it when the answer is a number and a unit.
  • Field wraps a single control, and a free-text description of where something is belongs in one.
  • ScaleInput records how strong something is rather than where it is.

On this page