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

@rocapine/spin-to-win

v0.1.3

Published

A reusable spin-to-win discount wheel flow for React Native apps: scripted wheel, second-chance sheet, unlocked celebration and countdown offer screen, themed from a single primary colour.

Readme

@rocapine/spin-to-win

A reusable spin-to-win discount flow for React Native apps: a scripted wheel, a second-chance nudge, an unlocked celebration and a countdown offer screen — themed end to end from a single primaryColor.

The wheel never decides anything. Each spin's outcome is scripted by the host, because this is a gamified lead-in to a monetisation offer, not a game of chance.

The package ships no assets. No images, no fonts, no icons. Your logo and your fonts stay yours; the package renders a generic hub and the platform system font until you pass your own.


Prerequisites

  • React Native or Expo app, TypeScript.

  • These peer dependencies installed in the host app:

    npx expo install react-native-svg expo-linear-gradient expo-haptics

    | Peer | Why | | --- | --- | | react-native-svg | the wheel, the background rays, the gift and gem artwork | | expo-linear-gradient | the reward card and offer banner gradients | | expo-haptics | the tick as the pin crosses each wedge |

  • Access to the @rocapine npm registry.

npm install @rocapine/spin-to-win

How to use

import { SpinToWinFlow, type Scenario } from '@rocapine/spin-to-win';

const scenario: Scenario = {
  title: 'Discover your offer',
  subtitle: 'Spin the wheel to get up to 81% off',
  spinCta: 'Spin the wheel',
  spinningCta: 'Spinning…',

  // Rendered clockwise from 12 o'clock.
  segments: [
    { id: 'big', label: '🎁', kind: 'big' },
    { id: 's5a', label: '5%', kind: 'percent' },
    { id: 's10a', label: '10%', kind: 'percent' },
    { id: 's5b', label: '5%', kind: 'percent' },
    { id: 's10b', label: '10%', kind: 'percent' },
    { id: 's5c', label: '5%', kind: 'percent' },
    { id: 's10c', label: '10%', kind: 'percent' },
    { id: 's5d', label: '5%', kind: 'percent' },
  ],

  // The scripted spins, in order. The last one must be `final`.
  spins: [
    {
      landOn: 's5d',
      nearMiss: true, // slow toward the jackpot, then tip into 5%
      outcome: 'retry',
      retrySheet: {
        title: 'Try your luck again',
        body: '5% is a good start! But who knows, you might get luckier.',
        cta: 'Spin again',
      },
    },
    { landOn: 'big', outcome: 'final', autoSpin: true },
  ],

  unlockedKicker: "You've unlocked",

  offer: {
    title: 'Your exclusive offer',
    badge: '81% OFF',
    strikePrice: '$12.99/mo',
    price: '$2.50/mo',
    subtitle: 'Unlock your yearly plan at 81% off.',
    urgency: 'If you close this offer, it disappears.',
    expiresLabel: 'Offer expires in',
    countdownSeconds: 175,
    cta: 'Claim my offer',
    footnote: 'Billed yearly. Cancel anytime.',
    productId: 'yearly_discount_81',
  },
};

export function DiscountWheel() {
  return (
    <SpinToWinFlow
      primaryColor="#A32AFF"
      scenario={scenario}
      onComplete={(result) => purchase(result.productId)}
      onEvent={(event) => analytics.track(event.type, event)}
    />
  );
}

Props

| Prop | Required | What it does | | --- | --- | --- | | primaryColor | yes | Drives the entire derived palette. | | scenario | yes | Segments, scripted spins, copy and offer. | | logo | no | ImageSourcePropType for the wheel hub. Omit for the generic hub. | | theme | no | Partial override of any derived or default token. | | onComplete | no | The offer was claimed — run your purchase here. Return a promise and the flow waits for it. | | onDismiss | no | The user left the flow without claiming. Also fires when the countdown runs out. | | onEvent | no | Analytics. | | dismissible | no | Render the offer screen's close affordance. Default true — it only appears if you pass onDismiss. | | dismissDelayMs | no | How long the offer screen hides that affordance. Default 3000. | | topInset | no | Top safe-area inset, in points, so the affordance clears the notch. Default 44. |

Leaving the flow

The offer screen carries the flow's only exit: a close affordance in the top right, over the gradient. It stays hidden for dismissDelayMs (3s by default) so the offer lands before the user is shown the way past it, then fades in. Pressing it emits flow_dismissed and calls onDismiss.

The package has no safe-area dependency — adding one would force a peer dependency on every host. Pass your own inset instead:

const { top } = useSafeAreaInsets();

