@idealyst/animate 1.2.29

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * useGradientBorder - Native implementation
3
+ *
4
+ * Creates animated gradient border effects using react-native-svg
5
+ * and Reanimated for performant animations.
6
+ */
7
+
8
+ import React, { useMemo, useEffect } from 'react';
9
+ import { View, StyleSheet } from 'react-native';
10
+ import {
11
+ useSharedValue,
12
+ useAnimatedProps,
13
+ withRepeat,
14
+ withTiming,
15
+ Easing,
16
+ } from 'react-native-reanimated';
17
+ import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg';
18
+ import Animated from 'react-native-reanimated';
19
+ import { resolveDuration } from '@idealyst/theme/animation';
20
+ import type { UseGradientBorderOptions, UseGradientBorderResult, AnimatableStyle } from './types';
21
+
22
+ const AnimatedSvg = Animated.createAnimatedComponent(Svg);
23
+ const AnimatedRect = Animated.createAnimatedComponent(Rect);
24
+ const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
25
+
26
+ /**
27
+ * Hook that creates an animated gradient border effect.
28
+ * Uses SVG with Reanimated for smooth animations on native.
29
+ *
30
+ * @param options - Configuration for the gradient border
31
+ * @returns Container and content styles to apply
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * const { containerStyle, contentStyle, GradientBorder } = useGradientBorder({
36
+ * colors: ['#3b82f6', '#8b5cf6', '#ec4899'],
37
+ * borderWidth: 2,
38
+ * borderRadius: 8,
39
+ * animation: 'spin',
40
+ * duration: 2000,
41
+ * });
42
+ *
43
+ * return (
44
+ * <View style={containerStyle}>
45
+ * <GradientBorder />
46
+ * <View style={contentStyle}>
47
+ * Content here
48
+ * </View>
49
+ * </View>
50
+ * );
51
+ * ```
52
+ */
53
+ export function useGradientBorder(options: UseGradientBorderOptions): UseGradientBorderResult & {
54
+ GradientBorder: React.FC<{ width: number; height: number }>;
55
+ } {
56
+ const {
57
+ colors,
58
+ borderWidth = 2,
59
+ borderRadius = 8,
60
+ duration = 2000,
61
+ animation = 'spin',
62
+ active = true,
63
+ } = options;
64
+
65
+ const durationMs = resolveDuration(duration);
66
+
67
+ // Animation value for rotation (0-360) or progress (0-1)
68
+ const animationValue = useSharedValue(0);
69
+
70
+ // Start animation
71
+ useEffect(() => {
72
+ if (!active) {
73
+ animationValue.value = 0;
74
+ return;
75
+ }
76
+
77
+ switch (animation) {
78
+ case 'spin':
79
+ animationValue.value = withRepeat(
80
+ withTiming(360, {
81
+ duration: durationMs,
82
+ easing: Easing.linear,
83
+ }),
84
+ -1, // infinite
85
+ false // don't reverse
86
+ );
87
+ break;
88
+ case 'pulse':
89
+ animationValue.value = withRepeat(
90
+ withTiming(1, {
91
+ duration: durationMs / 2,
92
+ easing: Easing.inOut(Easing.ease),
93
+ }),
94
+ -1,
95
+ true // reverse
96
+ );
97
+ break;
98
+ case 'wave':
99
+ animationValue.value = withRepeat(
100
+ withTiming(1, {
101
+ duration: durationMs,
102
+ easing: Easing.inOut(Easing.ease),
103
+ }),
104
+ -1,
105
+ false
106
+ );
107
+ break;
108
+ }
109
+ }, [active, animation, durationMs]);
110
+
111
+ // Container style
112
+ const containerStyle = useMemo<AnimatableStyle>(() => {
113
+ return {
114
+ position: 'relative',
115
+ overflow: 'hidden',
116
+ } as AnimatableStyle;
117
+ }, []);
118
+
119
+ // Content style
120
+ const contentStyle = useMemo<AnimatableStyle>(() => {
121
+ return {
122
+ position: 'absolute',
123
+ top: borderWidth,
124
+ left: borderWidth,
125
+ right: borderWidth,
126
+ bottom: borderWidth,
127
+ borderRadius,
128
+ backgroundColor: 'white', // This should be set by the consumer
129
+ overflow: 'hidden',
130
+ } as AnimatableStyle;
131
+ }, [borderWidth, borderRadius]);
132
+
133
+ // Gradient border component
134
+ const GradientBorder: React.FC<{ width: number; height: number }> = useMemo(() => {
135
+ return ({ width, height }) => {
136
+ // Animated props for the gradient
137
+ const animatedGradientProps = useAnimatedProps(() => {
138
+ if (animation === 'spin') {
139
+ // Rotate the gradient
140
+ const angle = animationValue.value;
141
+ const radians = (angle * Math.PI) / 180;
142
+ const x2 = 0.5 + 0.5 * Math.cos(radians);
143
+ const y2 = 0.5 + 0.5 * Math.sin(radians);
144
+ const x1 = 1 - x2;
145
+ const y1 = 1 - y2;
146
+ return {
147
+ x1: String(x1),
148
+ y1: String(y1),
149
+ x2: String(x2),
150
+ y2: String(y2),
151
+ };
152
+ } else if (animation === 'wave') {
153
+ // Move gradient position
154
+ const progress = animationValue.value;
155
+ return {
156
+ x1: String(-1 + progress * 2),
157
+ y1: '0',
158
+ x2: String(progress * 2),
159
+ y2: '0',
160
+ };
161
+ }
162
+ return {
163
+ x1: '0',
164
+ y1: '0',
165
+ x2: '1',
166
+ y2: '1',
167
+ };
168
+ });
169
+
170
+ const animatedRectProps = useAnimatedProps(() => {
171
+ if (animation === 'pulse') {
172
+ const opacity = 0.5 + animationValue.value * 0.5;
173
+ return { opacity };
174
+ }
175
+ return { opacity: 1 };
176
+ });
177
+
178
+ return (
179
+ <Svg
180
+ width={width}
181
+ height={height}
182
+ style={StyleSheet.absoluteFill}
183
+ >
184
+ <Defs>
185
+ <AnimatedLinearGradient
186
+ id="gradient"
187
+ animatedProps={animatedGradientProps}
188
+ >
189
+ {colors.map((color, index) => (
190
+ <Stop
191
+ key={index}
192
+ offset={`${(index / (colors.length - 1)) * 100}%`}
193
+ stopColor={color}
194
+ />
195
+ ))}
196
+ </AnimatedLinearGradient>
197
+ </Defs>
198
+ <AnimatedRect
199
+ x={0}
200
+ y={0}
201
+ width={width}
202
+ height={height}
203
+ rx={borderRadius + borderWidth}
204
+ ry={borderRadius + borderWidth}
205
+ fill="url(#gradient)"
206
+ animatedProps={animatedRectProps}
207
+ />
208
+ {/* Inner cutout (white background) */}
209
+ <Rect
210
+ x={borderWidth}
211
+ y={borderWidth}
212
+ width={width - borderWidth * 2}
213
+ height={height - borderWidth * 2}
214
+ rx={borderRadius}
215
+ ry={borderRadius}
216
+ fill="white"
217
+ />
218
+ </Svg>
219
+ );
220
+ };
221
+ }, [colors, borderWidth, borderRadius, animation, animationValue]);
222
+
223
+ return {
224
+ containerStyle,
225
+ contentStyle,
226
+ isReady: true,
227
+ GradientBorder,
228
+ };
229
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * useGradientBorder - Web implementation
3
+ *
4
+ * Creates animated gradient border effects using CSS @property
5
+ * and conic gradients for performant animations.
6
+ */
7
+
8
+ import { useMemo, useEffect, useRef } from 'react';
9
+ import { resolveDuration, injectGradientCSS } from '@idealyst/theme/animation';
10
+ import type { UseGradientBorderOptions, UseGradientBorderResult, AnimatableStyle } from './types';
11
+
12
+ /**
13
+ * Hook that creates an animated gradient border effect.
14
+ * Uses CSS @property for smooth, GPU-accelerated gradient animations.
15
+ *
16
+ * @param options - Configuration for the gradient border
17
+ * @returns Container and content styles to apply
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * const { containerStyle, contentStyle, isReady } = useGradientBorder({
22
+ * colors: ['#3b82f6', '#8b5cf6', '#ec4899'],
23
+ * borderWidth: 2,
24
+ * borderRadius: 8,
25
+ * animation: 'spin',
26
+ * duration: 2000,
27
+ * });
28
+ *
29
+ * return (
30
+ * <div style={containerStyle}>
31
+ * <div style={contentStyle}>
32
+ * Content here
33
+ * </div>
34
+ * </div>
35
+ * );
36
+ * ```
37
+ */
38
+ export function useGradientBorder(options: UseGradientBorderOptions): UseGradientBorderResult {
39
+ const {
40
+ colors,
41
+ borderWidth = 2,
42
+ borderRadius = 8,
43
+ duration = 2000,
44
+ animation = 'spin',
45
+ active = true,
46
+ } = options;
47
+
48
+ const isReady = useRef(false);
49
+ const durationMs = resolveDuration(duration);
50
+
51
+ // Inject gradient CSS on first use
52
+ useEffect(() => {
53
+ injectGradientCSS();
54
+ isReady.current = true;
55
+ }, []);
56
+
57
+ // Generate the color string for conic gradient
58
+ const colorString = useMemo(() => {
59
+ // For spinning gradient, distribute colors evenly
60
+ if (animation === 'spin') {
61
+ return colors.join(', ');
62
+ }
63
+ // For pulse/wave, use linear gradient
64
+ return colors.join(', ');
65
+ }, [colors, animation]);
66
+
67
+ // Container style (the gradient background)
68
+ const containerStyle = useMemo<AnimatableStyle>(() => {
69
+ const baseStyle: Record<string, any> = {
70
+ position: 'relative',
71
+ padding: borderWidth,
72
+ borderRadius: borderRadius + borderWidth,
73
+ overflow: 'hidden',
74
+ };
75
+
76
+ if (!active) {
77
+ // When inactive, show a solid border color
78
+ baseStyle.background = colors[0];
79
+ return baseStyle as AnimatableStyle;
80
+ }
81
+
82
+ switch (animation) {
83
+ case 'spin':
84
+ return {
85
+ ...baseStyle,
86
+ background: `conic-gradient(from var(--gradient-angle, 0deg), ${colorString})`,
87
+ animation: `spin-gradient ${durationMs}ms linear infinite`,
88
+ } as AnimatableStyle;
89
+
90
+ case 'pulse':
91
+ return {
92
+ ...baseStyle,
93
+ background: `linear-gradient(135deg, ${colorString})`,
94
+ animation: `pulse-gradient ${durationMs}ms ease-in-out infinite`,
95
+ } as AnimatableStyle;
96
+
97
+ case 'wave':
98
+ // Wave uses a moving gradient position
99
+ return {
100
+ ...baseStyle,
101
+ background: `linear-gradient(
102
+ 90deg,
103
+ ${colors[0]} var(--gradient-position, 0%),
104
+ ${colors[colors.length - 1]} calc(var(--gradient-position, 0%) + 50%),
105
+ ${colors[0]} calc(var(--gradient-position, 0%) + 100%)
106
+ )`,
107
+ backgroundSize: '200% 100%',
108
+ animation: `wave-gradient ${durationMs}ms ease-in-out infinite`,
109
+ } as AnimatableStyle;
110
+
111
+ default:
112
+ return baseStyle as AnimatableStyle;
113
+ }
114
+ }, [colors, colorString, borderWidth, borderRadius, durationMs, animation, active]);
115
+
116
+ // Content style (the inner container with solid background)
117
+ const contentStyle = useMemo<AnimatableStyle>(() => {
118
+ return {
119
+ borderRadius,
120
+ backgroundColor: 'inherit',
121
+ // Ensure content fills the container minus the border
122
+ width: '100%',
123
+ height: '100%',
124
+ } as AnimatableStyle;
125
+ }, [borderRadius]);
126
+
127
+ return {
128
+ containerStyle,
129
+ contentStyle,
130
+ isReady: isReady.current,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Utility to create inline gradient border styles without the hook.
136
+ * Useful for static gradient borders or server-side rendering.
137
+ *
138
+ * Note: You must call injectGradientCSS() somewhere in your app for animations to work.
139
+ */
140
+ export function createGradientBorderStyle(
141
+ colors: string[],
142
+ borderWidth: number = 2,
143
+ borderRadius: number = 8,
144
+ animation: 'spin' | 'pulse' | 'wave' = 'spin',
145
+ durationMs: number = 2000
146
+ ): { container: Record<string, any>; content: Record<string, any> } {
147
+ const colorString = colors.join(', ');
148
+
149
+ const container: Record<string, any> = {
150
+ position: 'relative',
151
+ padding: borderWidth,
152
+ borderRadius: borderRadius + borderWidth,
153
+ overflow: 'hidden',
154
+ };
155
+
156
+ switch (animation) {
157
+ case 'spin':
158
+ container.background = `conic-gradient(from var(--gradient-angle, 0deg), ${colorString})`;
159
+ container.animation = `spin-gradient ${durationMs}ms linear infinite`;
160
+ break;
161
+ case 'pulse':
162
+ container.background = `linear-gradient(135deg, ${colorString})`;
163
+ container.animation = `pulse-gradient ${durationMs}ms ease-in-out infinite`;
164
+ break;
165
+ case 'wave':
166
+ container.background = `linear-gradient(90deg, ${colors[0]}, ${colors[colors.length - 1]}, ${colors[0]})`;
167
+ container.backgroundSize = '200% 100%';
168
+ container.animation = `wave-gradient ${durationMs}ms ease-in-out infinite`;
169
+ break;
170
+ }
171
+
172
+ const content = {
173
+ borderRadius,
174
+ backgroundColor: 'inherit',
175
+ width: '100%',
176
+ height: '100%',
177
+ };
178
+
179
+ return { container, content };
180
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * usePresence - Native implementation
3
+ *
4
+ * Manages mount/unmount animations using Reanimated.
5
+ */
6
+
7
+ import { useState, useEffect, useRef, useCallback } from 'react';
8
+ import {
9
+ useSharedValue,
10
+ useAnimatedStyle,
11
+ withTiming,
12
+ withSpring,
13
+ withDelay,
14
+ Easing,
15
+ runOnJS,
16
+ } from 'react-native-reanimated';
17
+ import { timingConfig, springConfig, isSpringEasing, resolveDuration } from '@idealyst/theme/animation';
18
+ import type { UsePresenceOptions, UsePresenceResult, AnimatableStyle } from './types';
19
+
20
+ /**
21
+ * Hook that manages presence animations for mount/unmount.
22
+ * The element stays rendered during exit animation.
23
+ *
24
+ * @param isVisible - Whether the element should be visible
25
+ * @param options - Animation configuration with enter/exit styles
26
+ * @returns Object with isPresent, style, and exit function
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * import Animated from 'react-native-reanimated';
31
+ *
32
+ * const { isPresent, style } = usePresence(isOpen, {
33
+ * enter: { opacity: 1, transform: [{ translateY: 0 }] },
34
+ * exit: { opacity: 0, transform: [{ translateY: -20 }] },
35
+ * duration: 'normal',
36
+ * });
37
+ *
38
+ * return isPresent && <Animated.View style={style}>Content</Animated.View>;
39
+ * ```
40
+ */
41
+ export function usePresence(isVisible: boolean, options: UsePresenceOptions): UsePresenceResult {
42
+ const { enter, exit, initial, duration = 'normal', easing = 'easeOut', delay = 0 } = options;
43
+
44
+ // Track whether the element should be in the DOM
45
+ const [isPresent, setIsPresent] = useState(isVisible);
46
+ // Track initial mount
47
+ const isInitialMount = useRef(true);
48
+
49
+ const durationMs = resolveDuration(duration);
50
+ const useSpring = isSpringEasing(easing);
51
+
52
+ // Shared value for animation progress (0 = exit, 1 = enter)
53
+ const progress = useSharedValue(isVisible ? 1 : 0);
54
+
55
+ // Extract values from enter/exit styles
56
+ const enterOpacity = enter.opacity ?? 1;
57
+ const exitOpacity = exit.opacity ?? 0;
58
+ const initialOpacity = initial?.opacity ?? exitOpacity;
59
+
60
+ // Extract transform values
61
+ const getTransformValue = (
62
+ style: typeof enter | typeof exit,
63
+ key: string,
64
+ defaultValue: number | string
65
+ ) => {
66
+ if (!style.transform) return defaultValue;
67
+ const transform = style.transform.find((t) => key in t);
68
+ return transform ? (transform as any)[key] : defaultValue;
69
+ };
70
+
71
+ const enterTranslateY = getTransformValue(enter, 'translateY', 0) as number;
72
+ const exitTranslateY = getTransformValue(exit, 'translateY', 0) as number;
73
+ const enterTranslateX = getTransformValue(enter, 'translateX', 0) as number;
74
+ const exitTranslateX = getTransformValue(exit, 'translateX', 0) as number;
75
+ const enterScale = getTransformValue(enter, 'scale', 1) as number;
76
+ const exitScale = getTransformValue(exit, 'scale', 1) as number;
77
+
78
+ // Animation helper
79
+ const animateTo = useCallback(
80
+ (target: number, onComplete?: () => void) => {
81
+ 'worklet';
82
+ const withCallback = (animation: any) => {
83
+ if (onComplete) {
84
+ return {
85
+ ...animation,
86
+ callback: (finished: boolean) => {
87
+ if (finished) {
88
+ runOnJS(onComplete)();
89
+ }
90
+ },
91
+ };
92
+ }
93
+ return animation;
94
+ };
95
+
96
+ if (useSpring) {
97
+ const config = springConfig(easing as any);
98
+ return delay > 0
99
+ ? withDelay(delay, withSpring(target, config, onComplete ? (finished) => finished && runOnJS(onComplete)() : undefined))
100
+ : withSpring(target, config, onComplete ? (finished) => finished && runOnJS(onComplete)() : undefined);
101
+ } else {
102
+ const config = timingConfig(duration, easing);
103
+ const timingOptions = {
104
+ duration: config.duration,
105
+ easing: Easing.bezier(config.easing[0], config.easing[1], config.easing[2], config.easing[3]),
106
+ };
107
+ return delay > 0
108
+ ? withDelay(delay, withTiming(target, timingOptions, onComplete ? (finished) => finished && runOnJS(onComplete)() : undefined))
109
+ : withTiming(target, timingOptions, onComplete ? (finished) => finished && runOnJS(onComplete)() : undefined);
110
+ }
111
+ },
112
+ [useSpring, easing, duration, delay]
113
+ );
114
+
115
+ // Handle visibility changes
116
+ useEffect(() => {
117
+ if (isVisible) {
118
+ // Entering: mount first, then animate
119
+ setIsPresent(true);
120
+ progress.value = animateTo(1);
121
+ } else if (!isInitialMount.current) {
122
+ // Exiting: animate, then unmount
123
+ progress.value = animateTo(0, () => {
124
+ setIsPresent(false);
125
+ });
126
+ }
127
+
128
+ isInitialMount.current = false;
129
+ }, [isVisible]);
130
+
131
+ // Manual exit trigger
132
+ const triggerExit = useCallback(() => {
133
+ progress.value = animateTo(0, () => {
134
+ setIsPresent(false);
135
+ });
136
+ }, [animateTo]);
137
+
138
+ // Animated style
139
+ const style = useAnimatedStyle(() => {
140
+ const p = progress.value;
141
+
142
+ return {
143
+ opacity: exitOpacity + (enterOpacity - exitOpacity) * p,
144
+ transform: [
145
+ { translateX: exitTranslateX + (enterTranslateX - exitTranslateX) * p },
146
+ { translateY: exitTranslateY + (enterTranslateY - exitTranslateY) * p },
147
+ { scale: exitScale + (enterScale - exitScale) * p },
148
+ ],
149
+ };
150
+ });
151
+
152
+ return {
153
+ isPresent,
154
+ style: style as AnimatableStyle,
155
+ exit: triggerExit,
156
+ };
157
+ }