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) orgetOutcome()(called once, when the spin starts). The outcome names its wedge bysegmentId, and the wheel is aimed at that wedge. If the id is not insegmentsthe 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
buttonmode a spin button renders and the wheel itself is inert — the button is the interaction. Inflickmode 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 itsmanualandautomodes, 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 inflickmode by the wheel-button itself, and inbuttonmode by the control. That serves tremor, dexterity and trackpad users as much as keyboard and screen-reader ones. Replace the button with your own viarenderSpinAction. - 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.resolvingis 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
onSpinCompleteif you want it. termsis 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
- Press or flick.
getOutcome()fires once, and the wheel begins a free linear rotation. - The outcome arrives. Its
segmentIdis 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. - Deceleration. The eased transition runs from wherever the free spin had reached to the landing.
- 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.