@cxde/wheel-of-fortune
v0.1.7
Published
Highly customizable and performant wheel of fortune component for React
Maintainers
Readme
React Wheel of Fortune
A working MVP of a customizable wheel-of-fortune library for React, plus an interactive playground for exploring the API.
Live demo
Explore the interactive playground at customizablewheel.netlify.app.
Run the playground
npm install
npm run devOpen the URL printed by Vite (usually http://localhost:5173). Run a production build with:
npm run buildInstall the library
npm install @cxde/wheel-of-fortuneImport the stylesheet once from your application entry point:
import '@cxde/wheel-of-fortune/style.css';The geometry and winner-selection checks run without an additional test runner:
npm testImplemented features
- sectors with arbitrary weights, colors, transparent image layers above the color, per-sector text styles, and optional normalized chance labels;
- labels clipped to the sector wedge; long text uses an ellipsis by default, with
hideandshrinkalternatives; - configurable wheel borders, text strokes and shadows, and divider shadows;
- client-side weighted selection using
crypto.getRandomValues, server-side selection throughwinnerId, and an asynchronousresolveWinnerwithAbortSignal; - landing at the center or at a random point inside the winning sector with safe edge padding;
- Web Animations API spin with a single rotating layer, no React render on every frame, custom CSS
easing, duration, and rotation count; - CSS-backed pointer bounce, rate-limited sector-pass events, Web Audio tick playback, and pointer placement at the top or right (
pointerPosition); overlay,center, andpointerasReactNode, supporting<img>, inline SVG, GIF, and<video>;centerSizecontrols the center slot size;- responsive sizing through
size, including values such as"50%"inside a container; spin/tick/winsounds from a URL or a localFile/Blob;- a controlled
itemslist: add and remove items by updating React state, withcrossfade, Canvas LODcollapse, or no animation while idle; - optional idle animation (subtle tilt and pulse) on a compositor layer; the pointer reacts to sector-boundary crossings through the same bounce/tick event;
- Canvas is the only wheel renderer; it caps DPR and hides labels/dividers that would no longer be readable;
- geometric hit testing, hover, click, and controlled visual highlighting (
onSectorHover,onSectorClick,highlightedItemId,highlightStyle); - a visually hidden semantic item list is emitted by default for assistive technology; set
accessibleItemList={false}when the application provides its own external list; WheelMediafor quickly connecting image/GIF/WebM content tooverlay,center, andpointer;prefers-reduced-motion: spin and transitions are shortened, and the winner is announced through a live region.
Minimal usage
import { useState } from 'react';
import { Wheel, useWheel, type WheelItem } from '@cxde/wheel-of-fortune';
import '@cxde/wheel-of-fortune/style.css';
function PrizeWheel() {
const wheel = useWheel();
const [items, setItems] = useState<WheelItem[]>([
{
id: 'bonus', label: 'A super-long bonus for a new customer', weight: 70, color: '#7c3aed',
image: { src: '/sparkles.png', opacity: 0.55, fit: 'cover' },
text: { overflow: 'shrink-wrap', minFontSize: 1.8, maxLines: 2, innerRadius: 0.28, strokeColor: '#312e81', strokeWidth: 0.25 },
},
{ id: 'gift', label: 'Gift', weight: 30, color: '#db2777' },
]);
return (
<>
<Wheel
controller={wheel}
items={items}
size="100%"
showProbability
itemsTransition={{ mode: 'collapse', duration: 360, easing: 'cubic-bezier(.22, 1, .36, 1)' }}
spinAnimation={{
duration: 4200,
rotations: { min: 5, max: 7 },
easing: 'cubic-bezier(.12, .82, .18, 1)',
}}
overlay={<img src="/frame.svg" alt="" />}
center={<img src="/logo.gif" alt="" />}
centerSize="32%"
pointerPosition="right"
idleAnimation={{ enabled: true, duration: 3200, rotation: 0.5, scale: 1.01 }}
onSpinEnd={({ winner }) => console.log(winner)}
/>
<button onClick={() => wheel.spin({ mode: 'client' })}>Spin</button>
<button onClick={() => wheel.spin({ mode: 'server', winnerId: 'gift' })}>
Select the server-side gift
</button>
</>
);
}For labels that can be longer than their sectors, use overflow: 'shrink-wrap'.
It wraps at word boundaries; an unbreakable word or text beyond maxLines is
truncated with an ellipsis. It reduces text only when the resulting lines do
not fit vertically, only as far as minFontSize (both defaults are 55% of
fontSize and two lines).
Set innerRadius (0–1) to reserve space from the wheel centre, such as for a
large centre element; text is fitted and clipped outside that inner boundary.
Debug layout guides
Pass debug to inspect the Canvas geometry while configuring a wheel:
<Wheel items={items} debug />The cyan guides show each sector's radial axis and configured text radius;
pink arcs show innerRadius; yellow rectangles show the available text area
after orientation, align, maxWidth, and offsets are applied. Debug mode is
visual only and does not change hit testing, probabilities, or spin results.
Asynchronous server result
Use resolveWinner when the authoritative result should be requested immediately after the user clicks. While the promise is pending, getState() returns status: 'resolving'; item updates are deferred and cancel() propagates through AbortSignal.
await wheel.spin({
mode: 'server',
resolveWinner: async ({ signal }) => {
const response = await fetch('/api/spins', { method: 'POST', signal });
const { winnerId } = await response.json();
return { winnerId, landing: { mode: 'random', edgePadding: 0.15 } };
},
});For dense wheels, Canvas exposes the same geometric sector events. highlightedItemId provides controlled highlighting without relying on individual DOM nodes:
<Wheel
items={items}
highlightedItemId={hoveredId}
onSectorHover={(sector) => setHoveredId(sector?.item.id)}
onSectorClick={({ item }) => setServerWinnerId(item.id)}
/>Detailed architectural decisions and future work are documented in ARCHITECTURE.md.
Canvas collapse follows a level-of-detail policy without affecting weights or winner selection: 1–50 sectors redraw the full wheel at up to 60 FPS; 51–150 redraw fills, simple dividers and border at up to 30 FPS; 151+ sectors crossfade two prepared Canvas bitmaps.
highlightStyle controls the controlled highlight layer without redrawing the base wheel:
<Wheel
items={items}
highlightedItemId={hoveredId}
highlightStyle={{ color: '#fef08a', opacity: 0.28, blendMode: 'screen' }}
/>For repeatable local performance checks, run the app and open /benchmark.html. See BENCHMARKS.md for the scenarios and measurement rules.
Open /visual-fixtures.html to inspect the supported Canvas customisation matrix before updating visual snapshots.
The sector image (WheelItem.image) is always rendered above its color and clipped to the sector wedge. Transparent PNG/GIF pixels therefore leave the sector color visible. The object form also accepts opacity and fit: 'cover' | 'contain' | 'stretch'. scale (default 1) scales the layer from the wheel center: values below 1 zoom out and values above 1 zoom in. rotation rotates the image around the center in degrees, while offsetX and offsetY move it horizontally and vertically in wheel coordinates from 0 to 100 (positive values move right and down).
Current limitations
- Hover and click are geometric; applications that need full keyboard navigation for hundreds of sectors should provide an external prize list.
- For valuable prizes, the server should remain the source of truth: client-side mode selects locally for UI-only experiences.
