---
title: "DoseTracker"
description: "A record of the medicine doses a product has already logged, drawing each as a fact with a word and a shape, never computing adherence or judging a missed dose."
url: "https://opsinjs.pensievelabs.org/components/dose-tracker"
source: "https://opsinjs.pensievelabs.org/components/dose-tracker.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-input"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["medication", "pill tracker", "dose", "adherence tracker"]
governedBy: ["clinical-interaction-guidelines", "uncertainty-and-staleness", "regulatory-context"]
usedIn: ["diabetes-medicines-app"]
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="dose-tracker"
  status="shipped"
  questions="[
  &#x22;Nothing in the code stops a product summing the taken rows into an adherence figure, or tinting a missed row through className. No lint on a consumer's screen catches it.&#x22;,
  &#x22;Nobody has listened to this list in a screen reader. Whether the note is heard as part of its entry, and whether the list needs its own name, is untested.&#x22;,
  &#x22;No contrast pair has been measured. Each marker is a neutral hairline chip on whatever ground the host provides, so the floors are argued rather than checked.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="dose-tracker" />

## Installation [#installation]

<ComponentInstall name="dose-tracker" unbuilt="false" importPath="@/components/ui/dose-tracker" dependencies="[&#x22;lucide-react&#x22;]" registryDependencies="[&#x22;relative-time&#x22;]" />

## Usage [#usage]

```tsx
import { DoseTracker } from "@/components/ui/dose-tracker"
```

```tsx
<DoseTracker
  label="Today's doses"
  now={new Date().toISOString()}
  entries={[
    { name: "Morning tablet", time: "2026-03-14T08:00:00+00:00", state: "taken" },
    { name: "Afternoon tablet", time: "2026-03-14T15:00:00+00:00", state: "missed" },
  ]}
/>
```

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

