@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
@rocapinenpm registry.
npm install @rocapine/spin-to-winHow 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 simulatorThe 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 insrc/, noassets/directory, noexpo-fontdependency. - 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.
