opsinjs
FoundationsAcross the system

Data states

Empty, loading, error, stale and partial are the five states every data-bearing surface must render, so "we do not know" is never drawn as an ordinary reading.

Overview

Every screen in a health product spends part of its life not knowing the thing it is meant to show. The measurement has not synced. The wearable was off. The API returned four of six readings. The number on screen is nine days old.

The failure this page exists to prevent is specific and it is common: a surface that does not know a value renders the space where the value would be as blank, or as a dash, or as zero. A worried reader therefore interprets an absence as reassurance. Nothing there means nothing wrong. That inference is wrong often enough to matter, and it is entirely the interface's fault.

So opsinjs defines five data states, once, for every surface that displays data. A component either renders all five, or its When to use it section names the state it omits and says why drawing that state would be dishonest or impossible. Every component page carries When to use it at every status, so the check has a section that exists to land in. The third principle is say what you do not know, and these five states are that principle turned into something a reviewer can check.

Not written yet.

No data surface has yet written that paragraph. When to use it on every built component page argues about when to reach for the component, and none of them yet names the data state it cannot render or says why leaving it out is honest. Until each one does, this check names a real section but points at prose nobody has written, so the count of unanswered surfaces stands at every built data surface.

How it works

The five states are ordered by resolution: a surface can only be in one, and it is the first one that applies.

1. Error. The system tried and failed. This is a state about the system, not about the person, and the copy must make that obvious: the reading is not missing, the fetch failed. Rendered as an EmptyState with reason="could-not-load", which draws a neutral marker in neither axis of colour and makes its action mandatory. An error state always offers a retry or a route onwards, and it never occupies the space with something that could be read as a value.

2. Loading. The system is trying. Rendered as a skeleton that preserves the final layout, so the page does not reflow under a reader's thumb when the data lands. A skeleton never shows a shape that could be mistaken for a value. It has no placeholder digits, no example number and no plausible-looking chart.

3. Empty. There is genuinely nothing, and that is a legitimate answer. First run, a log with no entries, a metric never recorded. An empty state names what would appear here and gives exactly one next action. It is not an error and must not be styled like one.

4. Partial. Some of the data exists and some does not, and this is the state most systems collapse into one of the others. A weekly average computed from three days is not an average of the week; a trend line with a two-week gap is not a trend across that gap. Partial data is shown with its own coverage stated. The coverage says how much of the intended window is actually present, and any derived figure either says what it was derived from or is not shown at all.

5. Stale. The value is real but old enough that it may no longer describe the person. Staleness is not a boolean and it is not universal: a resting heart rate from yesterday is fresh, a blood-pressure reading from yesterday may be fresh, and a blood-glucose reading from yesterday is not. The threshold therefore belongs to the product's clinical governance, and opsinjs provides the mechanism and the rendering, never the number.

Diagram source (mermaid)
flowchart TD
A[Surface asks for a value] --> B{Did the request fail?}
B -->|yes| E[Error: say the system failed, offer retry]
B -->|no| C{Still in flight?}
C -->|yes| L[Loading: layout-preserving skeleton]
C -->|no| D{Any data at all?}
D -->|no| M[Empty: name what goes here, one next action]
D -->|some| P[Partial: state the coverage]
D -->|all| S{Older than the product threshold?}
S -->|yes| T[Stale: show the value with its age and a refresh route]
S -->|no| V[Resolved: render the value]

Today these five states are legible only from the visible words a component renders, and from nothing else. No data-state attribute is emitted and no registry field publishes them, so CSS that selects on a state and a test that asserts on one have nothing to bind to: the state is in the reading, not in the DOM. The DOM attribute contract a fifth attribute would join is Handbook → Data attributes.

Not written yet.

A machine-readable form of the five states does not exist yet. Publishing one means adding data-state as a fifth member of the substrate's data-attribute vocabulary and emitting it from every data surface, and that is a decision every component page that today calls the vocabulary closed at four has to make together rather than one page at a time. Until it lands, select on the visible words a surface renders, not on an attribute the DOM does not carry.

Using it

Never let an unknown value take the shape of a known one. This is the rule that generates most of the others.

Do

