@farcaster/snap 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ export declare const SUPPORTED_SPEC_VERSIONS: readonly ["1.0", "2.0"];
5
5
  export type SpecVersion = (typeof SUPPORTED_SPEC_VERSIONS)[number];
6
6
  export declare const SNAP_PAYLOAD_HEADER: "X-Snap-Payload";
7
7
  export declare const MEDIA_TYPE: "application/vnd.farcaster.snap+json";
8
- export declare const EFFECT_VALUES: readonly ["confetti"];
8
+ export declare const EFFECT_VALUES: readonly ["confetti", "fireworks"];
9
9
  export declare const POST_GRID_TAP_KEY: "grid_tap";
10
10
  export declare const GRID_MIN_COLS = 2;
11
11
  export declare const GRID_MAX_COLS = 32;
package/dist/constants.js CHANGED
@@ -7,7 +7,7 @@ export const SUPPORTED_SPEC_VERSIONS = [
7
7
  ];
8
8
  export const SNAP_PAYLOAD_HEADER = "X-Snap-Payload";
9
9
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json";
10
- export const EFFECT_VALUES = ["confetti"];
10
+ export const EFFECT_VALUES = ["confetti", "fireworks"];
11
11
  // ─── Pixel grid ────────────────────────────────────────
12
12
  export const POST_GRID_TAP_KEY = "grid_tap";
13
13
  export const GRID_MIN_COLS = 2;
@@ -47,33 +47,119 @@ const CONFETTI_COLORS = [
47
47
  "#EF4444",
48
48
  "#06B6D4",
49
49
  ];
