GoalRing
A ring that shows how far a reading has come towards a goal the product set, paired with a text twin that states every figure, and rewarding no streak.
Preview
goal-ring · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/goal-ring
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 { GoalRing } from "@/components/ui/goal-ring"<GoalRing
label="Steps today"
value={6200}
goal={8000}
unit="steps"
category="activity"
/>When to use it
Use it when
- A single reading against a goal the product set, where the goal is the product's own rather than a clinical target.
- A daily count that resets, such as steps, active minutes or water, where the words carry the exact figures.
Do not use it when
- A composite number the product calculated, with named bands. That is a score against a scale, not progress towards a goal.Use
score-dialinstead. - One reading on its own, with its unit and when it was taken, and no goal to measure it against.Use
metric-tileinstead. - A reading against a reference range, where the meaning is which band it falls in rather than how far along a target it is.Use
range-barinstead.
Clinical meaning
Asserts. One thing: this value is this far towards a goal the product set. The fraction is arithmetic over the two values, and the readout prints both in the reader's locale, unit after. The fill is drawn where the reading sits, and nothing sweeps on first paint.
Never read as. A diagnosis, a verdict on the reader, or a target opsinjs chose. A full ring means the product's goal was met, never that the reader is well, and an open ring never means something is wrong. It rewards no streak, and "normal" and "abnormal" appear in no state it draws.
Colour axis. Category only. The fill takes
the identity tint of what the ring counts,
the track stays neutral, and no element carries data-status.
Thresholds. None. The product owns the goal, the label and the unit; GoalRing ships no default goal and no clinical vocabulary.
Anatomy
GoalRingThe root, a stacked column holding the ring above its words. No colour, no axis attribute, no fixed height.Controlled byclassNameGoalRing.RingThe SVG, role="img", with an accessible name repeating the label, readout and progress sentence. Never focusable.Controlled byvalueGoalRing.TrackThe whole ring, always fully drawn in the neutral border role. Never a category or a status colour.GoalRing.FillThe arc from the top, its length the fraction reached. Carries data-category, never data-status. Absent at zero progress.Controlled bycategoryGoalRing.ValueThe readout: the real value against the goal in the reader's locale, unit after. A value past the goal prints truthfully.Controlled byvalueGoalRing.DerivationThe progress sentence, derived from value and goal alone. States a met goal as a fact and never celebrates.
Examples
Progress towards a goal
A synthetic step count part-way towards a goal the product set.
category="activity" tints the fill and the sentence beneath names the progress
without praise.
goal-ring-progress-to-a-goal · base base · style base-lyraOpen under the product themeNo reading yet
value={null} draws an empty ring and words that say nothing has been measured,
never a zero.
goal-ring-no-reading-yet · base base · style base-lyraOpen under the product themeContent guidelines
Write the label as the reader's own name for what the ring counts: "Steps today", not a metric code. Keep the unit in their words too. Never phrase the copy as a challenge, and never put a streak counter or celebration beside it. When the goal is met the ring says so and stops.
Do
"Steps today", "6,200 of 8,000 steps" prints the real figures against the product's goal.
Don’t
"Great job! 3 day streak, keep it up!" beside a closed ring turns a reading into a game.
Accessibility
Checked by pnpm run check:a11y on every commit.
- No element carries both axes: the fill carries
data-categoryalone and nothing stampsdata-status. - No raw colour literal and no ramp step. The track is the neutral border role and the fill a category
-accentrole. - No type size in pixels: label, readout and sentence are the
subheadline,title2andfootnotesteps.
Audited against WCAG 2.2 AA. A source pass and a rendered pass both ran and
found nothing to change in the source. This is an author audit, not an
independent review, and clinical review is still pending. The graphic is
role="img" with an accessible name that speaks the label, the value against the
goal and the progress sentence, all repeated in the visible words beside it.
Nothing takes focus or animates on first paint, so there is no keyboard contract.
Three states stay three: a finite value draws a fill, null says "no reading yet",
a broken value says "not available". Under forced colours the track maps to
CanvasText and the fill to Highlight.
Residual. No contrast pair is measured, so the page prints the contrast pairs as unmeasured rather than as a pass. The arc and track repeat every fact in the visible text and the accessible name, so under 1.4.11 they are not graphical objects required to understand the content. A read-only display carries no live region, so a screen reader is not told when the value changes over time; the product owns whether to wrap the ring in a live region.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the whole component | A labelled image, not a control. |
| Shift+Tab | Skips the whole component | Never in the focus order. |
| Arrow keys | Nothing | Nothing to select or change. |
| 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 |
| 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 |
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 part but the svg, always | goal-ring, goal-ring-track, goal-ring-fill, goal-ring-words, goal-ring-label, goal-ring-value, goal-ring-derivation |
| data-category | On the fill, while it is drawn with a known category | The category name, such as activity. Absent when no category is given. |
API reference
Prop
Type
Generated from GoalRingProps in registry/bases/base/goal-ring.tsx.
value is number | null: null is no reading and is never drawn as zero, and a
non-finite value is announced as "not available". A goal of zero or less draws
an empty ring and raises a development warning. category outside the six
known names is refused with a development warning and the fill falls back to
neutral.
Related
- ScoreDial is a composite number against a scale with named bands.
- MetricTile is one reading on its own, with no goal to measure it against.
- RangeBar is a reading against a reference range, where the band it lands in is the meaning.
TimelineEntry
One dated event in a vertical history, with a neutral rail and marker, the time written the way people say it, and any clinical status shown as a nested pill.
AlertBanner
A message that appears at the top of a screen because something needs your attention now, saying what has happened and what to do about it.