@expokit/cli 0.3.0 → 0.4.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 (188) hide show
  1. package/README.md +8 -4
  2. package/bin/expo-kit.js +137 -24
  3. package/bin/lib.mjs +110 -1
  4. package/bin/mcp.mjs +40 -18
  5. package/package.json +2 -2
  6. package/registry/_docs/accordion.json +1 -1
  7. package/registry/_docs/assistant-app.json +47 -0
  8. package/registry/_docs/avatar.json +6 -6
  9. package/registry/_docs/badge.json +4 -4
  10. package/registry/_docs/bottom-nav.json +6 -6
  11. package/registry/_docs/bottom-sheet.json +3 -3
  12. package/registry/_docs/bullet.json +6 -0
  13. package/registry/_docs/button-3d.json +3 -3
  14. package/registry/_docs/button.json +2 -2
  15. package/registry/_docs/calendar.json +5 -5
  16. package/registry/_docs/card.json +1 -1
  17. package/registry/_docs/checkout.json +7 -1
  18. package/registry/_docs/chip-group.json +1 -1
  19. package/registry/_docs/confetti.json +3 -3
  20. package/registry/_docs/date-picker.json +4 -4
  21. package/registry/_docs/donut-shop.json +88 -0
  22. package/registry/_docs/drawer.json +4 -4
  23. package/registry/_docs/fintech-app.json +49 -0
  24. package/registry/_docs/flip-card.json +4 -4
  25. package/registry/_docs/floating-input.json +5 -5
  26. package/registry/_docs/food-app.json +47 -0
  27. package/registry/_docs/glass.json +1 -1
  28. package/registry/_docs/grain.json +80 -0
  29. package/registry/_docs/isometric-bar.json +6 -0
  30. package/registry/_docs/liquidglass-tab.json +1 -1
  31. package/registry/_docs/morphing-button-group.json +96 -0
  32. package/registry/_docs/orb-reveal.json +132 -0
  33. package/registry/_docs/otp-input.json +4 -4
  34. package/registry/_docs/password-input.json +3 -3
  35. package/registry/_docs/payment-card.json +3 -3
  36. package/registry/_docs/pixel-bar.json +6 -0
  37. package/registry/_docs/polar-area.json +6 -0
  38. package/registry/_docs/progress-steps.json +4 -4
  39. package/registry/_docs/progress.json +2 -2
  40. package/registry/_docs/qr-code-button.json +122 -0
  41. package/registry/_docs/radio-group.json +1 -1
  42. package/registry/_docs/search-bar.json +4 -4
  43. package/registry/_docs/segmented-control.json +1 -1
  44. package/registry/_docs/select.json +7 -7
  45. package/registry/_docs/settings.json +6 -0
  46. package/registry/_docs/skeleton.json +3 -3
  47. package/registry/_docs/slider.json +6 -6
  48. package/registry/_docs/spinner.json +3 -3
  49. package/registry/_docs/stateful-button.json +1 -1
  50. package/registry/_docs/stepper.json +7 -7
  51. package/registry/_docs/tabs.json +2 -2
  52. package/registry/_docs/text-animation.json +4 -4
  53. package/registry/_docs/textarea.json +3 -3
  54. package/registry/_docs/ticket-shape.json +1 -1
  55. package/registry/_docs/tilt-card.json +4 -4
  56. package/registry/_docs/time-picker.json +6 -6
  57. package/registry/_docs/timeline-chart.json +6 -0
  58. package/registry/_docs/timeline.json +6 -6
  59. package/registry/_docs/toast.json +8 -8
  60. package/registry/_docs/treemap.json +6 -0
  61. package/registry/_docs/waterfall.json +6 -0
  62. package/registry/accordion/files/src/components/ui/accordion.tsx +36 -7
  63. package/registry/accordion/files/src/lib/kit.tsx +8 -5
  64. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +12 -8
  65. package/registry/action-sheet/files/src/lib/kit.tsx +8 -5
  66. package/registry/alert/files/src/components/ui/alert.tsx +11 -5
  67. package/registry/alert/files/src/components/ui/button.tsx +35 -6
  68. package/registry/alert/files/src/lib/kit.tsx +8 -5
  69. package/registry/assistant-app.json +49 -0
  70. package/registry/avatar/files/src/components/ui/avatar.tsx +7 -4
  71. package/registry/avatar/files/src/lib/kit.tsx +8 -5
  72. package/registry/badge/files/src/components/ui/badge.tsx +23 -9
  73. package/registry/badge/files/src/lib/kit.tsx +8 -5
  74. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +21 -8
  75. package/registry/bottom-nav/files/src/lib/kit.tsx +8 -5
  76. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -6
  77. package/registry/bottom-sheet/files/src/lib/kit.tsx +8 -5
  78. package/registry/button/files/src/components/ui/button.tsx +35 -6
  79. package/registry/button/files/src/lib/kit.tsx +8 -5
  80. package/registry/button-3d/files/src/components/ui/button-3d.tsx +35 -7
  81. package/registry/button-3d/files/src/lib/kit.tsx +8 -5
  82. package/registry/calendar/files/src/components/ui/calendar.tsx +68 -19
  83. package/registry/calendar/files/src/lib/kit.tsx +8 -5
  84. package/registry/card/files/src/components/ui/card.tsx +32 -10
  85. package/registry/card/files/src/lib/kit.tsx +8 -5
  86. package/registry/checkbox/files/src/components/ui/checkbox.tsx +31 -8
  87. package/registry/checkbox/files/src/lib/kit.tsx +8 -5
  88. package/registry/chip-group/files/src/components/ui/chip-group.tsx +39 -7
  89. package/registry/chip-group/files/src/lib/kit.tsx +8 -5
  90. package/registry/confetti/files/src/lib/kit.tsx +8 -5
  91. package/registry/coupon/files/src/components/ui/coupon.tsx +55 -28
  92. package/registry/coupon/files/src/components/ui/flip-card.tsx +23 -4
  93. package/registry/coupon/files/src/components/ui/tilt-card.tsx +59 -18
  94. package/registry/coupon/files/src/lib/kit.tsx +8 -5
  95. package/registry/date-picker/files/src/components/ui/calendar.tsx +68 -19
  96. package/registry/date-picker/files/src/components/ui/date-picker.tsx +98 -44
  97. package/registry/date-picker/files/src/lib/kit.tsx +8 -5
  98. package/registry/dialog/files/src/components/ui/dialog.tsx +19 -9
  99. package/registry/dialog/files/src/lib/kit.tsx +8 -5
  100. package/registry/donut-shop.json +18 -0
  101. package/registry/drawer/files/src/components/ui/drawer.tsx +31 -21
  102. package/registry/drawer/files/src/lib/kit.tsx +8 -5
  103. package/registry/fintech-app.json +57 -0
  104. package/registry/flip-card/files/src/components/ui/flip-card.tsx +23 -4
  105. package/registry/flip-card/files/src/lib/kit.tsx +8 -5
  106. package/registry/floating-input/files/src/components/ui/floating-input.tsx +91 -32
  107. package/registry/floating-input/files/src/lib/kit.tsx +8 -5
  108. package/registry/food-app.json +55 -0
  109. package/registry/grain/files/src/components/ui/grain.tsx +113 -0
  110. package/registry/{tag-input → grain}/files/src/lib/kit.tsx +8 -5
  111. package/registry/grain.json +17 -0
  112. package/registry/kit/files/src/lib/kit.tsx +8 -5
  113. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +2 -2
  114. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +1 -1
  115. package/registry/list-item/files/src/components/ui/list-item.tsx +28 -17
  116. package/registry/list-item/files/src/lib/kit.tsx +8 -5
  117. package/registry/morphing-button-group.json +18 -0
  118. package/registry/orb-reveal.json +20 -0
  119. package/registry/otp-input/files/src/components/ui/otp-input.tsx +57 -24
  120. package/registry/otp-input/files/src/lib/kit.tsx +8 -5
  121. package/registry/password-input/files/src/components/ui/floating-input.tsx +91 -32
  122. package/registry/password-input/files/src/components/ui/password-input.tsx +55 -21
  123. package/registry/password-input/files/src/lib/kit.tsx +8 -5
  124. package/registry/payment-card/files/src/components/ui/flip-card.tsx +23 -4
  125. package/registry/payment-card/files/src/components/ui/payment-card.tsx +35 -18
  126. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +59 -18
  127. package/registry/payment-card/files/src/lib/kit.tsx +8 -5
  128. package/registry/progress/files/src/components/ui/progress.tsx +16 -6
  129. package/registry/progress/files/src/lib/kit.tsx +8 -5
  130. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +22 -11
  131. package/registry/progress-steps/files/src/lib/kit.tsx +8 -5
  132. package/registry/qr-code-button.json +18 -0
  133. package/registry/radio-group/files/src/components/ui/radio-group.tsx +48 -17
  134. package/registry/radio-group/files/src/lib/kit.tsx +8 -5
  135. package/registry/search-bar/files/src/components/ui/search-bar.tsx +81 -26
  136. package/registry/search-bar/files/src/lib/kit.tsx +8 -5
  137. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +40 -10
  138. package/registry/segmented-control/files/src/lib/kit.tsx +8 -5
  139. package/registry/select/files/src/components/ui/select.tsx +161 -86
  140. package/registry/select/files/src/lib/kit.tsx +8 -5
  141. package/registry/skeleton/files/src/components/ui/skeleton.tsx +4 -4
  142. package/registry/skeleton/files/src/lib/kit.tsx +8 -5
  143. package/registry/slider/files/src/components/ui/slider.tsx +56 -27
  144. package/registry/slider/files/src/lib/kit.tsx +8 -5
  145. package/registry/spinner/files/src/components/ui/spinner.tsx +17 -12
  146. package/registry/spinner/files/src/lib/kit.tsx +8 -5
  147. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
  148. package/registry/stateful-button/files/src/lib/kit.tsx +8 -5
  149. package/registry/stepper/files/src/components/ui/stepper.tsx +83 -29
  150. package/registry/stepper/files/src/lib/kit.tsx +8 -5
  151. package/registry/switch/files/src/components/ui/switch.tsx +60 -22
  152. package/registry/switch/files/src/lib/kit.tsx +8 -5
  153. package/registry/tabs/files/src/components/ui/tabs.tsx +15 -10
  154. package/registry/tabs/files/src/lib/kit.tsx +8 -5
  155. package/registry/text-animation/files/src/components/ui/text-animation.tsx +11 -6
  156. package/registry/text-animation/files/src/lib/kit.tsx +8 -5
  157. package/registry/textarea/files/src/components/ui/textarea.tsx +115 -45
  158. package/registry/textarea/files/src/lib/kit.tsx +8 -5
  159. package/registry/ticket-shape/files/src/lib/kit.tsx +8 -5
  160. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +59 -18
  161. package/registry/tilt-card/files/src/lib/kit.tsx +8 -5
  162. package/registry/time-picker/files/src/components/ui/time-picker.tsx +156 -75
  163. package/registry/time-picker/files/src/lib/kit.tsx +8 -5
  164. package/registry/timeline/files/src/components/ui/avatar.tsx +7 -4
  165. package/registry/timeline/files/src/components/ui/timeline.tsx +19 -12
  166. package/registry/timeline/files/src/lib/kit.tsx +8 -5
  167. package/registry/toast/files/src/components/ui/button.tsx +35 -6
  168. package/registry/toast/files/src/components/ui/toast.tsx +25 -14
  169. package/registry/toast/files/src/lib/kit.tsx +8 -5
  170. package/schema/components.schema.json +1 -1
  171. package/registry/_docs/color-swatch.json +0 -82
  172. package/registry/_docs/kpi-card.json +0 -14
  173. package/registry/_docs/like-button.json +0 -92
  174. package/registry/_docs/morph-button.json +0 -128
  175. package/registry/_docs/pagination-dots.json +0 -108
  176. package/registry/_docs/shimmer-button.json +0 -98
  177. package/registry/_docs/tag-input.json +0 -148
  178. package/registry/color-swatch.json +0 -17
  179. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +0 -45
  180. package/registry/kpi-card.json +0 -14
  181. package/registry/like-button.json +0 -18
  182. package/registry/morph-button.json +0 -18
  183. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +0 -262
  184. package/registry/pagination-dots/files/src/lib/kit.tsx +0 -219
  185. package/registry/pagination-dots.json +0 -15
  186. package/registry/shimmer-button.json +0 -17
  187. package/registry/tag-input/files/src/components/ui/tag-input.tsx +0 -720
  188. package/registry/tag-input.json +0 -17
