Created
November 20, 2025 09:39
-
-
Save vanGalilea/089a6e500435047ec4d94fb3b7b52829 to your computer and use it in GitHub Desktop.
A fully custom, physics-driven confetti engine for React Native + Expo, built with Reanimated + SVG. 8 shapes, randomized trajectories, gravity simulation, peak arcs, opacity fades. Everything you need to instantly upgrade your success screens with joyful, performant confetti.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useEffect } from "react"; | |
| import { Dimensions, View } from "react-native"; | |
| import Animated, { | |
| Easing, | |
| useAnimatedStyle, | |
| useSharedValue, | |
| withDelay, | |
| withSequence, | |
| withTiming, | |
| } from "react-native-reanimated"; | |
| import Svg, { Circle, Polygon, Rect } from "react-native-svg"; | |
| import * as R from "remeda"; | |
| const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); | |
| // Available confetti colors | |
| const COLORS = ["#1b70de", "#32af4b"] as const; | |
| // Gravity fall duration after reaching peak (ms) | |
| const FALL_DURATION = 5500; | |
| // Opacity fade-in duration (ms) | |
| const OPACITY_FADE_DURATION = 80; | |
| // Allowed number of confetti pieces | |
| const PIECES_COUNT = 250; | |
| // Size range for confetti shapes | |
| const MIN_SIZE = 6; | |
| const MAX_SIZE = 12; | |
| // Angle range of the confetti cannon (degrees) | |
| const SHOOT_ANGLE_MIN = -15; | |
| const SHOOT_ANGLE_MAX = 15; | |
| // Upward velocity range (affects peak height) | |
| const VELOCITY_MIN = 1400; | |
| const VELOCITY_MAX = 2200; | |
| // Range for opacity | |
| const OPACITY_MIN = 25; | |
| const OPACITY_MAX = 90; | |
| // Allowed peak Y range (higher peak = lower visual height) | |
| const PEAK_MIN = SCREEN_HEIGHT * 0.05; | |
| const PEAK_MAX = SCREEN_HEIGHT * 0.65; | |
| // Horizontal drift during falling phase | |
| const DRIFT_MIN = -30; | |
| const DRIFT_MAX = 30; | |
| // Upward movement duration (ms) | |
| const UP_MIN_DURATION = 1000; | |
| const UP_MAX_DURATION = 1600; | |
| /** | |
| * Converts normalized polygon coordinates to actual SVG size | |
| */ | |
| const scalePoints = (points: string, size: number) => | |
| points | |
| .split(" ") | |
| .map((p) => { | |
| const [x, y] = p.split(",").map(Number); | |
| return `${(x / 100) * size},${(y / 100) * size}`; | |
| }) | |
| .join(" "); | |
| type ShapeProps = { | |
| type: number; | |
| size: number; | |
| color: string; | |
| opacity: number; | |
| }; | |
| /** | |
| * Renders different SVG shapes for each confetti type. | |
| */ | |
| const ConfettiShape: React.FC<ShapeProps> = ({ | |
| type, | |
| size, | |
| color, | |
| opacity, | |
| }) => { | |
| switch (type) { | |
| case 0: | |
| return ( | |
| <Circle | |
| cx={size / 2} | |
| cy={size / 2} | |
| r={size / 2} | |
| fill={color} | |
| opacity={opacity} | |
| /> | |
| ); | |
| case 1: | |
| return <Rect width={size} height={size} fill={color} opacity={opacity} />; | |
| case 2: | |
| return ( | |
| <Rect | |
| width={size * 2.8} | |
| height={size * 0.8} | |
| fill={color} | |
| opacity={opacity} | |
| /> | |
| ); | |
| case 3: | |
| return ( | |
| <Polygon | |
| points={`0,${size} ${size / 2},0 ${size},${size}`} | |
| fill={color} | |
| opacity={opacity} | |
| /> | |
| ); | |
| case 4: | |
| return ( | |
| <Polygon | |
| points={scalePoints("50,8 92,35 80,80 20,80 8,35", size)} | |
| fill={color} | |
| opacity={opacity} | |
| /> | |
| ); | |
| case 5: | |
| return ( | |
| <Polygon | |
| points={scalePoints("50,0 95,25 95,75 50,100 5,75 5,25", size)} | |
| fill={color} | |
| opacity={opacity} | |
| /> | |
| ); | |
| case 6: | |
| return ( | |
| <Polygon | |
| points={scalePoints( | |
| "50,0 65,35 98,40 70,60 80,95 50,75 20,95 30,60 2,40 35,35", | |
| size, | |
| )} | |
| fill={color} | |
| opacity={opacity} | |
| /> | |
| ); | |
| case 7: | |
| return ( | |
| <Polygon | |
| points={scalePoints( | |
| "50,0 65,30 95,35 70,60 80,95 50,75 20,95 30,60 5,35 35,30", | |
| size, | |
| )} | |
| fill={color} | |
| opacity={opacity} | |
| /> | |
| ); | |
| default: | |
| return null; | |
| } | |
| }; | |
| /** | |
| * One falling & rotating confetti particle. | |
| * Handles: | |
| * - upward launch | |
| * - peak arc | |
| * - gravity fall | |
| * - rotation | |
| * - opacity fade-in | |
| */ | |
| const ConfettiPiece = ({ delay }: { delay: number }) => { | |
| const y = useSharedValue(SCREEN_HEIGHT + 100); | |
| const x = useSharedValue(SCREEN_WIDTH / 2); | |
| const rotate = useSharedValue(0); | |
| const opacity = useSharedValue(0); | |
| // Randomized properties per piece | |
| const size = R.randomInteger(MIN_SIZE, MAX_SIZE); | |
| const color = COLORS[R.randomInteger(0, COLORS.length - 1)]; | |
| const op = R.randomInteger(OPACITY_MIN, OPACITY_MAX) / 100; | |
| const type = R.randomInteger(0, 7); | |
| // Shooting angle | |
| const angleDeg = R.randomInteger(SHOOT_ANGLE_MIN, SHOOT_ANGLE_MAX); | |
| const angleRad = (angleDeg * Math.PI) / 180; | |
| // Upward velocity controls peak height | |
| const velocity = R.randomInteger(VELOCITY_MIN, VELOCITY_MAX); | |
| const velX = velocity * Math.sin(angleRad); | |
| // Vertical peak range | |
| const peakY = R.randomInteger(PEAK_MIN, PEAK_MAX); | |
| // Horizontal distance reached at peak | |
| const peakX = SCREEN_WIDTH / 2 + velX * 0.5; | |
| // Gentle horizontal drifting during fall | |
| const fallDriftX = R.randomInteger(DRIFT_MIN, DRIFT_MAX); | |
| // Upward movement duration varies per piece | |
| const upDuration = R.randomInteger(UP_MIN_DURATION, UP_MAX_DURATION); | |
| useEffect(() => { | |
| // Fade in quickly | |
| opacity.value = withTiming(op, { duration: OPACITY_FADE_DURATION }); | |
| // Vertical movement: launch → peak → fall | |
| y.value = withDelay( | |
| delay, | |
| withSequence( | |
| withTiming(peakY, { | |
| duration: upDuration, | |
| easing: Easing.out(Easing.cubic), | |
| }), | |
| withTiming(SCREEN_HEIGHT + 500, { | |
| duration: FALL_DURATION, | |
| easing: Easing.in(Easing.quad), | |
| }), | |
| ), | |
| ); | |
| // Horizontal movement: cone spread → drift | |
| x.value = withDelay( | |
| delay, | |
| withSequence( | |
| withTiming(peakX, { | |
| duration: upDuration, | |
| easing: Easing.out(Easing.cubic), | |
| }), | |
| withTiming(peakX + fallDriftX, { | |
| duration: FALL_DURATION, | |
| easing: Easing.linear, | |
| }), | |
| ), | |
| ); | |
| // Continuous rotation | |
| rotate.value = withDelay( | |
| delay, | |
| withTiming(R.randomInteger(-2200, 2200), { | |
| duration: upDuration + FALL_DURATION, | |
| easing: Easing.linear, | |
| }), | |
| ); | |
| }, [delay]); | |
| // Apply animated transforms | |
| const style = useAnimatedStyle(() => ({ | |
| opacity: opacity.value, | |
| transform: [ | |
| { translateX: x.value }, | |
| { translateY: y.value }, | |
| { rotate: `${rotate.value}deg` }, | |
| ], | |
| })); | |
| return ( | |
| <Animated.View style={style} className="absolute"> | |
| <Svg height={size * 2.6} width={size * 4.2}> | |
| <ConfettiShape type={type} size={size} color={color} opacity={op} /> | |
| </Svg> | |
| </Animated.View> | |
| ); | |
| }; | |
| /** | |
| * Full screen confetti animation that fires upon mount | |
| */ | |
| export const Confetti = () => ( | |
| <View pointerEvents="none" className="absolute-fill"> | |
| {Array.from({ length: PIECES_COUNT }, (_, i) => ( | |
| <ConfettiPiece key={i} delay={i * 4} /> | |
| ))} | |
| </View> | |
| ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment