Overview
What makes opsinjs a health design system rather than a general-purpose component library, and which decisions it fixes on a product's behalf.
60 shipped
What this means
A generic component library will render the number 212 in red inside a rounded
rectangle without objection. It holds no position on whether that number is a
cholesterol reading, on whether red here means "bad" or "cardiovascular", on
whether the person looking at it knows what the unit is, or on whether the app
has just told someone they are ill in a colour they cannot distinguish, at 11pm,
with no way to ask a question until Monday.
Health is the domain in which the distance between "renders correctly" and "is safe to ship" is greatest. The failure that matters here is a reader misreading a result they were not prepared for, rather than a broken layout, and of the two possible misreadings false reassurance is more dangerous than panic. Every rule in this section exists because a plausible, well-built, good-looking interface can produce that outcome with no visible sign of malfunction.
This pillar is doctrine rather than library code: a small number of decisions, argued in public, that constrain what the components are allowed to do. The components are downstream of it, and it is the section that is complete today while the components are not, which is the reason to read it before writing code.
The rule
opsinjs decides how health information is presented. It never decides what that information means.
Decisions opsinjs fixes
Six decisions sit on the presentation side of that boundary. Each one is fixed for every product built on the system, and the enforcement column records how much of the fixing the build performs today and how much still rests on a reviewer.
| Decision | What is fixed | Failure it prevents | Where it is enforced | Doctrine page |
|---|---|---|---|---|
| Two colour axes | Category identity and clinical status are independent channels with independent palettes, so a colour that says "this is a heart metric" can never also say "this is worrying" | One hue carrying both identity and urgency, after which a reader can rely on neither | The --opsin-category-* and --opsin-status-* namespaces are disjoint, and a component that accepts a status does not accept a category for the same surface | The two colour axes |
| Four clinical status levels | steady, watch, attention and urgent form an ordinal ladder in that order. Each level has a fixed meaning and a fixed tone, and urgent is budgeted at one surface per screen | A fifth level invented for one screen, and an urgent treatment spent on a product problem | Every component takes the level from the exported ClinicalStatus union, so an invalid level is a type error rather than an unstyled element. The per-screen budget is held by review | Clinical status semantics |
| Status on more than one channel | Colour, shape, icon and a word carry a status together, every time | A status invisible to a reader with a colour vision deficiency, or on a greyscale display | StatusPill exposes no color prop, no variant prop and no way to remove the word | Clinical status semantics |
| Number formatting | Precision, units and rounding are a correctness surface: one metric is shown to the same number of decimal places every time, and no number is rendered without its unit | A reading rounded or padded into a claim the instrument did not make | Value takes a magnitude, a unit and a precision rather than a pre-formatted string, holds no default precision of its own, and puts the unit into the accessibility tree in words | Numbers, units and precision |
| Rendered uncertainty | A measurement, an estimate, a stale reading and an absent value are four visually distinct states | A three-day-old reading presented as current | RelativeTime ships no staleness default, so a product that states no threshold gets no stale treatment, and its one default, absoluteAfterDays, chooses the wording for an age without carrying a staleness verdict. Value refuses to render an unknown as a number | Uncertainty, staleness and missing data |
| The word "normal" | The word is banned from any user-facing string that describes a person's own result | A statement about one measurement read as a verdict on the person | check-a11y.mts gates the banned-word list from tokens/glossary.json across the component sources opsinjs ships. No check reaches a consuming product's strings. There the list falls to review, and the lint rule over user-facing strings has not been written | Reference ranges |
Motion is never one of the channels that carry a status, which is why it is
absent from the third row. A reader who has prefers-reduced-motion set receives
the end state instead of the animation, so a signal carried in motion reaches
some readers and not others.
Motion in health UI sets out the seven requirements
that follow from the rule that motion may support a message but may never be the
message.
Decisions opsinjs withholds
Four decisions sit on the clinical side of the boundary. The system supplies none of them, and each one has a named owner outside it.
| Decision withheld | Who owns it | Why opsinjs cannot supply it | Where to look |
|---|---|---|---|
The thresholds separating watch from attention | The product, and whoever is clinically accountable for it | A threshold is a claim about what a measurement means for a person, and a presentation layer that shipped one would be making that claim with no author | Clinical status semantics |
| The reference ranges | The laboratory, device manufacturer or clinical team that issued them | They vary by laboratory, assay, age, sex, pregnancy and population | Reference ranges |
| The words that carry clinical meaning | The product's clinical content owner | opsinjs supplies sentence shapes, and the clinical content placed inside them belongs to the product | Clinical interaction guidelines |
| Whether the product is a medical device | The organisation shipping it, answering to its regulator | Classification turns on the intended purpose the product states for itself, which the system never sees | Regulatory context, then Safety, scope and limitations |
Why (evidence)
Every page in this section declares an evidence field and is bound by it: a
page marked cited names a source the reader can open, and a page marked
opinion states which parts we would change our mind about. The full register of
sources is at Evidence and references. We have
not, at the time of writing, had this pillar reviewed by an external clinician,
and the open item below is where that gap is tracked.
reviewer: clinical does
not appear in any frontmatter in this directory.Applying it
Two pages in this section carry the operational load. Clinical interaction guidelines states ten numbered prohibitions, each written so that a reviewer can test a screen against it, and Safety review checklist is one printable list, grouped into ten lettered sections, whose items name the doctrine page they come from where one exists.
Do
Read Clinical interaction guidelines before your first screen, then keep Safety review checklist open during review.
Don’t
Treat this section as background reading to come back to. The colour axes and the status vocabulary are structural decisions, and retrofitting either one means revisiting every screen that has already chosen for itself.
Reading order by task
The order below depends on what the implementing team is building today. The third column names a page that a condition adds, rather than a page everyone needs.
| What the team is building | Read in this order | Read additionally when |
|---|---|---|
| A screen that shows a measured value | Reference ranges, then Numbers, units and precision, then Uncertainty, staleness and missing data | |
| A surface that alerts | Clinical status semantics, then Alarm fatigue, then Emergency and escalation | |
| Anything that asks the reader a question | Consent and disclosure | Any answer could disclose risk of harm: Crisis and self-harm |
Components that implement this
Every component listed here carries a governedBy field naming the doctrine
pages it must satisfy. scripts/assert-ia.mts fails the build with CAT003 when
a component's governedBy names a doctrine page whose implements does not list
it. The reverse asymmetry, a doctrine page claiming a component that does not
claim it back, is reported as a warning and does not fail pnpm check.
What this does not cover
This pillar is written for consumer and patient-facing interfaces: an app someone uses to look at their own health data, log something about themselves, or be told something about a result. The four domains below sit outside that scope, the reason differs in each, and this guidance should not be used as-is in any of them.
| Out of scope | Examples | Why this guidance does not transfer | What applies instead, or who is accountable |
|---|---|---|---|
| Clinician-facing software | Electronic health records, prescribing, triage tooling, diagnostic workstations | The reader is trained, the density requirements are the opposite of the ones here, and the safety analysis is different | |
| Regulated device user interfaces | Anything that is part of a medical device under EU MDR, UK MDR or the FDA's device framework | Classification determines what the interface must do and must prove, and opsinjs confers no regulatory status of any kind | Regulatory context |
| Clinical content | Thresholds, ranges, dosing, triage logic, and any sentence whose truth is a clinical claim | The truth of the sentence is a clinical question rather than a presentation one | Whoever is clinically accountable in your organisation |
| Legal advice | Whether a regime applies to a given product, and what it requires once it does | Regulatory context is a starting map written by engineers and designers rather than by lawyers |
Updates to this page
Last read through against the system on 2026-09-02. Due for review every 6 months; expiry is reported by pnpm run check:freshness.