@@ -6,6 +6,7 @@ import {
6
6
  StyleSheet,
7
7
  Text,
8
8
  View,
9
+ useWindowDimensions,
9
10
  type LayoutChangeEvent,
10
11
  type StyleProp,
11
12
  type TextStyle,
@@ -114,10 +115,10 @@ function nextStop(text: string, from: number) {
114
115
 
115
116
  const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
116
117
  const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
117
- /** A soft spring (~8% overshoot) sampled as a curve, so it can run on the native driver. */
118
- const soft = (t: number) => 1 - Math.exp(-6.5 * t) * Math.cos(8 * t);
119
- /** A firmer settle (~4% overshoot). */
120
- const settle = (t: number) => 1 - Math.exp(-7 * t) * Math.cos(7 * t);
118
+ /** A soft, critically damped settle (no overshoot), sampled as a curve so it runs on the native driver. */
119
+ const soft = (t: number) => 1 - Math.exp(-7 * t) * (1 + 7 * t);
120
+ /** A firmer settle, also without overshoot. */
121
+ const settle = (t: number) => 1 - Math.exp(-8 * t) * (1 + 8 * t);
121
122
 
122
123
  /**
123
124
  * Maps a millisecond clock onto `from → to` between `start` and `start + dur` through `ease`.
@@ -152,6 +153,7 @@ function useReduceMotion() {
152
153
 
153
154
  interface Ctx {
154
155
  glyph: StyleProp<TextStyle>;
156
+ /** Line height as rendered (after the user's text size). */
155
157
  lh: number;
156
158
  space: number;
157
159
  justify: ViewStyle['justifyContent'];
@@ -584,6 +586,9 @@ export function AnimatedText({
584
586
  }: AnimatedTextProps) {
585
587
  const { colors, font, text: type } = useKit();
586
588
  const reduce = useReduceMotion();
589
+ // Content text: it scales all the way with the user's text size, so the one-line clip windows,
590
+ // roll distances and word gaps (all derived from the line height) scale with it.
591
+ const { fontScale } = useWindowDimensions();
587
592
  const text = String(children ?? '');
588
593
  const mode = trigger ?? (variant === 'cascade' || variant === 'typewriter' ? 'change' : 'mount');
589
594
 
@@ -603,8 +608,8 @@ export function AnimatedText({
603
608
  const layout = useMemo(() => tokenize(text), [text]);
604
609
  const ctx: Ctx = {
605
610
  glyph: [styles.glyph, { fontSize, fontFamily: font.family }, textStyle, { lineHeight: lh, color: tint }],
606
- lh,
607
- space: fontSize * 0.28,
611
+ lh: lh * fontScale,
612
+ space: fontSize * 0.28 * fontScale,
608
613
  justify,
609
614
  reduce,
610
615
  live,
@@ -181,14 +181,17 @@ export function useKit(): Theme {
181
181
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
182
  }
183
183
 
184
- /** Shared motion presets so the whole kit moves the same way. */
184
+ /**
185
+ * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
+ * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
187
+ */
185
188
  export const motion = {
186
189
  /** Toggles, selection indicators. */
187
- snappy: { damping: 18, stiffness: 260, mass: 0.8 },
188
- /** Press feedback, pops. */
189
- bouncy: { damping: 11, stiffness: 220, mass: 0.7 },
190
+ snappy: { damping: 30, stiffness: 260, mass: 0.8 },
191
+ /** Press feedback, pops. (Kept under its old name; it no longer bounces.) */
192
+ bouncy: { damping: 26, stiffness: 220, mass: 0.7 },
190
193
  /** Sheets, layout changes. */
191
- gentle: { damping: 22, stiffness: 160, mass: 1 },
194
+ gentle: { damping: 27, stiffness: 160, mass: 1 },
192
195
  fast: 160,
193
196
  normal: 240,
194
197
  };
@@ -1,5 +1,6 @@
1
- import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
1
+ import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Easing,
5
6
  Platform,
@@ -8,6 +9,7 @@ import {
8
9
  Text,
9
10
  TextInput,
10
11
  View,
12
+ useWindowDimensions,
11
13
  type StyleProp,
12
14
  type TextInputProps,
13
15
  type TextStyle,
@@ -79,6 +81,9 @@ export const TEXTAREA_SIZES = {
79
81
  lg: { font: 18, line: 26, floated: 13.5, padX: 18, padY: 16 },
80
82
  };
81
83
 
84
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
85
+ // content text scales all the way.
86
+ const FONT_CAP = 1.35;
82
87
  const BORDER = 1.5;
83
88
  const HALO = 4;
84
89
  /** Font size of the static label used when `floatingLabel` is false. */
@@ -97,7 +102,7 @@ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
97
102
  * optional floating label, a focus halo, a counter that morphs into a progress ring near
98
103
  * `maxLength`, and a `toolbar` slot for chat composers.
99
104
  */
100
- export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
105
+ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps>(function Textarea(
101
106
  {
102
107
  label,
103
108
  floatingLabel = true,
@@ -133,6 +138,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
133
138
  ref
134
139
  ) {
135
140
  const { colors, radius, font } = useKit();
141
+ const reduce = useReduceMotion();
136
142
  const ff = font.family ? { fontFamily: font.family } : null;
137
143
  const S = TEXTAREA_SIZES[size];
138
144
  // Height is driven by rows, so the user's `height` is dropped; their border colour replaces the resting one.
@@ -142,17 +148,25 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
142
148
  const FONT = typeof userInput.fontSize === 'number' ? userInput.fontSize : S.font;
143
149
  const LINE = typeof userInput.lineHeight === 'number' ? userInput.lineHeight : Math.round((FONT * S.line) / S.font);
144
150
  const corner = typeof userField.borderRadius === 'number' ? userField.borderRadius : radius.md;
151
+ // The typed text is content, so it scales with the system text size (lineHeight too). Row maths use
152
+ // the rendered line height, so rows, the resting label and the check stay on the lines.
153
+ const { fontScale } = useWindowDimensions();
154
+ const userCap = props.maxFontSizeMultiplier;
155
+ const textScale = props.allowFontScaling === false ? 1 : userCap && userCap >= 1 ? Math.min(fontScale, userCap) : fontScale;
156
+ const ROW = LINE * textScale;
145
157
  // The hidden mirror must measure text exactly like the input does.
146
- const metrics: TextStyle = { ...ff };
147
- if (userInput.fontFamily !== undefined) metrics.fontFamily = userInput.fontFamily;
148
- if (userInput.fontWeight !== undefined) metrics.fontWeight = userInput.fontWeight;
149
- if (userInput.fontStyle !== undefined) metrics.fontStyle = userInput.fontStyle;
150
- if (userInput.letterSpacing !== undefined) metrics.letterSpacing = userInput.letterSpacing;
158
+ const metrics: TextStyle = {
159
+ ...ff,
160
+ ...(userInput.fontFamily !== undefined ? { fontFamily: userInput.fontFamily } : null),
161
+ ...(userInput.fontWeight !== undefined ? { fontWeight: userInput.fontWeight } : null),
162
+ ...(userInput.fontStyle !== undefined ? { fontStyle: userInput.fontStyle } : null),
163
+ ...(userInput.letterSpacing !== undefined ? { letterSpacing: userInput.letterSpacing } : null),
164
+ };
151
165
  const tint = accent ?? colors.accent;
152
166
  const bg = surface ?? colors.background;
153
167
  const filled = variant === 'filled';
154
- const inputRef = useRef<TextInput>(null);
155
- useImperativeHandle(ref, () => inputRef.current as TextInput);
168
+ const inputRef = useRef<ComponentRef<typeof TextInput>>(null);
169
+ useImperativeHandle(ref, () => inputRef.current as ComponentRef<typeof TextInput>);
156
170
 
157
171
  const [focused, setFocused] = useState(false);
158
172
  const [inner, setInner] = useState(defaultValue ?? '');
@@ -171,11 +185,11 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
171
185
 
172
186
  const lo = Math.max(1, minRows);
173
187
  const hi = Math.max(lo, maxRows);
174
- const lines = Math.round((contentH ?? LINE) / LINE);
188
+ const lines = Math.round((contentH ?? ROW) / ROW);
175
189
  const rows = Math.min(hi, Math.max(lo, lines));
176
190
  const padTop = filled && inLabel ? S.padY + 14 : S.padY;
177
191
  const padBottom = toolbar ? 4 : filled ? S.padY - 2 : S.padY;
178
- const height = padTop + rows * LINE + padBottom + (toolbar ? toolbarH ?? 0 : 0) + BORDER * 2;
192
+ const height = padTop + rows * ROW + padBottom + (toolbar ? toolbarH ?? 0 : 0) + BORDER * 2;
179
193
 
180
194
  const grow = useRef(new Animated.Value(height)).current; // JS: height
181
195
  const focus = useRef(new Animated.Value(0)).current; // JS: colours
@@ -186,35 +200,40 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
186
200
  const settled = useRef(false);
187
201
 
188
202
  useEffect(() => {
189
- // Snap to the first measured size; glide on every change after that.
190
- if (!settled.current) {
203
+ // Snap to the first measured size (and always with Reduce Motion); glide on every change after that.
204
+ if (!settled.current || reduce) {
191
205
  grow.setValue(height);
192
206
  if (contentH !== null && toolbarH !== null) settled.current = true;
193
207
  return;
194
208
  }
195
209
  Animated.timing(grow, { toValue: height, duration: 200, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
196
- }, [height, grow, contentH, toolbarH]);
210
+ }, [height, grow, contentH, toolbarH, reduce]);
197
211
 
198
212
  useEffect(() => {
199
- Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
200
- }, [floating, float]);
213
+ if (reduce) float.setValue(floating ? 1 : 0);
214
+ else Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
215
+ }, [floating, float, reduce]);
201
216
 
202
217
  useEffect(() => {
203
218
  Animated.timing(focus, { toValue: isFocused ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
204
- Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
205
- }, [isFocused, focus, glow]);
219
+ // Reduce Motion: the halo only fades (its scale is pinned below).
220
+ if (reduce) Animated.timing(glow, { toValue: isFocused ? 1 : 0, duration: 150, useNativeDriver: true }).start();
221
+ else Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
222
+ }, [isFocused, focus, glow, reduce]);
206
223
 
207
224
  useEffect(() => {
208
- Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
209
- }, [ok, check]);
225
+ if (reduce) Animated.timing(check, { toValue: ok ? 1 : 0, duration: 150, useNativeDriver: true }).start();
226
+ else Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
227
+ }, [ok, check, reduce]);
210
228
 
211
229
  useEffect(() => {
212
- if (!error) return;
230
+ // Reduce Motion: no shake; the red border and message still flag the error.
231
+ if (!error || reduce) return;
213
232
  shake.setValue(0);
214
233
  Animated.sequence(
215
234
  [10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))
216
235
  ).start();
217
- }, [error, shake]);
236
+ }, [error, shake, reduce]);
218
237
 
219
238
  const setText = (next: string) => {
220
239
  if (value === undefined) setInner(next);
@@ -235,7 +254,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
235
254
  });
236
255
 
237
256
  // Label rests on the first line; floats onto the border (outlined) or into the top padding (filled).
238
- const restCenter = padTop + LINE / 2;
257
+ const restCenter = padTop + ROW / 2;
239
258
  const floatY = filled ? S.padY + 1 - restCenter : -restCenter - BORDER / 2;
240
259
  const floatX = filled ? 0 : 12 - S.padX;
241
260
  const floatScale = S.floated / S.font;
@@ -270,7 +289,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
270
289
  borderRadius: corner + HALO,
271
290
  backgroundColor: active,
272
291
  opacity: glow.interpolate({ inputRange: [0, 1], outputRange: [0, 0.18], extrapolate: 'clamp' }),
273
- transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [0.98, 1] }) }],
292
+ transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.98, 1] }) }],
274
293
  },
