@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,165 @@
1
+ /**
2
+ * useAnimatedStyle - Web implementation
3
+ *
4
+ * Creates an animated style that transitions when properties change.
5
+ * Uses CSS transitions for smooth, performant animations.
6
+ */
7
+
8
+ import { useMemo, useRef, useEffect } from 'react';
9
+ import { cssTransition, resolveDuration, resolveEasing } from '@idealyst/theme/animation';
10
+ import type { AnimatableProperties, UseAnimatedStyleOptions, AnimatableStyle } from './types';
11
+
12
+ /**
13
+ * Hook that returns an animated style object.
14
+ * When the style properties change, they animate smoothly using CSS transitions.
15
+ *
16
+ * @param style - The target style properties
17
+ * @param options - Animation configuration
18
+ * @returns Animated style object to spread onto a component
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * const style = useAnimatedStyle({
23
+ * opacity: isVisible ? 1 : 0,
24
+ * transform: [{ translateY: isVisible ? 0 : 20 }],
25
+ * }, {
26
+ * duration: 'normal',
27
+ * easing: 'easeOut',
28
+ * });
29
+ *
30
+ * return <div style={style}>Content</div>;
31
+ * ```
32
+ */
33
+ export function useAnimatedStyle(
34
+ style: AnimatableProperties,
35
+ options: UseAnimatedStyleOptions = {}
36
+ ): AnimatableStyle {
37
+ const { duration = 'normal', easing = 'easeOut', delay = 0, web } = options;
38
+
39
+ // Use web-specific options if provided
40
+ const finalDuration = web?.duration ?? duration;
41
+ const finalEasing = web?.easing ?? easing;
42
+ const finalDelay = web?.delay ?? delay;
43
+
44
+ // Track if this is the initial render
45
+ const isInitialRender = useRef(true);
46
+
47
+ useEffect(() => {
48
+ isInitialRender.current = false;
49
+ }, []);
50
+
51
+ // Convert transform array to CSS transform string
52
+ const transformString = useMemo(() => {
53
+ if (!style.transform || !Array.isArray(style.transform)) {
54
+ return undefined;
55
+ }
56
+
57
+ return style.transform
58
+ .map((t) => {
59
+ const [key, value] = Object.entries(t)[0];
60
+ // Handle different transform types
61
+ switch (key) {
62
+ case 'translateX':
63
+ case 'translateY':
64
+ return `${key}(${typeof value === 'number' ? `${value}px` : value})`;
65
+ case 'rotate':
66
+ case 'rotateX':
67
+ case 'rotateY':
68
+ case 'rotateZ':
69
+ case 'skewX':
70
+ case 'skewY':
71
+ return `${key}(${value})`;
72
+ case 'scale':
73
+ case 'scaleX':
74
+ case 'scaleY':
75
+ case 'perspective':
76
+ return `${key}(${value})`;
77
+ default:
78
+ return `${key}(${value})`;
79
+ }
80
+ })
81
+ .join(' ');
82
+ }, [style.transform]);
83
+
84
+ // Build the transition string
85
+ const transition = useMemo(() => {
86
+ // Use custom transition if provided
87
+ if (web?.transition) {
88
+ return web.transition;
89
+ }
90
+
91
+ // Get all animatable property names from the style
92
+ const properties = Object.keys(style).filter((key) => key !== 'transform');
93
+ if (style.transform) {
94
+ properties.push('transform');
95
+ }
96
+
97
+ if (properties.length === 0) {
98
+ return undefined;
99
+ }
100
+
101
+ // Map RN property names to CSS property names
102
+ const cssProperties = properties.map((prop) => {
103
+ switch (prop) {
104
+ case 'backgroundColor':
105
+ return 'background-color';
106
+ case 'borderColor':
107
+ return 'border-color';
108
+ case 'borderWidth':
109
+ return 'border-width';
110
+ case 'borderRadius':
111
+ return 'border-radius';
112
+ case 'borderTopLeftRadius':
113
+ return 'border-top-left-radius';
114
+ case 'borderTopRightRadius':
115
+ return 'border-top-right-radius';
116
+ case 'borderBottomLeftRadius':
117
+ return 'border-bottom-left-radius';
118
+ case 'borderBottomRightRadius':
119
+ return 'border-bottom-right-radius';
120
+ case 'maxHeight':
121
+ return 'max-height';
122
+ case 'maxWidth':
123
+ return 'max-width';
124
+ case 'minHeight':
125
+ return 'min-height';
126
+ case 'minWidth':
127
+ return 'min-width';
128
+ default:
129
+ return prop;
130
+ }
131
+ });
132
+
133
+ const durationMs = resolveDuration(finalDuration);
134
+ const easingCss = resolveEasing(finalEasing);
135
+ const delayStr = finalDelay > 0 ? ` ${finalDelay}ms` : '';
136
+
137
+ return cssProperties.map((prop) => `${prop} ${durationMs}ms ${easingCss}${delayStr}`).join(', ');
138
+ }, [style, finalDuration, finalEasing, finalDelay, web?.transition]);
139
+
140
+ // Build the final style object
141
+ const animatedStyle = useMemo(() => {
142
+ const result: Record<string, any> = {};
143
+
144
+ // Copy all style properties except transform
145
+ Object.entries(style).forEach(([key, value]) => {
146
+ if (key !== 'transform' && value !== undefined) {
147
+ result[key] = value;
148
+ }
149
+ });
150
+
151
+ // Add transform string
152
+ if (transformString) {
153
+ result.transform = transformString;
154
+ }
155
+
156
+ // Add transition (skip on initial render to avoid animation on mount)
157
+ if (transition && !isInitialRender.current) {
158
+ result.transition = transition;
159
+ }
160
+
161
+ return result as AnimatableStyle;
162
+ }, [style, transformString, transition]);
163
+
164
+ return animatedStyle;
165
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * useAnimatedValue - Native implementation
3
+ *
4
+ * Creates an animated numeric value using Reanimated shared values.
5
+ */
6
+
7
+ import { useCallback, useMemo } from 'react';
8
+ import {
9
+ useSharedValue,
10
+ withTiming,
11
+ withSpring,
12
+ withDelay,
13
+ Easing,
14
+ interpolate as reanimatedInterpolate,
15
+ Extrapolation,
16
+ useDerivedValue,
17
+ } from 'react-native-reanimated';
18
+ import { timingConfig, springConfig, isSpringEasing, resolveDuration } from '@idealyst/theme/animation';
19
+ import type { AnimatedValue, AnimationOptions, InterpolationConfig } from './types';
20
+
21
+ /**
22
+ * Hook that creates an animated numeric value using Reanimated.
23
+ *
24
+ * @param initialValue - Starting value
25
+ * @returns AnimatedValue object with set, setImmediate, and interpolate methods
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * const progress = useAnimatedValue(0);
30
+ *
31
+ * // Animate to new value
32
+ * progress.set(1, { duration: 'slow', easing: 'easeOut' });
33
+ *
34
+ * // Use in animated style
35
+ * const animatedStyle = useAnimatedStyle(() => ({
36
+ * opacity: progress.value,
37
+ * }));
38
+ * ```
39
+ */
40
+ export function useAnimatedValue(initialValue: number): AnimatedValue {
41
+ const sharedValue = useSharedValue(initialValue);
42
+
43
+ // Set value with animation
44
+ const set = useCallback(
45
+ (target: number, options: AnimationOptions = {}) => {
46
+ const { duration = 'normal', easing = 'easeOut', delay = 0 } = options;
47
+
48
+ const useSpring = isSpringEasing(easing);
49
+
50
+ if (useSpring) {
51
+ const config = springConfig(easing as any);
52
+ sharedValue.value =
53
+ delay > 0 ? withDelay(delay, withSpring(target, config)) : withSpring(target, config);
54
+ } else {
55
+ const config = timingConfig(duration, easing);
56
+ const timingOptions = {
57
+ duration: config.duration,
58
+ easing: Easing.bezier(config.easing[0], config.easing[1], config.easing[2], config.easing[3]),
59
+ };
60
+ sharedValue.value =
61
+ delay > 0 ? withDelay(delay, withTiming(target, timingOptions)) : withTiming(target, timingOptions);
62
+ }
63
+ },
64
+ [sharedValue]
65
+ );
66
+
67
+ // Set value immediately without animation
68
+ const setImmediate = useCallback(
69
+ (target: number) => {
70
+ sharedValue.value = target;
71
+ },
72
+ [sharedValue]
73
+ );
74
+
75
+ // Interpolate value to another range
76
+ // Note: For native, this returns a function that should be called within useAnimatedStyle
77
+ const interpolate = useCallback(
78
+ <T extends string | number>(config: InterpolationConfig<T>): T => {
79
+ const { inputRange, outputRange, extrapolate = 'extend' } = config;
80
+ const extrapolateLeft = config.extrapolateLeft ?? extrapolate;
81
+ const extrapolateRight = config.extrapolateRight ?? extrapolate;
82
+
83
+ // Map extrapolation to Reanimated's Extrapolation enum
84
+ const getExtrapolation = (type: string) => {
85
+ switch (type) {
86
+ case 'clamp':
87
+ return Extrapolation.CLAMP;
88
+ case 'identity':
89
+ return Extrapolation.IDENTITY;
90
+ default:
91
+ return Extrapolation.EXTEND;
92
+ }
93
+ };
94
+
95
+ // For numbers, use Reanimated's interpolate
96
+ if (typeof outputRange[0] === 'number') {
97
+ return reanimatedInterpolate(
98
+ sharedValue.value,
99
+ inputRange,
100
+ outputRange as number[],
101
+ {
102
+ extrapolateLeft: getExtrapolation(extrapolateLeft),
103
+ extrapolateRight: getExtrapolation(extrapolateRight),
104
+ }
105
+ ) as T;
106
+ }
107
+
108
+ // For colors (strings), do manual interpolation
109
+ // Note: In real usage, you'd want to use interpolateColor from Reanimated
110
+ const value = sharedValue.value;
111
+
112
+ // Find the segment
113
+ let i = 0;
114
+ for (; i < inputRange.length - 1; i++) {
115
+ if (value < inputRange[i + 1]) break;
116
+ }
117
+ i = Math.min(i, inputRange.length - 2);
118
+
119
+ const inputMin = inputRange[i];
120
+ const inputMax = inputRange[i + 1];
121
+ const outputMin = outputRange[i] as string;
122
+ const outputMax = outputRange[i + 1] as string;
123
+
124
+ let ratio = (value - inputMin) / (inputMax - inputMin);
125
+
126
+ // Handle extrapolation for colors
127
+ if (value < inputRange[0] && extrapolateLeft === 'clamp') {
128
+ return outputRange[0];
129
+ } else if (value > inputRange[inputRange.length - 1] && extrapolateRight === 'clamp') {
130
+ return outputRange[outputRange.length - 1];
131
+ }
132
+
133
+ ratio = Math.max(0, Math.min(1, ratio));
134
+
135
+ // Simple hex color interpolation
136
+ if (outputMin.startsWith('#') && outputMax.startsWith('#')) {
137
+ const parseHex = (hex: string) => {
138
+ const h = hex.replace('#', '');
139
+ return {
140
+ r: parseInt(h.substring(0, 2), 16),
141
+ g: parseInt(h.substring(2, 4), 16),
142
+ b: parseInt(h.substring(4, 6), 16),
143
+ };
144
+ };
145
+ const min = parseHex(outputMin);
146
+ const max = parseHex(outputMax);
147
+ const r = Math.round(min.r + (max.r - min.r) * ratio);
148
+ const g = Math.round(min.g + (max.g - min.g) * ratio);
149
+ const b = Math.round(min.b + (max.b - min.b) * ratio);
150
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` as T;
151
+ }
152
+
153
+ return (ratio < 0.5 ? outputMin : outputMax) as T;
154
+ },
155
+ [sharedValue]
156
+ );
157
+
158
+ return useMemo(
159
+ () => ({
160
+ get value() {
161
+ return sharedValue.value;
162
+ },
163
+ set,
164
+ setImmediate,
165
+ interpolate,
166
+ }),
167
+ [sharedValue, set, setImmediate, interpolate]
168
+ );
169
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * useAnimatedValue - Web implementation
3
+ *
4
+ * Creates an animated numeric value that can be interpolated.
5
+ * Uses requestAnimationFrame for smooth animations on web.
6
+ */
7
+
8
+ import { useState, useRef, useCallback, useMemo } from 'react';
9
+ import { resolveDuration, resolveEasing } from '@idealyst/theme/animation';
10
+ import type { AnimatedValue, AnimationOptions, InterpolationConfig } from './types';
11
+
12
+ // Bezier curve evaluation for custom easing
13
+ function bezierEval(t: number, p1: number, p2: number, p3: number, p4: number): number {
14
+ const cx = 3 * p1;
15
+ const bx = 3 * (p3 - p1) - cx;
16
+ const ax = 1 - cx - bx;
17
+ const cy = 3 * p2;
18
+ const by = 3 * (p4 - p2) - cy;
19
+ const ay = 1 - cy - by;
20
+
21
+ function sampleCurveX(t: number) {
22
+ return ((ax * t + bx) * t + cx) * t;
23
+ }
24
+ function sampleCurveY(t: number) {
25
+ return ((ay * t + by) * t + cy) * t;
26
+ }
27
+ function sampleCurveDerivativeX(t: number) {
28
+ return (3 * ax * t + 2 * bx) * t + cx;
29
+ }
30
+
31
+ // Newton-Raphson iteration to find t for x
32
+ let t2 = t;
33
+ for (let i = 0; i < 8; i++) {
34
+ const x2 = sampleCurveX(t2) - t;
35
+ if (Math.abs(x2) < 1e-6) break;
36
+ const d2 = sampleCurveDerivativeX(t2);
37
+ if (Math.abs(d2) < 1e-6) break;
38
+ t2 = t2 - x2 / d2;
39
+ }
40
+
41
+ return sampleCurveY(t2);
42
+ }
43
+
44
+ // Parse CSS easing to bezier values
45
+ function getEasingBezier(easing: string): [number, number, number, number] {
46
+ const match = easing.match(/cubic-bezier\(([^)]+)\)/);
47
+ if (match) {
48
+ const values = match[1].split(',').map((v) => parseFloat(v.trim()));
49
+ return values as [number, number, number, number];
50
+ }
51
+
52
+ // Standard easings
53
+ switch (easing) {
54
+ case 'linear':
55
+ return [0, 0, 1, 1];
56
+ case 'ease':
57
+ return [0.25, 0.1, 0.25, 1];
58
+ case 'ease-in':
59
+ return [0.42, 0, 1, 1];
60
+ case 'ease-out':
61
+ return [0, 0, 0.58, 1];
62
+ case 'ease-in-out':
63
+ return [0.42, 0, 0.58, 1];
64
+ default:
65
+ return [0.25, 0.1, 0.25, 1]; // default to ease
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Hook that creates an animated numeric value.
71
+ *
72
+ * @param initialValue - Starting value
73
+ * @returns AnimatedValue object with set, setImmediate, and interpolate methods
74
+ *
75
+ * @example
76
+ * ```tsx
77
+ * const progress = useAnimatedValue(0);
78
+ *
79
+ * // Animate to new value
80
+ * progress.set(1, { duration: 'slow', easing: 'easeOut' });
81
+ *
82
+ * // Interpolate to color
83
+ * const color = progress.interpolate({
84
+ * inputRange: [0, 1],
85
+ * outputRange: ['#fff', '#000'],
86
+ * });
87
+ * ```
88
+ */
89
+ export function useAnimatedValue(initialValue: number): AnimatedValue {
90
+ const [value, setValue] = useState(initialValue);
91
+ const animationRef = useRef<number | null>(null);
92
+ const currentValueRef = useRef(initialValue);
93
+
94
+ // Cancel any running animation
95
+ const cancelAnimation = useCallback(() => {
96
+ if (animationRef.current !== null) {
97
+ cancelAnimationFrame(animationRef.current);
98
+ animationRef.current = null;
99
+ }
100
+ }, []);
101
+
102
+ // Set value with animation
103
+ const set = useCallback(
104
+ (target: number, options: AnimationOptions = {}) => {
105
+ const { duration = 'normal', easing = 'easeOut', delay = 0 } = options;
106
+ const durationMs = resolveDuration(duration);
107
+ const easingCss = resolveEasing(easing);
108
+ const bezier = getEasingBezier(easingCss);
109
+
110
+ cancelAnimation();
111
+
112
+ const startValue = currentValueRef.current;
113
+ const startTime = performance.now() + delay;
114
+
115
+ const animate = (currentTime: number) => {
116
+ if (currentTime < startTime) {
117
+ animationRef.current = requestAnimationFrame(animate);
118
+ return;
119
+ }
120
+
121
+ const elapsed = currentTime - startTime;
122
+ const progress = Math.min(elapsed / durationMs, 1);
123
+ const easedProgress = bezierEval(progress, bezier[0], bezier[1], bezier[2], bezier[3]);
124
+ const newValue = startValue + (target - startValue) * easedProgress;
125
+
126
+ currentValueRef.current = newValue;
127
+ setValue(newValue);
128
+
129
+ if (progress < 1) {
130
+ animationRef.current = requestAnimationFrame(animate);
131
+ } else {
132
+ animationRef.current = null;
133
+ }
134
+ };
135
+
136
+ animationRef.current = requestAnimationFrame(animate);
137
+ },
138
+ [cancelAnimation]
139
+ );
140
+
141
+ // Set value immediately without animation
142
+ const setImmediate = useCallback(
143
+ (target: number) => {
144
+ cancelAnimation();
145
+ currentValueRef.current = target;
146
+ setValue(target);
147
+ },
148
+ [cancelAnimation]
149
+ );
150
+
151
+ // Interpolate value to another range
152
+ const interpolate = useCallback(
153
+ <T extends string | number>(config: InterpolationConfig<T>): T => {
154
+ const { inputRange, outputRange, extrapolate = 'extend' } = config;
155
+ const extrapolateLeft = config.extrapolateLeft ?? extrapolate;
156
+ const extrapolateRight = config.extrapolateRight ?? extrapolate;
157
+
158
+ // Find the segment
159
+ let i = 0;
160
+ for (; i < inputRange.length - 1; i++) {
161
+ if (value < inputRange[i + 1]) break;
162
+ }
163
+ i = Math.min(i, inputRange.length - 2);
164
+
165
+ const inputMin = inputRange[i];
166
+ const inputMax = inputRange[i + 1];
167
+ const outputMin = outputRange[i];
168
+ const outputMax = outputRange[i + 1];
169
+
170
+ let ratio = (value - inputMin) / (inputMax - inputMin);
171
+
172
+ // Handle extrapolation
173
+ if (value < inputRange[0]) {
174
+ if (extrapolateLeft === 'clamp') {
175
+ return outputRange[0];
176
+ } else if (extrapolateLeft === 'identity') {
177
+ return value as T;
178
+ }
179
+ } else if (value > inputRange[inputRange.length - 1]) {
180
+ if (extrapolateRight === 'clamp') {
181
+ return outputRange[outputRange.length - 1];
182
+ } else if (extrapolateRight === 'identity') {
183
+ return value as T;
184
+ }
185
+ }
186
+
187
+ // Handle color interpolation
188
+ if (typeof outputMin === 'string' && typeof outputMax === 'string') {
189
+ // Simple hex color interpolation
190
+ if (outputMin.startsWith('#') && outputMax.startsWith('#')) {
191
+ const parseHex = (hex: string) => {
192
+ const h = hex.replace('#', '');
193
+ return {
194
+ r: parseInt(h.substring(0, 2), 16),
195
+ g: parseInt(h.substring(2, 4), 16),
196
+ b: parseInt(h.substring(4, 6), 16),
197
+ };
198
+ };
199
+ const min = parseHex(outputMin);
200
+ const max = parseHex(outputMax);
201
+ const r = Math.round(min.r + (max.r - min.r) * ratio);
202
+ const g = Math.round(min.g + (max.g - min.g) * ratio);
203
+ const b = Math.round(min.b + (max.b - min.b) * ratio);
204
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` as T;
205
+ }
206
+ // For other strings, just return based on threshold
207
+ return ratio < 0.5 ? outputMin : outputMax;
208
+ }
209
+
210
+ // Numeric interpolation
211
+ return ((outputMin as number) + ((outputMax as number) - (outputMin as number)) * ratio) as T;
212
+ },
213
+ [value]
214
+ );
215
+
216
+ return useMemo(
217
+ () => ({
218
+ get value() {
219
+ return currentValueRef.current;
220
+ },
221
+ set,
222
+ setImmediate,
223
+ interpolate,
224
+ }),
225
+ [set, setImmediate, interpolate]
226
+ );
227
+ }