---
title: "ScoreDial"
description: "A single composite score shown on a dial with its band named in words, alongside an honest statement of what went into the number."
url: "https://opsinjs.pensievelabs.org/components/score-dial"
source: "https://opsinjs.pensievelabs.org/components/score-dial.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["gauge", "dial", "ring", "score", "index", "bmi"]
governedBy: ["reference-ranges", "risk-and-statistics", "numbers-units-precision", "two-colour-axes", "motion-in-health-ui", "category-identity"]
usedIn: ["choose-a-component", "health-metric-card", "results-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="score-dial"
  status="shipped"
  questions="[
  &#x22;No provenance field exists, and nothing checks that the derivation names which inputs were device estimates and which the reader typed.&#x22;,
  &#x22;The indicator against its tinted band fails the non-text contrast floor in both themes, and whether that arc may ship is open.&#x22;,
  &#x22;The component mounts no live region, so what a screen reader hears when a score changes is unspecified.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="score-dial" />

## Installation [#installation]

<ComponentInstall name="score-dial" unbuilt="false" importPath="@/components/ui/score-dial" dependencies="[&#x22;lucide-react&#x22;]" registryDependencies="[&#x22;value&#x22;, &#x22;status-pill&#x22;]" />

## Usage [#usage]

```tsx
import { ScoreDial } from "@/components/ui/score-dial"
```

```tsx
<ScoreDial
  label="Example composite score"
  value={14}
  min={10}
  max={20}
  precision={0}
  bands={[]}
  derivation="Example data rather than a reference range. The score and the scale here are invented."
  calculatedAt="2026-03-14T08:12:00+00:00"
/>
```

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

<WhenToUse
  use="[
  &#x22;A questionnaire total, a readiness index, or any score you calculated, meaningful only against its own bands.&#x22;,
  &#x22;A bounded score whose scale the reader has no intuition for, where position carries more than digits.&#x22;,
  &#x22;A score whose derivation you can state in one sentence, on the same screen.&#x22;,
]"
  avoid="[
  { case: &#x22;The number is a measurement rather than a calculation, with a published reference range.&#x22;, instead: &#x22;range-bar&#x22; },
  { case: &#x22;You cannot say what went into the score, or it is a risk or probability, which a dial must never draw as an angle.&#x22;, instead: &#x22;value&#x22; },
  { case: &#x22;The score is only meaningful as a trajectory, so one number says nothing alone.&#x22;, instead: &#x22;trend-sparkline&#x22; },
  { case: &#x22;You are showing completion of something the person is doing, such as steps against a goal.&#x22;, instead: &#x22;progress&#x22; },
  { case: &#x22;The score crossed a threshold that needs action now. The dial refuses attention and urgent.&#x22;, instead: &#x22;care-card&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** The product computed a number from stated inputs, and it fell in a named band.
It asserts a calculation, never a fact about the reader's body.

**Never read as.** A measurement, a diagnosis, a percentage or a rank.
[Risk and statistics](../health/risk-and-statistics.mdx) keeps probabilities off it. The
scale reads *on a scale of 10 to 20*, never *out of 20*.

**Colour axis.** Status sits on the indicator and the band the score fell in, only when the
product passed `status`. Other bands stay neutral. Category tints the label alone, so
[the two colour axes](../health/two-colour-axes.mdx) never meet.

**Thresholds.** The product owns every band and cut-off; opsinjs ships none.
`ScoreBand.source` is required, as [Reference ranges](../health/reference-ranges.mdx)
demands. `status` is an input, never derived. `attention` and `urgent` are refused.

**Staleness.** `calculatedAt` renders as a plain date, with no staleness treatment. Add a RelativeTime when an old score must read as old.

<SafetyCallout severity="attention" evidence="opinion">
  When only part of the input data was available, `coverage` must say so. Name what was
  counted in your derivation; it will not guess a noun.
</SafetyCallout>

## Anatomy [#anatomy]

<Anatomy
  name="score-dial"
  parts="[
  {
    name: &#x22;ScoreDial&#x22;,
    describes: &#x22;The root. A stacked column, no colour, no tab stop.&#x22;,
    prop: &#x22;className&#x22;,
  },
  {
    name: &#x22;ScoreDial.Label&#x22;,
    describes: &#x22;What the score is called. The only part category tints.&#x22;,
    prop: &#x22;category&#x22;,
  },
  {
    name: &#x22;ScoreDial.Track&#x22;,
    describes: &#x22;The SVG, role=\&#x22;img\&#x22;. Never a meter, a progressbar or a slider.&#x22;,
    prop: &#x22;min, max&#x22;,
  },
  {
    name: &#x22;ScoreDial.Band&#x22;,
    describes: &#x22;One arc per band, neutral unless the score fell there with a status.&#x22;,
    prop: &#x22;bands&#x22;,
  },
  {
    name: &#x22;ScoreDial.Boundary&#x22;,
    describes: &#x22;A radial mark at each internal band edge, so bands survive greyscale.&#x22;,
  },
  {
    name: &#x22;ScoreDial.Indicator&#x22;,
    describes: &#x22;The score's position. Absent with no score, no width, or off-scale.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;ScoreDial.Reading&#x22;,
    describes: &#x22;The visible text twin: number, band name and StatusPill.&#x22;,
    prop: &#x22;value, precision, locale, status&#x22;,
  },
  {
    name: &#x22;ScoreDial.Coverage&#x22;,
    describes: &#x22;The partial-data sentence, only when coverage is short.&#x22;,
    prop: &#x22;coverage&#x22;,
  },
  {
    name: &#x22;ScoreDial.Scale&#x22;,
    describes: &#x22;The bounds in words, each band with its range, and whose bands.&#x22;,
    prop: &#x22;min, max, bands&#x22;,
  },
  {
    name: &#x22;ScoreDial.Derivation&#x22;,
    describes: &#x22;What went into the score, over what window. Always shown.&#x22;,
    prop: &#x22;derivation&#x22;,
  },
  {
    name: &#x22;ScoreDial.CalculatedAt&#x22;,
    describes: &#x22;When the score was calculated. Without an instant it says nobody knows.&#x22;,
    prop: &#x22;calculatedAt&#x22;,
  },
]"
/>

## Examples [#examples]

### opsinjs ships no bands [#opsinjs-ships-no-bands]

With an empty `bands`, the dial draws the scale, places the number and names no band.

<ComponentPreview name="score-dial-no-bands-supplied" kind="example" align="start" />

### A score built from less than usual [#a-score-built-from-less-than-usual]

`coverage` puts the gap on the dial's face. `status="watch"` is a prop on the dial, not a
field on a band.

<ComponentPreview name="score-dial-partial-data" kind="example" align="start" />

### Zero is a score; absence is not [#zero-is-a-score-absence-is-not]

`value={0}` reached the bottom of its scale. `value={null}` never ran, so nothing is placed.

<ComponentPreview name="score-dial-zero-is-not-absence" kind="example" align="start" />

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is author-run,
not an independent review, and clinical review is still pending. It found nothing to change:
no serious defect in the source, and a clean rendered pass over axe, target size and
320px reflow. There are no interactive elements, so keyboard and pointer criteria pass
vacuously.

**Argued from the source.**

* The graphic is `role="img"` with a complete `aria-label` and `focusable="false"`, never a
  `meter`, `progressbar` or `slider`, which would assert a within-range value it may not make.
* Every status tint carries a word and a glyph and stamps `data-status`, never colour alone.
* The band name, scale, source and derivation are visible text no prop removes, the layout
  is one stacked column that never truncates at 200% text, and nothing animates.

**Deferred.** No live region mounts, because it is static and read-only, so nothing
announces a score change; adding one is a recommended enhancement, not a fix. The tinted band
strokes below the non-text floor in light, left as-is because the band name, the StatusPill
word and glyph, the indicator and `data-status` all say the same thing.

**Unmeasured.** The pair the arc stroke forms against its card is not in the measured set;
the source records it and types no number.

<KeyboardTable
  name="score-dial"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the dial&#x22;,
    notes: &#x22;A picture of a reading, not a control.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the dial&#x22;,
    notes: &#x22;The SVG carries focusable=\&#x22;false\&#x22;.&#x22;,
  },
]"
/>

<ContrastReport component="score-dial" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="score-dial"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On every part that renders&#x22;,
    value: &#x22;score-dial, score-dial-label, score-dial-track, score-dial-band, score-dial-boundary, score-dial-indicator, score-dial-reading, score-dial-score, score-dial-band-name, status-pill, score-dial-coverage, score-dial-scale, score-dial-band-source, score-dial-derivation, score-dial-calculated-line, score-dial-calculated-at&#x22;,
  },
  {
    attribute: &#x22;data-status&#x22;,
    condition: &#x22;On the band the score fell in, the indicator and the StatusPill&#x22;,
    value: &#x22;steady | watch&#x22;,
  },
  {
    attribute: &#x22;data-category&#x22;,
    condition: &#x22;On ScoreDial.Label, with a category&#x22;,
    value: &#x22;sleep | heart | activity | nutrition | mind | labs&#x22;,
  },
  {
    attribute: &#x22;data-opsinjs-value&#x22;,
    condition: &#x22;On the Value inside ScoreDial.Score&#x22;,
    value: &#x22;The unrounded number, or empty with no reading&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="ScoreDialProps" />

`bands` is required with no default, and an empty array is legitimate. A `ScoreBand` carries
an inclusive `from`, an exclusive `to`, a `name` in the reader's language and a required
`source`. `status` accepts only `steady | watch`. `precision` is required, as
[Numbers, units and precision](../health/numbers-units-precision.mdx) demands. A non-finite
`value` prints and speaks *not available*.

## Related [#related]

* [RangeBar](./range-bar.mdx) is a measured value against a published range, not a calculation.
* [Progress](./progress.mdx) is a task advancing towards a goal, not a score.
* [GoalRing](./goal-ring.mdx) is the ring the reader fills by doing something.
* [MetricTile](./metric-tile.mdx) is the compact readout, with no room for a derivation.
* [Value](./value.mdx) is honest when the derivation cannot be stated.
