Loyalty Patternsv1.00

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

  • count is 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 missed has 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.freezes like 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.
  • onGoalReached is presentation, not permission. It fires when the markers fill. Whether anything may be claimed is status.
  • Focus never moves. A period is banked because a server said so.
  • terms is 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.