An unsynced blood-pressure tile that says No reading yet with the date of the last one and a route to add a measurement. The reader knows the absence is an absence.

Don’t

The same tile showing -- on the ordinary surface with the usual unit beneath it. It looks like a result. On a screen where every other tile is showing a real reading, that placeholder reads as "fine".

Do not average across a gap without saying so. If seven days were requested and four arrived, the surface says four of seven. If that makes the figure not worth showing, the honest render is Partial, not a smaller number.

Stale is a status question and a data question at once. A stale value keeps its own status colour, because a reading that was urgent yesterday is still an urgent reading. The surface must nevertheless carry the age. What the product does about a stale urgent reading is an escalation decision and belongs to Uncertainty and staleness.

Empty is not a blank. An empty state that shows nothing is indistinguishable from a broken one. Name the thing that would be here, in the reader's words, and give one action. Two actions in an empty state is usually a sign the screen has not decided what it is for.

Loading has a floor and a ceiling. Below roughly a tenth of a second, showing a skeleton is worse than showing nothing, because the flash reads as a glitch. Beyond a few seconds, a skeleton stops being informative and the surface should say what it is waiting for. Both thresholds are product decisions; the mechanism is documented on Motion → Using motion.

The kit does not yet satisfy this contract from one component. The five states are one state machine on the flow diagram above, and four separate mechanisms in the kit today, so a product assembles them itself. A Skeleton is a standalone widget the product swaps in and out for loading. An EmptyState carries empty through its four absence reasons, partial through reason="not-enough", and error through reason="could-not-load". TrendSparkline draws its own too-short caption rather than reaching for that EmptyState, so the too-short case lives in two places. Stale is staleAfterHours on MetricTile. No data surface takes a state input and none renders the machine itself. The one name they will converge on when they do is DataState in lib/opsinjs.ts, and no component accepts it yet, so do not reach for a state prop today.

Not written yet.

A product wires the five states together from separate parts rather than receiving them from one surface: Skeleton for loading, EmptyState for empty, partial and error, TrendSparkline's own caption for the too-short trend, and staleAfterHours on MetricTile for stale. DataState names the vocabulary those surfaces will share once they take a state input, and no surface accepts it yet.

Tokens

Data states consume the neutral and muted colour roles, the skeleton shimmer duration from tokens/motion.json, and nothing else. They deliberately do not consume the status axis: an unknown value is not a clinical judgement, and colouring it as one is exactly the mistake this page exists to prevent. The motion family is generated below; the neutral roles are on Colour roles.

