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:
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.
riskNoticerenders 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.