50
+ const FIREWORK_COLORS = [
51
+ "#FFD700",
52
+ "#FF6B6B",
53
+ "#4ECDC4",
54
+ "#C4A7E7",
55
+ "#F6C177",
56
+ "#EBBCBA",
57
+ "#9CCFD8",
58
+ "#fff",
59
+ ];
50
60
  function ConfettiOverlay() {
51
- const pieces = useMemo(() => Array.from({ length: 80 }, (_, i) => ({
52
- id: i,
53
- left: Math.random() * 100,
54
- delay: Math.random() * 1.2,
55
- duration: 2.5 + Math.random() * 2,
56
- color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
57
- size: 6 + Math.random() * 8,
58
- rotation: Math.random() * 360,
59
- })), []);
61
+ const pieces = useMemo(() => Array.from({ length: 80 }, (_, i) => {
62
+ const driftX = (Math.random() - 0.5) * 120;
63
+ return {
64
+ id: i,
65
+ left: Math.random() * 100,
66
+ delay: Math.random() * 1.2,
67
+ duration: 2.8 + Math.random() * 1.8,
68
+ color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
69
+ size: 6 + Math.random() * 8,
70
+ rotation: Math.random() * 360,
71
+ isCircle: Math.random() > 0.6,
72
+ driftX,
73
+ driftMid: -driftX * 0.4,
74
+ };
75
+ }), []);
60
76
  return (_jsxs("div", { style: {
61
77
  position: "absolute",
62
78
  inset: 0,
63
79
  overflow: "hidden",
64
80
  pointerEvents: "none",
65
81
  zIndex: 20,
66
- }, children: [pieces.map(({ id, left, delay, duration, color, size, rotation }) => (_jsx("div", { style: {
82
+ }, children: [pieces.map(({ id, left, delay, duration, color, size, rotation, isCircle, driftX, driftMid }) => (_jsx("div", { style: {
67
83
  position: "absolute",
68
84
  left: `${left}%`,
69
85
  top: -20,
70
86
  width: size,
71
- height: size * 0.6,
87
+ height: isCircle ? size : size * 0.5,
72
88
  backgroundColor: color,
73
- borderRadius: 2,
74
- transform: `rotate(${rotation}deg)`,
75
- animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
76
- } }, id))), _jsx("style", { children: `@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${Math.random() > 0.5 ? "" : "-"}40px)}}` })] }));
89
+ borderRadius: isCircle ? "50%" : 2,
90
+ transform: `rotateZ(${rotation}deg)`,
91
+ animation: `confettiFall ${duration}s cubic-bezier(0.25,0,0.75,1) ${delay}s forwards`,
92
+ "--dx": `${driftX}px`,
93
+ "--dm": `${driftMid}px`,
94
+ } }, id))), _jsx("style", { children: `@keyframes confettiFall{
95
+ 0% {top:-20px;opacity:1;transform:rotateZ(0deg) rotateY(0deg) translateX(0)}
96
+ 20% {transform:rotateZ(144deg) rotateY(60deg) translateX(var(--dm))}
97
+ 40% {transform:rotateZ(288deg) rotateY(120deg) translateX(0)}
98
+ 60% {opacity:1;transform:rotateZ(432deg) rotateY(200deg) translateX(calc(-1 * var(--dm)))}
99
+ 80% {transform:rotateZ(576deg) rotateY(280deg) translateX(var(--dx))}
100
+ 100%{top:110%;opacity:0;transform:rotateZ(720deg) rotateY(360deg) translateX(var(--dx))}
101
+ }` })] }));
102
+ }
103
+ function FireworksOverlay() {
104
+ const bursts = useMemo(() => Array.from({ length: 5 }, (_, b) => ({
105
+ id: b,
106
+ x: 15 + Math.random() * 70,
107
+ y: 10 + Math.random() * 50,
108
+ delay: b * 0.5 + Math.random() * 0.2,
109
+ particles: Array.from({ length: 24 }, (_, p) => {
110
+ const angle = (p / 24) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
111
+ const dist = 55 + Math.random() * 60;
112
+ return {
113
+ id: p,
114
+ vx: Math.cos(angle) * dist,
115
+ vy: Math.sin(angle) * dist,
116
+ color: FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)],
117
+ size: 3 + Math.random() * 3,
118
+ };
119
+ }),
120
+ })), []);
121
+ return (_jsxs("div", { style: {
122
+ position: "absolute",
123
+ inset: 0,
124
+ overflow: "hidden",
125
+ pointerEvents: "none",
126
+ zIndex: 20,
127
+ }, children: [bursts.map(({ id: bid, x, y, delay, particles }) => (_jsxs("div", { children: [_jsx("div", { style: {
128
+ position: "absolute",
129
+ left: `${x}%`,
130
+ top: `${y}%`,
131
+ width: 12,
132
+ height: 12,
133
+ borderRadius: "50%",
134
+ backgroundColor: "#fff",
135
+ transform: "translate(-50%,-50%)",
136
+ animation: `fwFlash 0.4s ease-out ${delay}s both`,
137
+ opacity: 0,
138
+ } }), particles.map(({ id: pid, vx, vy, color, size }) => (_jsx("div", { style: {
139
+ position: "absolute",
140
+ left: `${x}%`,
141
+ top: `${y}%`,
142
+ width: size,
143
+ height: size,
144
+ borderRadius: "50%",
145
+ backgroundColor: color,
146
+ transform: "translate(-50%,-50%)",
147
+ animation: `fwBurst 1s cubic-bezier(0.2,0,0.8,1) ${delay}s forwards`,
148
+ opacity: 0,
149
+ "--vx": `${vx}px`,
150
+ "--vy": `${vy}px`,
151
+ } }, pid)))] }, bid))), _jsx("style", { children: `
152
+ @keyframes fwFlash{
153
+ 0% {opacity:0;transform:translate(-50%,-50%) scale(0)}
154
+ 25% {opacity:1;transform:translate(-50%,-50%) scale(2.5)}
155
+ 100%{opacity:0;transform:translate(-50%,-50%) scale(5)}
156
+ }
157
+ @keyframes fwBurst{
158
+ 0% {opacity:1;transform:translate(-50%,-50%) translate(0,0) scale(1)}
159
+ 65% {opacity:1}
160
+ 100%{opacity:0;transform:translate(-50%,-50%) translate(var(--vx),var(--vy)) scale(0)}
161
+ }
162
+ ` })] }));
77
163
  }
78
164
  export function SnapLoadingOverlay({ appearance, accentHex, active, }) {
79
165
  const isDark = appearance === "dark";
@@ -156,11 +242,15 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
156
242
  setPageKey((k) => k + 1);
157
243
  }, [spec]);
158
244
  const showConfetti = snap.effects?.includes("confetti") ?? false;
