Value
A number and its unit, formatted the same way everywhere. It is rounded honestly, spoken correctly, and never shown to more precision than the measurement has.
Preview
value · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/value
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 { Value } from "@/components/ui/value"<Value value={1000.2} unit="kg" precision={1} />When to use it
Use it when
- Any number displayed to a reader that has a unit, a precision, or both.
- Inside other components: the reading in a MetricTile, the number beside a RangeBar tick, the score in a ScoreDial.
- Anywhere the same measurement appears on more than one screen and must look identical on all of them.
Do not use it when
- The number needs its reference range shown alongside it to mean anything.Use
range-barinstead. - The number is a point in time rather than a quantity.Use
relative-timeinstead. - You are collecting the number rather than displaying it.Use
reading-inputinstead. - The number needs a label, a timestamp and a surface of its own.Use
metric-tileinstead. - The number is a proportion or a risk that needs a denominator and a natural-frequency framing.Use
calloutinstead.
Clinical meaning
Asserts. That this quantity has this magnitude in this unit, to this
precision. 1000.2 and 1000.20 claim different things.
Never read as. A judgement. Value carries no status and no band; a bare number means nothing without the context its surface supplies.
Colour axis. Neither. A reading inherits the text colour of its context. An
absence at display takes the muted theme role.
Thresholds. None here, and none in tokens/units.json, which holds no
reference range, no plausibility bound and no default precision. Precision
belongs to the metric, so precision is required. See
Numbers, units and precision and
Reference ranges.
Masking. On-screen privacy lists this
among the components that implement a fixed-width mask. Value has no masked
prop and reserves no width, so masking a reading today moves the layout.
Anatomy
ValueThe root, and one accessible unit of meaning. Not focusable, not interactive, and it paints no colour for a reading.Controlled byvalueValue.NumberThe formatted magnitude in tabular figures, rounded once at display, half away from zero, with locale-aware separators.Controlled byprecisionValue.UnitThe symbol as written, carrying the no-break space that joins it to the number inside this part.Controlled byunitValue.SpokenThe unit in words, visually hidden and select-none. Present only when the reader's language is English or unset.Controlled byunitValue.AbsenceWords and no glyph: no reading yet for one never taken, not available for one that arrived broken.Controlled byabsenceLabel
Examples
Zero is a reading. Absence is not.
Three rows share a unit and a precision and differ only in the reading: a
number, a null, and a number that arrived broken. Rule 13 keeps them apart.
value-zero-is-not-absence · base base · style base-lyraOpen under the product themeOne precision makes a column comparable
Four readings of one metric at one precision, aligned by the layout and by
tabular figures.
value-aligned-in-a-column · base base · style base-lyraOpen under the product themeWhat a screen reader is actually given
One magnitude with six units, and beside each the phrase that reaches speech, which a screenshot cannot show.
value-spoken-units · base base · style base-lyraOpen under the product themeContent guidelines
A caller's absenceLabel is rendered exactly as passed, with no check, so run
your own absence copy past the banned-word list. Write a unit symbol exactly as
tokens/units.json spells it, case included. A number with no unit by design
takes null, never an omitted prop.
Do
"1000.2 kg" for a device that measures to 100 g. One decimal place, spoken as 1000.2 kilograms.
Don’t
"1000.2481 kg" because that is what the device sent. Four decimal places assert a precision the instrument lacks.
Accessibility
Audited against WCAG 2.2 AA in a source pass and a rendered pass on
/view/base/base-lyra/component/value. The audit is author-run, not an
independent review, and clinical review is still pending.
It changed nothing in source. The unit symbol is aria-hidden only when a
spoken form exists and the words follow it, so an English reader hears 1000.2
kilograms as one phrase. Absence is spoken in words, never drawn at hero
weight, and carries no decorative glyph. The component imposes no truncation, no
fixed height and no whitespace-nowrap. The rendered pass returned empty axe,
target-size, focus-visible and 320px reflow results.
Three items sit outside this source. A Value must never be the sole accessible
name of a tappable control, because the visible symbol and the spoken words
share no substring, so the control owes its own name carrying the symbol (WCAG
2.2 SC 2.5.3). The only path to a clipped reading is a caller passing a
truncating class through className, which twMerge lets win, so truncation is
a caller responsibility documented on ValueProps.className. The spoken span
uses select-none to keep the words off the clipboard while leaving them in the
accessibility tree, untested by a copy test.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the value | Not focusable, by contract. Thirty readings would otherwise be thirty tab stops doing nothing. |
| Shift+Tab | Skips the value | Same in reverse. A tappable row owes its own name and its own 44 by 44. |
| 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.
Data attributes
| Attribute | Condition | Value |
|---|---|---|
| data-slot | On the root and every inner part, when that part renders | value, value-number, value-unit, value-spoken, value-absence |
| data-opsinjs-value | On the root, always | The unrounded reading, or an empty string when there is no reading |
API reference
Prop
Type
Generated from ValueProps in registry/bases/base/value.tsx.
precision is required and nothing is defaulted in its place. unit={null}
declares a dimensionless number. Omitting unit raises OPSIN-0003 instead.
Under server rendering, pass locale explicitly, or the server's own ICU
default formats the first paint and hydration reformats it.
Related
- RelativeTime does the same job for points in time, and shares the absence contract.
- ReadingInput is the entry counterpart, so the two must agree on precision and units.
- MetricTile is a Value with a label, a time and a surface of its own.
- RangeBar is a Value with its reference range drawn around it.
- Callout owns the framing a bare risk proportion needs before it can be shown.
SymptomPicker
A multi-select over a symptom list the product supplies, recording which options a reader ticks and never checking, triaging or interpreting them.
RelativeTime
When something happened, written the way people say it. The exact date and time stay in reach, and explicit words appear once the staleness boundary is passed.