<WhenToUse
  use="[
  &#x22;A record of medicine doses the product has already logged, taken and missed together, read as history.&#x22;,
  &#x22;A day's doses as plain facts, each state a word and a shape beside its time, nothing computed across rows.&#x22;,
  &#x22;Anywhere the product owns the schedule and the drug data and needs only the log drawn.&#x22;,
]"
  avoid="[
  { case: &#x22;You are logging a general daily entry, such as a symptom or a note, not a dose with a state.&#x22;, instead: &#x22;log-sheet&#x22; },
  { case: &#x22;You need to tell the reader what to do next. That is advice, and this component asserts only facts.&#x22;, instead: &#x22;care-card&#x22; },
  { case: &#x22;You are showing a single reading or figure rather than a list of dose events.&#x22;, instead: &#x22;metric-tile&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** Exactly the log the product handed it: these medicines, at these times, in these states. A "Missed" marker says the product recorded that dose as missed, and nothing further.

**Never read as.** Advice, a diagnosis, an adherence score or an instruction. A "Missed" row names a fact, never a fault and never a next step. It computes no percentage, because that would be a clinical figure nobody authored. [Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx) draws the line between recording a fact and prescribing a response.

**Colour axis.** Neither. A dose state is a fact, not a level of urgency, so no marker carries `data-status` or `data-category`. Each marker is neutral chrome with a word and a distinct shape, so the four states survive greyscale. Painting a missed dose the red that means "act now" is exactly the axis mixing [The two colour axes](../health/two-colour-axes.mdx) forbids.

**Thresholds.** The consuming product, always. DoseTracker ships no schedule, no drug data, no dose, no threshold and no default text. Each would be a clinical claim [Regulatory context](../health/regulatory-context.mdx) says a presentation layer cannot make.

**Time.** Each entry's `time` and `now` go to [RelativeTime](./relative-time.mdx) unmodified, so it owns what a stale or future instant renders as. [Uncertainty and staleness](../health/uncertainty-and-staleness.mdx) is the rule it keeps.

## Anatomy [#anatomy]

<Anatomy
  name="dose-tracker"
  parts="[
  {
    name: &#x22;DoseTracker&#x22;,
    describes: &#x22;The root, an unordered list on neither colour axis, with an optional aria-label naming the log.&#x22;,
    prop: &#x22;entries&#x22;,
  },
  {
    name: &#x22;DoseTracker.Entry&#x22;,
    describes: &#x22;One logged dose, a list item holding the name, the marker, the time and an optional note.&#x22;,
    prop: &#x22;state&#x22;,
  },
  {
    name: &#x22;DoseTracker.Marker&#x22;,
    describes: &#x22;The state told twice: an aria-hidden lucide shape and the word, in neutral chrome, with no data-status.&#x22;,
    prop: &#x22;state&#x22;,
  },
  {
    name: &#x22;DoseTracker.Name&#x22;,
    describes: &#x22;The medicine's name, in the product's own words. A placeholder in every example, never a real drug.&#x22;,
    prop: &#x22;name&#x22;,
  },
  {
    name: &#x22;DoseTracker.Time&#x22;,
    describes: &#x22;When the dose event sits, rendered through RelativeTime, which keeps the exact date in its datetime attribute.&#x22;,
    prop: &#x22;time&#x22;,
  },
]"
/>

<CompositionTree
  name="dose-tracker"
  tree="[
  {
    part: &#x22;DoseTracker&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;data-slot=\&#x22;dose-tracker\&#x22;, a ul.&#x22;,
    children: [
      {
        part: &#x22;DoseTracker.Entry&#x22;,
        cardinality: &#x22;1..n&#x22;,
        note: &#x22;data-slot=\&#x22;dose-tracker-entry\&#x22;, a li.&#x22;,
        children: [
          {
            part: &#x22;DoseTracker.Name&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;data-slot=\&#x22;dose-tracker-name\&#x22;. An optional note sits beneath it, with no slot.&#x22;,
          },
          {
            part: &#x22;DoseTracker.Marker&#x22;,
            cardinality: &#x22;0..1&#x22;,
            note: &#x22;data-slot=\&#x22;dose-tracker-marker\&#x22;. Absent for an unrecognised state.&#x22;,
          },
          {
            part: &#x22;DoseTracker.Time&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;data-slot=\&#x22;dose-tracker-time\&#x22;. Wraps a RelativeTime.&#x22;,
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### A day's doses [#a-days-doses]

A day's medicines as the product logged them, some taken, one missed, one skipped, driven by each entry's `state`. In greyscale the states are still told apart by their words and shapes.

<ComponentPreview name="dose-tracker-a-days-doses" kind="example" align="start" />

### A missed dose [#a-missed-dose]

One `state="missed"` entry, shown as a fact and left there: a word, a shape, a time, no red, no next step and no adherence figure.

<ComponentPreview name="dose-tracker-a-missed-dose" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Write each medicine name in the words the reader already knows it by. Keep `note` short and factual, such as "with food" or who marked the dose. A note that tells the reader what to do is advice, and belongs in a [CareCard](./care-card.mdx). The banned words at [Reference ranges](../health/reference-ranges.mdx) apply to every name and note.

<DoDont>
  <DoDont.Do>
    **A missed dose marked "Missed" in neutral chrome, with nothing else on the row.**
  </DoDont.Do>

  <DoDont.Dont>
    **A missed dose in a red chip reading "Take now" under an "80% adherence" figure.**
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA, in a source pass and a rendered pass.** This is an author-run audit, not an independent review, and clinical review is pending. The rendered pass on `/view/base/base-lyra/component/dose-tracker` was clean, and the source pass found no defect, so nothing was changed.

**What the audit confirmed.**

* Each marker shape carries `aria-hidden` beside a visible word (1.1.1), and a real `ul`/`li` carries the list semantics (1.3.1).
* No status colour, category colour or colour literal: each state is told by a word and a distinct lucide shape.
* Icons are em-sized, so at 200% text they grow with the step and the times wrap rather than clip. No `px` type size.

**What a reader should still know.**

* The list's accessible name is offered only through `label`, as an `aria-label`. Every example supplies one, but a persistent visible label is a recommended enhancement.
* No `aria-live` region is announced: this static read-only log renders once and never updates in place, so 4.1.3 owes no status message.
* With no interactive element, target size (2.5.8), focus visibility and keyboard operation do not apply.

**Not measured.** Every contrast pair of hairline and muted ink against the host ground, and the markers under `forced-colors: active`.

<KeyboardTable
  name="dose-tracker"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the whole component&#x22;,
    notes: &#x22;Nothing is focusable. The tracker is a record to read, not a control.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the whole component&#x22;,
    notes: &#x22;Same in reverse. Never in the focus order.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;Nothing to select, move or expand. The entries are static content.&#x22;,
  },
]"
/>

<ContrastReport component="dose-tracker" />

## API reference [#api-reference]

<PropsTable name="DoseTrackerProps" />

An empty `entries` array renders nothing and warns in development. A `state` outside the four renders its row with no marker and warns, never approximated into a shape. `now` is required: read the clock once per screen and pass the same instant to every timestamp. `label` is the list's `aria-label`.

## Related [#related]

* [LogSheet](./log-sheet.mdx) records the general daily entries the product defines. A symptom, a meal or a mood is not a dose.
* [CareCard](./care-card.mdx) is where advice about a dose belongs, with its guidance and author explicit.
* [TimelineEntry](./timeline-entry.mdx) marks one event on a timeline, where DoseTracker is a day's doses read together.