275
294
  ]}
276
295
  />
@@ -290,6 +309,8 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
290
309
  {/* Invisible mirror of the text: its height tells us how many lines to show, on every platform. */}
291
310
  <View pointerEvents="none" style={[styles.mirrorBox, { left: S.padX - BORDER, right: S.padX - BORDER + (ok ? 28 : 0) }]}>
292
311
  <Text
312
+ allowFontScaling={props.allowFontScaling}
313
+ maxFontSizeMultiplier={props.maxFontSizeMultiplier}
293
314
  onLayout={(e) => setContentH(e.nativeEvent.layout.height)}
294
315
  style={[styles.mirror, metrics, { fontSize: FONT, lineHeight: LINE }]}
295
316
  >
@@ -348,7 +369,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
348
369
  top: padTop,
349
370
  left: S.padX - BORDER,
350
371
  right: S.padX,
351
- height: LINE,
372
+ height: ROW,
352
373
  transform: [
353
374
  { translateY: float.interpolate({ inputRange: [0, 1], outputRange: [0, floatY] }) },
354
375
  { translateX: float.interpolate({ inputRange: [0, 1], outputRange: [0, floatX] }) },
@@ -358,6 +379,7 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
358
379
  ]}
359
380
  >
360
381
  <Animated.Text
382
+ maxFontSizeMultiplier={FONT_CAP}
361
383
  numberOfLines={1}
