react-native-puff-pop 1.0.7 → 1.0.8

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.
package/src/index.tsx CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  useState,
5
5
  useCallback,
6
6
  useMemo,
7
+ memo,
7
8
  Children,
8
9
  type ReactNode,
9
10
  type ReactElement,
@@ -33,7 +34,12 @@ export type PuffPopEffect =
33
34
  | 'bounce' // Bounce effect with overshoot
34
35
  | 'flip' // 3D flip effect
35
36
  | 'zoom' // Zoom with slight overshoot
36
- | 'rotateScale'; // Rotate + Scale combined
37
+ | 'rotateScale' // Rotate + Scale combined
38
+ | 'shake' // Shake left-right (흔들림 효과)
39
+ | 'pulse' // Pulse scale effect (맥박처럼 커졌다 작아짐)
40
+ | 'swing' // Swing like pendulum (시계추처럼 흔들림)
41
+ | 'wobble' // Wobble with tilt (기울어지며 이동)
42
+ | 'elastic'; // Elastic stretch effect (탄성 효과)
37
43
 
38
44
  /**
39
45
  * Easing function types
@@ -46,6 +52,51 @@ export type PuffPopEasing =
46
52
  | 'spring'
47
53
  | 'bounce';
48
54
 
55
+ /**
56
+ * Anchor point for scale/rotate transformations
57
+ * Determines the origin point of the transformation
58
+ */
59
+ export type PuffPopAnchorPoint =
60
+ | 'center' // Default center point
61
+ | 'top' // Top center
62
+ | 'bottom' // Bottom center
63
+ | 'left' // Left center
64
+ | 'right' // Right center
65
+ | 'topLeft' // Top left corner
66
+ | 'topRight' // Top right corner
67
+ | 'bottomLeft' // Bottom left corner
68
+ | 'bottomRight'; // Bottom right corner
69
+
70
+ /**
71
+ * Spring animation configuration
72
+ * Used when useSpring is true for physics-based animations
73
+ */
74
+ export interface PuffPopSpringConfig {
75
+ /**
76
+ * Controls the spring stiffness. Higher values = faster, snappier animation
77
+ * @default 100
78
+ */
79
+ tension?: number;
80
+
81
+ /**
82
+ * Controls the spring damping. Higher values = less oscillation
83
+ * @default 10
84
+ */
85
+ friction?: number;
86
+
87
+ /**
88
+ * Animation speed multiplier
89
+ * @default 12
90
+ */
91
+ speed?: number;
92
+
93
+ /**
94
+ * Controls the bounciness. Higher values = more bouncy
95
+ * @default 8
96
+ */
97
+ bounciness?: number;
98
+ }
99
+
49
100
  export interface PuffPopProps {
50
101
  /**
51
102
  * Children to animate
@@ -216,6 +267,33 @@ export interface PuffPopProps {
216
267
  * @default 1
217
268
  */
218
269
  intensity?: number;
270
+
271
+ // ============ Anchor Point ============
272
+
273
+ /**
274
+ * Anchor point for scale/rotate transformations
275
+ * Determines the origin point of the transformation
276
+ * - 'center' = default center point
277
+ * - 'top', 'bottom', 'left', 'right' = edge centers
278
+ * - 'topLeft', 'topRight', 'bottomLeft', 'bottomRight' = corners
279
+ * @default 'center'
280
+ */
281
+ anchorPoint?: PuffPopAnchorPoint;
282
+
283
+ // ============ Spring Animation ============
284
+
285
+ /**
286
+ * Use spring physics-based animation instead of timing
287
+ * Provides more natural, bouncy animations
288
+ * @default false
289
+ */
290
+ useSpring?: boolean;
291
+
292
+ /**
293
+ * Spring animation configuration
294
+ * Only used when useSpring is true
295
+ */
296
+ springConfig?: PuffPopSpringConfig;
219
297
  }
220
298
 