TokenWhat it controlsUsed by
--opsin-ease-spring-snaplinear(0, 0.0715, 0.2271, 0.4053, 0.5722, 0.7119, 0.8198, 0.8978, 0.9505, 0.9836, 1.0025, 1.0118, 1.015, 1.0147, 1.0126, 1.01, 1.0073, 1.0051, 1.0033, 1.0019, 1)Direct manipulation only: a switch the reader just flipped, a segmented control, a pressed button settling. It overshoots by 1.5%. That is enough to feel physical but not enough to look playful. The 1.5% is the largest overshoot in the system but not the only one: `spring-settle` overshoots by 0.88%. Only `spring-calm` and `spring-sheet` reach their target without passing it.no component
--opsin-duration-spring-snap283msSettle time for the spring-snap spring, measured from its own parameters.no component
--opsin-ease-spring-settlelinear(0, 0.0742, 0.2328, 0.4113, 0.5758, 0.7116, 0.8157, 0.8905, 0.9412, 0.9736, 0.9928, 1.003, 1.0076, 1.0088, 1.0082, 1.0068, 1.0053, 1.0038, 1.0026, 1.0017, 1)The workhorse for chrome: popovers, tooltips, menus, chips appearing and disappearing.no component
--opsin-duration-spring-settle382msSettle time for the spring-settle spring, measured from its own parameters.no component
--opsin-ease-spring-calmlinear(0, 0.0829, 0.2457, 0.4157, 0.5642, 0.6832, 0.774, 0.841, 0.8893, 0.9236, 0.9476, 0.9643, 0.9757, 0.9836, 0.9889, 0.9926, 0.995, 0.9967, 0.9978, 0.9985, 1)A health value that changes while it is already on screen: a bar re-filling from one reading to the next, a dial travelling between two values the reader has already been shown. Never a first paint and never a first reveal. A value arrives at its final figure, with no count-up, no dial sweep and no line drawing itself in (health/motion-in-health-ui rule 2). Slightly overdamped (zeta just over 1) so it never overshoots and never bounces.no component
--opsin-duration-spring-calm550msSettle time for the spring-calm spring, measured from its own parameters.no component
--opsin-ease-spring-sheetlinear(0, 0.0881, 0.2576, 0.431, 0.5798, 0.6971, 0.7854, 0.8497, 0.8958, 0.9282, 0.9508, 0.9664, 0.9771, 0.9845, 0.9895, 0.9929, 0.9952, 0.9967, 0.9978, 0.9985, 1)Large surfaces travelling a long distance: sheets, dialogs, full-screen pushes. Overdamped, because a sheet that bounces at the top of its travel reads as a dropped object.dialog, sheet
--opsin-duration-spring-sheet483msSettle time for the spring-sheet spring, measured from its own parameters.dialog, sheet
--opsin-ease-standardcubic-bezier(0.2, 0, 0, 1)Non-spring transitions where a spring would be overkill: colour, opacity, border. Fast out, slow in.accordion, body-map, button, checkbox, combobox, consent-sheet, dialog, menu, number-field, popover, progress, radio-group, scale-input, scroll-area, segmented-control, select, sheet, skeleton, slider, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip
--opsin-ease-entercubic-bezier(0.05, 0.7, 0.1, 1)Something arriving from off-screen or from nothing. Decelerating, because an arrival should feel like it is coming to rest.no component
--opsin-ease-exitcubic-bezier(0.3, 0, 0.8, 0.15)Something leaving. Accelerating and shorter than its enter, because a reader does not need to watch a dismissal finish.dialog
--opsin-duration-instant80msState change with no travel: hover tint, focus ring, checkbox tick.no component
--opsin-duration-fast140msSmall elements moving a small distance.accordion, body-map, button, checkbox, combobox, consent-sheet, dialog, menu, number-field, popover, progress, radio-group, scale-input, scroll-area, segmented-control, select, sheet, skeleton, slider, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip
--opsin-duration-base220msThe default for chrome that is not spring-driven.dialog, sheet
--opsin-duration-slow360msLayout change: a list reflowing, a card expanding.no component
--opsin-duration-deliberate560msThe ceiling on a transition a reader is waiting on, such as a first-run reveal or a consent sheet, where the point is that the reader notices. No such transition may exceed it. A looping placeholder period, like the skeleton shimmer, is bounded instead by its iteration count, so a single sweep of it may run longer.no component
--opsin-duration-shimmer1600msOne sweep of a loading placeholder's sheen. This is a period, the time for the sheen to make one pass across the bar, and not a travel time. At 1600ms the movement reads as calm rather than as urgency, which the first motion rule requires of anything that is not a value the reader controls. Its consumer pairs it with a finite iteration count so the total motion stays under the five seconds at which WCAG 2.2 SC 2.2.2 engages.skeleton

Accessibility impact

Data states are announced, not just drawn, and this is where most implementations fail their first screen-reader test.

  • Every state change is announced once. A tile moving from loading to resolved announces the resolved value; it does not announce the skeleton, and it does not re-announce on every re-render. Repeated announcements are how a live region becomes noise a person turns off.
  • Skeletons are hidden from assistive technology. A skeleton has no accessible name and is not focusable. Announcing "loading" once through the region that owns the value is correct; announcing eight decorative bars is not.
  • Stale and Partial are part of the accessible name, not a visual garnish. A reader using a screen reader must hear that a value is nine days old at the same moment a sighted reader sees it, in the same sentence rather than in a separate element they may never reach.
  • Error states move focus deliberately or not at all. An error that appears in place does not steal focus; an error that replaces the whole surface must put focus somewhere predictable and say what happened.
  • Colour is never the state. Every one of the five carries a word. Greyscale and CVD renderings are audited on Colour independence.

The full announcement contract per component is aggregated on Screen readers.

On this page