---
title: "TrendSparkline"
description: "A small line showing how one measurement has moved over time, with a caption that says what changed and leaves the verdict to you."
url: "https://opsinjs.pensievelabs.org/components/trend-sparkline"
source: "https://opsinjs.pensievelabs.org/components/trend-sparkline.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["chart", "graph", "sparkline", "over time", "trend", "mini chart", "line chart"]
governedBy: ["trends-and-change", "numbers-units-precision", "uncertainty-and-staleness", "category-identity"]
usedIn: ["choose-a-component", "sharing-with-a-clinician", "trend-review", "trend-with-a-caption", "trends-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="trend-sparkline"
  status="shipped"
  questions="[
  &#x22;Accessible charts requires a data-table twin beside every chart, and this component has none, so nothing checks a caller placed one.&#x22;,
  &#x22;window is a display string, so two sparklines compare only where their series cover the same span.&#x22;,
  &#x22;minimumPoints is required with no default, so the copied demo carries a threshold-shaped number A11Y014 cannot see.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="trend-sparkline" />

## Installation [#installation]

<ComponentInstall name="trend-sparkline" unbuilt="false" importPath="@/components/ui/trend-sparkline" registryDependencies="[&#x22;status-pill&#x22;, &#x22;value&#x22;]" />

## Usage [#usage]

```tsx
import { TrendSparkline } from "@/components/ui/trend-sparkline"
```

```tsx
<TrendSparkline
  label="Example measurement"
  unit="steps"
  precision={0}
  locale="en-GB"
  window="the last 6 entries"
  series={series}
  minimumPoints={readingsATrendNeeds}
/>
```

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

<WhenToUse
  use="[
  &#x22;One measurement over time, where the shape of the change is the point.&#x22;,
  &#x22;A compact companion to a reading, showing whether today's number fits a pattern.&#x22;,
  &#x22;Anywhere a reader would otherwise ask 'is this new?' about a value shown.&#x22;,
]"
  avoid="[
  { case: &#x22;Fewer readings than your product's minimum, so a line would imply an unsupported pattern.&#x22;, instead: &#x22;empty-state&#x22; },
  { case: &#x22;The reader compares one value with a reference range, not its own history.&#x22;, instead: &#x22;range-bar&#x22; },
  { case: &#x22;The reader needs which day a value belongs to, so axes and hover are required.&#x22;, instead: &#x22;table&#x22; },
  { case: &#x22;You show one number and its change since last time, without a line.&#x22;, instead: &#x22;metric-tile&#x22; },
  { case: &#x22;The trend crossed a threshold and the reader must do something about it.&#x22;, instead: &#x22;care-card&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** That these readings, in this unit, were recorded at these times. Nothing
about cause.

**Never read as.** A prediction, an explanation or a verdict. Upward is not worse and
downward is not better, because valence belongs to the measurement and often to the
person. Direction only ([Trends and change](../health/trends-and-change.mdx)).

**Colour axis.** Category, on the line alone
([Category identity](../health/category-identity.mdx)). Status is never drawn: the
marked reading is neutral and its verdict is a [StatusPill](./status-pill.mdx) in the
caption.

**Thresholds.** None. `changeThreshold` is required before any direction word prints,
and `minimumPoints` before any line is drawn
([Numbers, units and precision](../health/numbers-units-precision.mdx)).

**Gaps and age.** A `{ value: null }` entry breaks the path and is never interpolated.
The caption prints the last reading's date and invents no staleness boundary
([Uncertainty and staleness](../health/uncertainty-and-staleness.mdx)).

## Anatomy [#anatomy]

<Anatomy
  name="trend-sparkline"
  parts="[
  {
    name: &#x22;TrendSparkline&#x22;,
    describes: &#x22;The root: a flex column of period, plot and caption.&#x22;,
    prop: &#x22;className&#x22;,
  },
  {
    name: &#x22;TrendSparkline.Window&#x22;,
    describes: &#x22;The period, drawn only when you supplied a caption.&#x22;,
    prop: &#x22;window&#x22;,
  },
  {
    name: &#x22;TrendSparkline.Plot&#x22;,
    describes: &#x22;The SVG. role=\&#x22;img\&#x22; with a composed name, focusable=\&#x22;false\&#x22;. Absent below minimumPoints.&#x22;,
    prop: &#x22;series&#x22;,
  },
  {
    name: &#x22;TrendSparkline.Baseline&#x22;,
    describes: &#x22;The zero anchor: a neutral hairline, so a flat series reads flat.&#x22;,
    prop: &#x22;series&#x22;,
  },
  {
    name: &#x22;TrendSparkline.Band&#x22;,
    describes: &#x22;The optional interval, dashed and neutral. Needs a source and both bounds.&#x22;,
    prop: &#x22;range&#x22;,
  },
  {
    name: &#x22;TrendSparkline.Line&#x22;,
    describes: &#x22;One path, broken at every gap. Carries data-category, never data-status.&#x22;,
    prop: &#x22;category&#x22;,
  },
  {
    name: &#x22;TrendSparkline.GapEdge&#x22;,
    describes: &#x22;A dot each side of a break, in the line's tint.&#x22;,
    prop: &#x22;series&#x22;,
  },
  {
    name: &#x22;TrendSparkline.Point&#x22;,
    describes: &#x22;The marked reading: a neutral dot, weight and halo apart. Stamps data-status.&#x22;,
    prop: &#x22;series&#x22;,
  },
  {
    name: &#x22;TrendSparkline.Caption&#x22;,
    describes: &#x22;The visible text twin: sentence, marked reading with pill, coverage, date, range.&#x22;,
    prop: &#x22;caption&#x22;,
  },
]"
/>

## Examples [#examples]

### A band behind the line, and one marked reading [#a-band-behind-the-line-and-one-marked-reading]

`range` shades a band only where it carries a source and both bounds. The one-sided
range beside it is stated in words, drawn as nothing.

<ComponentPreview name="trend-sparkline-a-band-and-a-marked-reading" kind="example" align="start" />

### Not enough readings [#not-enough-readings]

`minimumPoints` of four, against series of four and three. The short one draws nothing
and appends its `caption` to the refusal.

<ComponentPreview name="trend-sparkline-not-enough-readings" kind="example" align="start" />

### A row of tiles [#a-row-of-tiles]

What `category` is for: finding one line among several. Greyscale loses no fact.

<ComponentPreview name="trend-sparkline-a-row-of-tiles" kind="example" align="start" />

## States [#states]

| Data state | What TrendSparkline renders                                                                 |
| ---------- | ------------------------------------------------------------------------------------------- |
| Error      | A non-finite value is counted apart from gaps and called unreadable in the caption.         |
| Loading    | None. The surface fetching the series owns the skeleton.                                    |
| Empty      | Below `minimumPoints` it draws no line and prints the refusal, naming count and minimum.    |
| Partial    | A `{ value: null }` entry is a real break, counted in the caption. Nothing is interpolated. |
| Stale      | The caption prints the last reading's date. No boundary invented, no verdict passed.        |

## Content guidelines [#content-guidelines]

Captions state direction where a `changeThreshold` allows one, then magnitude, window,
the marked reading, coverage, date and range. Avoid *improving*, *worsening*, *better*
and *on track*, because each is valence nobody signed. Pass `locale`, or date order is
the runtime's.

<DoDont>
  <DoDont.Do>
    **"Down, from 20 steps to 16 steps, over the last 14 days."** Checkable against
    the picture.
  </DoDont.Do>

  <DoDont.Dont>
    **"Trending in the right direction."** Valence with no author, magnitude or
    window.
  </DoDont.Dont>
</DoDont>

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

**What the audit found.** Nothing to change. The rendered pass was clean, with no serious
axe, target-size or focus breach at 320px, and the source pass
confirmed every check: the two colour axes stay apart, and status is carried by the
caption [StatusPill](./status-pill.mdx), never by colour alone. The committed
`check:a11y` gate reruns the literal, bound, colour and axis rules.

**Residual and deferred.**

* No per-point data table twin. The text alternative is the `role="img"` name plus the
  visible caption. A full table is an enhancement, not an AA requirement.
* `className` merges onto the whole subtree, so an `sr-only` variant can hide the
  caption, the only text carrier. A documented API risk; keep it visible.
* Under forced colours the line drops its category tint to `CanvasText`. Category is
  identity only, so nothing else is lost, and this is intentional.

<KeyboardTable
  name="trend-sparkline"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the whole component&#x22;,
    notes: &#x22;Nothing is focusable. focusable=\&#x22;false\&#x22; covers engines that made SVG focusable.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the whole component&#x22;,
    notes: &#x22;Never enters the focus order, in either direction.&#x22;,
  },
  {
    keys: &#x22;Any other key&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;No hover, no tooltip, no point-by-point navigation. Days belong in a table.&#x22;,
  },
]"
/>

<ContrastReport component="trend-sparkline" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="trend-sparkline"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every rendered part&#x22;,
    value: &#x22;trend-sparkline, trend-sparkline-window, trend-sparkline-plot, trend-sparkline-baseline, trend-sparkline-band, trend-sparkline-line, trend-sparkline-gap-edge, trend-sparkline-point, trend-sparkline-caption&#x22;,
  },
  {
    attribute: &#x22;data-category&#x22;,
    condition: &#x22;On the line, for a known category&#x22;,
    value: &#x22;sleep | heart | activity | nutrition | mind | labs&#x22;,
  },
  {
    attribute: &#x22;data-status&#x22;,
    condition: &#x22;On the marked reading alone&#x22;,
    value: &#x22;steady | watch | attention | urgent&#x22;,
  },
  {
    attribute: &#x22;data-opsinjs-value&#x22;,
    condition: &#x22;On the marked reading and every caption Value. De-duplicate before scraping&#x22;,
    value: &#x22;The unrounded number behind that reading&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="TrendSparklineProps" />

`precision`, `series` and `minimumPoints` are required with no default, because none is a
number opsinjs could supply. `TrendPoint` and `ReferenceRange` come from `@/lib/opsinjs`,
so no second copy can drift.

## Related [#related]

* [RangeBar](./range-bar.mdx) shows where one value sits in a reference range, not how a series moved.
* [MetricTile](./metric-tile.mdx) can embed a sparkline. A sparkline is not a tile.
* [ScoreDial](./score-dial.mdx) is a state at one moment, not a shape over time.
* [EmptyState](./empty-state.mdx) owns the too-short refusal where the surface has no chart slot.