221
299
  /**
@@ -240,10 +318,133 @@ function getEasing(type: PuffPopEasing): (value: number) => number {
240
318
  }
241
319
  }
242
320
 
321
+ /**
322
+ * Get effect flags for any effect type
323
+ * Returns flags indicating which transforms are needed for the effect
324
+ */
325
+ function getEffectFlags(eff: PuffPopEffect) {
326
+ return {
327
+ hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip', 'pulse', 'elastic'].includes(eff),
328
+ hasRotate: ['rotate', 'rotateScale', 'swing', 'wobble'].includes(eff),
329
+ hasFlip: eff === 'flip',
330
+ hasTranslateX: ['slideLeft', 'slideRight', 'shake', 'wobble'].includes(eff),
331
+ hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(eff),
332
+ hasRotateEffect: ['rotate', 'rotateScale', 'flip', 'swing', 'wobble'].includes(eff),
333
+ // Special effects that need sequence animation
334
+ isShake: eff === 'shake',
335
+ isPulse: eff === 'pulse',
336
+ isSwing: eff === 'swing',
337
+ isWobble: eff === 'wobble',
338
+ isElastic: eff === 'elastic',
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Get anchor point offset multipliers
344
+ * Returns { x: -1 to 1, y: -1 to 1 } where 0 is center
345
+ */
346
+ function getAnchorPointOffset(anchorPoint: PuffPopAnchorPoint): { x: number; y: number } {
347
+ switch (anchorPoint) {
348
+ case 'top':
349
+ return { x: 0, y: -0.5 };
350
+ case 'bottom':
351
+ return { x: 0, y: 0.5 };
352
+ case 'left':
353
+ return { x: -0.5, y: 0 };
354
+ case 'right':
355
+ return { x: 0.5, y: 0 };
356
+ case 'topLeft':
357
+ return { x: -0.5, y: -0.5 };
358
+ case 'topRight':
359
+ return { x: 0.5, y: -0.5 };
360
+ case 'bottomLeft':
361
+ return { x: -0.5, y: 0.5 };
362
+ case 'bottomRight':
363
+ return { x: 0.5, y: 0.5 };
364
+ case 'center':
365
+ default:
366
+ return { x: 0, y: 0 };
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Props comparison function for PuffPop memoization
372
+ * Performs shallow comparison of props to prevent unnecessary re-renders
373
+ */
374
+ function arePuffPopPropsEqual(
375
+ prevProps: PuffPopProps,
376
+ nextProps: PuffPopProps
377
+ ): boolean {
378
+ // Compare primitive props
379
+ if (
380
+ prevProps.effect !== nextProps.effect ||
381
+ prevProps.duration !== nextProps.duration ||
382
+ prevProps.delay !== nextProps.delay ||
383
+ prevProps.easing !== nextProps.easing ||
384
+ prevProps.skeleton !== nextProps.skeleton ||
385
+ prevProps.visible !== nextProps.visible ||
386
+ prevProps.animateOnMount !== nextProps.animateOnMount ||
387
+ prevProps.loop !== nextProps.loop ||
388
+ prevProps.loopDelay !== nextProps.loopDelay ||
389
+ prevProps.respectReduceMotion !== nextProps.respectReduceMotion ||
390
+ prevProps.testID !== nextProps.testID ||
391
+ prevProps.reverse !== nextProps.reverse ||
392
+ prevProps.intensity !== nextProps.intensity ||
393
+ prevProps.anchorPoint !== nextProps.anchorPoint ||
394
+ prevProps.useSpring !== nextProps.useSpring ||
395
+ prevProps.exitEffect !== nextProps.exitEffect ||
396
+ prevProps.exitDuration !== nextProps.exitDuration ||
397
+ prevProps.exitEasing !== nextProps.exitEasing ||
398
+ prevProps.exitDelay !== nextProps.exitDelay ||
399
+ prevProps.initialOpacity !== nextProps.initialOpacity ||
400
+ prevProps.initialScale !== nextProps.initialScale ||
401
+ prevProps.initialRotate !== nextProps.initialRotate ||
402
+ prevProps.initialTranslateX !== nextProps.initialTranslateX ||
403
+ prevProps.initialTranslateY !== nextProps.initialTranslateY
404
+ ) {
405
+ return false;
406
+ }
407
+
408
+ // Compare springConfig object (shallow)
409
+ if (prevProps.springConfig !== nextProps.springConfig) {
410
+ if (
411
+ !prevProps.springConfig ||
412
+ !nextProps.springConfig ||
413
+ prevProps.springConfig.tension !== nextProps.springConfig.tension ||
414
+ prevProps.springConfig.friction !== nextProps.springConfig.friction ||
415
+ prevProps.springConfig.speed !== nextProps.springConfig.speed ||
416
+ prevProps.springConfig.bounciness !== nextProps.springConfig.bounciness
417
+ ) {
418
+ return false;
419
+ }
420
+ }
421
+
422
+ // Compare callbacks (reference equality - if changed, should re-render)
423
+ if (
424
+ prevProps.onAnimationStart !== nextProps.onAnimationStart ||
425
+ prevProps.onAnimationComplete !== nextProps.onAnimationComplete
426
+ ) {
427
+ return false;
428
+ }
429
+
430
+ // Style comparison - if style prop changes, re-render
431
+ // Note: Deep comparison of style is expensive, so we use reference equality
432
+ if (prevProps.style !== nextProps.style) {
433
+ return false;
434
+ }
435
+
436
+ // Children comparison - if children change, re-render
437
+ if (prevProps.children !== nextProps.children) {
438
+ return false;
439
+ }
440
+
441
+ return true;
442
+ }
443
+
243
444
  /**
244
445
  * PuffPop - Animate children with beautiful entrance effects
245
446
  */
246
- export function PuffPop({
447
+ function PuffPopComponent({
247
448
  children,
248
449
  effect = 'scale',
249
450
  duration = 400,
@@ -274,6 +475,11 @@ export function PuffPop({
274
475
  reverse = false,
275
476
  // Animation intensity
276
477
  intensity = 1,
478
+ // Anchor point
479
+ anchorPoint = 'center',
480
+ // Spring animation
481
+ useSpring = false,
482
+ springConfig,
277
483
  }: PuffPopProps): ReactElement {
278
484
  // Clamp intensity between 0 and 1
279
485
  const clampedIntensity = Math.max(0, Math.min(1, intensity));
@@ -340,23 +546,13 @@ export function PuffPop({
340
546
  const effectiveDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : duration;
341
547
  const effectiveExitDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : (exitDuration ?? duration);
342
548
 
343
- // Helper to get effect flags for any effect type
344
- const getEffectFlags = useCallback((eff: PuffPopEffect) => ({
345
- hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(eff),
346
- hasRotate: ['rotate', 'rotateScale'].includes(eff),
347
- hasFlip: eff === 'flip',
348
- hasTranslateX: ['slideLeft', 'slideRight'].includes(eff),
349
- hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(eff),
350
- hasRotateEffect: ['rotate', 'rotateScale', 'flip'].includes(eff),
351
- }), []);
352
-
353
549
  // Memoize effect type checks to avoid repeated includes() calls
354
- const effectFlags = useMemo(() => getEffectFlags(effect), [effect, getEffectFlags]);
550
+ const effectFlags = useMemo(() => getEffectFlags(effect), [effect]);
355
551
 
356
552
  // Exit effect flags (use exitEffect if specified, otherwise same as enter effect)
357
553
  const exitEffectFlags = useMemo(() =>
358
554
  exitEffect ? getEffectFlags(exitEffect) : effectFlags
359
- , [exitEffect, getEffectFlags, effectFlags]);
555
+ , [exitEffect, effectFlags]);
360
556
 
361
557
  // Memoize interpolations to avoid recreating on every render
362
558
  const rotateInterpolation = useMemo(() =>
@@ -401,68 +597,99 @@ export function PuffPop({
401
597
  // When skeleton is false, we animate height which doesn't support native driver
402
598
  // So we must use JS driver for all animations in that case
403
599
  const useNative = skeleton;
404
- const config = {
600
+
601
+ // Spring configuration
602
+ const springConf = {
603
+ tension: springConfig?.tension ?? 100,
604
+ friction: springConfig?.friction ?? 10,
605
+ speed: springConfig?.speed,
606
+ bounciness: springConfig?.bounciness,
607
+ useNativeDriver: useNative,
608
+ };
609
+
610
+ const timingConfig = {
405
611
  duration: currentDuration,
406
612
  easing: easingFn,
407
613
  useNativeDriver: useNative,
408
614
  };
409
615
 
616
+ // Helper to create animation (spring or timing)
617
+ const createAnimation = (
618
+ value: Animated.Value,
619
+ toValue: number,
620
+ customEasing?: (t: number) => number
621
+ ): Animated.CompositeAnimation => {
622
+ if (useSpring) {
623
+ return Animated.spring(value, {
624
+ toValue,
625
+ ...springConf,
626
+ });
627
+ }
628
+ return Animated.timing(value, {
629
+ toValue,
630
+ ...timingConfig,
631
+ ...(customEasing ? { easing: customEasing } : {}),
632
+ });
633
+ };
634
+
410
635
  const animations: Animated.CompositeAnimation[] = [];
411
636
 
412
- // Opacity animation
637
+ // Opacity animation (always use timing for opacity for smoother fade)
413
638
  animations.push(
414
639
  Animated.timing(opacity, {
415
640
  toValue: toVisible ? 1 : 0,
416
- ...config,
641
+ ...timingConfig,
417
642
  })
418
643
  );
419
644
 
420
645
  // Scale animation
421
646
  if (currentFlags.hasScale) {
422
647
  const targetScale = toVisible ? 1 : getInitialScaleValue(currentEffect);
423
- animations.push(
424
- Animated.timing(scale, {
425
- toValue: targetScale,
426
- ...config,
427
- easing: currentEffect === 'bounce' ? Easing.bounce : easingFn,
428
- })
429
- );
648
+ // Special easing for different effects
649
+ let scaleEasing = easingFn;
650
+ if (currentEffect === 'bounce') {
651
+ scaleEasing = Easing.bounce;
652
+ } else if (currentEffect === 'elastic') {
653
+ scaleEasing = Easing.elastic(1.5);
654
+ } else if (currentEffect === 'pulse') {
655
+ scaleEasing = Easing.out(Easing.back(3));
656
+ }
657
+ animations.push(createAnimation(scale, targetScale, scaleEasing));
430
658
  }
431
659
 
432
660
  // Rotate animation
433
661
  if (currentFlags.hasRotate || currentFlags.hasFlip) {
434
662
  const targetRotate = toVisible ? 0 : getInitialRotateValue(currentEffect);
435
- animations.push(
436
- Animated.timing(rotate, {
437
- toValue: targetRotate,
438
- ...config,
439
- })
440
- );
663
+ // Special easing for swing and wobble
664
+ let rotateEasing = easingFn;
665
+ if (currentEffect === 'swing') {
666
+ rotateEasing = Easing.elastic(1.2);
667
+ } else if (currentEffect === 'wobble') {
668
+ rotateEasing = Easing.elastic(1.5);
669
+ }
670
+ animations.push(createAnimation(rotate, targetRotate, rotateEasing));
441
671
  }
442
672
 
443
673
  // TranslateX animation
444
674
  if (currentFlags.hasTranslateX) {
445
675
  const targetX = toVisible ? 0 : getInitialTranslateXValue(currentEffect);
446
- animations.push(
447
- Animated.timing(translateX, {
448
- toValue: targetX,
449
- ...config,
450
- })
451
- );
676
+ // Special easing for shake and wobble
677
+ let translateXEasing = easingFn;
678
+ if (currentEffect === 'shake') {
679
+ translateXEasing = Easing.elastic(3);
680
+ } else if (currentEffect === 'wobble') {
681
+ translateXEasing = Easing.elastic(1.5);
682
+ }
683
+ animations.push(createAnimation(translateX, targetX, translateXEasing));
452
684
  }
453
685
 
454
686
  // TranslateY animation
455
687
  if (currentFlags.hasTranslateY) {
456
688
  const targetY = toVisible ? 0 : getInitialTranslateYValue(currentEffect);
457
- animations.push(
458
- Animated.timing(translateY, {
459
- toValue: targetY,
460
- ...config,
461
- })
462
- );
689
+ animations.push(createAnimation(translateY, targetY));
463
690
  }
464
691
 
465
- // Height animation for non-skeleton mode
692
+ // Height animation for non-skeleton mode (always use timing)
466
693
  if (!skeleton && measuredHeight !== null) {
467
694
  const targetHeight = toVisible ? measuredHeight : 0;
468
695
  animations.push(
@@ -586,6 +813,8 @@ export function PuffPop({
586
813
  animatedHeight,
587
814
  loop,
588
815
  loopDelay,
816
+ useSpring,
817
+ springConfig,
589
818
  ]
590
819
  );
591
820
 
@@ -616,12 +845,36 @@ export function PuffPop({
616
845
  };
617
846
  }, []);
618
847
 
848
+ // Calculate anchor point offset (using 100px as base size for skeleton mode)
849
+ const anchorOffset = useMemo(() => {
850
+ const offset = getAnchorPointOffset(anchorPoint);
851
+ // Use measured height if available, otherwise use 100px as base
852
+ const baseSize = measuredHeight ?? 100;
853
+ return {
854
+ x: offset.x * baseSize,
855
+ y: offset.y * baseSize,
856
+ };
857
+ }, [anchorPoint, measuredHeight]);
858
+
619
859
  // Memoize transform array to avoid recreating on every render
620
860
  // IMPORTANT: All hooks must be called before any conditional returns
621
861
  const transform = useMemo(() => {
622
862
  const { hasScale, hasRotate, hasFlip, hasTranslateX, hasTranslateY } = effectFlags;
623
- const transforms = [];
863
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
864
+ const transforms: any[] = [];
865
+ const needsAnchorOffset = anchorPoint !== 'center' && (hasScale || hasRotate || hasFlip);
866
+
867
+ // Step 1: Move to anchor point (negative offset)
868
+ if (needsAnchorOffset) {
869
+ if (anchorOffset.x !== 0) {
870
+ transforms.push({ translateX: -anchorOffset.x });
871
+ }
872
+ if (anchorOffset.y !== 0) {
873
+ transforms.push({ translateY: -anchorOffset.y });
874
+ }
875
+ }
624
876
 
877
+ // Step 2: Apply scale/rotate transforms
625
878
  if (hasScale) {
626
879
  transforms.push({ scale });
627
880
  }
@@ -634,6 +887,17 @@ export function PuffPop({
634
887
  transforms.push({ rotateY: flipInterpolation });
635
888
  }
636
889
 
890
+ // Step 3: Move back from anchor point (positive offset)
891
+ if (needsAnchorOffset) {
892
+ if (anchorOffset.x !== 0) {
893
+ transforms.push({ translateX: anchorOffset.x });
894
+ }
895
+ if (anchorOffset.y !== 0) {
896
+ transforms.push({ translateY: anchorOffset.y });
897
+ }
898
+ }
899
+
900
+ // Step 4: Apply other translate transforms
637
901
  if (hasTranslateX) {
638
902
  transforms.push({ translateX });
639
903
  }
@@ -643,7 +907,7 @@ export function PuffPop({
643
907
  }
644
908
 
645
909
  return transforms.length > 0 ? transforms : undefined;
646
- }, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY]);
910
+ }, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY, anchorPoint, anchorOffset]);
647
911
 
648
912
  // Memoize animated style
649
913
  const animatedStyle = useMemo(() => ({
@@ -681,6 +945,9 @@ export function PuffPop({
681
945
  );
682
946
  }
683
947
 
948
+ // Memoize PuffPop to prevent unnecessary re-renders
949
+ export const PuffPop = memo(PuffPopComponent, arePuffPopPropsEqual);
950
+
684
951
  /**
685
952
  * Get initial scale value based on effect
686
953
  */
@@ -689,6 +956,7 @@ function getInitialScale(effect: PuffPopEffect, _reverse = false): number {
689
956
  switch (effect) {
690
957
  case 'scale':
691
958
  case 'rotateScale':
959
+ case 'elastic':
692
960
  return 0;
693
961
  case 'bounce':
694
962
  return 0.3;
@@ -696,6 +964,8 @@ function getInitialScale(effect: PuffPopEffect, _reverse = false): number {
696
964
  return 0.5;
697
965
  case 'flip':
698
966
  return 0.8;
967
+ case 'pulse':
968
+ return 0.6;
699
969
  default:
700
970
  return 1;
701
971
  }
@@ -713,6 +983,10 @@ function getInitialRotate(effect: PuffPopEffect, reverse = false): number {
713
983
  return -180 * multiplier;
714
984
  case 'flip':
715
985
  return -180 * multiplier;
986
+ case 'swing':
987
+ return -15 * multiplier;
988
+ case 'wobble':
989
+ return -5 * multiplier;
716
990
  default:
717
991
  return 0;
718
992
  }
@@ -728,6 +1002,10 @@ function getInitialTranslateX(effect: PuffPopEffect, reverse = false): number {
728
1002
  return 100 * multiplier;
729
1003
  case 'slideRight':
730
1004
  return -100 * multiplier;
1005
+ case 'shake':
1006
+ return -10 * multiplier;
1007
+ case 'wobble':
1008
+ return -25 * multiplier;
731
1009
  default:
732
1010
  return 0;
733
1011
  }
@@ -911,6 +1189,27 @@ export interface PuffPopGroupProps {
911
1189
  */
912
1190
  intensity?: number;
913
1191
 
1192
+ // ============ Anchor Point ============
1193
+
1194
+ /**
1195
+ * Anchor point for scale/rotate transformations for all children
1196
+ * @default 'center'
1197
+ */
1198
+ anchorPoint?: PuffPopAnchorPoint;
1199
+
1200
+ // ============ Spring Animation ============
1201
+
1202
+ /**
1203
+ * Use spring physics-based animation for all children
1204
+ * @default false
1205
+ */
1206
+ useSpring?: boolean;
1207
+
1208
+ /**
1209
+ * Spring animation configuration for all children
1210
+ */
1211
+ springConfig?: PuffPopSpringConfig;
1212
+
914
1213
  // ============ Exit Animation Settings ============
915
1214
 
916
1215
  /**
@@ -936,6 +1235,100 @@ export interface PuffPopGroupProps {
936
1235
  * @default 0
937
1236
  */
938
1237
  exitDelay?: number;
1238
+
1239
+ /**
1240
+ * Delay between each child's exit animation start in milliseconds
1241
+ * If not specified, all children exit simultaneously
1242
+ * @default 0
1243
+ */
1244
+ exitStaggerDelay?: number;
1245
+
1246
+ /**
1247
+ * Direction of exit stagger animation
1248
+ * - 'forward': First to last child
1249
+ * - 'reverse': Last to first child (most natural for exit)
1250
+ * - 'center': From center outward
1251
+ * - 'edges': From edges toward center
1252
+ * @default 'reverse'
1253
+ */
1254
+ exitStaggerDirection?: 'forward' | 'reverse' | 'center' | 'edges';
1255
+ }
1256
+
1257
+ /**
1258
+ * Props comparison function for PuffPopGroup memoization
1259
+ * Performs shallow comparison of props to prevent unnecessary re-renders
1260
+ */
1261
+ function arePuffPopGroupPropsEqual(
1262
+ prevProps: PuffPopGroupProps,
1263
+ nextProps: PuffPopGroupProps
1264
+ ): boolean {
1265
+ // Compare primitive props
1266
+ if (
1267
+ prevProps.effect !== nextProps.effect ||
1268
+ prevProps.duration !== nextProps.duration ||
1269
+ prevProps.staggerDelay !== nextProps.staggerDelay ||
1270
+ prevProps.initialDelay !== nextProps.initialDelay ||
1271
+ prevProps.easing !== nextProps.easing ||
1272
+ prevProps.skeleton !== nextProps.skeleton ||
1273
+ prevProps.visible !== nextProps.visible ||
1274
+ prevProps.animateOnMount !== nextProps.animateOnMount ||
1275
+ prevProps.respectReduceMotion !== nextProps.respectReduceMotion ||
1276
+ prevProps.testID !== nextProps.testID ||
1277
+ prevProps.staggerDirection !== nextProps.staggerDirection ||
1278
+ prevProps.horizontal !== nextProps.horizontal ||
1279
+ prevProps.gap !== nextProps.gap ||
1280
+ prevProps.reverse !== nextProps.reverse ||
1281
+ prevProps.intensity !== nextProps.intensity ||
1282
+ prevProps.anchorPoint !== nextProps.anchorPoint ||
1283
+ prevProps.useSpring !== nextProps.useSpring ||
1284
+ prevProps.exitEffect !== nextProps.exitEffect ||
1285
+ prevProps.exitDuration !== nextProps.exitDuration ||
1286
+ prevProps.exitEasing !== nextProps.exitEasing ||
1287
+ prevProps.exitDelay !== nextProps.exitDelay ||
1288
+ prevProps.exitStaggerDelay !== nextProps.exitStaggerDelay ||
1289
+ prevProps.exitStaggerDirection !== nextProps.exitStaggerDirection ||
1290
+ prevProps.initialOpacity !== nextProps.initialOpacity ||
1291
+ prevProps.initialScale !== nextProps.initialScale ||
1292
+ prevProps.initialRotate !== nextProps.initialRotate ||
1293
+ prevProps.initialTranslateX !== nextProps.initialTranslateX ||
1294
+ prevProps.initialTranslateY !== nextProps.initialTranslateY
1295
+ ) {
1296
+ return false;
1297
+ }
1298
+
1299
+ // Compare springConfig object (shallow)
1300
+ if (prevProps.springConfig !== nextProps.springConfig) {
1301
+ if (
1302
+ !prevProps.springConfig ||
1303
+ !nextProps.springConfig ||
1304
+ prevProps.springConfig.tension !== nextProps.springConfig.tension ||
1305
+ prevProps.springConfig.friction !== nextProps.springConfig.friction ||
1306
+ prevProps.springConfig.speed !== nextProps.springConfig.speed ||
1307
+ prevProps.springConfig.bounciness !== nextProps.springConfig.bounciness
1308
+ ) {
1309
+ return false;
1310
+ }
1311
+ }
1312
+
1313
+ // Compare callbacks
1314
+ if (
1315
+ prevProps.onAnimationStart !== nextProps.onAnimationStart ||
1316
+ prevProps.onAnimationComplete !== nextProps.onAnimationComplete
1317
+ ) {
1318
+ return false;
1319
+ }
1320
+
1321
+ // Style comparison
1322
+ if (prevProps.style !== nextProps.style) {
1323
+ return false;
1324
+ }
1325
+
1326
+ // Children comparison - if children change, re-render
1327
+ if (prevProps.children !== nextProps.children) {
1328
+ return false;
1329
+ }
1330
+
1331
+ return true;
939
1332
  }
940
1333
 
941
1334
  /**
@@ -950,7 +1343,7 @@ export interface PuffPopGroupProps {
950
1343
  * </PuffPopGroup>
951
1344
  * ```
952
1345
  */
953
- export function PuffPopGroup({
1346
+ function PuffPopGroupComponent({
954
1347
  children,
955
1348
  effect = 'scale',
956
1349
  duration = 400,
@@ -978,11 +1371,18 @@ export function PuffPopGroup({
978
1371
  reverse,
979
1372
  // Animation intensity
980
1373
  intensity,
1374
+ // Anchor point
1375
+ anchorPoint,
1376
+ // Spring animation
1377
+ useSpring,
1378
+ springConfig,
981
1379
  // Exit animation settings
982
1380
  exitEffect,
983
1381
  exitDuration,
984
1382
  exitEasing,
985
1383
  exitDelay,
1384
+ exitStaggerDelay = 0,
1385
+ exitStaggerDirection = 'reverse',
986
1386
  }: PuffPopGroupProps): ReactElement {
987
1387
  const childArray = Children.toArray(children);
988
1388
  const childCount = childArray.length;
@@ -1019,6 +1419,41 @@ export function PuffPopGroup({
1019
1419
  [childCount, initialDelay, staggerDelay, staggerDirection]
1020
1420
  );
1021
1421
 
1422
+ // Calculate exit delay for each child based on exit stagger direction
1423
+ const getChildExitDelay = useCallback(
1424
+ (index: number): number => {
1425
+ if (exitStaggerDelay === 0) {
1426
+ return exitDelay ?? 0;
1427
+ }
1428
+
1429
+ let delayIndex: number;
1430
+
1431
+ switch (exitStaggerDirection) {
1432
+ case 'forward':
1433
+ delayIndex = index;
1434
+ break;
1435
+ case 'center': {
1436
+ const center = (childCount - 1) / 2;
1437
+ delayIndex = Math.abs(index - center);
1438
+ break;
1439
+ }
1440
+ case 'edges': {
1441
+ const center = (childCount - 1) / 2;
1442
+ delayIndex = center - Math.abs(index - center);
1443
+ break;
1444
+ }
1445
+ case 'reverse':
1446
+ default:
1447
+ // Reverse is default for exit (last in, first out)
1448
+ delayIndex = childCount - 1 - index;
1449
+ break;
1450
+ }
1451
+
1452
+ return (exitDelay ?? 0) + delayIndex * exitStaggerDelay;
1453
+ },
1454
+ [childCount, exitDelay, exitStaggerDelay, exitStaggerDirection]
1455
+ );
1456
+
1022
1457
  // Handle individual child animation complete
1023
1458
  const handleChildComplete = useCallback(() => {
1024
1459
  completedCount.current += 1;
@@ -1075,10 +1510,13 @@ export function PuffPopGroup({
1075
1510
  initialTranslateY={initialTranslateY}
1076
1511
  reverse={reverse}
1077
1512
  intensity={intensity}
1513
+ anchorPoint={anchorPoint}
1514
+ useSpring={useSpring}
1515
+ springConfig={springConfig}
1078
1516
  exitEffect={exitEffect}
1079
1517
  exitDuration={exitDuration}
1080
1518
  exitEasing={exitEasing}
1081
- exitDelay={exitDelay}
1519
+ exitDelay={getChildExitDelay(index)}
1082
1520
  >
1083
1521
  {child}
1084
1522
  </PuffPop>
@@ -1087,5 +1525,8 @@ export function PuffPopGroup({
1087
1525
  );
1088
1526
  }
1089
1527
 
1528
+ // Memoize PuffPopGroup to prevent unnecessary re-renders
1529
+ export const PuffPopGroup = memo(PuffPopGroupComponent, arePuffPopGroupPropsEqual);
1530
+
1090
1531
  export default PuffPop;
1091
1532