TrendSparkline
A small line showing how one measurement has moved over time, with a caption that says what changed and leaves the verdict to you.
Preview
trend-sparkline · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/trend-sparkline
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 { TrendSparkline } from "@/components/ui/trend-sparkline"<TrendSparkline
label="Example measurement"
unit="steps"
precision={0}
locale="en-GB"
window="the last 6 entries"
series={series}
minimumPoints={readingsATrendNeeds}
/>When to use it
Use it when
- One measurement over time, where the shape of the change is the point.
- A compact companion to a reading, showing whether today's number fits a pattern.
- Anywhere a reader would otherwise ask 'is this new?' about a value shown.
Do not use it when
- Fewer readings than your product's minimum, so a line would imply an unsupported pattern.Use
empty-stateinstead. - The reader compares one value with a reference range, not its own history.Use
range-barinstead. - The reader needs which day a value belongs to, so axes and hover are required.Use
tableinstead. - You show one number and its change since last time, without a line.Use
metric-tileinstead. - The trend crossed a threshold and the reader must do something about it.Use
care-cardinstead.
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).
Colour axis. Category, on the line alone (Category identity). Status is never drawn: the marked reading is neutral and its verdict is a StatusPill in the caption.
Thresholds. None. changeThreshold is required before any direction word prints,
and minimumPoints before any line is drawn
(Numbers, units and precision).
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).
Anatomy
TrendSparklineThe root: a flex column of period, plot and caption.Controlled byclassNameTrendSparkline.WindowThe period, drawn only when you supplied a caption.Controlled bywindowTrendSparkline.PlotThe SVG. role="img" with a composed name, focusable="false". Absent below minimumPoints.Controlled byseriesTrendSparkline.BaselineThe zero anchor: a neutral hairline, so a flat series reads flat.Controlled byseriesTrendSparkline.BandThe optional interval, dashed and neutral. Needs a source and both bounds.Controlled byrangeTrendSparkline.LineOne path, broken at every gap. Carries data-category, never data-status.Controlled bycategoryTrendSparkline.GapEdgeA dot each side of a break, in the line's tint.Controlled byseriesTrendSparkline.PointThe marked reading: a neutral dot, weight and halo apart. Stamps data-status.Controlled byseriesTrendSparkline.CaptionThe visible text twin: sentence, marked reading with pill, coverage, date, range.Controlled bycaption
Examples
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.
trend-sparkline-a-band-and-a-marked-reading · base base · style base-lyraOpen under the product themeNot enough readings
minimumPoints of four, against series of four and three. The short one draws nothing
and appends its caption to the refusal.
trend-sparkline-not-enough-readings · base base · style base-lyraOpen under the product themeA row of tiles
What category is for: finding one line among several. Greyscale loses no fact.
trend-sparkline-a-row-of-tiles · base base · style base-lyraOpen under the product themeStates
| 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
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.
Do
"Down, from 20 steps to 16 steps, over the last 14 days." Checkable against the picture.
Don’t
"Trending in the right direction." Valence with no author, magnitude or window.
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, 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. classNamemerges onto the whole subtree, so ansr-onlyvariant 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.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the whole component | Nothing is focusable. focusable="false" covers engines that made SVG focusable. |
| Shift+Tab | Skips the whole component | Never enters the focus order, in either direction. |
| Any other key | Nothing | No hover, no tooltip, no point-by-point navigation. Days belong in a table. |
| 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 rendered part | 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 |
| data-category | On the line, for a known category | sleep | heart | activity | nutrition | mind | labs |
| data-status | On the marked reading alone | steady | watch | attention | urgent |
| data-opsinjs-value | On the marked reading and every caption Value. De-duplicate before scraping | The unrounded number behind that reading |
API reference
Prop
Type
Generated from TrendSparklineProps in registry/bases/base/trend-sparkline.tsx.
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
- RangeBar shows where one value sits in a reference range, not how a series moved.
- MetricTile can embed a sparkline. A sparkline is not a tile.
- ScoreDial is a state at one moment, not a shape over time.
- EmptyState owns the too-short refusal where the surface has no chart slot.