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
body-map · base base · style base-lyraOpen under the product themeInstallation
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-inputinstead. - You need a realistic figure that represents a particular body, age or condition.Use
fieldinstead. - You are asking how strong or how bad something is rather than where it is.Use
scale-inputinstead. - The answer is really free text and a diagram would only get in the way.Use
fieldinstead.
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
BodyMapThe root: a group named by label, laying out the figures. Carries neither data-status nor data-category.Controlled bylabelFigureOne abstract silhouette per view, aria-hidden, as a group named for the view, with region buttons positioned over it.Controlled byviewRegionOne native toggle button per region, with aria-pressed and an aria-label from the region label. Selected shows a tick.Controlled byregions
- 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
- Figure 1 to 2data-slot="body-map-figure", one per view, role="group" named for the view
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.
body-map-pointing-at-what-hurts · base base · style base-lyraOpen under the product themePairing 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.
body-map-with-a-checkbox-list · base base · style base-lyraOpen under the product themeContent 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
buttonper region, named by its label, witharia-pressedfor its state. The map is a group named bylabel. - Selected is a muted fill, a hairline and a tick, and unselected shows a plus.
The difference survives greyscale, and
aria-pressedis 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-armmarker 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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves to the next region | Each region is its own stop, so the default map is nine stops. |
| Shift+Tab | Moves to the previous region | The same sequence in reverse. |
| Space or Enter | Toggles the focused region | Native button activation. aria-pressed flips, and the fill and tick follow it. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below 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.
Related
- 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.
ReadingInput
A field for typing in a measurement, with the unit shown and switchable beside the number, and room for an advisory the product wrote.
ScaleInput
A rating scale drawn from the points and end words the product supplies, for a self-report the product reads, shipping no instrument of its own.