About this animation
A three-reel reward reveal with a pull lever and printed coupon, with configurable rewards, labels, accent color, and fonts.
Why this one
Instead of a flat rotating wheel, this pattern feels mechanical: a brass frame holds dark reflective reel windows, the lever stretches against the finger, and each reel settles independently. A traveling wave of 20 bulbs, a luminous winning row, eight jumping coins, and a coupon feeding through a physical slot give the result its own identity. Icons: Lucide (ISC).
How it works
onLayoutmeasures the component. Its frame uses 86% of the available width, while each cream symbol tile stays 56 pt.- A Gesture Handler pan applies progressive elastic resistance after 56 pt. A pull over 35 pt, a lever tap, an accessibility activation, or the separate Spin button triggers the same operation.
- One shared millisecond timeline drives all reels and celebrations. Reel travel follows a cubic deceleration; stops occur at 900, 1,300, and 1,700 ms. A 180 ms settling arc adds exactly 6 pt of overshoot.
- Two translucent, vertically displaced copies accompany each symbol. Their separation and opacity follow the analytic reel velocity, so the trails disappear as the reel slows down.
- Matching symbols light the winning row, accelerate the bulb wave, and launch eight Skia coins. At 1,800 ms the coupon starts feeding downward over 480 ms.
onResultruns on the JS thread at 2,350 ms. - The component subtracts accepted spins from
spinsLeft. The demo uses an infinite allowance during autoplay: pull at 1 s, miss, win on the second pull at 3.5 s, then restart at 8 s. Touching anywhere in the demo permanently stops that autoplay session.
Install
npx expo install @shopify/react-native-skia expo-linear-gradient react-native-gesture-handler react-native-reanimated react-native-workletsUse GestureHandlerRootView at the app root. In web projects, configure Skia's CanvasKit loading before importing this component; Expo catalog demos should be registered through the existing withSkia loader. No new packages or native configuration are needed in this repository.
Usage
import RewardReelSpin from './reward-reel-spin';
export function DailyReward() {
return <RewardReelSpin spinsLeft={1} />;
}Provide a backend-issued outcome and your own content for a real rewards feature:
import { useRef } from 'react';
import RewardReelSpin, { type RewardReelSpinRef } from './reward-reel-spin';
export function RewardScreen() {
const machine = useRef<RewardReelSpinRef>(null);
return (
<RewardReelSpin
ref={machine}
spinsLeft={1}
accentColor="#FBBF24"
machineWidthRatio={0.9}
rewards={[{ label: 'A free pastry', symbol: 'croissant', code: 'PASTRY-4K8' }]}
getOutcome={() => 0}
onResult={(reward) => saveReward(reward)}
/>
);
}saveReward represents your application's result handler. Fetch and authorize the result before allowing a spin. The included random default and client-side allowance are presentation behavior; enforce daily limits and coupon redemption on your backend. spinsLeft is the allowance for this mounted instance, rather than a counter to decrement again in onSpin. Remount the component with a new day/session key to start a new allowance.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
rewards | readonly Reward[] | DEFAULT_REWARDS | Reward data. Defaults to three generic coupon rewards. |
spinsLeft | number | 1 | Available spins for the current day. The component also subtracts locally consumed spins. |
getOutcome | () => RewardOutcome | undefined | Resolves the result once per spin. Use a backend-issued result for real rewards. |
accentColor | string | '#F59E0B' | Accent for active bulbs, lever, and the winning row. |
machineWidthRatio | number | 0.86 | Machine width as a fraction of its container. |
machineHeightRatio | number | 0.45 | Machine height as a fraction of its container, clamped to 190–254 pt. |
spinLabel | string | 'Spin' | Button text. |
couponHeading | string | 'YOUR REWARD' | Coupon heading. |
exhaustedLabel | string | 'Come back tomorrow' | Text when no spins remain. |
missLabel | string | 'So close. Thanks for playing.' | Text for a nonwinning result. |
fontFamily | string | undefined | Body typeface; defaults to the system font. |
titleFontFamily | string | undefined | Coupon headline typeface; defaults to the system font. |
monoFontFamily | string | undefined | Code typeface; defaults to the system font. |
onSpin | () => void | undefined | Called once for each accepted spin. |
onResult | (reward: Reward | null) => void | undefined | Called after the result and coupon are revealed. |
onInteraction | () => void | undefined | Called when a finger touches the lever or spin button. |
accessibilityLabel | string | 'Pull the lever to spin' | Accessible label for the lever. |
style | StyleProp<ViewStyle> | undefined | Outer container styling. |
testID | string | undefined | Outer test identifier. |
duration | number | 2350 | Reel spin duration in milliseconds. Spring physics and reduced-motion timing are unchanged. |
Reward.symbol supports coffee, croissant, cookie, cup-soda, percent, and gift. Invalid indices and empty reward lists produce a miss.
Ref methods: spin(outcome?) returns whether the operation was accepted. spin(null) forces a miss; spin(0) selects the first reward. reset() clears an idle result without replenishing the allowance. Repeated triggers while the reels are active are ignored, while the lever remains movable and interruptible.
Compatibility
Short containers scroll vertically when the coupon and controls need more space. The empty coupon window collapses in this layout; the full coupon remains available after a win.
Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, Gesture Handler 3.3, and Skia 2.14.0. Intended for iOS, Android, and web, including the matching Expo Go build. Keep at least 310 pt of width and approximately 415 pt of height for the complete machine, coupon, status, and button.
Accessibility
The lever exposes a button role, action, label, hint, and disabled state. Its 46×48 pt touch target supports drag and tap through Gesture.Exclusive; the separate button is 52 pt tall. Important spin and result changes use a polite live region and native accessibility announcements. The coupon is hidden from the accessibility tree until it is revealed.
Reduced motion keeps the staggered results but replaces symbol changes with 200 ms fades, the coupon with a 200 ms fade, and the lever with a static control. It removes rolling, trails, rebounding, coin flights, and bulb waves. Interface pressure uses a separate shared value and never cancels the reel timeline.
Art direction and review notes
The demo uses the kit's Ember & Oat flame tile, warm ember mesh, cream tiles, and one amber accent. Its brand occupies the upper left; the upper-right viewer close area is clear. Geometry is tuned for 390×844 and 320×694 with a two-line serif headline and a separate lower prize menu. Velocity trails and traveling light are visible within the first three seconds; the second-attempt coupon win follows at approximately five seconds. Glows are blurred strokes or radial gradients, and shading stays inside the reel windows; no rectangular highlight covers text.




