Loyalty Patternsv1.00

Spin wheel

Spin-to-reveal over a server-decided outcome. The chance-mechanic contract the scratch card established, applied to a second mechanic — which is the point of building it.

Install

Copy this folder alongside tokens.css.

Behavior contract

  • The component never decides the result, and never picks a segment. Pass outcome (already decided) or getOutcome() (called once, when the spin starts). The outcome names its wedge by segmentId, and the wheel is aimed at that wedge. If the id is not in segments the wheel raises the failure state rather than choosing the nearest one — guessing would mean the browser picked a prize.
  • There is exactly one control, never two. In button mode a spin button renders and the wheel itself is inert — the button is the interaction. In flick mode the wheel IS a real <button>, so a tap or the Enter key spins it, and no separate control renders at all. Same bargain the scratch card strikes between its manual and auto modes, for the same reason: a surface that is already a button does not need a second affordance underneath it. What is never negotiable is that the gesture is not the only way in. Flicking is a path-based gesture and WCAG 2.5.1 (Level A) requires a single-pointer equivalent — served in flick mode by the wheel-button itself, and in button mode by the control. That serves tremor, dexterity and trackpad users as much as keyboard and screen-reader ones. Replace the button with your own via renderSpinAction.
  • A flick changes the animation, never the odds. Flick speed scales the number of turns and therefore how long the spin lasts. It cannot move the result: the result was decided before the wheel moved. Worth saying out loud, because "spin harder" is exactly what a member will assume otherwise.
  • The wheel spins freely while the request is in flight. The outcome is fetched when the spin starts, and the free rotation runs until it lands — so a 400ms round trip disappears inside a gesture the member is already enjoying. labels.resolving is announced meanwhile, so the wheel never sits silently stuck.
  • The deceleration never overshoots. All three easings are monotonic. A spring would carry the marker past the winning wedge and back, which means displaying a result that is not the result — in a mechanic whose entire contract is that the outcome came from a server. A paused frame at that moment is a dispute nobody should have to answer.
  • It never stops dead centre. The landing is jittered within the winning wedge, bounded well clear of the boundary. Cosmetic by construction: the jitter cannot leave the segment, so it cannot move the result.
  • The result is announced via a polite live region and focus moves to it — without scrolling. A landing can be programmatic (restored state, an autoplaying demo), and a component that yanks the reader's viewport is worse than one that does not scroll. Scroll from onSpinComplete if you want it.
  • terms is the compliance slot: odds disclosure belongs here, at eye level, not in a footer.

Campaign status

The component renders host-provided state; it never computes it. The union is identical to the scratch card's, so a campaign running both mechanics maps its lifecycle once.

Your campaign status Member sees
Draft, Archived don't mount Nothing
Scheduled not-started Teaser / opens-soon
Active available A spinnable wheel
Paused not-eligible Temporarily unavailable
Ended ended Campaign closed
Attempts used attempts-exhausted Come back tomorrow
Played already already-played Refusal (no prior result)

Locked statuses render no wheel at all, only renderLocked content — a wheel that cannot turn invites the press that will not work. It keeps the wheel's circular shape and its exact footprint, so flipping a campaign from active to ended does not reflow the page under it.

Use revealed + outcome instead when the server returns the member's prior result rather than a refusal.

Props

