Loyalty Patternsv1.00

Tier progress

Where the member stands between named states. Silver, Gold, Platinum — the status system, and the last of the kit's progress family.

Install

Copy this folder alongside tokens.css.

Not a third progress bar

The stamp card counts identical things. The streak counts consecutive things. This component positions the member between named states, and the position can move down. Everything unusual about it follows from those two facts.

Stamp card Streak tracker Tier progress
Progress is cumulative consecutive positional
The units are identical stamps identical periods named tiers
Down means a correction the mechanic firing scheduled maintenance
The warning comes after the break before the drop

The names are the content: a status ladder without them is just a percentage, which is why nodes is the default track and why past six tiers the component clamps — a status system nobody can recite is a points system wearing a costume. Six is where real programmes top out (an entry tier plus five earned ones), and it is also where six names stop fitting a phone's width, which is the same ceiling arriving from the layout side. The name boxes size themselves against the gap between two nodes, so a six-tier ladder does not overlap its own labels.

The bar never grants what the server has not

currentTierId is the verdict; progress numbers are display. When the numbers say the threshold is crossed but the server still says Silver, the member is Silver — assessments run on cycles, spend settles late, fraud checks hold — and a fill that visually touches the Gold node has promoted them with pixels.

So the fill is capped at 96% of the way to the next node, however loud the numbers get. Close enough to read as "almost there"; short enough that the node stays unlit. At the top tier the position is exactly 1 — there is nothing left to overstate. The missions list's rule, state beats arithmetic, applied to status.

An id the ladder does not contain renders the error state rather than a guess — the wheel's unknown-segment rule, because picking the nearest tier would be assigning a status in the browser.

Qualified is not promoted, and silence about that reads as a bug

The cap above creates a state the component has to be able to say out loud. Assessments run nightly, weekly or monthly, so a member who crossed the line at lunchtime is genuinely qualified and genuinely not yet promoted — that is the programme working exactly as designed. Rendered naively it comes out as "0 more points to Platinum", which reads as a broken component.

So when everything the server told us the next tier asks for is satisfied, the nudge is replaced by labels.qualified(next) in a quiet positive treatment:

You've qualified for Platinum — it applies after the next review.

Three things about that line are deliberate:

  • The fill is still capped and the node is still unlit. Naming the state is honest; granting it is the server's. Both claims are in the same paragraph on screen and only one of them is ours to make.
  • It is quieter than a win. A tint and a weight, not a celebration: confetti here would do with pixels exactly what the cap prevents.
  • It says when the tier lands, because you write it. The component does not know your assessment schedule, and "you've qualified" with no follow-up invites the member to reload the page until it changes.

It announces once, at the crossing, and never on mount — a member who arrives already qualified can read it. There is no onQualified callback: you supplied the numbers and the flags, so a component reporting that they add up would report something you can already see.

The nudge itself now renders only while something remains to nudge about, which closes the same wart from the other side. A zero means two different things — nothing left to pay (conditions are what remain, and the checklist says which) or nobody said there was anything to pay (no progress was passed) — and both used to print "0 more points to Gold". Neither does now.

Points are not the only way up

Some ladders are climbed with actions: two stays, a review, a linked card. requirements takes up to three short conditions, each with the server's verdict on whether it is done:

tsx
requirements={[
  { id: "stays", label: "Book 2 stays", met: true },
  { id: "review", label: "Leave a review", met: false },
]}

met arrives decided, exactly as a catalog item's state does — "has this member left a review" is a question about data the browser does not hold, and a client that answered it would tick a box the backend never ticked.

Conditions feed the fill too, as met-over-total, so an action-based ladder has a bar that moves rather than one parked at the current node. Where a programme sends both numbers and conditions, the fill takes the lesser of the two: a member with the spend but one review outstanding is as far away as their furthest-away condition, and showing the flattering half of the payload is the same overstatement the cap exists to prevent. Qualifying likewise needs both sides satisfied.

Conditions render only when labels.requirements is supplied. A checklist whose ticks cannot be spoken is a picture of a checklist, and three is the cap because a fourth makes it a form — a member who has to scroll to find out how to get promoted has been handed the rulebook instead of the next step.

Down is designed

Tiers demote. It is not a failure state; it is how requalification works, and the component treats it with the same calm as promotion:

  • Demotion is announced through the same polite live region as promotion, with labels.demoted — copy you write to be read by someone who just lost something.
  • The warning comes first. riskNotice renders in the warning treatment before the drop. The streak announces after the break because a break is instantaneous; a tier slips on a known schedule, and warning is the honest use of knowing the schedule.

riskNotice is a content slot, not a flag with a label, because the sentence needs numbers and dates the component is never given — "Spend £120 by 31 March to keep Gold" is written from the requalification ledger, and the ledger is the server's. Its presence is the at-risk state.

Numbers drive the bar; your words drive the sentences