245
+ const showFireworks = snap.effects?.includes("fireworks") ?? false;
159
246
  const [confettiKey, setConfettiKey] = useState(0);
247
+ const [fireworksKey, setFireworksKey] = useState(0);
160
248
  useEffect(() => {
161
249
  if (showConfetti)
162
250
  setConfettiKey((k) => k + 1);
163
- }, [showConfetti, snap]);
251
+ if (showFireworks)
252
+ setFireworksKey((k) => k + 1);
253
+ }, [showConfetti, showFireworks, snap]);
164
254
  const accentName = snap.theme?.accent ?? "purple";
165
255
  const accentHex = useMemo(() => resolveSnapPaletteHex(accentName, appearance), [accentName, appearance]);
166
256
  const previewSurfaceStyle = useMemo(() => {
@@ -226,7 +316,7 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
226
316
  break;
227
317
  }
228
318
  }, [handlers]);
229
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
319
+ return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), showFireworks && _jsx(FireworksOverlay, {}, fireworksKey), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
230
320
  applyStatePaths(stateRef.current, changes);
231
321
  }, onAction: handleAction }, pageKey) }) })] }));
232
322
  }
@@ -0,0 +1 @@
1
+ export declare function FireworksOverlay(): import("react").JSX.Element;
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { Animated, StyleSheet, View, useWindowDimensions } from "react-native";
4
+ const FIREWORK_COLORS = [
5
+ "#FFD700",
6
+ "#FF6B6B",
7
+ "#4ECDC4",
8
+ "#C4A7E7",
9
+ "#F6C177",
10
+ "#EBBCBA",
11
+ "#9CCFD8",
12
+ "#fff",
13
+ ];
14
+ const BURST_COUNT = 5;
15
+ const PARTICLE_COUNT = 24;
16
+ function FireworkBurst({ burst }) {
17
+ const flashAnim = useRef(new Animated.Value(0)).current;
18
+ const burstAnim = useRef(new Animated.Value(0)).current;
19
+ useEffect(() => {
20
+ const composite = Animated.parallel([
21
+ Animated.timing(flashAnim, {
22
+ toValue: 1,
23
+ duration: 400,
24
+ useNativeDriver: true,
25
+ }),
26
+ Animated.timing(burstAnim, {
27
+ toValue: 1,
28
+ duration: 1000,
29
+ useNativeDriver: true,
30
+ }),
31
+ ]);
32
+ composite.start();
33
+ return () => composite.stop();
34
+ }, [flashAnim, burstAnim]);
35
+ const flashOpacity = flashAnim.interpolate({
36
+ inputRange: [0, 0.25, 1],
37
+ outputRange: [0, 1, 0],
38
+ });
39
+ const flashScale = flashAnim.interpolate({
40
+ inputRange: [0, 0.25, 1],
41
+ outputRange: [0, 2.5, 5],
42
+ });
43
+ return (_jsxs(_Fragment, { children: [_jsx(Animated.View, { style: [
44
+ styles.flash,
45
+ {
46
+ left: burst.x - 6,
47
+ top: burst.y - 6,
48
+ opacity: flashOpacity,
49
+ transform: [{ scale: flashScale }],
50
+ },
51
+ ] }), burst.particles.map((p) => {
52
+ const opacity = burstAnim.interpolate({
53
+ inputRange: [0, 0.65, 1],
54
+ outputRange: [1, 1, 0],
55
+ });
56
+ const translateX = burstAnim.interpolate({
57
+ inputRange: [0, 1],
58
+ outputRange: [0, p.vx],
59
+ });
60
+ const translateY = burstAnim.interpolate({
61
+ inputRange: [0, 1],
62
+ outputRange: [0, p.vy],
63
+ });
64
+ const scale = burstAnim.interpolate({
65
+ inputRange: [0, 1],
66
+ outputRange: [1, 0],
67
+ });
68
+ return (_jsx(Animated.View, { style: [
69
+ styles.particle,
70
+ {
71
+ left: burst.x - p.size / 2,
72
+ top: burst.y - p.size / 2,
73
+ width: p.size,
74
+ height: p.size,
75
+ backgroundColor: p.color,
76
+ opacity,
77
+ transform: [{ translateX }, { translateY }, { scale }],
78
+ },
79
+ ] }, p.id));
80
+ })] }));
81
+ }
82
+ export function FireworksOverlay() {
83
+ const { width, height } = useWindowDimensions();
84
+ const bursts = useMemo(() => Array.from({ length: BURST_COUNT }, (_, b) => ({
85
+ id: b,
86
+ x: (0.15 + Math.random() * 0.7) * width,
87
+ y: (0.1 + Math.random() * 0.5) * height,
88
+ delay: b * 500 + Math.random() * 200,
89
+ particles: Array.from({ length: PARTICLE_COUNT }, (_, p) => {
90
+ const angle = (p / PARTICLE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
91
+ const dist = 55 + Math.random() * 60;
92
+ return {
93
+ id: p,
94
+ vx: Math.cos(angle) * dist,
95
+ vy: Math.sin(angle) * dist,
96
+ color: FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)],
97
+ size: 3 + Math.random() * 3,
98
+ };
99
+ }),
100
+ })),
101
+ // stable on mount
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ []);
104
+ const [mountedBursts, setMountedBursts] = useState([]);
105
+ useEffect(() => {
106
+ const timers = bursts.map((burst, b) => setTimeout(() => {
107
+ setMountedBursts((prev) => [...prev, b]);
108
+ }, burst.delay));
109
+ return () => timers.forEach(clearTimeout);
110
+ }, [bursts]);
111
+ return (_jsx(View, { style: StyleSheet.absoluteFill, pointerEvents: "none", children: mountedBursts.map((b) => (_jsx(FireworkBurst, { burst: bursts[b] }, b))) }));
112
+ }
113
+ const styles = StyleSheet.create({
114
+ flash: {
115
+ position: "absolute",
116
+ width: 12,
117
+ height: 12,
118
+ borderRadius: 6,
119
+ backgroundColor: "#fff",
120
+ },
121
+ particle: {
122
+ position: "absolute",
123
+ borderRadius: 999,
124
+ },
125
+ });
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
3
  import { SnapCatalogView } from "./catalog-renderer.js";
