react-native-ai-animations 0.0.0-stage → 0.8.1

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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +226 -2
  3. package/lib/module/brand/index.js +4 -0
  4. package/lib/module/brand/index.js.map +1 -0
  5. package/lib/module/brand/spinning-mark.js +96 -0
  6. package/lib/module/brand/spinning-mark.js.map +1 -0
  7. package/lib/module/chat/effort-bar.js +853 -0
  8. package/lib/module/chat/effort-bar.js.map +1 -0
  9. package/lib/module/chat/index.js +8 -0
  10. package/lib/module/chat/index.js.map +1 -0
  11. package/lib/module/chat/question-card.js +720 -0
  12. package/lib/module/chat/question-card.js.map +1 -0
  13. package/lib/module/chat/reasoning.js +38 -0
  14. package/lib/module/chat/reasoning.js.map +1 -0
  15. package/lib/module/chat/status-row.js +140 -0
  16. package/lib/module/chat/status-row.js.map +1 -0
  17. package/lib/module/chat/thinking-block.js +255 -0
  18. package/lib/module/chat/thinking-block.js.map +1 -0
  19. package/lib/module/index.js +5 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/internal/color.js +15 -0
  22. package/lib/module/internal/color.js.map +1 -0
  23. package/lib/module/internal/use-elapsed.js +20 -0
  24. package/lib/module/internal/use-elapsed.js.map +1 -0
  25. package/lib/module/package.json +1 -0
  26. package/lib/module/skia/dot-orb.js +256 -0
  27. package/lib/module/skia/dot-orb.js.map +1 -0
  28. package/lib/module/skia/index.js +8 -0
  29. package/lib/module/skia/index.js.map +1 -0
  30. package/lib/module/skia/liquid-orb.js +303 -0
  31. package/lib/module/skia/liquid-orb.js.map +1 -0
  32. package/lib/module/skia/matrix-orb.js +272 -0
  33. package/lib/module/skia/matrix-orb.js.map +1 -0
  34. package/lib/module/skia/media-generating.js +524 -0
  35. package/lib/module/skia/media-generating.js.map +1 -0
  36. package/lib/module/skia/voice-wave.js +278 -0
  37. package/lib/module/skia/voice-wave.js.map +1 -0
  38. package/lib/module/text/index.js +6 -0
  39. package/lib/module/text/index.js.map +1 -0
  40. package/lib/module/text/shimmer-text.js +122 -0
  41. package/lib/module/text/shimmer-text.js.map +1 -0
  42. package/lib/module/text/spoken-text.js +85 -0
  43. package/lib/module/text/spoken-text.js.map +1 -0
  44. package/lib/module/text/streaming-text.js +135 -0
  45. package/lib/module/text/streaming-text.js.map +1 -0
  46. package/lib/module/theme/index.js +5 -0
  47. package/lib/module/theme/index.js.map +1 -0
  48. package/lib/module/theme/palette.js +45 -0
  49. package/lib/module/theme/palette.js.map +1 -0
  50. package/lib/module/theme/provider.js +65 -0
  51. package/lib/module/theme/provider.js.map +1 -0
  52. package/lib/typescript/package.json +1 -0
  53. package/lib/typescript/src/brand/index.d.ts +2 -0
  54. package/lib/typescript/src/brand/index.d.ts.map +1 -0
  55. package/lib/typescript/src/brand/spinning-mark.d.ts +21 -0
  56. package/lib/typescript/src/brand/spinning-mark.d.ts.map +1 -0
  57. package/lib/typescript/src/chat/effort-bar.d.ts +42 -0
  58. package/lib/typescript/src/chat/effort-bar.d.ts.map +1 -0
  59. package/lib/typescript/src/chat/index.d.ts +6 -0
  60. package/lib/typescript/src/chat/index.d.ts.map +1 -0
  61. package/lib/typescript/src/chat/question-card.d.ts +73 -0
  62. package/lib/typescript/src/chat/question-card.d.ts.map +1 -0
  63. package/lib/typescript/src/chat/reasoning.d.ts +20 -0
  64. package/lib/typescript/src/chat/reasoning.d.ts.map +1 -0
  65. package/lib/typescript/src/chat/status-row.d.ts +20 -0
  66. package/lib/typescript/src/chat/status-row.d.ts.map +1 -0
  67. package/lib/typescript/src/chat/thinking-block.d.ts +35 -0
  68. package/lib/typescript/src/chat/thinking-block.d.ts.map +1 -0
  69. package/lib/typescript/src/index.d.ts +3 -0
  70. package/lib/typescript/src/index.d.ts.map +1 -0
  71. package/lib/typescript/src/internal/color.d.ts +2 -0
  72. package/lib/typescript/src/internal/color.d.ts.map +1 -0
  73. package/lib/typescript/src/internal/use-elapsed.d.ts +3 -0
  74. package/lib/typescript/src/internal/use-elapsed.d.ts.map +1 -0
  75. package/lib/typescript/src/skia/dot-orb.d.ts +16 -0
  76. package/lib/typescript/src/skia/dot-orb.d.ts.map +1 -0
  77. package/lib/typescript/src/skia/index.d.ts +6 -0
  78. package/lib/typescript/src/skia/index.d.ts.map +1 -0
  79. package/lib/typescript/src/skia/liquid-orb.d.ts +20 -0
  80. package/lib/typescript/src/skia/liquid-orb.d.ts.map +1 -0
  81. package/lib/typescript/src/skia/matrix-orb.d.ts +12 -0
  82. package/lib/typescript/src/skia/matrix-orb.d.ts.map +1 -0
  83. package/lib/typescript/src/skia/media-generating.d.ts +59 -0
  84. package/lib/typescript/src/skia/media-generating.d.ts.map +1 -0
  85. package/lib/typescript/src/skia/voice-wave.d.ts +17 -0
  86. package/lib/typescript/src/skia/voice-wave.d.ts.map +1 -0
  87. package/lib/typescript/src/text/index.d.ts +4 -0
  88. package/lib/typescript/src/text/index.d.ts.map +1 -0
  89. package/lib/typescript/src/text/shimmer-text.d.ts +13 -0
  90. package/lib/typescript/src/text/shimmer-text.d.ts.map +1 -0
  91. package/lib/typescript/src/text/spoken-text.d.ts +15 -0
  92. package/lib/typescript/src/text/spoken-text.d.ts.map +1 -0
  93. package/lib/typescript/src/text/streaming-text.d.ts +16 -0
  94. package/lib/typescript/src/text/streaming-text.d.ts.map +1 -0
  95. package/lib/typescript/src/theme/index.d.ts +3 -0
  96. package/lib/typescript/src/theme/index.d.ts.map +1 -0
  97. package/lib/typescript/src/theme/palette.d.ts +33 -0
  98. package/lib/typescript/src/theme/palette.d.ts.map +1 -0
  99. package/lib/typescript/src/theme/provider.d.ts +13 -0
  100. package/lib/typescript/src/theme/provider.d.ts.map +1 -0
  101. package/package.json +133 -4
  102. package/src/brand/index.ts +5 -0
  103. package/src/brand/spinning-mark.tsx +118 -0
  104. package/src/chat/effort-bar.tsx +748 -0
  105. package/src/chat/index.ts +45 -0
  106. package/src/chat/question-card.tsx +649 -0
  107. package/src/chat/reasoning.ts +30 -0
  108. package/src/chat/status-row.tsx +127 -0
  109. package/src/chat/thinking-block.tsx +250 -0
  110. package/src/index.ts +2 -0
  111. package/src/internal/color.ts +18 -0
  112. package/src/internal/use-elapsed.ts +21 -0
  113. package/src/skia/dot-orb.tsx +248 -0
  114. package/src/skia/index.ts +34 -0
  115. package/src/skia/liquid-orb.tsx +274 -0
  116. package/src/skia/matrix-orb.tsx +337 -0
  117. package/src/skia/media-generating.tsx +389 -0
  118. package/src/skia/voice-wave.tsx +215 -0
  119. package/src/text/index.ts +3 -0
  120. package/src/text/shimmer-text.tsx +140 -0
  121. package/src/text/spoken-text.tsx +82 -0
  122. package/src/text/streaming-text.tsx +144 -0
  123. package/src/theme/index.ts +11 -0
  124. package/src/theme/palette.ts +62 -0
  125. package/src/theme/provider.tsx +54 -0