Every label that mentions an amount receives it as a number and returns your words: nextHint(120, "Platinum") becomes "£120 to Platinum" or "120 more points to Platinum" or "3 more nights" — the component cannot know which, because currency and unit formatting belong to the host. This is the clock rule's sibling: a value the runtime would happily format for you, wrongly, in someone else's locale and currency.

Props

Prop Type Notes
tiers Tier[] The ladder, lowest first: { id, name, threshold? }. 2–6. threshold captions a node; which tier the member holds arrives separately.
currentTierId string Server truth. The verdict.
labels { tier(name), progress(current, next), nextHint(remaining, next), qualified(next), reachedTop, promoted(name), demoted(name), requirements? } Required; all copy, announcements included. requirements is { heading(next), state(met) } and only needed when you pass conditions.
progress { current, target } The server's numbers toward the next tier. Display only — they place the fill and feed the hint, and can never promote.
requirements TierRequirement[] Up to three qualifying actions: { id, label, met }. met is server truth. They feed the fill as met-over-total; with progress too, the fill takes the lesser.
track "nodes" | "bar" | "none" Default nodes — the names are the content. bar for a dense dashboard row; none for standing as text alone.
renderTier ({ tier, state, index }) => ReactNode Per-tier content under each node: benefits, icons. state is reached | current | ahead.
renderQualified ({ next }) => ReactNode Replaces the qualified line's node. The announcement keeps using labels.qualified, so a custom node cannot go silent.
renderRequirement ({ requirement, index }) => ReactNode Replaces one condition's row.
showNextHint boolean The nudge line. Default true. Renders only while something remains to nudge about; at the top of the ladder it becomes reachedTop.
riskNotice ReactNode The maintenance warning. Presence is the at-risk state.
trackStyle TierTrackConfig fill, track, node, nodeSize, nameColor, qualifiedColor. 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 standing is a record.
header / footer ReactNode The programme; when the period ends.
terms ReactNode Compliance slot: how tiers are earned and kept.
onTierChange (tier, direction) => void Fires on promotion and demotion, with which way it went.

Any data-* or aria-* prop passes through to the root element.

Campaign status

Your campaign status Member sees
Running available The live ladder
Programme closed ended The ladder, dimmed, with your copy
Not enrolled not-eligible The ladder, dimmed, with your copy
This period's assessment settled already-played The standing, with your copy
Earning capped this period attempts-exhausted The standing, with your come-back-later copy
First assessment pending not-started The ladder, with an opens-on line

Tokens

Token Default Purpose
--lk-tier-fill --lk-action The fill, and reached/current nodes.
--lk-tier-track --lk-border The unfilled rail and hollow nodes' rings.
--lk-tier-node --lk-surface Hollow node fill.
--lk-tier-node-size 14px Node diameter, and the rail's thickness.
--lk-tier-name-color --lk-text-muted Tier names; the current one lifts to --lk-text.
--lk-tier-qualified --lk-success The qualified line and met condition marks.
--lk-tier-align center flex-start for a dashboard row.

Two lines here are coloured by meaning rather than by brand: the risk notice takes --lk-warning and the qualified line takes --lk-success. They are the two sentences on this component that must read at a glance — "act or lose this" and "you have done everything" — and the action colour already means "press things". One override covers the qualified line and the condition marks together, because they say the same thing at two scales.

The ladder adds nothing to tokens.css.

Accessibility

One progressbar for the whole ladder — the stamp card's finding — with the position as its value and labels.progress as its words, because "62" says nothing about Silver or Gold. Tier names render as text along the track; nodes and fill are the picture, hidden beneath.

Promotion, demotion and qualifying are announced; focus never moves — standing changes because an assessment ran, not because the member pressed anything here. Qualifying announces at the crossing only, so arriving already qualified is silent.

Conditions are a real list, so a reader is told how many there are. Each mark is decoration with its state in words beside it: a filled dot says "done" to everyone except the people most likely to need telling.

The rail, fill and node positions are all inline-start based, so the ladder runs right-to-left for free.

There is no gesture, so WCAG 2.5.1 does not apply.

When to use it / when not to

Use it when crossing the line changes the member's standing — benefits, recognition, a different card colour — rather than paying out once. renderTier carries each tier's benefits, which is the motivation surface: the next tier's teaser does more work than the bar.

Prefer the stamp card when the goal pays out and resets, and the points counter when the number is a currency rather than a position. Pair it with the points counter in a header: balance and standing are the two facts members check together.

Common mistakes

Deriving the tier from the numbers; letting the fill touch an ungranted node; saying nothing when the member has qualified, which turns a working assessment cycle into "0 more points to Platinum"; promising the tier rather than the review in that sentence; computing met in the browser; formatting amounts inside the component; treating demotion as an error state; warning after the drop instead of before; and shipping seven tiers nobody can recite.