4
4
  import { ConfettiOverlay } from "./confetti-overlay.js";
5
+ import { FireworksOverlay } from "./fireworks-overlay.js";
5
6
  import { useSnapTheme } from "./theme.js";
6
7
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
7
8
  import { ActivityIndicator, StyleSheet, View } from "react-native";
@@ -81,11 +82,15 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
81
82
  setPageKey((k) => k + 1);
82
83
  }, [spec]);
83
84
  const showConfetti = snap.effects?.includes("confetti") ?? false;
85
+ const showFireworks = snap.effects?.includes("fireworks") ?? false;
84
86
  const [confettiKey, setConfettiKey] = useState(0);
87
+ const [fireworksKey, setFireworksKey] = useState(0);
85
88
  useEffect(() => {
86
89
  if (showConfetti)
87
90
  setConfettiKey((k) => k + 1);
88
- }, [showConfetti, snap]);
91
+ if (showFireworks)
92
+ setFireworksKey((k) => k + 1);
93
+ }, [showConfetti, showFireworks, snap]);
89
94
  const handlersRef = useRef(handlers);
90
95
  handlersRef.current = handlers;
91
96
  const handleAction = useCallback((name, params) => {
@@ -147,7 +152,7 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
147
152
  : loadingOverlay
148
153
  : null, _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
149
154
  applyStatePaths(stateRef.current, changes);
150
- }, onAction: handleAction }, pageKey), showConfetti && _jsx(ConfettiOverlay, {}, confettiKey)] }));
155
+ }, onAction: handleAction }, pageKey), showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), showFireworks && _jsx(FireworksOverlay, {}, fireworksKey)] }));
151
156
  }
