Spin Wheel
A prize wheel that reveals an outcome drawn somewhere else — weighted SVG slices, a flick or a button to start, ticking deceleration onto a pre-computed angle, and a one-shot settle.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/spin-wheel.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SpinWheel" component: a prize wheel drawn
as hand-written SVG that REVEALS an outcome somebody else already drew. Only
lucide-react for the button icon; no animation library, no canvas.
Contract
- export interface SpinWheelEntry { id: string; label: string; weight?: number }
weight defaults to 1 and is the entry's relative share of the turn. Anything
that is not a finite positive number falls back to 1: a zero-sweep slice can
never be pointed at, and quietly deleting a prize is worse than drawing it.
Ids must be unique (they are React keys and the result lookup); the first
match wins if they are not.
- forwardRef div extending React.HTMLAttributes<HTMLDivElement>. Props:
entries (required), result (string | null, default null), turns (4, clamped
1-12), spinDuration (4200 ms), landingJitter (0.28, clamped 0-0.45),
disabled (false), label ("Prize wheel"), spinLabel ("Spin"), idleHint,
onSpinStart(entryId), onTick(index), onSettle(entry, index),
onRefuse(message), className.
- `result` IS THE WHOLE POINT: the component never generates an outcome. A
front-end that rolls its own prize is a prize nobody can audit, so the draw
belongs to the server and this component only performs the reveal.
- Ship the layout as a pure exported function beside the component,
buildSpinWheelSegments(entries) -> { entry, index, start, end, sweep, mid,
share, path, tone, texture, label, labelAnchor, labelTransform }[]. It
touches no DOM and measures nothing, so the geometry is testable without a
renderer and a consumer can print the odds from the same numbers the picture
is made of.
Behavior
- SWEEP IS THE ODDS. Slice i owns 360 x weight_i / sum(weights), laid out
clockwise from twelve o'clock in array order. The last boundary is pinned to
exactly 360 instead of accumulated, or float drift leaves a hairline the
pointer can fall into.
- AIMING. Let θ be the rotor's rotation, clockwise. A point at wheel-local
angle a shows up on screen at a + θ, and the pointer is fixed at 0, so the
local angle under the pointer is (-θ mod 360). To land slice k the reveal
must therefore end at θ = -(k.start + k.sweep x (0.5 + jitter)). Travel is
the shortest CLOCKWISE advance to that angle plus whole turns:
advance = mod360( mod360(-landing) - mod360(from) ) // 0 -> use 360
distance = advance + 360 x (turns + flickBonus)
Zero advance is replaced by a full turn so a wheel already parked on the
outcome still stages a spin instead of a dead press.
- SEEDED LANDING. The jitter inside the winning slice is an FNV-1a hash of
`${entryId}:${spinIndex}` folded into [0,1) and mapped to
±landingJitter x sweep — never Math.random, never a clock. The same reveal
replays identically in a test, a screenshot and a rerender, and landing dead
centre every time (landingJitter 0) reads as mechanical.
- THE FLICK IS A START, NOT A VOTE. Pointer-drag winds the wheel by hand
(angles accumulated through a shortest-signed-delta so it never jumps at the
359/0 seam) using setPointerCapture, so the gesture survives the finger
leaving the wheel and there is no document listener to forget. On release,
blended angular speed above 0.22 deg/ms starts the reveal and buys up to
three extra turns; direction is dropped (the reveal always runs clockwise)
and a pointer that has been still for more than 120 ms has no flick left in
it. A slower release says so in the status line and leaves the wheel wound.
Speed changes the flourish and NEVER the outcome.
- REFUSALS, out loud, through the status line and onRefuse: no entries; result
null or undefined ("no outcome has been drawn yet"); a result id that is not
on the wheel (name it in the message — the wheel will not invent a winner);
disabled. Refusing is a first-class branch, not a silent no-op.
- TICKS. Each frame, resolve the slice under the pointer; when the index
changes, kick the pawl aside by 9 degrees and call onTick(index). The kick
decays as exp(-elapsed / 130ms) so ticks thin out as the wheel slows, with
no timers of their own. The very first reading is where the pointer already
was, not a crossing, so it is swallowed. Winding by hand ticks the same way.
- ONE-SHOT SETTLE. The loop's last frame, a tap on the wheel, Enter, Space and
Escape all race for the same settle. A `done` flag on the run object is read
AND written in the same synchronous step, so onSettle fires exactly once per
spin and the consumer cannot award a prize twice.
- CANCEL ON DEPENDENCY CHANGE. A run is planned against the geometry present
at take-off. Each frame compares a signature of the live geometry (entry ids
plus start angles, plus `disabled`) against the one captured then; if they
differ the run aborts where it stands and awards nothing, because the angle
it was travelling to no longer means what it meant. Compare CONTENT, not
array identity: a consumer who rebuilds an equal `entries` array every render
must not have their spin cancelled. The live value is read through a ref
written in an effect, never touched during render.
- prefers-reduced-motion (useSyncExternalStore over matchMedia, false server
snapshot): no rAF at all. The rotor is parked on the final angle and a 200ms
opacity fade carries the change; the reveal settles in the same step the
press happened in, so the feature is whole with the decoration off.
- The rAF loop integrates against the timestamp the browser hands the frame,
never a clock read during render, so a backgrounded tab resumes at the right
progress instead of finishing the spin in slow motion. The frame is cancelled
on unmount, on abort and on settle.
- Rotation lives in state at rest and is written straight to the rotor's
transform attribute during a spin, so a 4-second reveal costs zero renders.
React only patches an attribute whose rendered value changed, so a re-render
mid-spin leaves the live transform alone. The settle paints AND commits the
final angle, because if the settled angle equals the last rendered one React
has nothing to patch and the DOM would keep a mid-spin transform.
Rendering & styling
- Semantic tokens only. Slices cycle var(--chart-1..5); the bed under them is
--muted, the bezel and hub ring are --border, the hub is --card, the pawl is
--foreground, refusals are text-destructive, everything else is
--muted-foreground. Merge the consumer's className with cn().
- COLOUR IS NOT THE ONLY ENCODING. There are five hue tokens, so slice six
wears slice one's colour again: the second lap is striped, the third dotted,
the fourth crossed, via SVG patterns painted in --card over the fill — a
channel that survives greyscale and colour blindness. Past twenty entries the
pairs repeat, which is the point at which a wheel is the wrong picture.
- ONE ANNULAR-SECTOR PATH BUILDER, and the full-turn case is why it is
hand-written: a single entry owns 360 degrees, its start and end points are
the same point, and an SVG elliptical arc between two identical points is
defined to draw NOTHING — the only slice on the wheel would silently vanish.
Draw that case as two half turns with the inner circle wound the other way,
so the even-odd fill rule punches the hub out.
- LABELS RIDE THE RADIUS, anchored at the rim and growing toward the hub:
rotate the frame onto the slice's middle, translate out to the label ring,
then turn the baseline onto the radius (-90 on the right half, +90 on the
left, anchor flipped to match) so no glyph ever tilts more than 90 degrees.
The character budget is the radial room divided by the average glyph width;
longer labels are truncated with an ellipsis, and a slice whose arc at the
label ring is thinner than a line of text drops its label entirely — it
loses nothing else, because the sr-only table still names it. Every label
carries a --card halo through paint-order:stroke, the SVG equivalent of a
knockout, so it stays readable over any fill.
- Layout is responsive through the viewBox on an aspect-square svg, so it can
never collapse to zero height in a flex parent.
- KEYBOARD, on a single focusable wheel surface (role="button", tabIndex 0)
plus a real Spin button: Enter or Space spins, and while it is turning either
one settles it at once; Escape (bound on the root) settles a spin in flight
and is silent otherwise, so a surrounding dialog still closes on the same
key; a click or tap on the wheel does what Enter does. A held key auto-repeats,
and that repeat is dropped — otherwise leaning on Enter would settle the spin
it just started; a deliberate second press still skips. Arrow keys are left
alone on purpose — nothing here needs them, and eating them would eat the
page's scroll. A drag that moved more than 6px swallows the click its release
fires, so one gesture is one action. touch-action is surrendered only while the
wheel can be wound; a closed wheel that kept it would be a region a phone
cannot scroll the page through.
- ARIA: the root is role="group" with the wheel's name; the svg is aria-hidden
because everything it says is said again in text; the wheel surface is
aria-describedby the status line; the status line is one polite atomic
role="status" carrying the idle hint, "Spinning…", the landing sentence or a
refusal; an sr-only WRAPPER div holds a real table of every entry and its
share of the turn. Put sr-only on the wrapper, never on the table — a CSS
width is only a lower bound for a table box, so width:1px does not hold one
back and a narrow viewport picks up horizontal scroll. The table never names
the pending result: that would spoil the reveal for exactly the users who
cannot watch it.
- Inert is aria-disabled plus a handler guard, never the native attribute: the
browser blurs a node the instant it becomes disabled, and the Spin button
goes inert underneath the user the moment a spin starts. Nothing unmounts on
settle either, so focus never has to be rehomed.
Customization levers
- turns / spinDuration: 4 turns over 4.2s reads as a fairground wheel; 2 turns
over 1.5s reads as a UI affordance. Keep duration and turns in step — every
bonus turn a flick buys adds 250ms so a longer spin is never a faster one.
- Easing: the quartic ease-out assumes the wheel starts at full speed (a flick
does). Swap in a quintic for a longer glide, or an ease-in-out for a wheel
that is meant to be button-driven only.
- landingJitter: 0 lands dead centre every time (good for a debug view where
you are checking the maths), 0.3-0.4 for a wheel that should feel physical.
- Geometry: RIM / HUB / LABEL_OUTER trade diagram size against label room;
raise HUB to fit a logo in the middle, drop it to 0 for solid pie wedges.
- Palette: re-point the five chart tokens at brand tokens, or map tone by
prize tier instead of position so "grand prize" is always the same colour.
The texture ladder is what keeps that legible past five.
- onTick is the hook for the sound or haptics a real wheel would make
(navigator.vibrate, a pooled WebAudio click); it runs inside rAF, so keep it
cheap and never setState from it.
- onSpinStart / onSettle are where the app lives: start a server call on
onSpinStart if the draw is not in hand yet and keep the spin refused until
`result` arrives, and award, log or fire a confetti burst on onSettle.Concepts
- Reveal, not draw — the outcome arrives as a prop and the wheel performs it. A component that rolled its own prize would be a prize nobody can audit, so refusing to spin without a
resultis the feature, not a gap. - Sweep is the odds — a slice owns
360 x weight / total, so the picture and the probability are the same number. What the sweep cannot promise is the landing: a 5% sliver wins whenever the draw says it did. - Aiming at an angle — rotation θ puts wheel-local angle
-θunder a fixed pointer, so a reveal is “end at-(slice mid + jitter), having travelled the shortest clockwise advance plus whole turns”. Every question about where it stops is that one line. - Seeded landing jitter — the offset inside the winning slice is a hash of the entry id and the spin index, not
Math.random, so the wheel looks unpredictable and behaves reproducibly: the same reveal replays frame for frame in a test or a screenshot. - A flick that only buys flourish — releasing fast starts the spin and adds turns; it never touches the result. That is what lets the button, Enter and Space be exact equivalents of the gesture instead of poor relations of it.
- One-shot settle under four racers — the last frame, a tap, Enter and Escape all reach for the same landing, so a
doneflag on the run is read and written in one synchronous step.onSettlefires once per spin, or somebody gets paid twice.
Hexdump Viewer
A windowed hex dump whose offset gutter, hex pane and ASCII pane share one selection, with 8/16/32 bytes per row and copy of the selected range as hex or text.
Currency Converter
A two-way money converter: type in either field and the other recomputes through a rate table you pass in, with per-currency Intl decimals, a rate breakdown line and an as-of / stale note.