Loyalty Patternsv1.00

Missions list

A set of things to do, and what each is worth. Challenges, quests, weekly goals — the pattern that drives specific behaviour rather than repetition.

Install

Copy this folder alongside tokens.css.

The first composite

Every other component in the kit renders one state. This renders a list whose items each carry their own state, their own progress and their own action, and two things follow from that.

Campaign status and mission state are different axes. The campaign can be over while a mission is complete; a mission can be locked while the campaign is live. Collapsing them into one union would have made half the real combinations unsayable, so status stays the kit's usual six and mission.state is its own vocabulary.

The tile is not the control. The scratch card's panel and the coupon's cover are both buttons, because each is a single surface with a single action. A mission tile carries a title, a description, a bar and a button — making the whole thing a button would swallow the text into the button's accessible name and leave the real action ambiguous.

Mission states

Six, and each renders differently because each means something different to the member. Server truth, always.

state Means Renders
locked A prerequisite is unmet Dimmed, no progress. Shown rather than hidden — a mission you cannot start yet is a reason to come back
available Startable, untouched Full strength, empty progress
in-progress Begun Full strength, partial progress
complete Finished and claimable Emphasised border, and the only state with an action
claimed Finished and taken Half-faded, full progress, its reward in the success tone, and a "claimed" chip where the claim button was. Kept on the list — one that empties as you succeed erases the evidence
expired The window closed Dimmed, full progress, reward muted

Claimed is not expired

It is tempting to dim both identically, on the argument that what a success and a loss have in common is that there is nothing left to do. The argument is true and it is the wrong thing to design from: a member scanning the list could not tell the reward they won from the one they missed. An identical grey row says neither.

Three differences now, and each is doing a distinct job:

  • The claim control's position carries the answer. A claimed mission renders a quiet chip in the claim control's position — the eye is already going there to look for something to press, and what it finds is that you already did. The word is labels.state("claimed"), the same string the row already speaks, so there is no second piece of copy to write.
  • The reward line changes tone rather than disappearing. Claimed shows it in the success colour, because that is what you got. Expired shows it muted, because advertising an unearned "+50 pts" at full strength reads as if it had been earned.
  • Claimed is only half-faded. Expired is over and nothing about it should draw the eye; claimed is a settled success and stays legible.

The row's own emphasis rule is unchanged: complete is still the only state that takes the action colour, because it is still the only one with something to press. What claimed gains is content emphasis, not an affordance.

State beats arithmetic

progress and target drive a bar. state decides what the mission is. They are allowed to disagree, and when they do, state wins.

A mission at 5/5 that the server still calls in-progress is a mission whose completion has not settled yet, and the honest render is a full bar with no claim button. A component that inferred completion from arithmetic would offer a reward the backend has not granted — the same class of mistake as deciding a prize in the browser.

Progress

tracker Draws
auto (default) Per mission: steps when countable, a bar when a bare fraction, nothing when there is no progress
steps Pips wherever the mission is countable; a fraction mission keeps its bar — pips need whole things to count
bar A continuous fill everywhere progress exists
none No progress at all

auto is the default because a list where one mission is countable and another is not should not force the same picture onto both. Pips beat a percentage when the unit is countable — "3/5 friends invited" is the number the member is actually tracking — and a bar is right when progress is a fraction of something with no natural steps.

A mission is countable when it has a target. With progress alone, the value is treated as a fraction 0..1.

The fill takes --lk-action, so progress reads in the same colour as every control in the kit.

Props