Prop Type Notes
segments SpinSegment[] The prize pool, in wheel order from twelve o'clock. Each is { id, label, kind?, color?, textColor?, icon? }. id is what the server names. 2–12; outside that range it is clamped.
renderOutcome (o: T) => ReactNode Content once the wheel lands.
outcome T extends { kind, segmentId } Pre-decided result.
getOutcome () => Promise<T> Fetched when the spin starts.
labels { spinButton, wheel, announce(o), resolving, error, retry } Required; all copy. Phrase resolving as checking, never calculating — the server already decided.
status "available" | "not-started" | "already-played" | "not-eligible" | "attempts-exhausted" | "ended" Host-provided campaign state. The component never computes these.
renderLocked (status) => ReactNode Content for locked statuses.
renderSpinAction ({ spin, disabled }) => ReactNode Your button instead of the kit's. Cannot be omitted.
renderError (error, { retry }) => ReactNode Replaces the default failure UI. retry() re-invokes getOutcome with the same request identity.
onError (error) => void The rejection from getOutcome, or an unknown-segmentId error.
mode "button" | "flick" button (default): a spin button, inert wheel. flick: the wheel is a button you can also drag, and no separate control renders.
revolutions number Whole turns before settling. Default 5. Presentation only. Ignored when a flick supplied its own.
spinMs number Deceleration duration before --lk-motion scaling. Default 4200.
ease "glide" | "heavy" | "even" glide (default) is a heavy wheel running down; heavy settles sooner; even is near-linear until a late drop, for flows where the spin is a transition rather than a moment.
pointerAt "top" | "right" | "bottom" | "left" Which edge the marker sits on, always pointing inward. Default top. Which edge is right depends on the layout around the wheel, not on the wheel — a top marker needs vertical room above it.
resultAt "above" | "below" | "over" Where the reward appears. Default below. A layout control: the result is the one element whose height is unknown until it exists, so put it where your page can afford the movement. over renders it inside the wheel, which dims behind it, so the footprint does not grow at all — pair with mode="flick" for a component that never changes size by a pixel. The spin control always stays below.
celebrate boolean Wins pop with a spark burst in the reward accent; losses settle quietly. --lk-motion: 0 always suppresses both. Default true.
showLabels boolean Prize names on the wedges. Off for an icon- or colour-only wheel; the accessible segment list is unaffected. Default true.
labelOrientation "horizontal" | "vertical" | "flat" How those names are set, relative to their wedge. horizontal (default) runs the label across the slice at the rim, tops outward — the prize-wheel convention. vertical runs it along the radius, hub to rim, which suits longer prize names. Both turn with the wheel. flat keeps labels screen-horizontal even while spinning; it has no playground control but is fully supported.
hub boolean The centre cap. Default true.
wedges SpinWedgeConfig Wedge colours: color, colorAlt, labelColor, iconColor, rim, markerColor, hubColor. Every value accepts any CSS value including var(--your-token) — these props write the matching --lk-* property, so there is still only one theming system.
renderMarker () => ReactNode Your marker shape instead of the kit's triangle. Placement and the inward rotation for the chosen edge stay with the component.
idle "none" | "drift" Slow rotation while the wheel waits to be played. Default none — motion is an opinion, and a component that moves on its own is one you have to switch off rather than switch on. Stops when a spin begins, and its accumulated angle is carried into the landing rather than discarded, so it cannot move the result. Suppressed under reduced motion.
direction "clockwise" | "anticlockwise" Which way it turns. Default clockwise. Presentation only.
revealed boolean Render already landed (restored state).
disabled boolean Blocks the flick and the button, and shows it: the wheel dims to --lk-disabled-opacity, the cursor becomes not-allowed, and any idle drift stops. Distinct from a locked status, which is the campaign refusing rather than the control being temporarily unavailable.
header / footer ReactNode Above/below: headline and badge; remaining spins and expiry.
terms ReactNode Compliance slot.
onSpinStart () => void First spin only.
onSpinComplete (o: T) => void After the wheel lands.

Any data-* or aria-* prop passes through to the root element, so instrumentation needs no wrapper. Pass aria-label to name the wheel; it is given a group role automatically.

Tokens

Token Default Purpose
--lk-wheel-segment --lk-surface Even wedges.
--lk-wheel-segment-alt color-mix(--lk-text 8%, --lk-surface) Odd wedges. Mixed from the text colour rather than taken from --lk-surface-raised: raised sits three percent from surface in light mode, which is fine for separating a panel from a page and unusable when the fill is the only thing telling one wedge from the next.
--lk-wheel-label --lk-text Wedge labels.
--lk-wheel-icon --lk-wheel-label Wedge icons.
--lk-wheel-rim --lk-border Rim ring, wedge edges and the hub ring.
--lk-wheel-rim-width 2px Rim thickness.
--lk-wheel-marker --lk-text The marker triangle.
--lk-wheel-hub --lk-surface Centre cap fill.
--lk-wheel-dim 0.28 Disc opacity behind an over reward. The wheel stays faintly visible on purpose: it is where the result came from.
--lk-wheel-shadow none Disc elevation. Flat by default — the rim already separates it, and elevation is your design system's call. Opt in with var(--lk-shadow-raised).
--lk-wheel-cursor / -active grab / grabbing Flick mode only; touch has no cursor.
--lk-wheel-idle-ms 64s One full drift revolution, when idle="drift". A token rather than a prop because there is no duration prop to collide with — the one-knob rule cuts both ways.
--lk-spin-value-color --lk-text The prize value. Text is deliberately NOT accent-driven: an accent can be white or near-black and would vanish against a matching surface. Set to var(--lk-reward) to opt in.

There is deliberately no duration token. Timing comes from the spinMs prop, scaled by --lk-motion like everything else in the kit. A token as well would be a second way to set one thing, and the two would disagree the first time someone used both.

The wheel adds nothing to tokens.css. The scratch card has to, because its foil is a painted material that needs restating in dark. Every wheel default resolves to a Tier-1 token that already retints per mode, so there is nothing to declare twice.

Prize names that do not fit

A wedge is a triangle, and a long name will run over its edges. Rather than let that happen the drawn label is truncated with an ellipsis to what the wedge can hold, which depends on the wedge count and on labelOrientation:

Roughly what fits
horizontal, 4 wedges ~20 characters
horizontal, 8 wedges ~11 characters
horizontal, 12 wedges ~7 characters
vertical, any count ~15 characters

horizontal labels are bounded by the ARC they sit on, so they get shorter as the wheel gains wedges. vertical labels run along the radius, which is the same length whatever the count — reach for it when the names are long.

