Streak tracker
Consecutive periods, and what they are worth. Days, weeks, visits, workouts — the component has no opinion about the period, only about the run.
Install
Copy this folder alongside tokens.css.
Not a stamp card
They look alike and behave nothing alike, and every decision here comes out of the difference.
| Stamp card | Streak tracker | |
|---|---|---|
| Progress is | cumulative | consecutive |
| Can it go backwards? | only as a correction | yes, and that is the mechanic |
| The live slot | there isn't one | today, with a deadline |
| A gap means | nothing | the run is over |
| Grace | n/a | freezes, held and spent server-side |
A stamp card is safe: eight of ten stays eight of ten forever. A streak is losable, and the loss is the whole motive force. That is why this component has a pulsing live marker, a distinct missed state, a hint line counting down the gap, and no way at all to work out what day it is.
The clock is not here
There is no date type in this folder and no call to a clock. today
arrives as a state the server has already decided: done, pending or
missed.
A member in Auckland and a server in Dublin disagree about "today" for most of the day. A component that decides the period boundary itself will eventually break a streak that was never broken — the single most valuable thing this pattern holds, destroyed by a timezone. The same rule the coupon card applies to expiry, with more at stake.
pending is the state worth understanding: the period is still open.
It is the difference between "you have missed it" and "you still have
time", and only the server knows which, because only the server knows
when the period ends.
Behavior contract
countis periods already banked, and never includes one still open. That is what lets the same number drive the headline and the markers without the two disagreeing by one for half of every day.- A missed marker is not an empty one. "You had this and lost it" and
"you have not reached this yet" look identical as an empty circle and
are not the same news, so
missedhas its own state. - Freezes are rendered, never spent. Whether a grace period is
consumed is a server decision with real value attached, and a browser
that could spend one would be a browser that could mint one. Their
copy comes from
labels.freezeslike every other string — the first version baked in a snowflake, which was hardcoded copy wearing an emoji. - Going backwards is announced. Unlike a stamp card losing a stamp, which is a correction and stays quiet, a broken streak is the mechanic doing what it threatens to do. The member needs to know.
onGoalReachedis presentation, not permission. It fires when the markers fill. Whether anything may be claimed isstatus.- Focus never moves. A period is banked because a server said so.
termsis the compliance slot: what counts as a qualifying action.
Choosing a tracker
tracker |
Draws | Use it when |
|---|---|---|
steps (default) |
one marker per period | The goal is countable at a glance — a member can see how many are left without reading. |
bar |
a continuous fill | The goal is long enough that two dozen markers become texture rather than information. |
none |
headline and hint only | The component sits in a dense dashboard row. |
The reward renders in all three. With none it simply sits beside the
headline — a goal with no visible prize is a progress bar to nowhere, and
the prize is the only part of this component the kit cannot supply.
Props
| Prop | Type | Notes |
|---|---|---|
count |
number |
Periods banked. Server truth; never includes an open period. |
goal |
number |
The run that earns the reward. 2–24; outside that it is clamped. |
labels |
{ streak, count(n), progress(c, g), goalHint(remaining), goalReached, extended(n), broken, freezes?(n) } |
Required; all copy, screen-reader announcements included. freezes is the one optional member — it is only reachable when the prop is set. |
today |
"done" | "pending" | "missed" |
Where the member stands with the current period. Server truth. Default pending. |
tracker |
"steps" | "bar" | "none" |
Default steps. See above. |
renderStep |
({ index, state, goal }) => ReactNode |
Replaces a marker — a flame, a brand glyph, a tick. Receives its state, so one function draws all four. |
renderReward |
({ reached }) => ReactNode |
What sits at the end of the track. |
showGoalHint |
boolean |
The nudge line. Default true. |
freezes |
number |
Grace periods held. Rendered only, and only when labels.freezes supplies the copy — the kit bakes no glyph and no wording for it. |
markers |
StreakStyleConfig |
done, todo, today, missed, size, radius. Every value accepts any CSS value including var(--your-token). |
status |
the kit's six | Host-provided campaign state. |
renderLocked |
(status) => ReactNode |
Renders alongside the tracker — the record is still true. |
header / footer |
ReactNode |
Above/below: the programme; when the period ends. |
terms |
ReactNode |
Compliance slot. |
onGoalReached |
() => void |
Fires once when the markers fill. |
Any data-* or aria-* prop passes through to the root element.
Campaign status
| Your campaign | status |
Member sees |
|---|---|---|
| Running | available |
The live tracker |
| Season closed | ended |
The tracker, dimmed, with your copy |
| Not enrolled | not-eligible |
The tracker, dimmed, with your copy |
| Reward already claimed for this run | already-played |
The tracker, dimmed, with your copy |
| Today's action already counted elsewhere | attempts-exhausted |
The tracker, with your come-back-tomorrow copy |
| Season not open yet | not-started |
An empty tracker and an opens-on line |
Tokens
| Token | Default | Purpose |
|---|---|---|
--lk-streak-done |
--lk-action |
Banked markers and the bar fill. |
--lk-streak-todo |
--lk-border |
Markers not yet reached. |
--lk-streak-today |
--lk-action |
The live marker's ring. |
--lk-streak-missed |
--lk-danger |
A marker that was lost. |
--lk-streak-reward |
--lk-reward |
The prize at the end. |
--lk-streak-step-size |
22px |
Marker size, and the bar's thickness. |
--lk-streak-step-radius |
--lk-radius-round |
Marker shape. A dot at 50%, a square at 0. |
--lk-streak-gap |
calc(--lk-space * 1.5) |
Space between markers. |
--lk-streak-align |
center |
flex-start for a dashboard row. |
The tracker adds nothing to tokens.css.
Accessibility
One progressbar for the whole tracker, whichever way it is drawn. The
markers and the bar are two pictures of the same value, so they share one
accessible node and are hidden beneath it — the stamp card's finding,
applied again. aria-valuetext gives it words, because "3" on its own is
not a standing.
The live marker is the one element in the kit allowed to draw attention to itself. It pulses, and it earns that: unlike every other marker in every other component, this one has a deadline, and the member losing it is the failure the whole mechanic exists to prevent. Suppressed entirely under reduced motion.
Extending and breaking are both announced through a polite live region. Focus never moves.
There is no gesture here, so WCAG 2.5.1 does not apply.
When to use it / when not to
Use it when the behaviour you want is repetition and the threat of losing progress is the motivation. Prefer the stamp card when progress should be safe — collecting ten coffees over six months is not a streak, and dressing it as one punishes people for going on holiday. Prefer missions when the actions differ from each other rather than repeating.
Do not use it for anything a member cannot reasonably do every period. A streak that most people cannot hold is a mechanic that teaches them to stop trying.
Common mistakes
Working out "today" from the device clock; counting an open period in
count; drawing a missed marker the same as an unreached one; spending a
freeze client-side; hiding the gap so the member has to count markers;
and animating the live marker under reduced motion.