362
384
  style={[
363
385
  styles.label,
@@ -382,12 +404,12 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
382
404
  style={[
383
405
  styles.check,
384
406
  {
385
- top: padTop - BORDER + (LINE - checkSize) / 2,
407
+ top: padTop - BORDER + (ROW - checkSize) / 2,
386
408
  right: S.padX - BORDER - 4,
387
409
  opacity: check.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
388
410
  transform: [
389
- { scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
390
- { rotate: check.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'] }) },
411
+ { scale: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.3, 1] }) },
412
+ { rotate: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? '0deg' : '-30deg', '0deg'] }) },
391
413
  ],
392
414
  },
393
415
  ]}
@@ -403,9 +425,9 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
403
425
  {message || counting ? (
404
426
  <View style={styles.footer}>
405
427
  <View style={styles.messageSlot}>
406
- {message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} /> : null}
428
+ {message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} reduce={reduce} /> : null}
407
429
  </View>
408
- {counting ? <Counter length={text.length} max={maxLength} tint={tint} style={counterStyle} /> : null}
430
+ {counting ? <Counter length={text.length} max={maxLength} tint={tint} style={counterStyle} reduce={reduce} /> : null}
409
431
  </View>
410
432
  ) : null}
411
433
  </Animated.View>
@@ -414,13 +436,27 @@ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea(
414
436
 
415
437
  type MessageTone = 'error' | 'success' | 'helper';
416
438
 
417
- /** Helper / error / success line. Error and success slide up and fade in. */
418
- function FieldMessage({ text, tone, color, style }: { text: string; tone: MessageTone; color: string; style?: StyleProp<TextStyle> }) {
439
+ /** Helper / error / success line. Error and success slide up and fade in (only fade with Reduce Motion). */
440
+ function FieldMessage({
441
+ text,
442
+ tone,
443
+ color,
444
+ style,
445
+ reduce,
446
+ }: {
447
+ text: string;
448
+ tone: MessageTone;
449
+ color: string;
450
+ style?: StyleProp<TextStyle>;
451
+ reduce: boolean;
452
+ }) {
419
453
  const { font } = useKit();
420
454
  const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
421
455
  useEffect(() => {
422
- if (tone !== 'helper') Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
423
- }, [tone, enter]);
456
+ if (tone === 'helper') return;
457
+ if (reduce) Animated.timing(enter, { toValue: 1, duration: 150, useNativeDriver: true }).start();
458
+ else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
459
+ }, [tone, enter, reduce]);
424
460
 
425
461
  return (
426
462
  <Animated.View
@@ -428,7 +464,7 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
428
464
  styles.message,
429
465
  {
430
466
  opacity: enter.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
431
- transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }],
467
+ transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }],
432
468
  },