<SpinToWinFlow primaryColor="#A32AFF" scenario={scenario} topInset={top} onDismiss={goNext} />;

The close glyph's accessibility label is host-supplied like every other string on the screen: set offer.dismissLabel (it falls back to 'Close').

Claiming, and claims that fail

onComplete may return a promise. The flow awaits it and only leaves the offer once it resolves, so a cancelled purchase sheet or a store error returns the user to a live CTA rather than a dead one. While it is in flight the button renders disabled, and a second tap is ignored.

A rejection is re-thrown out of useSpinFlow's claim() — the flow never swallows your error — and leaves the phase on offer.

<SpinToWinFlow
  primaryColor="#A32AFF"
  scenario={scenario}
  onComplete={async (result) => {
    await purchase(result.productId); // throws if the user cancels
  }}
/>

A synchronous onComplete still works exactly as before.

Expiry

When offer.countdownSeconds runs out the flow emits offer_expired and then dismisses itself (flow_dismissed, onDismiss). An offer that stayed claimable past its own countdown would make "Offer expires in" a false claim. Omit countdownSeconds if you do not want the offer to end.

Theming

primaryColor is the only styling input you need. From it the package derives the wheel gradient, the wedge tints, the gift artwork, the confetti ramp, the reward-card blobs and glow, and the offer banner surface — measured as a single lightness ladder off your colour, with the gift artwork on a slightly cooler sub-ramp.

Two things are deliberately not derived:

  • the off-white fallback wedge (palette.wedgeNeutral), which is a fixed neutral by design;
  • the neutrals and text colours in theme.colors.

Override anything you like:

<SpinToWinFlow
  primaryColor="#0E7C66"
  scenario={scenario}
  theme={{
    palette: { wedgeNeutral: '#EFEFEF' },
    colors: { pointer: '#111111' },
    radii: { pill: 12 },
    fontFamily: { medium: 'Inter', bold: 'Inter-Bold' },
  }}
/>

Wedge label colour is computed from the wedge's relative luminance, so a pale primaryColor gets dark labels rather than invisible white ones.

Fonts

The package sets no fontFamily by default, so its text renders in the platform system font (San Francisco / Roboto).

React Native has no inheritable app-wide default font for a library's own <Text>, so a brand font used elsewhere in your app will not be picked up automatically. Load the font yourself and pass its family name:

const [loaded] = useFonts({ Inter: require('./assets/Inter.ttf') });

<SpinToWinFlow theme={{ fontFamily: { medium: 'Inter', bold: 'Inter' } }} … />

Using the pieces separately

To keep only the wheel and hand the reward to your own paywall (Superwall, onboarding-studio), compose the exported pieces yourself:

import {
  SpinToWinThemeProvider,
  WheelScreen,
  SecondChanceSheet,
  UnlockedScreen,
  useSpinFlow,
} from '@rocapine/spin-to-win';

SpinWheel, OfferScreen, Countdown, Confetti, ConfettiBurst, DiscountCard, PrimaryButton, ScreenBackground, buildTheme, derivePalette and the wheel geometry helpers are all exported too.

Analytics events

flow_started, spin_started, spin_landed, second_chance_shown, second_chance_dismissed, offer_shown, offer_claimed, offer_expired, flow_dismissed.

Scenario validation

In __DEV__ the flow validates the scenario and throws with every problem listed at once: unknown landOn ids, a missing retrySheet, a final step that is not last, duplicate segment ids, and a nearMiss on a wedge that does not neighbour the jackpot (without that adjacency the wheel visibly decelerates toward the wrong wedge).

How to contribute

npm install          # installs the package and the example app
npm test             # jest
npm run type         # tsc --noEmit
npm run build        # tsup → dist/ (cjs, esm, d.ts)
npm run example:ios  # run the example app on the iOS simulator

The example/ workspace is the development harness and the visual QA target. It links the package with file:.., and Metro reads the package's react-native field, which points at src/index.ts — so there is no build step between editing the package and seeing it in the example.

Expo SDK 52+ configures Metro for workspaces automatically; the example deliberately has no metro.config.js.

Two invariants are enforced by tests, not by vigilance:

  • No assets in the package (__tests__/no-assets.test.ts) — no image or font import anywhere in src/, no assets/ directory, no expo-font dependency.
  • Readable wedge labels (__tests__/derivePalette.test.ts) — every wedge label holds WCAG AA contrast across a spread of primaries, including the pale-yellow case that a naive always-white rule fails.

Requires Expo SDK 57. Read the versioned docs at https://docs.expo.dev/versions/v57.0.0/ before changing anything.