---
title: "ReadingInput"
description: "A field for typing in a measurement, with the unit shown and switchable beside the number, and room for an advisory the product wrote."
url: "https://opsinjs.pensievelabs.org/components/reading-input"
source: "https://opsinjs.pensievelabs.org/components/reading-input.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-input"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["measurement input", "numeric input", "unit switch", "enter a reading", "numeric entry", "manual entry"]
governedBy: ["numbers-units-precision", "unit-systems", "uncertainty-and-staleness"]
usedIn: ["ask-users-for/date-of-birth", "ask-users-for/height-and-weight", "ask-users-for/medications", "daily-log-entry", "daily-log-screen", "daily-logging", "forms/autocomplete-and-input-types", "forms/question-pages", "forms/units-and-numeric-entry", "forms/validation-timing", "onboarding-and-first-run", "onboarding-screen"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="reading-input"
  status="shipped"
  questions="[
  &#x22;Nobody has listened to a compound reading with a screen reader, and the announcement contract is unwritten.&#x22;,
  &#x22;A compound reading is three tab stops, and one stop with arrow keys inside is untested.&#x22;,
  &#x22;The component mounts no live region, and nobody who depends on speech has checked that effectText arrives in time.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="reading-input" />

## Installation [#installation]

<ComponentInstall name="reading-input" unbuilt="false" importPath="@/components/ui/reading-input" dependencies="[&#x22;lucide-react&#x22;]" registryDependencies="[&#x22;field&#x22;]" />

## Usage [#usage]

```tsx
import { ReadingInput } from "@/components/ui/reading-input"
```

```tsx
<ReadingInput
  label="Example measurement"
  unit={unit}
  units={["kg", "lb", "st"]}
  precision={1}
  value={reading}
  onChange={(next) => {
    setReading(next.value)
    setUnit(next.unit)
  }}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Anywhere a person types a measurement of themselves: weight, blood pressure, glucose, temperature.&#x22;,
  &#x22;Entry where the unit is part of the value.&#x22;,
  &#x22;Entry where the product flags an unlikely number without stopping anybody.&#x22;,
]"
  avoid="[
  { case: &#x22;A quantity, a count or a code, not a measurement of a person.&#x22;, instead: &#x22;number-field&#x22; },
  { case: &#x22;The value is best chosen from a small set rather than typed.&#x22;, instead: &#x22;segmented-control&#x22; },
  { case: &#x22;The reader estimates the value, such as a pain score, not measures it.&#x22;, instead: &#x22;scale-input&#x22; },
  { case: &#x22;You are displaying a reading, not collecting one.&#x22;, instead: &#x22;value&#x22; },
  { case: &#x22;Several related measurements captured in one interaction.&#x22;, instead: &#x22;log-sheet&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** That the reader typed this number, in this unit, as a measurement of
themselves. It is always self-reported.

**Never read as.** A validated measurement, a device reading or an assessed value.

**Colour axis.** Neither; the advisory is ordinary foreground.

**Thresholds.** None. The component holds no bound. The product
supplies the finished `warning`, which never sets `aria-invalid`, moves focus or blocks
submission. Staleness belongs to
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx).

**Unit switches.** Only definitional conversions from `tokens/units.json`: kg, lb, st,
°C and °F. mmol/L to mg/dL is refused, because the factor is the substance's molar mass,
so that switch clears the entry and says so. See
[Unit systems](../health/unit-systems.mdx) and
[Numbers, units and precision](../health/numbers-units-precision.mdx).

<SafetyCallout severity="attention" evidence="opinion">
  Changing the unit must never silently change the stored value or keep the typed digits
  without saying so on screen.
</SafetyCallout>

## Anatomy [#anatomy]

<Anatomy
  name="reading-input"
  parts="[
  {
    name: &#x22;ReadingInput&#x22;,
    describes: &#x22;A div for a single reading, a fieldset for a compound one.&#x22;,
  },
  {
    name: &#x22;Field&#x22;,
    describes: &#x22;Embedded whole. Owns the label, descriptions, invalid state and 44px floor.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;ReadingInput.Legend&#x22;,
    describes: &#x22;Compound only: the measurement's name, as a legend.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;ReadingInput.Hint&#x22;,
    describes: &#x22;Compound only: the group's guidance, in every box's description.&#x22;,
    prop: &#x22;hint&#x22;,
  },
  {
    name: &#x22;ReadingInput.Control&#x22;,
    describes: &#x22;The row of boxes and the unit. It wraps at 200% text.&#x22;,
  },
  {
    name: &#x22;ReadingInput.Segment&#x22;,
    describes: &#x22;One labelled box carrying data-opsinjs-value, one per part.&#x22;,
    prop: &#x22;segments&#x22;,
  },
  {
    name: &#x22;ReadingInput.Unit&#x22;,
    describes: &#x22;A native select at the 44px floor, or plain text for one unit.&#x22;,
    prop: &#x22;units&#x22;,
  },
  {
    name: &#x22;ReadingInput.Warning&#x22;,
    describes: &#x22;The product's advisory, as a description rather than an error.&#x22;,
    prop: &#x22;warning&#x22;,
  },
  {
    name: &#x22;ReadingInput.Effect&#x22;,
    describes: &#x22;What a switch did: converted, cleared or restored. Gone once a box is touched.&#x22;,
  },
]"
/>

## Examples [#examples]

### Two numbers that are one measurement [#two-numbers-that-are-one-measurement]

`segments` turns the field into a `<fieldset>` with a `<legend>`, one box per part.

<ComponentPreview name="reading-input-a-compound-reading" kind="example" align="start" />

### Switching the unit, and the sentence that follows it [#switching-the-unit-and-the-sentence-that-follows-it]

`precision` sets the decimal places a conversion rounds to.

<ComponentPreview name="reading-input-switching-the-unit" kind="example" align="start" />

### A sentence the product wrote [#a-sentence-the-product-wrote]

`warning` shows the product's sentence without comparing anything.

<ComponentPreview name="reading-input-a-warning-the-product-owns" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Label the measurement in the reader's words and put the unit beside the field, not in
the label. Hints show the shape of an answer, never a sample reading and never a
bound. Advisories ask a question and offer the likely fix.

<DoDont>
  <DoDont.Do>
    **"That is a long way above most readings. Did you mean to type a decimal point?"**
  </DoDont.Do>

  <DoDont.Dont>
    **"Invalid value. That reading is outside the accepted range."** blames the reader.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA** in a source pass and a rendered pass, and
`pnpm run check:a11y` runs on every commit. The author ran the audit, so it is not
independent, and clinical review is pending.

**Fixed in this pass.** `autoComplete` is now a prop reaching `Field.Control`, defaulting
to `"off"`, because a self-reported reading has no WCAG autofill token and the component
invents none (1.3.5).

**From the source.** `Field` renders a real `<label for>`, and a compound reading is a
`<fieldset>` of one `Field` per part. The unit's spoken form joins the box's description,
each option carries its plural on `aria-label`, and the box is `type="text"` with
`inputMode` defaulting to `decimal`. The advisory and the effect line are descriptions:
no `aria-invalid` and no focus move.

**Left to the product.** The effect line reporting a switch sits in no live region, so a
product must wire `effectText` to a polite announcer. Without that, a non-sighted reader
hears the new unit but not that the number changed.

**Not gated.** No hit area is measured, and the boundary inherits `--border` without a
gated pair.

<KeyboardTable
  name="reading-input"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves to the box, then the unit switch&#x22;,
    notes: &#x22;Three stops on a compound reading: two boxes and the switch.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves backwards through the same stops&#x22;,
    notes: &#x22;Reverse order matches the visual order.&#x22;,
  },
  {
    keys: &#x22;Any digit, . or ,&#x22;,
    action: &#x22;Types into the focused box&#x22;,
    notes: &#x22;Nothing is rejected, clamped or reformatted while typing.&#x22;,
  },
  {
    keys: &#x22;Up / Down&#x22;,
    action: &#x22;Changes the unit when the switch has focus&#x22;,
    notes: &#x22;Native select. The entry converts or clears, and the line says which.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Whatever the surrounding form does&#x22;,
    notes: &#x22;Nothing is swallowed. Set enterKeyHint to name the return key's action.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Nothing here&#x22;,
    notes: &#x22;No overlay to dismiss, no entry to revert.&#x22;,
  },
]"
/>

<ContrastReport component="reading-input" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="reading-input"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root, the control row, every segment and the unit, always.&#x22;,
    value: &#x22;reading-input, reading-input-control, reading-input-segment, reading-input-unit&#x22;,
  },
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;Legend and hint on a compound reading, warning when supplied, effect after a switch.&#x22;,
    value: &#x22;reading-input-legend, reading-input-hint, reading-input-warning, reading-input-effect&#x22;,
  },
  {
    attribute: &#x22;data-opsinjs-value&#x22;,
    condition: &#x22;On every ReadingInput.Segment, always&#x22;,
    value: &#x22;The number in that box, read off the box not the prop&#x22;,
  },
  {
    attribute: &#x22;data-status, data-category&#x22;,
    condition: &#x22;Never, at any value of any prop&#x22;,
    value: &#x22;Never set&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="ReadingInputProps" />

`unit` is required and `value` is in it. Without `precision` a conversion is not rounded.
There is no `error` prop: an error belongs to `Field`.

## Related [#related]

* [Field](./field.mdx) is what this is built on, without a unit or an advisory.
* [NumberField](./number-field.mdx) is a plain numeric input with no clinical semantics.
* [Value](./value.mdx) is the display counterpart, and both must agree on precision and units.
* [ResultCard](./result-card.mdx) is where a compound reading goes, in the same `segments` shape.
* [ScaleInput](./scale-input.mdx) is for estimated scores such as pain, which are not measurements.