433
469
  ]}
434
470
  >
@@ -456,7 +492,19 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
456
492
  }
457
493
 
458
494
  /** "42/280", turning into a filling ring with the characters left once you get close. */
459
- function Counter({ length, max, tint, style }: { length: number; max: number; tint: string; style?: StyleProp<TextStyle> }) {
495
+ function Counter({
496
+ length,
497
+ max,
498
+ tint,
499
+ style,
500
+ reduce,
501
+ }: {
502
+ length: number;
503
+ max: number;
504
+ tint: string;
505
+ style?: StyleProp<TextStyle>;
506
+ reduce: boolean;
507
+ }) {
460
508
  const { colors, font } = useKit();
461
509
  const ff = font.family ? { fontFamily: font.family } : null;
462
510
  const remaining = max - length;
@@ -469,19 +517,22 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
469
517
  const progress = useRef(new Animated.Value(Math.min(1, ratio))).current; // JS (SVG prop)
470
518
 
471
519
  useEffect(() => {
472
- Animated.spring(mode, { toValue: near ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
473
- }, [near, mode]);
520
+ // Reduce Motion: cross-fade only (the ring's scale is pinned below).
521
+ if (reduce) Animated.timing(mode, { toValue: near ? 1 : 0, duration: 150, useNativeDriver: true }).start();
522
+ else Animated.spring(mode, { toValue: near ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
523
+ }, [near, mode, reduce]);
474
524
 
475
525
  useEffect(() => {
476
- Animated.spring(progress, { toValue: Math.min(1, ratio), useNativeDriver: false, ...motion.snappy }).start();
477
- }, [ratio, progress]);
526
+ if (reduce) progress.setValue(Math.min(1, ratio));
527
+ else Animated.spring(progress, { toValue: Math.min(1, ratio), useNativeDriver: false, ...motion.snappy }).start();
528
+ }, [ratio, progress, reduce]);
478
529
 
479
530
  const limitHit = remaining <= 0;
480
531
  useEffect(() => {
481
- if (!limitHit) return;
532
+ if (!limitHit || reduce) return;
482
533
  pulse.setValue(1.35);
483
534
  Animated.spring(pulse, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
484
- }, [limitHit, pulse]);
535
+ }, [limitHit, pulse, reduce]);
485
536
 
486
537
  return (
487
538
  <View
@@ -491,6 +542,7 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
491
542
  accessibilityLabel={remaining >= 0 ? `${remaining} of ${max} characters left` : `${-remaining} characters over the ${max} limit`}
492
543
  >
493
544
  <Animated.Text
545
+ maxFontSizeMultiplier={FONT_CAP}
494
546
  style={[
495
547
  styles.count,
496
548
  ff,
@@ -504,10 +556,12 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
504
556
  <Animated.View
505
557
  style={[
506
558
  styles.ringRow,
507
- { opacity: mode, transform: [{ scale: mode.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }) }] },
559
+ { opacity: mode, transform: [{ scale: mode.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.6, 1] }) }] },
508
560
  ]}
