Loyalty Patternsv1.00

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. value is 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. compact rounds — 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.
  • terms is 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.