Prop Type Notes
missions Mission[] In reading order. Capped at ten — past that it is a backlog, and a member scrolling a backlog of chores closes the tab.
labels { list, claim, progress(ctx), state(s), ready(title), claimed(title) } Required; all copy, screen-reader announcements included. state("claimed") is also the visible chip on a claimed row.
tracker "auto" | "bar" | "steps" | "none" Default auto.
surface "card" | "none" Whether each mission sits on a tile. Default card.
icons boolean The icon column. Default true; missions without artwork get a neutral placeholder so a mixed list still aligns.
renderIcon ({ mission, index }) => ReactNode Replaces a mission's artwork.
renderBadge ({ mission }) => ReactNode Replaces the badge.
renderAction ({ mission, claim }) => ReactNode Replaces the claim control.
renderMission ({ mission, index }) => ReactNode Replaces a whole tile — the seam for a layout this component does not have.
tiles MissionTileConfig color, border, radius, fill, badgeColor, badgeTextColor, rewardColor, iconSize. 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 list.
header / footer ReactNode Above/below: the season and a countdown; how missions refresh.
terms ReactNode Compliance slot.
onClaim (mission) => void Fires when the default claim control is pressed.

Mission

Field Type Notes
id string Stable across renders. What your backend calls it, and what announcements key on.
title string
description string One supporting line.
state MissionState See above. Server truth.
progress number Steps done, or a 0..1 fraction when there is no target.
target number Steps needed. Its presence is what makes a mission countable.
badge string "New", "Ends today", "2x points". Text, not an icon — a badge nobody can read is decoration.
reward string What it pays.
icon ReactNode Artwork. The kit ships none.

There is a default claim control, which is why onClaim exists alongside renderAction: a component that ships a control must ship the event that control fires, or the button does nothing until the adopter discovers it needs replacing.

Campaign status

Your campaign status Member sees
Running available The live list
Season closed ended The list, dimmed, with your copy
Not enrolled not-eligible The list, dimmed, with your copy
All rewards taken already-played The list, dimmed, with your copy
Weekly claim cap hit attempts-exhausted The list, with your come-back-later copy
Season not open not-started The list, with an opens-on line

Tokens

Token Default Purpose
--lk-mission-surface --lk-surface Tile fill.
--lk-mission-border --lk-border Tile outline.
--lk-mission-radius --lk-radius Tile corners.
--lk-mission-padding calc(--lk-space * 3.5) Tile padding.
--lk-mission-gap calc(--lk-space * 2) Space between tiles.
--lk-mission-fill --lk-action The progress fill.
--lk-mission-bar-height 6px Bar and pip thickness.
--lk-mission-badge color-mix(--lk-action 14%) Badge background.
--lk-mission-badge-text --lk-action Badge text.
--lk-mission-reward --lk-reward What it pays.
--lk-mission-claimed --lk-success A claimed row's chip and reward line. Coloured by meaning, like the tier ladder's qualified line.
--lk-mission-icon-size 40px The icon well.
--lk-mission-icon-radius --lk-radius-sm Its corners.

The list adds nothing to tokens.css.

Accessibility

A real <ul>, so a screen reader announces how many missions there are before reading them — the one fact a member most wants from a set of chores, and the one a stack of divs cannot give.

One progressbar per mission, with the bar or pips hidden beneath it. aria-valuetext carries words, because "2" inside a list of six missions is not a standing.

Every state is spoken. A row distinguished by colour alone says nothing to a screen reader and nothing at all to anyone who cannot see it, so labels.state names each one in the accessible tree.

Announcements key on id, not index. A list that reorders or drops a finished mission would otherwise announce whatever slid into the slot.

The claimable animation and every transition are suppressed under reduced motion. There is no gesture here, so WCAG 2.5.1 does not apply.

When to use it / when not to

Use it when the actions differ from each other — invite a friend, try a category, spend in-store. Prefer the streak tracker when the same action repeats and losing the run is the motivation, and the stamp card when it repeats safely.

Don't use it as a to-do list for the business. Ten missions is the cap because a member reading a backlog of chores stops reading; if you have more, the answer is rotation, not scroll.

Common mistakes

Inferring completion from progress; hiding locked missions; removing claimed ones; dimming a claimed mission and an expired one identically; badging every row so the list becomes bunting; keying announcements on index; and colouring states without naming them.