509
561
  >
510
- <Text style={[styles.count, ff, { color: colors.mutedForeground }, style, ratio >= 0.9 && { color }]}>{remaining}</Text>
562
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.count, ff, { color: colors.mutedForeground }, style, ratio >= 0.9 && { color }]}>
563
+ {remaining}
564
+ </Text>
511
565
  <Animated.View style={{ transform: [{ scale: pulse }] }}>
512
566
  <Svg width={RING} height={RING}>
513
567
  <Circle cx={RING / 2} cy={RING / 2} r={RING_R} stroke={colors.border} strokeWidth={RING_STROKE} fill="none" />
@@ -530,6 +584,22 @@ function Counter({ length, max, tint, style }: { length: number; max: number; ti
530
584
  );
531
585
  }
532
586
 
587
+ function useReduceMotion() {
588
+ const [reduce, setReduce] = useState(false);
589
+ useEffect(() => {
590
+ let alive = true;
591
+ AccessibilityInfo.isReduceMotionEnabled()
592
+ .then((r) => alive && setReduce(r))
593
+ .catch(() => {});
594
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
595
+ return () => {
596
+ alive = false;
597
+ sub.remove();
598
+ };
599
+ }, []);
600
+ return reduce;
601
+ }
602
+
533
603
  const styles = StyleSheet.create({
534
604
  disabled: { opacity: 0.5 },
535
605
  halo: { position: 'absolute', top: -HALO, left: -HALO, right: -HALO, bottom: -HALO },
@@ -547,7 +617,7 @@ const styles = StyleSheet.create({
547
617
  message: { flexDirection: 'row', alignItems: 'flex-start', gap: 5 },
548
618
  messageIcon: { marginTop: 1.5 },
549
619
  helper: { flexShrink: 1, fontSize: 12.5, lineHeight: 17 },
550
- counter: { minWidth: 56, height: RING, alignItems: 'flex-end', justifyContent: 'center' },
620
+ counter: { minWidth: 56, minHeight: RING, alignItems: 'flex-end', justifyContent: 'center' },
551
621
  count: { fontSize: 12.5, fontVariant: ['tabular-nums'], fontWeight: '400' },
552
622
  ringRow: { position: 'absolute', right: 0, flexDirection: 'row', alignItems: 'center', gap: 6 },
553
623
  });
@@ -181,14 +181,17 @@ export function useKit(): Theme {
181
181
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
182
  }
183
183
 
184
- /** Shared motion presets so the whole kit moves the same way. */
184
+ /**
185
+ * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
+ * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
187
+ */
185
188
  export const motion = {
186
189
  /** Toggles, selection indicators. */
187
- snappy: { damping: 18, stiffness: 260, mass: 0.8 },
188
- /** Press feedback, pops. */
189
- bouncy: { damping: 11, stiffness: 220, mass: 0.7 },
190
+ snappy: { damping: 30, stiffness: 260, mass: 0.8 },
191
+ /** Press feedback, pops. (Kept under its old name; it no longer bounces.) */
192
+ bouncy: { damping: 26, stiffness: 220, mass: 0.7 },
190
193
  /** Sheets, layout changes. */
191
- gentle: { damping: 22, stiffness: 160, mass: 1 },
194
+ gentle: { damping: 27, stiffness: 160, mass: 1 },
192
195
  fast: 160,
193
196
  normal: 240,
194
197
  };
@@ -181,14 +181,17 @@ export function useKit(): Theme {
181
181
  return useMemo(() => build(scheme, ctx?.overrides), [scheme, ctx?.overrides]);
182
182
  }
183
183
 
184
- /** Shared motion presets so the whole kit moves the same way. */
184
+ /**
185
+ * Shared motion presets so the whole kit moves the same way. Every spring is critically damped
186
+ * (damping ≥ 2·√(stiffness·mass)): it settles smoothly with no overshoot or wobble.
187
+ */
185
188
  export const motion = {
186
189
  /** Toggles, selection indicators. */
187
- snappy: { damping: 18, stiffness: 260, mass: 0.8 },
188
- /** Press feedback, pops. */
189
- bouncy: { damping: 11, stiffness: 220, mass: 0.7 },
190
+ snappy: { damping: 30, stiffness: 260, mass: 0.8 },
191
+ /** Press feedback, pops. (Kept under its old name; it no longer bounces.) */
192
+ bouncy: { damping: 26, stiffness: 220, mass: 0.7 },
190
193
  /** Sheets, layout changes. */
191
- gentle: { damping: 22, stiffness: 160, mass: 1 },
194
+ gentle: { damping: 27, stiffness: 160, mass: 1 },
192
195
  fast: 160,
193
196
  normal: 240,
194
197
  };
@@ -1,5 +1,6 @@
1
- import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
1
+ import React, { createContext, useContext, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Easing,
5
6
  Image,
@@ -92,7 +93,8 @@ export interface TiltState {
92
93
  height: number;
93
94
  }
94
95
 
95
- const Ctx = createContext<TiltState | null>(null);
96
+ // `reduce` (Reduce Motion) rides along privately so layers stay flat too; useTilt still returns a TiltState.
97
+ const Ctx = createContext<(TiltState & { reduce: boolean }) | null>(null);
96
98
 
97
99
  /** The surrounding TiltCard's live tilt (null outside one) — for your own layers and effects. */
98
100
  export function useTilt() {
@@ -105,6 +107,8 @@ const PAINT = 96;
105
107
 
106
108
  // Six colour cycles across the paint, so a couple of bands cross the card like real foil. Cool
107
109
  // pinks, violets and cyans stay clean when blended over dark cards (yellow and green turn olive).
110
+ const SMOOTH = Easing.bezier(0.32, 0.72, 0, 1);
111
+
108
112
  const HOLO = Array.from({ length: 37 }, (_, i) => ['#ff6bd6', '#b38cff', '#6fb6ff', '#5ff0ff', '#a7ffe0', '#ffc2ef'][i % 6]);
109
113
 
110
114
  function GlarePaint({ variant }: { variant: 'soft' | 'sharp' }) {
@@ -256,6 +260,7 @@ export function TiltCard({
256
260
  accessibilityLabel,
257
261
  style,
258
262
  }: TiltCardProps) {
263
+ const reduce = useReduceMotion();
259
264
  const [size, setSize] = useState({ w: 0, h: 0 });
260
265
  const sizeRef = useRef(size);
261
266
  sizeRef.current = size;
@@ -267,14 +272,16 @@ export function TiltCard({
267
272
  const touching = useRef(false);
268
273
  const origin = useRef({ x: 0, y: 0 });
269
274
  const moved = useRef(false);
270
- const viewRef = useRef<View>(null);
275
+ const viewRef = useRef<ComponentRef<typeof View>>(null);
271
276
  const disabledRef = useRef(disabled);
272
277
  disabledRef.current = disabled;
278
+ const reduceRef = useRef(reduce);
279
+ reduceRef.current = reduce;
273
280
 
274
281
  // Idle sway: a slow figure-eight while nobody is touching.
275
282
  const idleLoop = useRef<Animated.CompositeAnimation | null>(null);
276
283
  const startIdle = () => {
277
- if (!idle) return;
284
+ if (!idle || reduce) return;
278
285
  const ease = Easing.inOut(Easing.sin);
279
286
  const leg = (v: Animated.Value, to: number, ms: number) =>
280
287
  Animated.timing(v, {
@@ -292,10 +299,15 @@ export function TiltCard({
292
299
  idleLoop.current.start();
293
300
  };
294
301
  useEffect(() => {
302
+ // Reduce Motion: no sway; settle where it stopped back at rest.
303
+ if (reduce && !touching.current) {
304
+ tx.setValue(0);
305
+ ty.setValue(0);
306
+ }
295
307
  startIdle();
296
308
  return () => idleLoop.current?.stop();
297
309
  // eslint-disable-next-line react-hooks/exhaustive-deps
298
- }, [idle]);
310
+ }, [idle, reduce]);
299
311
 
300
312
  const aim = (pageX: number, pageY: number) => {
301
313
  const { w, h } = sizeRef.current;
@@ -307,6 +319,12 @@ export function TiltCard({
307
319
  const release = (tapped: boolean) => {
308
320
  touching.current = false;
309
321
  if (tapped && !moved.current) onPressRef.current?.();
322
+ if (reduce) {
323
+ // The card never leaves flat, so only the light has to settle: a short ease, no spring.
324
+ const settle = (v: Animated.Value) => Animated.timing(v, { toValue: 0, duration: 150, easing: SMOOTH, useNativeDriver: true });
325
+ Animated.parallel([settle(tx), settle(ty), settle(press)]).start();
326
+ return;
327
+ }
310
328
  Animated.parallel([
311
329
  Animated.spring(tx, {
312
330
  toValue: 0,
@@ -355,11 +373,15 @@ export function TiltCard({
355
373
  if (typeof px === 'number' && typeof py === 'number') origin.current = { x: px, y: py };
356
374
  });
357
375
  aim(pageX, pageY);
358
- Animated.spring(press, {
359
- toValue: 1,
360
- ...motion.snappy,
361
- useNativeDriver: true,
362
- }).start();
376
+ if (reduceRef.current) {
377
+ Animated.timing(press, { toValue: 1, duration: 150, easing: SMOOTH, useNativeDriver: true }).start();
378
+ } else {
379
+ Animated.spring(press, {
380
+ toValue: 1,
381
+ ...motion.snappy,
382
+ useNativeDriver: true,
383
+ }).start();
384
+ }
363
385
  },
364
386
  onPanResponderMove: (e, g) => {
365
387
  if (Math.abs(g.dx) + Math.abs(g.dy) > 6) moved.current = true;
@@ -380,21 +402,24 @@ export function TiltCard({
380
402
  if (width !== size.w || height !== size.h) setSize({ w: width, h: height });
381
403
  };
382
404
 
405
+ // Reduce Motion keeps the card flat and still: no tilt, lift or parallax. The reflection (light,
406
+ // not movement of the content) still follows the finger.
407
+ const tilt = reduce ? 0 : maxTilt;
383
408
  const rotateY = tx.interpolate({
384
409
  inputRange: [-1, 1],
385
- outputRange: [`${-maxTilt}deg`, `${maxTilt}deg`],
410
+ outputRange: [`${-tilt}deg`, `${tilt}deg`],
386
411
  });
387
412
  const rotateX = ty.interpolate({
388
413
  inputRange: [-1, 1],
389
- outputRange: [`${maxTilt}deg`, `${-maxTilt}deg`],
414
+ outputRange: [`${tilt}deg`, `${-tilt}deg`],
390
415
  });
391
416
  const scale = press.interpolate({
392
417
  inputRange: [0, 1],
393
- outputRange: [1, pressScale],
418
+ outputRange: [1, reduce ? 1 : pressScale],
394
419
  });
395
420
 
396
- const ctx = useMemo(() => ({ tx, ty, press, width: size.w, height: size.h }), [tx, ty, press, size.w, size.h]);
397
- const drift = Math.min(10, size.w * 0.03);
421
+ const ctx = useMemo(() => ({ tx, ty, press, width: size.w, height: size.h, reduce }), [tx, ty, press, size.w, size.h, reduce]);
422
+ const drift = reduce ? 0 : Math.min(10, size.w * 0.03);
398
423
  // The plate sits well inside the outline — far enough that it never shows through a notch or a
399
424
  // see-through corner, even at full tilt; the shadow offset does the downward shift.
400
425
  const body = shadowRect ?? { x: 0, y: 0, width: size.w, height: size.h };
@@ -433,7 +458,7 @@ export function TiltCard({
433
458
  {
434
459
  scale: press.interpolate({
435
460
  inputRange: [0, 1],
436
- outputRange: [1, 1.035],
461
+ outputRange: [1, reduce ? 1 : 1.035],
437
462
  }),
438
463
  },
439
464
  { scaleX: shadowScaleX ?? 1 },
@@ -516,7 +541,7 @@ export function TiltCard({
516
541
  export function TiltLayer({ children, depth = 0.5, style }: TiltLayerProps) {
517
542
  const ctx = useContext(Ctx);
518
543
  if (!ctx) throw new Error('TiltLayer must be inside TiltCard');
519
- const d = depth * 14;
544
+ const d = ctx.reduce ? 0 : depth * 14;
520
545
  return (
521
546
  <Animated.View
522
547
  pointerEvents="box-none"
@@ -539,7 +564,7 @@ export function TiltLayer({ children, depth = 0.5, style }: TiltLayerProps) {
539
564
  {
540
565
  scale: ctx.press.interpolate({
541
566
  inputRange: [0, 1],
542
- outputRange: [1, 1 + Math.max(0, depth) * 0.035],
567
+ outputRange: [1, ctx.reduce ? 1 : 1 + Math.max(0, depth) * 0.035],
543
568
  }),
544
569
  },
545
570
  ],
@@ -616,6 +641,22 @@ export function TiltFoil({ children, radius = 8, base = '#d7dbe2', style }: Tilt
616
641
  );
617
642
  }
618
643
 
644
+ function useReduceMotion() {
645
+ const [reduce, setReduce] = useState(false);
646
+ useEffect(() => {
647
+ let alive = true;
648
+ AccessibilityInfo.isReduceMotionEnabled()
649
+ .then((r) => alive && setReduce(r))
650
+ .catch(() => {});
651
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
652
+ return () => {
653
+ alive = false;
654
+ sub.remove();
655
+ };
656
+ }, []);
657
+ return reduce;
658
+ }
659
+
619
660
  const styles = StyleSheet.create({
620
661
  plate: {
621
662
  position: 'absolute',