Points counter
A balance, and the moment it changes. The most universal surface in the category — every programme has one — and the first component in the kit whose whole reason to exist is a transition rather than a state.
Install
Copy this folder alongside tokens.css.
Behavior contract
- The balance is an input.
valueis server truth. This component never adds to it, never spends from it, and has nothing to request. - It does not count on mount. A balance that arrives at 1,240 shows 1,240. Counting up from zero on every page load replays a history the member did not just live — the same mistake the stamp card refuses when it declines to animate stamps it never saw arrive.
- The count is never narrated. A number ticking from 1,200 to 1,450 inside a live region is roughly two hundred announcements. The digits animate for people watching them; everyone else is told what moved and where it landed, once, when it arrives.
- The announcement leads with the change. "50 points earned. Balance 1,290 points" answers the question a member actually has. The balance alone makes them do the subtraction, using a count they could not see.
- The exact figure is always available.
compactrounds — 1.2K is a display convenience and a poor answer to "how many points do I have" — so the accessible name carries the full number whatever the display does. - Focus never moves. A balance changes because a server said so, not because the member pressed anything.
termsis the compliance slot: how points expire, what they are worth.
Formatting
Loyalty balances span four orders of magnitude across real programmes — a coffee card counts in tens, an airline in hundreds of thousands — and the right treatment is not the same at both ends. This is the one decision an adopter genuinely has to make about a number, so it is a prop rather than something to discover.
format |
Renders | Use it when |
|---|---|---|
grouped (default) |
1,250 |
Almost always. The only one that never loses information. |
compact |
1.2K, 3.4M |
The balance outgrows its space. It rounds, so never for a figure a member reconciles against a statement. |
plain |
1250 |
Your design supplies its own separators, or the locale makes grouping wrong. |
locale is pinned, and it matters. It defaults to "en" rather
than to the runtime, because Intl.NumberFormat(undefined) resolves to
the build machine on the server and the member's browser on the client —
so the same balance renders two different strings and hydration
mismatches. Pass your app's actual locale. Do not pass undefined and
hope.
The unit
unit takes a node: "pts", "°P", a coin SVG, a brand mark. The kit
ships none, for the reason it ships one stamp mark and no icon set.
It is hidden from the accessibility tree. A screen reader
pronouncing °P is not information, so the unit is spoken by
labels.announce in words — "1,290 points" — while the visible mark
does the visual job.
Props
| Prop | Type | Notes |
|---|---|---|
value |
number |
The balance. Server truth. |
labels |
{ balance, announce(exact), earned(amount), spent(amount) } |
Required; all copy, screen-reader announcements included. |
unit |
ReactNode |
The mark beside the figure. Hidden from the accessibility tree. |
unitPosition |
"before" | "after" |
Default after. |
format |
"grouped" | "compact" | "plain" |
Default grouped. See above. |
locale |
string |
Default "en", pinned deliberately. |
animate |
"count" | "fade" | "none" |
How the figure moves. Default count. |
countMs |
number |
Base duration, scaled by the size of the change. Default 900. |
showDelta |
boolean |
The chip reporting what just moved. Default true. |
renderValue |
({ display, value }) => ReactNode |
Replaces the figure — a bespoke odometer, digits with their own choreography. |
renderDelta |
({ amount, direction }) => ReactNode |
Replaces the chip. |
points |
PointsStyleConfig |
color, unitColor, size, upColor, downColor. 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 balance — the member still has the points. |
header / footer |
ReactNode |
Above/below: the programme and tier; pending points and expiry. |
terms |
ReactNode |
Compliance slot. |
onCountComplete |
(value) => void |
Fires when a count finishes. Not on mount, and not when nothing moved. |
Any data-* or aria-* prop passes through to the root element.
Campaign status
| Your campaign | status |
Member sees |
|---|---|---|
| Live | available |
The balance |
| Programme closed | ended |
The balance, dimmed, with your copy |
| Account suspended | not-eligible |
The balance, dimmed, with your copy |
| Points already redeemed out | already-played |
The balance, dimmed, with your copy |
| Earning capped this period | attempts-exhausted |
The balance, with your come-back-later copy |
| Enrolled, not yet started | not-started |
The balance, with an opens-on line |
A locked balance still shows its figure. The member still has the points; what has changed is what they can do with them.
Tokens
| Token | Default | Purpose |
|---|---|---|
--lk-points-value |
--lk-text |
The figure. Deliberately not accent-driven: an accent can be white or near-black and would vanish against a matching surface. Opt in with var(--lk-action). |
--lk-points-size |
2.6em |
How large it runs. |
--lk-points-unit-color |
--lk-text-muted |
The unit beside it. |
--lk-points-up |
--lk-success |
The rise chip. |
--lk-points-down |
--lk-text-muted |
The fall chip. |
--lk-points-align |
center |
flex-start for a left-aligned balance in a dashboard row. |
The delta chip uses the semantic status tokens rather than the action colour, and that is the one place in the kit where content is coloured by meaning: a rise and a fall have to be told apart at a glance, and the action colour is a single hue that would have to say both.
The counter adds nothing to tokens.css. Every default resolves to a
Tier-1 token that already retints per mode.
Architecture
points-engine.ts is framework-free: numbers in, strings and frames
out. Two things in it are worth knowing.
The count eases out, and rounds toward its destination. A linear count reads as a progress bar made of digits; an ease-out reads as a total settling. Rounding toward the destination rather than to nearest means the last frame before completion can never display a number one above the true balance — a counter that overshoots and corrects is a counter nobody trusts.
Duration scales with the size of the change. Five points ticking for the same second as five thousand looks broken in one direction and interminable in the other. Bounded at both ends, so a rounding error cannot produce a flicker and a jackpot cannot produce a wait.
Accessibility
Tabular figures are not cosmetic here. Proportional digits have different widths, so a counting number reflows on almost every frame and the whole balance jitters horizontally while it runs. One declaration is the difference between a count that settles and one that vibrates.
The figure and the unit are hidden from the accessibility tree individually and named once as a whole, because reading them apart produces "one comma two K" followed by a degree sign.
Under reduced motion the count does not shorten — it does not happen. The figure changes to its new value and the announcement is identical.
There is no gesture here, so WCAG 2.5.1 does not apply.
When to use it / when not to
Use it for a spendable balance the member accumulates. Pair it with the reward catalog so the points have somewhere to go, and with the coupon card for what they buy.
Prefer the stamp card when the goal is a fixed count with a single reward at the end, and tier progress when the number is a threshold toward a status rather than a currency to spend.
Common mistakes
Counting up from zero on mount; putting the count inside a live region;
announcing the compact figure instead of the exact one; letting
Intl.NumberFormat pick the runtime locale; proportional digits; and
treating the balance as something the client can add to.