@@ -0,0 +1,140 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { Text, View, type StyleProp, type TextStyle } from "react-native";
3
+ import Animated, {
4
+ Easing,
5
+ Extrapolation,
6
+ interpolate,
7
+ interpolateColor,
8
+ type SharedValue,
9
+ useAnimatedStyle,
10
+ useReducedMotion,
11
+ useSharedValue,
12
+ withDelay,
13
+ withRepeat,
14
+ withSequence,
15
+ withTiming,
16
+ } from "react-native-reanimated";
17
+
18
+ const DEFAULT_DURATION = 1400;
19
+ const DEFAULT_HIGHLIGHT_WIDTH = 0.22;
20
+ const GLYPH_BLEED: TextStyle = { paddingHorizontal: 2, marginHorizontal: -2 };
21
+
22
+ const DEFAULT_TEXT_STYLE: TextStyle = {
23
+ fontSize: 14,
24
+ fontWeight: "500",
25
+ lineHeight: 20,
26
+ };
27
+
28
+ export type ShimmerTextProps = {
29
+ text: string;
30
+ activeColor: string;
31
+ mutedColor: string;
32
+ textStyle?: StyleProp<TextStyle>;
33
+ duration?: number;
34
+ highlightWidth?: number;
35
+ pause?: number;
36
+ direction?: "ltr" | "rtl";
37
+ };
38
+
39
+ type Glyph = { char: string; index: number };
40
+
41
+ export function ShimmerText({
42
+ text,
43
+ activeColor,
44
+ mutedColor,
45
+ textStyle = DEFAULT_TEXT_STYLE,
46
+ duration = DEFAULT_DURATION,
47
+ highlightWidth = DEFAULT_HIGHLIGHT_WIDTH,
48
+ pause = 0,
49
+ direction = "ltr",
50
+ }: ShimmerTextProps) {
51
+ const reduceMotion = useReducedMotion();
52
+ const progress = useSharedValue(0);
53
+ const { words, total } = useMemo(() => {
54
+ const characters = Array.from(text);
55
+ const groups: Glyph[][] = [];
56
+ let current: Glyph[] = [];
57
+ characters.forEach((char, index) => {
58
+ current.push({ char, index });
59
+ if (char === " ") {
60
+ groups.push(current);
61
+ current = [];
62
+ }
63
+ });
64
+ if (current.length) groups.push(current);
65
+ return { words: groups, total: characters.length };
66
+ }, [text]);
67
+
68
+ useEffect(() => {
69
+ if (reduceMotion) return;
70
+ progress.value = 0;
71
+ const sweep = withTiming(1, { duration, easing: Easing.linear });
72
+ progress.value = withRepeat(
73
+ pause > 0 ? withSequence(sweep, withDelay(pause, withTiming(0, { duration: 0 }))) : sweep,
74
+ -1,
75
+ false,
76
+ );
77
+ }, [duration, pause, progress, reduceMotion]);
78
+
79
+ if (reduceMotion) {
80
+ return <Text style={[textStyle, { color: activeColor }]}>{text}</Text>;
81
+ }
82
+
83
+ return (
84
+ <View
85
+ accessible
86
+ accessibilityRole="text"
87
+ accessibilityLabel={text}
88
+ style={{ flexDirection: "row", flexWrap: "wrap" }}
89
+ >
90
+ {words.map((word, wordIndex) => (
91
+ <View key={wordIndex} style={{ flexDirection: "row" }}>
92
+ {word.map((glyph) => (
93
+ <ShimmerChar
94
+ key={glyph.index}
95
+ char={glyph.char}
96
+ index={direction === "rtl" ? total - 1 - glyph.index : glyph.index}
97
+ total={total}
98
+ progress={progress}
99
+ activeColor={activeColor}
100
+ mutedColor={mutedColor}
101
+ textStyle={textStyle}
102
+ highlightWidth={highlightWidth}
103
+ />
104
+ ))}
105
+ </View>
106
+ ))}
107
+ </View>
108
+ );
109
+ }
110
+
111
+ function ShimmerChar({
112
+ char,
113
+ index,
114
+ total,
115
+ progress,
116
+ activeColor,
117
+ mutedColor,
118
+ textStyle,
119
+ highlightWidth,
120
+ }: {
121
+ char: string;
122
+ index: number;
123
+ total: number;
124
+ progress: SharedValue<number>;
125
+ activeColor: string;
126
+ mutedColor: string;
127
+ textStyle: StyleProp<TextStyle>;
128
+ highlightWidth: number;
129
+ }) {
130
+ const style = useAnimatedStyle(() => {
131
+ const phase = total > 1 ? index / (total - 1) : 0;
132
+ const position = -highlightWidth + progress.value * (1 + highlightWidth * 2);
133
+ const distance = Math.abs(position - phase);
134
+ const linear = interpolate(distance, [0, highlightWidth], [0, 1], Extrapolation.CLAMP);
135
+ const t = linear * linear * (3 - 2 * linear);
136
+ return { color: interpolateColor(t, [0, 1], [activeColor, mutedColor]) };
137
+ });
138
+
139
+ return <Animated.Text style={[textStyle, GLYPH_BLEED, style]}>{char}</Animated.Text>;
140
+ }
@@ -0,0 +1,82 @@
1
+ import { memo, useEffect, useMemo } from "react";
2
+ import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
3
+ import Animated, { interpolateColor, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from "react-native-reanimated";
4
+ import { type MotionPalette } from "../theme/palette";
5
+ import { useMotionPalette } from "../theme/provider";
6
+
7
+ const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 22, lineHeight: 30, fontWeight: "600" };
8
+
9
+ export type SpokenTextProps = {
10
+ text: string;
11
+ spoken: number;
12
+ textStyle?: StyleProp<TextStyle>;
13
+ activeColor?: string;
14
+ spokenColor?: string;
15
+ upcomingColor?: string;
16
+ align?: "left" | "center";
17
+ colors?: Partial<MotionPalette>;
18
+ style?: StyleProp<ViewStyle>;
19
+ };
20
+
21
+ export function SpokenText({
22
+ text,
23
+ spoken,
24
+ textStyle = DEFAULT_TEXT_STYLE,
25
+ activeColor,
26
+ spokenColor,
27
+ upcomingColor,
28
+ align = "center",
29
+ colors,
30
+ style,
31
+ }: SpokenTextProps) {
32
+ const palette = useMotionPalette(colors);
33
+ const reduceMotion = useReducedMotion();
34
+ const words = useMemo(() => text.match(/\S+\s*/g) ?? [], [text]);
35
+ const active = activeColor ?? palette.text;
36
+ const done = spokenColor ?? palette.textSecondary;
37
+ const upcoming = upcomingColor ?? palette.textTertiary;
38
+ const current = Math.min(words.length, Math.max(0, Math.floor(spoken)));
39
+
40
+ if (reduceMotion) {
41
+ return (
42
+ <Text accessibilityLabel={text} style={[textStyle, { color: active, textAlign: align }, style as StyleProp<TextStyle>]}>
43
+ {text}
44
+ </Text>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <View
50
+ accessible
51
+ accessibilityRole="text"
52
+ accessibilityLabel={text}
53
+ style={[{ flexDirection: "row", flexWrap: "wrap", justifyContent: align === "center" ? "center" : "flex-start" }, style]}
54
+ >
55
+ {words.map((word, index) => (
56
+ <SpokenWord
57
+ key={index}
58
+ word={word}
59
+ phase={index < current ? 2 : index === current ? 1 : 0}
60
+ textStyle={textStyle}
61
+ active={active}
62
+ done={done}
63
+ upcoming={upcoming}
64
+ />
65
+ ))}
66
+ </View>
67
+ );
68
+ }
69
+
70
+ type SpokenWordProps = { word: string; phase: number; textStyle: StyleProp<TextStyle>; active: string; done: string; upcoming: string };
71
+
72
+ const SpokenWord = memo(function SpokenWord({ word, phase, textStyle, active, done, upcoming }: SpokenWordProps) {
73
+ const value = useSharedValue(phase);
74
+ useEffect(() => {
75
+ value.value = withTiming(phase, { duration: 180 });
76
+ }, [phase, value]);
77
+ const style = useAnimatedStyle(() => ({
78
+ color: interpolateColor(value.value, [0, 1, 2], [upcoming, active, done]),
79
+ transform: [{ translateY: -2 * Math.max(0, 1 - Math.abs(value.value - 1)) }],
80
+ }));
81
+ return <Animated.Text style={[textStyle, style]}>{word}</Animated.Text>;
82
+ });
@@ -0,0 +1,144 @@
1
+ import { isValidElement, memo, useEffect, useMemo, type ReactNode } from "react";
2
+ import { StyleSheet, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
3
+ import Animated, {
4
+ cancelAnimation,
5
+ Easing,
6
+ useAnimatedStyle,
7
+ useReducedMotion,
8
+ useSharedValue,
9
+ withRepeat,
10
+ withSequence,
11
+ withTiming,
12
+ } from "react-native-reanimated";
13
+ import { type MotionPalette } from "../theme/palette";
14
+ import { useMotionPalette } from "../theme/provider";
15
+
16
+ const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 16, lineHeight: 24 };
17
+ const DEFAULT_FADE = 360;
18
+ const DEFAULT_RISE = 6;
19
+ const CARET_BLINK = 530;
20
+
21
+ export type StreamingTextProps = {
22
+ text: string;
23
+ streaming: boolean;
24
+ textStyle?: StyleProp<TextStyle>;
25
+ paragraphGap?: number;
26
+ fadeDuration?: number;
27
+ rise?: number;
28
+ caret?: boolean | ReactNode;
29
+ colors?: Partial<MotionPalette>;
30
+ style?: StyleProp<ViewStyle>;
31
+ };
32
+
33
+ type Token = { value: string; key: number };
34
+
35
+ function tokenize(paragraph: string): Token[] {
36
+ const parts = paragraph.match(/\S+\s*/g) ?? [];
37
+ return parts.map((value, key) => ({ value, key }));
38
+ }
39
+
40
+ export function StreamingText({
41
+ text,
42
+ streaming,
43
+ textStyle = DEFAULT_TEXT_STYLE,
44
+ paragraphGap = 12,
45
+ fadeDuration = DEFAULT_FADE,
46
+ rise = DEFAULT_RISE,
47
+ caret = true,
48
+ colors,
49
+ style,
50
+ }: StreamingTextProps) {
51
+ const palette = useMotionPalette(colors);
52
+ const reduceMotion = useReducedMotion();
53
+ const paragraphs = useMemo(() => text.split(/\n{2,}/), [text]);
54
+ const animate = streaming && !reduceMotion;
55
+ const color = palette.text;
56
+ const caretNode = caret === false ? null : isValidElement(caret) ? caret : <Caret color={color} blink={!reduceMotion} textStyle={textStyle} />;
57
+
58
+ return (
59
+ <View style={[{ gap: paragraphGap }, style]}>
60
+ {paragraphs.map((paragraph, index) => {
61
+ const last = index === paragraphs.length - 1;
62
+ if (!animate || !last) {
63
+ return (
64
+ <Text key={index} style={[textStyle, { color }]}>
65
+ {paragraph}
66
+ {last && streaming && caretNode}
67
+ </Text>
68
+ );
69
+ }
70
+ return (
71
+ <View
72
+ key={index}
73
+ accessible
74
+ accessibilityRole="text"
75
+ accessibilityLabel={paragraph}
76
+ style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "flex-end" }}
77
+ >
78
+ {tokenize(paragraph).map((token) => (
79
+ <StreamingWord
80
+ key={token.key}
81
+ value={token.value}
82
+ textStyle={textStyle}
83
+ color={color}
84
+ fadeDuration={fadeDuration}
85
+ rise={rise}
86
+ />
87
+ ))}
88
+ {caretNode}
89
+ </View>
90
+ );
91
+ })}
92
+ </View>
93
+ );
94
+ }
95
+
96
+ type StreamingWordProps = {
97
+ value: string;
98
+ textStyle: StyleProp<TextStyle>;
99
+ color: string;
100
+ fadeDuration: number;
101
+ rise: number;
102
+ };
103
+
104
+ const StreamingWord = memo(function StreamingWord({ value, textStyle, color, fadeDuration, rise }: StreamingWordProps) {
105
+ const progress = useSharedValue(0);
106
+
107
+ useEffect(() => {
108
+ progress.value = withTiming(1, { duration: fadeDuration, easing: Easing.out(Easing.cubic) });
109
+ }, [fadeDuration, progress]);
110
+
111
+ const animatedStyle = useAnimatedStyle(() => ({
112
+ opacity: progress.value,
113
+ transform: [{ translateY: rise * (1 - progress.value) }],
114
+ }));
115
+
116
+ return <Animated.Text style={[textStyle, { color }, animatedStyle]}>{value}</Animated.Text>;
117
+ });
118
+
119
+ function Caret({ color, blink, textStyle }: { color: string; blink: boolean; textStyle: StyleProp<TextStyle> }) {
120
+ const opacity = useSharedValue(1);
121
+ const flat = StyleSheet.flatten(textStyle);
122
+ const height = Math.round((flat?.fontSize ?? 16) * 1.05);
123
+
124
+ useEffect(() => {
125
+ if (!blink) return;
126
+ opacity.value = withRepeat(
127
+ withSequence(withTiming(0, { duration: CARET_BLINK }), withTiming(1, { duration: CARET_BLINK })),
128
+ -1,
129
+ false,
130
+ );
131
+ return () => cancelAnimation(opacity);
132
+ }, [blink, opacity]);
133
+
134
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
135
+
136
+ return (
137
+ <Animated.View
138
+ style={[
139
+ { width: 2, height, borderRadius: 1, backgroundColor: color, marginLeft: 2, marginBottom: 3 },
140
+ animatedStyle,
141
+ ]}
142
+ />
143
+ );
144
+ }
@@ -0,0 +1,11 @@
1
+ export {
2
+ moodstone,
3
+ moodstoneSwatches,
4
+ type MotionPalette,
5
+ type MotionPaletteSet,
6
+ } from "./palette";
7
+ export {
8
+ MotionThemeProvider,
9
+ useMotionPalette,
10
+ type MotionThemeProviderProps,
11
+ } from "./provider";
@@ -0,0 +1,62 @@
1
+ export type MotionPalette = {
2
+ background: string;
3
+ card: string;
4
+ surface: string;
5
+ raised: string;
6
+ text: string;
7
+ textSecondary: string;
8
+ textTertiary: string;
9
+ accent: string;
10
+ accentContrast: string;
11
+ success: string;
12
+ danger: string;
13
+ };
14
+
15
+ export type MotionPaletteSet = {
16
+ light: MotionPalette;
17
+ dark: MotionPalette;
18
+ };
19
+
20
+ export const moodstone: MotionPaletteSet = {
21
+ light: {
22
+ background: "#F3F4F5",
23
+ card: "#FFFFFF",
24
+ surface: "#E6EAEC",
25
+ raised: "#D5DCDF",
26
+ text: "#1A2023",
27
+ textSecondary: "#6D7A7F",
28
+ textTertiary: "#A3AEB2",
29
+ accent: "#1F8A70",
30
+ accentContrast: "#FFFFFF",
31
+ success: "#1F8A70",
32
+ danger: "#D63A2F",
33
+ },
34
+ dark: {
35
+ background: "#0B0E10",
36
+ card: "#141A1D",
37
+ surface: "#1B2327",
38
+ raised: "#2A353A",
39
+ text: "#EEF2F3",
40
+ textSecondary: "#7D8A8F",
41
+ textTertiary: "#4F5B60",
42
+ accent: "#5FC7B0",
43
+ accentContrast: "#0B0E10",
44
+ success: "#5FC7B0",
45
+ danger: "#F2705B",
46
+ },
47
+ };
48
+
49
+ export const moodstoneSwatches = {
50
+ sand: "#F5D48A",
51
+ sun: "#FFD447",
52
+ tangerine: "#FF9F43",
53
+ blossom: "#F48FB1",
54
+ coral: "#F2705B",
55
+ ember: "#D63A2F",
56
+ lilac: "#C9B8F0",
57
+ sky: "#3FC1F5",
58
+ plum: "#9B59B6",
59
+ lime: "#C5D94A",
60
+ mint: "#5FC7B0",
61
+ pine: "#1F8A70",
62
+ } as const;
@@ -0,0 +1,54 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+ import { useColorScheme } from "react-native";
3
+ import { moodstone, type MotionPalette, type MotionPaletteSet } from "./palette";
4
+
5
+ type MotionThemeValue = {
6
+ palettes: MotionPaletteSet;
7
+ colorScheme?: "light" | "dark";
8
+ };
9
+
10
+ const MotionPaletteContext = createContext<MotionThemeValue | null>(null);
11
+
12
+ export type MotionThemeProviderProps = {
13
+ palette?: Partial<MotionPalette> | { light?: Partial<MotionPalette>; dark?: Partial<MotionPalette> };
14
+ colorScheme?: "light" | "dark";
15
+ children?: ReactNode;
16
+ };
17
+
18
+ function isPaletteSet(
19
+ palette: MotionThemeProviderProps["palette"],
20
+ ): palette is { light?: Partial<MotionPalette>; dark?: Partial<MotionPalette> } {
21
+ return !!palette && ("light" in palette || "dark" in palette);
22
+ }
23
+
24
+ export function MotionThemeProvider({ palette, colorScheme, children }: MotionThemeProviderProps) {
25
+ const value = useMemo<MotionThemeValue>(() => {
26
+ if (!palette) return { palettes: moodstone, colorScheme };
27
+ if (isPaletteSet(palette)) {
28
+ return {
29
+ palettes: {
30
+ light: { ...moodstone.light, ...palette.light },
31
+ dark: { ...moodstone.dark, ...palette.dark },
32
+ },
33
+ colorScheme,
34
+ };
35
+ }
36
+ return {
37
+ palettes: {
38
+ light: { ...moodstone.light, ...palette },
39
+ dark: { ...moodstone.dark, ...palette },
40
+ },
41
+ colorScheme,
42
+ };
43
+ }, [palette, colorScheme]);
44
+
45
+ return <MotionPaletteContext.Provider value={value}>{children}</MotionPaletteContext.Provider>;
46
+ }
47
+
48
+ export function useMotionPalette(override?: Partial<MotionPalette>): MotionPalette {
49
+ const value = useContext(MotionPaletteContext);
50
+ const systemScheme = useColorScheme();
51
+ const scheme = value?.colorScheme ?? (systemScheme === "dark" ? "dark" : "light");
52
+ const base = (value?.palettes ?? moodstone)[scheme];
53
+ return useMemo(() => (override ? { ...base, ...override } : base), [base, override]);
54
+ }