npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cxde/wheel-of-fortune

v0.1.7

Published

Highly customizable and performant wheel of fortune component for React

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 dev

Open the URL printed by Vite (usually http://localhost:5173). Run a production build with:

npm run build

Install the library

npm install @cxde/wheel-of-fortune

Import 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 test

Implemented 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 hide and shrink alternatives;
  • configurable wheel borders, text strokes and shadows, and divider shadows;
  • client-side weighted selection using crypto.getRandomValues, server-side selection through winnerId, and an asynchronous resolveWinner with AbortSignal;
  • 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, and pointer as ReactNode, supporting <img>, inline SVG, GIF, and <video>; centerSize controls the center slot size;
  • responsive sizing through size, including values such as "50%" inside a container;
  • spin/tick/win sounds from a URL or a local File/Blob;
  • a controlled items list: add and remove items by updating React state, with crossfade, Canvas LOD collapse, 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;
  • WheelMedia for quickly connecting image/GIF/WebM content to overlay, center, and pointer;
  • 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.