ScoreDial
A single composite score shown on a dial with its band named in words, alongside an honest statement of what went into the number.
Preview
score-dial · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/score-dial
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 { ScoreDial } from "@/components/ui/score-dial"<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
Use it when
- A questionnaire total, a readiness index, or any score you calculated, meaningful only against its own bands.
- A bounded score whose scale the reader has no intuition for, where position carries more than digits.
- A score whose derivation you can state in one sentence, on the same screen.
Do not use it when
- The number is a measurement rather than a calculation, with a published reference range.Use
range-barinstead. - You cannot say what went into the score, or it is a risk or probability, which a dial must never draw as an angle.Use
valueinstead. - The score is only meaningful as a trajectory, so one number says nothing alone.Use
trend-sparklineinstead. - You are showing completion of something the person is doing, such as steps against a goal.Use
progressinstead. - The score crossed a threshold that needs action now. The dial refuses attention and urgent.Use
care-cardinstead.
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 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 never meet.
Thresholds. The product owns every band and cut-off; opsinjs ships none.
ScoreBand.source is required, as Reference ranges
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.
Anatomy
ScoreDialThe root. A stacked column, no colour, no tab stop.Controlled byclassNameScoreDial.LabelWhat the score is called. The only part category tints.Controlled bycategoryScoreDial.TrackThe SVG, role="img". Never a meter, a progressbar or a slider.Controlled bymin, maxScoreDial.BandOne arc per band, neutral unless the score fell there with a status.Controlled bybandsScoreDial.BoundaryA radial mark at each internal band edge, so bands survive greyscale.ScoreDial.IndicatorThe score's position. Absent with no score, no width, or off-scale.Controlled byvalueScoreDial.ReadingThe visible text twin: number, band name and StatusPill.Controlled byvalue, precision, locale, statusScoreDial.CoverageThe partial-data sentence, only when coverage is short.Controlled bycoverageScoreDial.ScaleThe bounds in words, each band with its range, and whose bands.Controlled bymin, max, bandsScoreDial.DerivationWhat went into the score, over what window. Always shown.Controlled byderivationScoreDial.CalculatedAtWhen the score was calculated. Without an instant it says nobody knows.Controlled bycalculatedAt
Examples
opsinjs ships no bands
With an empty bands, the dial draws the scale, places the number and names no band.
score-dial-no-bands-supplied · base base · style base-lyraOpen under the product themeA 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.
score-dial-partial-data · base base · style base-lyraOpen under the product themeZero is a score; absence is not
value={0} reached the bottom of its scale. value={null} never ran, so nothing is placed.
score-dial-zero-is-not-absence · base base · style base-lyraOpen under the product themeAccessibility
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 completearia-labelandfocusable="false", never ameter,progressbarorslider, 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.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the dial | A picture of a reading, not a control. |
| Shift+Tab | Skips the dial | The SVG carries focusable="false". |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| activity ink on activity surface | light | 88.5 | 9.61:1 | Pass |
| activity ink on activity surface | dark | -83.1 | 12.58:1 | Pass |
| activity line on activity surface | light | 67.6 | 4.42:1 | Pass |
| activity line on activity surface | dark | -69.0 | 9.93:1 | Pass |
| activity accent on activity surface | light | 44.1 | 2.34:1 | Below floor |
| activity accent on activity surface | dark | -53.3 | 7.26:1 | Pass |
| activity accent on the page | light | 46.1 | 2.41:1 | Below floor |
| activity accent on the page | dark | -54.6 | 8.22:1 | Pass |
| activity ink on the page | light | 90.5 | 9.90:1 | Pass |
| activity ink on the page | dark | -84.4 | 14.24:1 | Pass |
| activity line on the band | light | 66.1 | 4.32:1 | Pass |
| activity line on the band | dark | -67.4 | 8.60:1 | Pass |
| heart ink on heart surface | light | 89.1 | 10.51:1 | Pass |
| heart ink on heart surface | dark | -80.5 | 12.55:1 | Pass |
| heart line on heart surface | light | 70.4 | 5.05:1 | Pass |
| heart line on heart surface | dark | -64.5 | 9.46:1 | Pass |
| heart accent on heart surface | light | 60.3 | 3.64:1 | Pass |
| heart accent on heart surface | dark | -48.2 | 6.68:1 | Pass |
| heart accent on the page | light | 63.2 | 3.81:1 | Pass |
| heart accent on the page | dark | -49.5 | 7.27:1 | Pass |
| heart ink on the page | light | 92.0 | 10.97:1 | Pass |
| heart ink on the page | dark | -81.8 | 13.65:1 | Pass |
| heart line on the band | light | 69.9 | 5.01:1 | Pass |
| heart line on the band | dark | -62.8 | 7.88:1 | Pass |
| labs ink on labs surface | light | 88.8 | 9.78:1 | Pass |
| labs ink on labs surface | dark | -83.1 | 12.69:1 | Pass |
| labs line on labs surface | light | 67.9 | 4.51:1 | Pass |
| labs line on labs surface | dark | -68.4 | 9.90:1 | Pass |
| labs accent on labs surface | light | 59.3 | 3.49:1 | Pass |
| labs accent on labs surface | dark | -52.7 | 7.21:1 | Pass |
| labs accent on the page | light | 61.5 | 3.60:1 | Pass |
| labs accent on the page | dark | -53.9 | 8.07:1 | Pass |
| labs ink on the page | light | 90.9 | 10.09:1 | Pass |
| labs ink on the page | dark | -84.3 | 14.22:1 | Pass |
| labs line on the band | light | 66.5 | 4.42:1 | Pass |
| labs line on the band | dark | -66.7 | 8.49:1 | Pass |
| mind ink on mind surface | light | 89.9 | 10.36:1 | Pass |
| mind ink on mind surface | dark | -81.3 | 12.56:1 | Pass |
| mind line on mind surface | light | 70.7 | 4.94:1 | Pass |
| mind line on mind surface | dark | -65.4 | 9.54:1 | Pass |
| mind accent on mind surface | light | 60.0 | 3.55:1 | Pass |
| mind accent on mind surface | dark | -49.1 | 6.77:1 | Pass |
| mind accent on the page | light | 62.8 | 3.70:1 | Pass |
| mind accent on the page | dark | -50.0 | 7.42:1 | Pass |
| mind ink on the page | light | 92.7 | 10.80:1 | Pass |
| mind ink on the page | dark | -82.2 | 13.77:1 | Pass |
| mind line on the band | light | 70.0 | 4.89:1 | Pass |
| mind line on the band | dark | -63.4 | 8.00:1 | Pass |
| nutrition ink on nutrition surface | light | 89.3 | 10.06:1 | Pass |
| nutrition ink on nutrition surface | dark | -81.9 | 12.55:1 | Pass |
| nutrition line on nutrition surface | light | 69.7 | 4.78:1 | Pass |
| nutrition line on nutrition surface | dark | -66.7 | 9.66:1 | Pass |
| nutrition accent on nutrition surface | light | 39.2 | 2.11:1 | Below floor |
| nutrition accent on nutrition surface | dark | -50.2 | 6.88:1 | Pass |
| nutrition accent on the page | light | 41.8 | 2.19:1 | Below floor |
| nutrition accent on the page | dark | -51.3 | 7.63:1 | Pass |
| nutrition ink on the page | light | 91.9 | 10.44:1 | Pass |
| nutrition ink on the page | dark | -82.9 | 13.91:1 | Pass |
| nutrition line on the band | light | 68.8 | 4.71:1 | Pass |
| nutrition line on the band | dark | -64.8 | 8.19:1 | Pass |
| sleep ink on sleep surface | light | 89.6 | 10.19:1 | Pass |
| sleep ink on sleep surface | dark | -81.9 | 12.60:1 | Pass |
| sleep line on sleep surface | light | 70.0 | 4.80:1 | Pass |
| sleep line on sleep surface | dark | -66.4 | 9.65:1 | Pass |
| sleep accent on sleep surface | light | 64.6 | 4.05:1 | Pass |
| sleep accent on sleep surface | dark | -50.4 | 6.94:1 | Pass |
| sleep accent on the page | light | 67.3 | 4.22:1 | Pass |
| sleep accent on the page | dark | -51.4 | 7.67:1 | Pass |
| sleep ink on the page | light | 92.3 | 10.60:1 | Pass |
| sleep ink on the page | dark | -82.9 | 13.92:1 | Pass |
| sleep line on the band | light | 69.1 | 4.75:1 | Pass |
| sleep line on the band | dark | -64.5 | 8.16:1 | Pass |
| attention ink on attention surface | light | 89.4 | 10.35:1 | Pass |
| attention ink on attention surface | dark | -81.1 | 12.54:1 | Pass |
| attention line on attention surface | light | 70.0 | 4.90:1 | Pass |
| attention line on attention surface | dark | -65.5 | 9.54:1 | Pass |
| attention accent on attention surface | light | 65.1 | 4.18:1 | Pass |
| attention accent on attention surface | dark | -49.4 | 6.80:1 | Pass |
| attention ink on the attention band | light | 22.4 | 2.48:1 | Below floor |
| attention ink on the attention band | dark | -31.0 | 1.84:1 | Below floor |
| attention accent on the page | light | 67.8 | 4.35:1 | Pass |
| attention accent on the page | dark | -50.5 | 7.47:1 | Pass |
| attention ink on the page | light | 92.2 | 10.77:1 | Pass |
| attention ink on the page | dark | -82.3 | 13.76:1 | Pass |
| steady ink on steady surface | light | 88.3 | 9.62:1 | Pass |
| steady ink on steady surface | dark | -82.9 | 12.56:1 | Pass |
| steady line on steady surface | light | 67.5 | 4.45:1 | Pass |
| steady line on steady surface | dark | -68.9 | 9.91:1 | Pass |
| steady accent on steady surface | light | 56.4 | 3.21:1 | Pass |
| steady accent on steady surface | dark | -53.2 | 7.25:1 | Pass |
| steady ink on the steady band | light | 30.0 | 3.00:1 | Below floor |
| steady ink on the steady band | dark | -28.8 | 1.73:1 | Below floor |
| steady accent on the page | light | 58.6 | 3.31:1 | Pass |
| steady accent on the page | dark | -54.5 | 8.19:1 | Pass |
| steady ink on the page | light | 90.6 | 9.94:1 | Pass |
| steady ink on the page | dark | -84.2 | 14.20:1 | Pass |
| unknown ink on unknown surface | light | 89.5 | 9.98:1 | Pass |
| unknown ink on unknown surface | dark | -82.2 | 12.56:1 | Pass |
| unknown line on unknown surface | light | 69.2 | 4.64:1 | Pass |
| unknown line on unknown surface | dark | -67.2 | 9.72:1 | Pass |
| unknown accent on unknown surface | light | 43.1 | 2.29:1 | Below floor |
| unknown accent on unknown surface | dark | -51.3 | 7.05:1 | Pass |
| unknown accent on the page | light | 45.6 | 2.37:1 | Below floor |
| unknown accent on the page | dark | -52.3 | 7.84:1 | Pass |
| unknown ink on the page | light | 92.0 | 10.34:1 | Pass |
| unknown ink on the page | dark | -83.1 | 13.97:1 | Pass |
| urgent ink on urgent surface | light | 88.2 | 10.58:1 | Pass |
| urgent ink on urgent surface | dark | -80.3 | 12.52:1 | Pass |
| urgent line on urgent surface | light | 70.0 | 5.09:1 | Pass |
| urgent line on urgent surface | dark | -64.8 | 9.52:1 | Pass |
| urgent accent on urgent surface | light | 72.3 | 5.58:1 | Pass |
| urgent accent on urgent surface | dark | -48.0 | 6.63:1 | Pass |
| urgent ink on the urgent band | light | 13.9 | 1.90:1 | Below floor |
| urgent ink on the urgent band | dark | -31.5 | 1.89:1 | Below floor |
| urgent accent on the page | light | 75.3 | 5.84:1 | Pass |
| urgent accent on the page | dark | -49.5 | 7.24:1 | Pass |
| urgent ink on the page | light | 91.2 | 11.06:1 | Pass |
| urgent ink on the page | dark | -81.8 | 13.66:1 | Pass |
| watch ink on watch surface | light | 89.4 | 10.02:1 | Pass |
| watch ink on watch surface | dark | -82.1 | 12.58:1 | Pass |
| watch line on watch surface | light | 69.5 | 4.72:1 | Pass |
| watch line on watch surface | dark | -66.8 | 9.68:1 | Pass |
| watch accent on watch surface | light | 61.7 | 3.72:1 | Pass |
| watch accent on watch surface | dark | -50.7 | 6.95:1 | Pass |
| watch ink on the watch band | light | 25.7 | 2.70:1 | Below floor |
| watch ink on the watch band | dark | -30.6 | 1.81:1 | Below floor |
| watch accent on the page | light | 64.2 | 3.85:1 | Pass |
| watch accent on the page | dark | -51.7 | 7.71:1 | Pass |
| watch ink on the page | light | 91.9 | 10.38:1 | Pass |
| watch ink on the page | dark | -83.1 | 13.95:1 | Pass |
| 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 |
| activity ink on the card material | light | 93.6 | 10.34:1 | Pass |
| activity ink on the card material | dark | -83.5 | 12.91:1 | Pass |
| heart ink on the card material | light | 95.1 | 11.47:1 | Pass |
| heart ink on the card material | dark | -80.9 | 12.38:1 | Pass |
| labs ink on the card material | light | 94.0 | 10.54:1 | Pass |
| labs ink on the card material | dark | -83.5 | 12.90:1 | Pass |
| mind ink on the card material | light | 95.7 | 11.28:1 | Pass |
| mind ink on the card material | dark | -81.4 | 12.49:1 | Pass |
| nutrition ink on the card material | light | 95.0 | 10.91:1 | Pass |
| nutrition ink on the card material | dark | -82.0 | 12.61:1 | Pass |
| sleep ink on the card material | light | 95.4 | 11.08:1 | Pass |
| sleep ink on the card material | dark | -82.1 | 12.62:1 | Pass |
| attention surface on the card material | light | 0.0 | 1.09:1 | Below floor |
| attention surface on the card material | dark | 0.0 | 1.00:1 | Below floor |
| steady surface on the card material | light | 0.0 | 1.08:1 | Below floor |
| steady surface on the card material | dark | 0.0 | 1.03:1 | Below floor |
| urgent surface on the card material | light | 0.0 | 1.09:1 | Below floor |
| urgent surface on the card material | dark | 0.0 | 1.01:1 | Below floor |
| watch surface on the card material | light | 0.0 | 1.08:1 | Below floor |
| watch surface on the card material | dark | 0.0 | 1.01:1 | Below floor |
| the focus ring on the attention surface | light | 67.3 | 4.44:1 | Pass |
| the focus ring on the attention surface | dark | -45.9 | 6.30:1 | Pass |
| attention ink on the neutral hover fill | light | 88.6 | 10.23:1 | Pass |
| attention ink on the neutral hover fill | dark | -79.3 | 10.53:1 | Pass |
| the focus ring on the steady surface | light | 67.8 | 4.47:1 | Pass |
| the focus ring on the steady surface | dark | -45.8 | 6.12:1 | Pass |
| steady ink on the neutral hover fill | light | 87.0 | 9.43:1 | Pass |
| steady ink on the neutral hover fill | dark | -81.3 | 10.86:1 | Pass |
| the focus ring on the urgent surface | light | 67.0 | 4.42:1 | Pass |
| the focus ring on the urgent surface | dark | -45.5 | 6.34:1 | Pass |
| urgent ink on the neutral hover fill | light | 87.7 | 10.50:1 | Pass |
| urgent ink on the neutral hover fill | dark | -78.9 | 10.46:1 | Pass |
| the focus ring on the watch surface | light | 67.6 | 4.46:1 | Pass |
| the focus ring on the watch surface | dark | -46.0 | 6.24:1 | Pass |
| watch ink on the neutral hover fill | light | 88.3 | 9.86:1 | Pass |
| watch ink on the neutral hover fill | dark | -80.1 | 10.67:1 | Pass |
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 every part that renders | 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 |
| data-status | On the band the score fell in, the indicator and the StatusPill | steady | watch |
| data-category | On ScoreDial.Label, with a category | sleep | heart | activity | nutrition | mind | labs |
| data-opsinjs-value | On the Value inside ScoreDial.Score | The unrounded number, or empty with no reading |
API reference
Prop
Type
Generated from ScoreDialProps in registry/bases/base/score-dial.tsx.
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 demands. A non-finite
value prints and speaks not available.
Related
- RangeBar is a measured value against a published range, not a calculation.
- Progress is a task advancing towards a goal, not a score.
- GoalRing is the ring the reader fills by doing something.
- MetricTile is the compact readout, with no room for a derivation.
- Value is honest when the derivation cannot be stated.