Only the drawn text is shortened. The accessible prize list carries every label in full, so a screen-reader user is never handed the truncation, and a member who cannot make out Free s… can still reach the whole name. Cutting both would turn a layout constraint into a content one.

The widths are estimated from an average glyph advance rather than measured: measuring text means laying it out, which means a DOM, which the engine does not have and should not gain. A generous estimate that occasionally truncates a character early is a much better failure than labels crossing their own edges.

Icons

segments[].icon takes SVG content — a <path>, <g> or <use>, not an <img> or anything that renders HTML, because it is drawn inside the wheel's own <svg>. Author it around the origin at roughly 10 units across; the viewBox is 100 units wide.

Leave fill off your paths and they inherit --lk-wheel-icon, which is what lets an icon set theme with the rest of the kit instead of arriving with its own colours baked in. A path that sets its own fill still wins — the token is a default, not a constraint.

Icons sit inboard of the label when there is one and centred when there is not, at the same angle as the label so the two read as one unit.

Architecture

spin-engine.ts is framework-free: no React, no DOM, just counts and angles in, path strings and rotations out. Same hedge as the scratch card's engine — a Vue, Svelte or web-component wrapper would be a shell around this file rather than a rewrite.

It renders as SVG, not canvas, and that is a fix rather than a preference. The scratch card paints its foil into a canvas, so it has to repaint whenever the theme changes — which it detects by watching <html>, a mechanism that cannot see a theme scope below that. Wedges are shapes, shapes are elements, and an element reads --lk-* through the cascade like everything else. The wheel therefore themes correctly at any scope, with no observer and no repaint.

The spin and the landing are both CSS transitions on transform, with duration and easing in custom properties and transitionend driving the state change, so the stylesheet stays the single source of timing. The free spin is deliberately not a keyframe animation: an animation outranks the inline transform the landing writes, and removing it depends on a React commit that has not happened yet — which turns the handover into a race with a visible jump in it.

How a spin resolves

  1. Press or flick. getOutcome() fires once, and the wheel begins a free linear rotation.
  2. The outcome arrives. Its segmentId is resolved to an index. The engine computes the smallest forward rotation that puts that wedge under the marker, past the requested number of turns, jittered within the wedge.
  3. Deceleration. The eased transition runs from wherever the free spin had reached to the landing.
  4. Landed. The result is announced, focus moves to it, and the spin control gives way to the result. With the default resultAt: "below" they occupy the same slot, so the thing you pressed becomes the thing you won.

Under --lk-motion: 0 steps 1–3 collapse: the wheel is placed at the landing angle and the result appears. Reduced motion is not a shorter spin, it is no spin.

Accessibility

Label orientation is a prop rather than a decision made here. The names describe the text's axis within its wedge, not on screen — a horizontal label at three o'clock runs vertically down the page.

Each mode applies one rule to every label, with no per-label exceptions. An earlier version flipped labels past the halfway point so none read upside down, and that is precisely what made the set look arbitrary: two rules operating at once with an invisible boundary between them. A wheel where every label follows the same rule reads as designed even where one is inverted. If inverted labels are unacceptable for your campaign, use flat.

The wedges are aria-hidden; a visually hidden list carries the prize pool instead. That list is not the outcome — it is what is on offer, which is public information and in most jurisdictions has to be disclosed anyway. The result itself stays out of the accessibility tree until it has been decided.

Everything else matches the kit's baseline: a non-gesture path that cannot be removed, a polite live region for the announcement, managed focus that does not scroll, all copy through labels, and touch-action: none on the stage so a vertical flick does not scroll the page.

Content: where things go

Where
Prize names segments[].label (on the wedges)
Eyebrow, headline, badge header slot
Remaining spins, expiry footer slot
Odds disclosure, terms link terms slot
The reward itself renderOutcome

SpinResult (title / value / description / action / secondaryAction) ships as the default reward presentation and is yours to replace or edit. There is no SpinHeader: the header slot takes any node, and a second near-identical header component is not worth shipping.

Integration contract

The wheel lands on a server-provided outcome. It does not determine eligibility, probability, inventory, attempts, or reward fulfilment. Your backend should: assign outcomes server-side, treat the outcome request as idempotent (retry re-calls getOutcome with the same identity), persist the result before returning it, enforce its own timeouts inside getOutcome, and keep probability logic out of the browser entirely.

The wedge count is presentation, not probability. Eight equal-looking wedges do not mean a one-in-eight chance, and the component makes no such claim — the server's distribution is whatever it is. If your campaign implies otherwise to the member, say so in terms.

When to use it / when not to

Use for a single decided result with a moment of suspense in front of it, where the prize pool is worth showing: a wheel tells the member what they could have won as well as what they did. Prefer the scratch card when the pool is not the point, or when the reveal should feel private rather than theatrical. Don't use either when the member must compare or deliberate: that is a choice, not a reveal.

Common mistakes

Deciding outcomes client-side; letting flick speed influence the result; reading the wedge count as odds; forgetting touch-action: none; announcing a win before the outcome resolved; and springing the deceleration so the marker crosses back over a boundary.