---
title: "GoalRing"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/goal-ring"
source: "https://opsinjs.pensievelabs.org/components/goal-ring.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["activity ring", "close your rings", "daily goal"]
governedBy: ["two-colour-axes", "category-identity", "numbers-units-precision", "motion-in-health-ui"]
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="goal-ring"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured: the fill and the track sit on whatever ground the host paints, not a matching surface the token contract tunes against.&#x22;,
  &#x22;Nobody has listened to the ring in a screen reader, so whether the accessible name and the same three visible lines read as thorough or as a stammer is unheard.&#x22;,
  &#x22;No gate stops a product composing a streak counter beside the ring, which undoes the refusal to reward a streak that the code keeps.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="goal-ring" />

## Installation [#installation]

<ComponentInstall name="goal-ring" unbuilt="false" importPath="@/components/ui/goal-ring" />

## Usage [#usage]

```tsx
import { GoalRing } from "@/components/ui/goal-ring"
```

```tsx
<GoalRing
  label="Steps today"
  value={6200}
  goal={8000}
  unit="steps"
  category="activity"
/>
```

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

<WhenToUse
  use="[
  &#x22;A single reading against a goal the product set, where the goal is the product's own rather than a clinical target.&#x22;,
  &#x22;A daily count that resets, such as steps, active minutes or water, where the words carry the exact figures.&#x22;,
]"
  avoid="[
  { case: &#x22;A composite number the product calculated, with named bands. That is a score against a scale, not progress towards a goal.&#x22;, instead: &#x22;score-dial&#x22; },
  { case: &#x22;One reading on its own, with its unit and when it was taken, and no goal to measure it against.&#x22;, instead: &#x22;metric-tile&#x22; },
  { case: &#x22;A reading against a reference range, where the meaning is which band it falls in rather than how far along a target it is.&#x22;, instead: &#x22;range-bar&#x22; },
]"
/>

## Clinical meaning [#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](../health/numbers-units-precision.mdx), unit after. The
fill is drawn where the reading sits, and
[nothing sweeps](../health/motion-in-health-ui.mdx) 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](../health/reference-ranges.mdx).

**Colour axis.** [Category only](../health/two-colour-axes.mdx). The fill takes
the [identity tint](../health/category-identity.mdx) 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 [#anatomy]

<Anatomy
  name="goal-ring"
  parts="[
  {
    name: &#x22;GoalRing&#x22;,
    describes: &#x22;The root, a stacked column holding the ring above its words. No colour, no axis attribute, no fixed height.&#x22;,
    prop: &#x22;className&#x22;,
  },
  {
    name: &#x22;GoalRing.Ring&#x22;,
    describes: &#x22;The SVG, role=\&#x22;img\&#x22;, with an accessible name repeating the label, readout and progress sentence. Never focusable.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;GoalRing.Track&#x22;,
    describes: &#x22;The whole ring, always fully drawn in the neutral border role. Never a category or a status colour.&#x22;,
  },
  {
    name: &#x22;GoalRing.Fill&#x22;,
    describes: &#x22;The arc from the top, its length the fraction reached. Carries data-category, never data-status. Absent at zero progress.&#x22;,
    prop: &#x22;category&#x22;,
  },
  {
    name: &#x22;GoalRing.Value&#x22;,
    describes: &#x22;The readout: the real value against the goal in the reader's locale, unit after. A value past the goal prints truthfully.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;GoalRing.Derivation&#x22;,
    describes: &#x22;The progress sentence, derived from value and goal alone. States a met goal as a fact and never celebrates.&#x22;,
  },
]"
/>

## Examples [#examples]

### Progress towards a goal [#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.

<ComponentPreview name="goal-ring-progress-to-a-goal" kind="example" align="start" />

### No reading yet [#no-reading-yet]

`value={null}` draws an empty ring and words that say nothing has been measured,
never a zero.

<ComponentPreview name="goal-ring-no-reading-yet" kind="example" align="start" />

## Content guidelines [#content-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.

<DoDont>
  <DoDont.Do>
    **"Steps today", "6,200 of 8,000 steps"** prints the real figures against the product's goal.
  </DoDont.Do>

  <DoDont.Dont>
    **"Great job! 3 day streak, keep it up!"** beside a closed ring turns a reading into a game.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Checked by `pnpm run check:a11y` on every commit.**

* No element carries both axes: the fill carries `data-category` alone and nothing stamps `data-status`.
* No raw colour literal and no ramp step. The track is the neutral border role and the fill a category `-accent` role.
* No type size in pixels: label, readout and sentence are the `subheadline`, `title2` and `footnote` steps.

**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.

<KeyboardTable
  name="goal-ring"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the whole component&#x22;,
    notes: &#x22;A labelled image, not a control.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the whole component&#x22;,
    notes: &#x22;Never in the focus order.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;Nothing to select or change.&#x22;,
  },
]"
/>

<ContrastReport component="goal-ring" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="goal-ring"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every part but the svg, always&#x22;,
    value: &#x22;goal-ring, goal-ring-track, goal-ring-fill, goal-ring-words, goal-ring-label, goal-ring-value, goal-ring-derivation&#x22;,
  },
  {
    attribute: &#x22;data-category&#x22;,
    condition: &#x22;On the fill, while it is drawn with a known category&#x22;,
    value: &#x22;The category name, such as activity. Absent when no category is given.&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="GoalRingProps" />

`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 [#related]

* [ScoreDial](./score-dial.mdx) is a composite number against a scale with named bands.
* [MetricTile](./metric-tile.mdx) is one reading on its own, with no goal to measure it against.
* [RangeBar](./range-bar.mdx) is a reading against a reference range, where the band it lands in is the meaning.