152
157
  export function SnapLoadingOverlay({ appearance, accentHex, }) {
153
158
  return (_jsx(View, { style: [
package/dist/schemas.d.ts CHANGED
@@ -30,6 +30,7 @@ export declare const snapResponseSchema: z.ZodObject<{
30
30
  }, z.core.$strict>>>;
31
31
  effects: z.ZodOptional<z.ZodArray<z.ZodEnum<{
32
32
  confetti: "confetti";
33
+ fireworks: "fireworks";
33
34
  }>>>;
34
35
  ui: z.ZodCustom<Spec, Spec>;
35
36
  }, z.core.$strict>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/constants.ts CHANGED
@@ -11,7 +11,7 @@ export const SNAP_PAYLOAD_HEADER = "X-Snap-Payload" as const;
11
11
 
12
12
  export const MEDIA_TYPE = "application/vnd.farcaster.snap+json" as const;
13
13
 
14
- export const EFFECT_VALUES = ["confetti"] as const;
14
+ export const EFFECT_VALUES = ["confetti", "fireworks"] as const;
15
15
 
16
16
  // ─── Pixel grid ────────────────────────────────────────
17
17
  export const POST_GRID_TAP_KEY = "grid_tap" as const;
@@ -63,18 +63,106 @@ const CONFETTI_COLORS = [
63
63
  "#06B6D4",
64
64
  ];
65
65
 
66
+ const FIREWORK_COLORS = [
67
+ "#FFD700",
68
+ "#FF6B6B",
69
+ "#4ECDC4",
70
+ "#C4A7E7",
71
+ "#F6C177",
72
+ "#EBBCBA",
73
+ "#9CCFD8",
74
+ "#fff",
75
+ ];
76
+
66
77
  function ConfettiOverlay() {
67
78
  const pieces = useMemo(
68
79
  () =>
69
- Array.from({ length: 80 }, (_, i) => ({
70
- id: i,
71
- left: Math.random() * 100,
72
- delay: Math.random() * 1.2,
73
- duration: 2.5 + Math.random() * 2,
74
- color:
75
- CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
76
- size: 6 + Math.random() * 8,
77
- rotation: Math.random() * 360,
80
+ Array.from({ length: 80 }, (_, i) => {
81
+ const driftX = (Math.random() - 0.5) * 120;
82
+ return {
83
+ id: i,
84
+ left: Math.random() * 100,
85
+ delay: Math.random() * 1.2,
86
+ duration: 2.8 + Math.random() * 1.8,
87
+ color:
88
+ CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
89
+ size: 6 + Math.random() * 8,
90
+ rotation: Math.random() * 360,
91
+ isCircle: Math.random() > 0.6,
92
+ driftX,
93
+ driftMid: -driftX * 0.4,
94
+ };
95
+ }),
96
+ [],
97
+ );
98
+
99
+ return (
100
+ <div
101
+ style={{
102
+ position: "absolute",
103
+ inset: 0,
104
+ overflow: "hidden",
105
+ pointerEvents: "none",
106
+ zIndex: 20,
107
+ }}
108
+ >
109
+ {pieces.map(
110
+ ({ id, left, delay, duration, color, size, rotation, isCircle, driftX, driftMid }) => (
111
+ <div
112
+ key={id}
113
+ style={
114
+ {
115
+ position: "absolute",
116
+ left: `${left}%`,
117
+ top: -20,
118
+ width: size,
119
+ height: isCircle ? size : size * 0.5,
120
+ backgroundColor: color,
121
+ borderRadius: isCircle ? "50%" : 2,
122
+ transform: `rotateZ(${rotation}deg)`,
123
+ animation: `confettiFall ${duration}s cubic-bezier(0.25,0,0.75,1) ${delay}s forwards`,
124
+ "--dx": `${driftX}px`,
125
+ "--dm": `${driftMid}px`,
126
+ } as CSSProperties
127
+ }
128
+ />
129
+ ),
130
+ )}
131
+ <style>{`@keyframes confettiFall{
132
+ 0% {top:-20px;opacity:1;transform:rotateZ(0deg) rotateY(0deg) translateX(0)}
133
+ 20% {transform:rotateZ(144deg) rotateY(60deg) translateX(var(--dm))}
134
+ 40% {transform:rotateZ(288deg) rotateY(120deg) translateX(0)}
135
+ 60% {opacity:1;transform:rotateZ(432deg) rotateY(200deg) translateX(calc(-1 * var(--dm)))}
136
+ 80% {transform:rotateZ(576deg) rotateY(280deg) translateX(var(--dx))}
137
+ 100%{top:110%;opacity:0;transform:rotateZ(720deg) rotateY(360deg) translateX(var(--dx))}
138
+ }`}</style>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ function FireworksOverlay() {
144
+ const bursts = useMemo(
145
+ () =>
146
+ Array.from({ length: 5 }, (_, b) => ({
147
+ id: b,
148
+ x: 15 + Math.random() * 70,
149
+ y: 10 + Math.random() * 50,
150
+ delay: b * 0.5 + Math.random() * 0.2,
151
+ particles: Array.from({ length: 24 }, (_, p) => {
152
+ const angle =
153
+ (p / 24) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
154
+ const dist = 55 + Math.random() * 60;
155
+ return {
156
+ id: p,
157
+ vx: Math.cos(angle) * dist,
158
+ vy: Math.sin(angle) * dist,
159
+ color:
160
+ FIREWORK_COLORS[
161
+ Math.floor(Math.random() * FIREWORK_COLORS.length)
162
+ ],
163
+ size: 3 + Math.random() * 3,
164
+ };
165
+ }),
78
166
  })),
79
167
  [],
80
168
  );
@@ -89,25 +177,57 @@ function ConfettiOverlay() {
89
177
  zIndex: 20,
90
178
  }}
91
179
  >
92
- {pieces.map(({ id, left, delay, duration, color, size, rotation }) => (
93
- <div
94
- key={id}
95
- style={{
96
- position: "absolute",
97
- left: `${left}%`,
98
- top: -20,
99
- width: size,
100
- height: size * 0.6,
101
- backgroundColor: color,
102
- borderRadius: 2,
103
- transform: `rotate(${rotation}deg)`,
104
- animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
105
- }}
106
- />
180
+ {bursts.map(({ id: bid, x, y, delay, particles }) => (
181
+ <div key={bid}>
182
+ <div
183
+ style={{
184
+ position: "absolute",
185
+ left: `${x}%`,
186
+ top: `${y}%`,
187
+ width: 12,
188
+ height: 12,
189
+ borderRadius: "50%",
190
+ backgroundColor: "#fff",
191
+ transform: "translate(-50%,-50%)",
192
+ animation: `fwFlash 0.4s ease-out ${delay}s both`,
193
+ opacity: 0,
194
+ }}
195
+ />
196
+ {particles.map(({ id: pid, vx, vy, color, size }) => (
197
+ <div
198
+ key={pid}
199
+ style={
200
+ {
201
+ position: "absolute",
202
+ left: `${x}%`,
203
+ top: `${y}%`,
204
+ width: size,
205
+ height: size,
206
+ borderRadius: "50%",
207
+ backgroundColor: color,
208
+ transform: "translate(-50%,-50%)",
209
+ animation: `fwBurst 1s cubic-bezier(0.2,0,0.8,1) ${delay}s forwards`,
210
+ opacity: 0,
211
+ "--vx": `${vx}px`,
212
+ "--vy": `${vy}px`,
213
+ } as CSSProperties
214
+ }
215
+ />
216
+ ))}
217
+ </div>
107
218
  ))}
108
- <style>{`@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${
109
- Math.random() > 0.5 ? "" : "-"
110
- }40px)}}`}</style>
219
+ <style>{`
220
+ @keyframes fwFlash{
221
+ 0% {opacity:0;transform:translate(-50%,-50%) scale(0)}
222
+ 25% {opacity:1;transform:translate(-50%,-50%) scale(2.5)}
223
+ 100%{opacity:0;transform:translate(-50%,-50%) scale(5)}
224
+ }
225
+ @keyframes fwBurst{
226
+ 0% {opacity:1;transform:translate(-50%,-50%) translate(0,0) scale(1)}
227
+ 65% {opacity:1}
228
+ 100%{opacity:0;transform:translate(-50%,-50%) translate(var(--vx),var(--vy)) scale(0)}
229
+ }
230
+ `}</style>
111
231
  </div>
112
232
  );
113
233
  }
@@ -240,10 +360,13 @@ export function SnapViewCore({
240
360
  }, [spec]);
241
361
 
242
362
  const showConfetti = snap.effects?.includes("confetti") ?? false;
363
+ const showFireworks = snap.effects?.includes("fireworks") ?? false;
243
364
  const [confettiKey, setConfettiKey] = useState(0);
365
+ const [fireworksKey, setFireworksKey] = useState(0);
244
366
  useEffect(() => {
245
367
  if (showConfetti) setConfettiKey((k) => k + 1);
246
- }, [showConfetti, snap]);
368
+ if (showFireworks) setFireworksKey((k) => k + 1);
369
+ }, [showConfetti, showFireworks, snap]);
247
370
 
248
371
  const accentName = snap.theme?.accent ?? "purple";
249
372
 
@@ -326,6 +449,7 @@ export function SnapViewCore({
326
449
  return (
327
450
  <div style={{ position: "relative", width: "100%" }}>
328
451
  {showConfetti && <ConfettiOverlay key={confettiKey} />}
452
+ {showFireworks && <FireworksOverlay key={fireworksKey} />}
329
453
  {loadingOverlay === undefined ? (
330
454
  <SnapLoadingOverlay
331
455
  appearance={appearance}
@@ -0,0 +1,176 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Animated, StyleSheet, View, useWindowDimensions } from "react-native";
3
+
4
+ const FIREWORK_COLORS = [
5
+ "#FFD700",
6
+ "#FF6B6B",
7
+ "#4ECDC4",
8
+ "#C4A7E7",
9
+ "#F6C177",
10
+ "#EBBCBA",
11
+ "#9CCFD8",
12
+ "#fff",
13
+ ];
14
+
15
+ const BURST_COUNT = 5;
16
+ const PARTICLE_COUNT = 24;
17
+
18
+ type BurstData = {
19
+ id: number;
20
+ x: number;
21
+ y: number;
22
+ particles: Array<{
23
+ id: number;
24
+ vx: number;
25
+ vy: number;
26
+ color: string;
27
+ size: number;
28
+ }>;
29
+ };
30
+
31
+ function FireworkBurst({ burst }: { burst: BurstData }) {
32
+ const flashAnim = useRef(new Animated.Value(0)).current;
33
+ const burstAnim = useRef(new Animated.Value(0)).current;
34
+
35
+ useEffect(() => {
36
+ const composite = Animated.parallel([
37
+ Animated.timing(flashAnim, {
38
+ toValue: 1,
39
+ duration: 400,
40
+ useNativeDriver: true,
41
+ }),
42
+ Animated.timing(burstAnim, {
43
+ toValue: 1,
44
+ duration: 1000,
45
+ useNativeDriver: true,
46
+ }),
47
+ ]);
48
+ composite.start();
49
+ return () => composite.stop();
50
+ }, [flashAnim, burstAnim]);
51
+
52
+ const flashOpacity = flashAnim.interpolate({
53
+ inputRange: [0, 0.25, 1],
54
+ outputRange: [0, 1, 0],
55
+ });
56
+ const flashScale = flashAnim.interpolate({
57
+ inputRange: [0, 0.25, 1],
58
+ outputRange: [0, 2.5, 5],
59
+ });
60
+
61
+ return (
62
+ <>
63
+ <Animated.View
64
+ style={[
65
+ styles.flash,
66
+ {
67
+ left: burst.x - 6,
68
+ top: burst.y - 6,
69
+ opacity: flashOpacity,
70
+ transform: [{ scale: flashScale }],
71
+ },
72
+ ]}
73
+ />
74
+ {burst.particles.map((p) => {
75
+ const opacity = burstAnim.interpolate({
76
+ inputRange: [0, 0.65, 1],
77
+ outputRange: [1, 1, 0],
78
+ });
79
+ const translateX = burstAnim.interpolate({
80
+ inputRange: [0, 1],
81
+ outputRange: [0, p.vx],
82
+ });
83
+ const translateY = burstAnim.interpolate({
84
+ inputRange: [0, 1],
85
+ outputRange: [0, p.vy],
86
+ });
87
+ const scale = burstAnim.interpolate({
88
+ inputRange: [0, 1],
89
+ outputRange: [1, 0],
90
+ });
91
+ return (
92
+ <Animated.View
93
+ key={p.id}
94
+ style={[
95
+ styles.particle,
96
+ {
97
+ left: burst.x - p.size / 2,
98
+ top: burst.y - p.size / 2,
99
+ width: p.size,
100
+ height: p.size,
101
+ backgroundColor: p.color,
102
+ opacity,
103
+ transform: [{ translateX }, { translateY }, { scale }],
104
+ },
105
+ ]}
106
+ />
107
+ );
108
+ })}
109
+ </>
110
+ );
111
+ }
112
+
113
+ export function FireworksOverlay() {
114
+ const { width, height } = useWindowDimensions();
115
+
116
+ const bursts = useMemo<(BurstData & { delay: number })[]>(
117
+ () =>
118
+ Array.from({ length: BURST_COUNT }, (_, b) => ({
119
+ id: b,
120
+ x: (0.15 + Math.random() * 0.7) * width,
121
+ y: (0.1 + Math.random() * 0.5) * height,
122
+ delay: b * 500 + Math.random() * 200,
123
+ particles: Array.from({ length: PARTICLE_COUNT }, (_, p) => {
124
+ const angle =
125
+ (p / PARTICLE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
126
+ const dist = 55 + Math.random() * 60;
127
+ return {
128
+ id: p,
129
+ vx: Math.cos(angle) * dist,
130
+ vy: Math.sin(angle) * dist,
131
+ color:
132
+ FIREWORK_COLORS[
133
+ Math.floor(Math.random() * FIREWORK_COLORS.length)
134
+ ]!,
135
+ size: 3 + Math.random() * 3,
136
+ };
137
+ }),
138
+ })),
139
+ // stable on mount
140
+ // eslint-disable-next-line react-hooks/exhaustive-deps
141
+ [],
142
+ );
143
+
144
+ const [mountedBursts, setMountedBursts] = useState<number[]>([]);
145
+
146
+ useEffect(() => {
147
+ const timers = bursts.map((burst, b) =>
148
+ setTimeout(() => {
149
+ setMountedBursts((prev) => [...prev, b]);
150
+ }, burst.delay),
151
+ );
152
+ return () => timers.forEach(clearTimeout);
153
+ }, [bursts]);
154
+
155
+ return (
156
+ <View style={StyleSheet.absoluteFill} pointerEvents="none">
157
+ {mountedBursts.map((b) => (
158
+ <FireworkBurst key={b} burst={bursts[b]!} />
159
+ ))}
160
+ </View>
161
+ );
162
+ }
163
+
164
+ const styles = StyleSheet.create({
165
+ flash: {
166
+ position: "absolute",
167
+ width: 12,
168
+ height: 12,
169
+ borderRadius: 6,
170
+ backgroundColor: "#fff",
171
+ },
172
+ particle: {
173
+ position: "absolute",
174
+ borderRadius: 999,
175
+ },
176
+ });
@@ -2,6 +2,7 @@ import type { Spec } from "@json-render/core";
2
2
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
3
  import { SnapCatalogView } from "./catalog-renderer";
4
4
  import { ConfettiOverlay } from "./confetti-overlay";
5
+ import { FireworksOverlay } from "./fireworks-overlay";
5
6
  import { useSnapTheme } from "./theme";
6
7
  import {
7
8
  type ReactNode,
@@ -128,10 +129,13 @@ export function SnapViewCoreInner({
128
129
  }, [spec]);
129
130
 
130
131
  const showConfetti = snap.effects?.includes("confetti") ?? false;
132
+ const showFireworks = snap.effects?.includes("fireworks") ?? false;
131
133
  const [confettiKey, setConfettiKey] = useState(0);
134
+ const [fireworksKey, setFireworksKey] = useState(0);
132
135
  useEffect(() => {
133
136
  if (showConfetti) setConfettiKey((k) => k + 1);
134
- }, [showConfetti, snap]);
137
+ if (showFireworks) setFireworksKey((k) => k + 1);
138
+ }, [showConfetti, showFireworks, snap]);
135
139
 
136
140
  const handlersRef = useRef(handlers);
137
141
  handlersRef.current = handlers;
@@ -213,6 +217,7 @@ export function SnapViewCoreInner({
213
217
  onAction={handleAction}
214
218
  />
215
219
  {showConfetti && <ConfettiOverlay key={confettiKey} />}
220
+ {showFireworks && <FireworksOverlay key={fireworksKey} />}
216
221
  </View>
217
222
  );
218
223
  }