@expokit/cli 0.4.0 → 0.4.2

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 (102) hide show
  1. package/README.md +17 -0
  2. package/bin/lib.mjs +1 -1
  3. package/bin/liquidglass.mjs +281 -0
  4. package/package.json +17 -3
  5. package/registry/_docs/accordion.json +1 -1
  6. package/registry/_docs/avatar.json +6 -6
  7. package/registry/_docs/badge.json +3 -3
  8. package/registry/_docs/bottom-nav.json +6 -6
  9. package/registry/_docs/bottom-sheet.json +3 -3
  10. package/registry/_docs/bullet.json +6 -0
  11. package/registry/_docs/button-3d.json +2 -2
  12. package/registry/_docs/button.json +2 -2
  13. package/registry/_docs/calendar.json +5 -5
  14. package/registry/_docs/card.json +1 -1
  15. package/registry/_docs/chip-group.json +1 -1
  16. package/registry/_docs/confetti.json +3 -3
  17. package/registry/_docs/date-picker.json +4 -4
  18. package/registry/_docs/drawer.json +4 -4
  19. package/registry/_docs/flip-card.json +4 -4
  20. package/registry/_docs/floating-input.json +1 -1
  21. package/registry/_docs/grain.json +2 -2
  22. package/registry/_docs/isometric-bar.json +6 -0
  23. package/registry/_docs/liquidglass-tab.json +2 -1
  24. package/registry/_docs/otp-input.json +1 -1
  25. package/registry/_docs/payment-card.json +3 -3
  26. package/registry/_docs/pixel-bar.json +6 -0
  27. package/registry/_docs/polar-area.json +6 -0
  28. package/registry/_docs/progress-steps.json +4 -4
  29. package/registry/_docs/progress.json +2 -2
  30. package/registry/_docs/radio-group.json +1 -1
  31. package/registry/_docs/search-bar.json +3 -3
  32. package/registry/_docs/segmented-control.json +1 -1
  33. package/registry/_docs/select.json +6 -6
  34. package/registry/_docs/skeleton.json +1 -1
  35. package/registry/_docs/slider.json +6 -6
  36. package/registry/_docs/spinner.json +2 -2
  37. package/registry/_docs/stateful-button.json +1 -1
  38. package/registry/_docs/stepper.json +3 -3
  39. package/registry/_docs/tabs.json +2 -2
  40. package/registry/_docs/text-animation.json +4 -4
  41. package/registry/_docs/textarea.json +1 -1
  42. package/registry/_docs/tilt-card.json +3 -3
  43. package/registry/_docs/time-picker.json +6 -6
  44. package/registry/_docs/timeline-chart.json +6 -0
  45. package/registry/_docs/timeline.json +4 -4
  46. package/registry/_docs/treemap.json +6 -0
  47. package/registry/_docs/waterfall.json +6 -0
  48. package/registry/accordion/files/src/components/ui/accordion.tsx +31 -3
  49. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +4 -1
  50. package/registry/alert/files/src/components/ui/alert.tsx +5 -2
  51. package/registry/alert/files/src/components/ui/button.tsx +32 -4
  52. package/registry/badge/files/src/components/ui/badge.tsx +2 -2
  53. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -5
  54. package/registry/button/files/src/components/ui/button.tsx +32 -4
  55. package/registry/button-3d/files/src/components/ui/button-3d.tsx +33 -6
  56. package/registry/calendar/files/src/components/ui/calendar.tsx +62 -17
  57. package/registry/card/files/src/components/ui/card.tsx +25 -5
  58. package/registry/checkbox/files/src/components/ui/checkbox.tsx +26 -4
  59. package/registry/chip-group/files/src/components/ui/chip-group.tsx +35 -5
  60. package/registry/coupon/files/src/components/ui/coupon.tsx +45 -21
  61. package/registry/coupon/files/src/components/ui/flip-card.tsx +22 -3
  62. package/registry/coupon/files/src/components/ui/tilt-card.tsx +57 -16
  63. package/registry/date-picker/files/src/components/ui/calendar.tsx +62 -17
  64. package/registry/date-picker/files/src/components/ui/date-picker.tsx +96 -42
  65. package/registry/dialog/files/src/components/ui/dialog.tsx +9 -5
  66. package/registry/drawer/files/src/components/ui/drawer.tsx +8 -4
  67. package/registry/flip-card/files/src/components/ui/flip-card.tsx +22 -3
  68. package/registry/floating-input/files/src/components/ui/floating-input.tsx +75 -26
  69. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +3 -94
  70. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +2 -1
  71. package/registry/liquidglass-tab/files/src/app/_layout.tsx +2 -2
  72. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +2 -1
  73. package/registry/liquidglass-tab/files/src/components/LiquidGlassTabs.tsx +110 -0
  74. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -6
  75. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +12 -4
  76. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +6 -3
  77. package/registry/liquidglass-tab/files/src/lib/tabs.ts +4 -2
  78. package/registry/list-item/files/src/components/ui/list-item.tsx +10 -3
  79. package/registry/otp-input/files/src/components/ui/otp-input.tsx +50 -19
  80. package/registry/password-input/files/src/components/ui/floating-input.tsx +75 -26
  81. package/registry/password-input/files/src/components/ui/password-input.tsx +53 -19
  82. package/registry/payment-card/files/src/components/ui/flip-card.tsx +22 -3
  83. package/registry/payment-card/files/src/components/ui/payment-card.tsx +19 -15
  84. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +57 -16
  85. package/registry/progress/files/src/components/ui/progress.tsx +9 -2
  86. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +11 -4
  87. package/registry/radio-group/files/src/components/ui/radio-group.tsx +39 -9
  88. package/registry/search-bar/files/src/components/ui/search-bar.tsx +70 -17
  89. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +37 -9
  90. package/registry/select/files/src/components/ui/select.tsx +158 -83
  91. package/registry/slider/files/src/components/ui/slider.tsx +53 -25
  92. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
  93. package/registry/stepper/files/src/components/ui/stepper.tsx +76 -26
  94. package/registry/switch/files/src/components/ui/switch.tsx +56 -19
  95. package/registry/tabs/files/src/components/ui/tabs.tsx +7 -4
  96. package/registry/text-animation/files/src/components/ui/text-animation.tsx +7 -2
  97. package/registry/textarea/files/src/components/ui/textarea.tsx +104 -36
  98. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +57 -16
  99. package/registry/time-picker/files/src/components/ui/time-picker.tsx +154 -73
  100. package/registry/timeline/files/src/components/ui/timeline.tsx +9 -4
  101. package/registry/toast/files/src/components/ui/button.tsx +32 -4
  102. package/registry/toast/files/src/components/ui/toast.tsx +15 -6
@@ -104,6 +104,9 @@ const SIZES: Record<ListItemSize, { minH: number; tile: number; glyph: number; a
104
104
  lg: { minH: 68, tile: 38, glyph: 21, avatar: 48, title: 17, sub: 14, padY: 14 },
105
105
  };
106
106
 
107
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
108
+ // content text scales all the way.
109
+ const FONT_CAP = 1.35;
107
110
  const PAD_X = 16;
108
111
  const GAP = 12;
109
112
  const AVATAR_COLORS = ['#f97316', '#0ea5e9', '#22c55e', '#a855f7', '#ec4899', '#14b8a6', '#6366f1', '#eab308'];
@@ -219,7 +222,9 @@ export function ListItem({
219
222
  {badge === true ? <View style={[styles.dot, { backgroundColor: tint }]} /> : null}
220
223
  {badgeText ? (
221
224
  <View style={[styles.badge, { borderRadius: radius.capsule(22), backgroundColor: tint }]}>
222
- <Text style={[styles.badgeText, ff, { color: readableOn(tint) }]}>{badgeText}</Text>
225
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.badgeText, ff, { color: readableOn(tint) }]}>
226
+ {badgeText}
227
+ </Text>
223
228
  </View>
224
229
  ) : null}
225
230
  {isTextNode(trailing) ? <Text style={[styles.value, ff, { color: colors.mutedForeground }]}>{trailing}</Text> : trailing}
@@ -360,7 +365,9 @@ function AvatarCircle({ src, name = '', color, online, size, ring }: ListItemAva
360
365
  return (
361
366
  <View style={{ width: size, height: size }}>
362
367
  <View style={[styles.avatar, { width: size, height: size, borderRadius: size / 2, backgroundColor: bg }]}>
363
- <Text style={[{ color: readableOn(bg), fontSize: size * 0.38, fontWeight: '400' }, ff]}>{initials}</Text>
368
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[{ color: readableOn(bg), fontSize: size * 0.38, fontWeight: '400' }, ff]}>
369
+ {initials}
370
+ </Text>
364
371
  {src && !failed ? (
365
372
  <Image source={{ uri: src }} onError={() => setFailed(true)} style={[StyleSheet.absoluteFill, { borderRadius: size / 2 }]} />
366
373
  ) : null}
@@ -499,7 +506,7 @@ const styles = StyleSheet.create({
499
506
  trailing: { flexDirection: 'row', alignItems: 'center', gap: 8, flexShrink: 0, maxWidth: '50%' },
500
507
  value: { fontSize: 15, flexShrink: 1 },
501
508
  dot: { width: 9, height: 9, borderRadius: 5 },
502
- badge: { minWidth: 22, height: 22, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center' },
509
+ badge: { minWidth: 22, minHeight: 22, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center' },
503
510
  badgeText: { fontSize: 12, fontWeight: '400', fontVariant: ['tabular-nums'] },
504
511
  divider: { position: 'absolute', right: 0, bottom: 0, height: StyleSheet.hairlineWidth },
505
512
  avatar: { alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Easing,
5
6
  Platform,
@@ -55,6 +56,9 @@ export interface OtpInputProps {
55
56
  type CellState = 'idle' | 'error' | 'success';
56
57
 
57
58
  const GAP = 8;
59
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
60
+ // content text scales all the way. A capped digit (1.35 × 0.46w, line 1.2×) still fits the shortest cell (pill, 1w).
61
+ const FONT_CAP = 1.35;
58
62
  /** react-native-web draws the browser focus outline; our border + halo is the focus indicator. */
59
63
  const webNoOutline = Platform.OS === 'web' ? ({ outlineStyle: 'none', outlineWidth: 0 } as object) : null;
60
64
  const SEP = 22;
@@ -84,6 +88,7 @@ export function OtpInput({
84
88
  style,
85
89
  }: OtpInputProps) {
86
90
  const { colors } = useKit();
91
+ const reduce = useReduceMotion();
87
92
  const input = useRef<ComponentRef<typeof TextInput>>(null);
88
93
  const [inner, setInner] = useState('');
89
94
  const [focused, setFocused] = useState(false);
@@ -92,12 +97,13 @@ export function OtpInput({
92
97
  const shake = useRef(new Animated.Value(0)).current;
93
98
 
94
99
  useEffect(() => {
95
- if (!error) return;
100
+ // Reduce Motion: no shake; the cells still turn the danger colour.
101
+ if (!error || reduce) return;
96
102
  shake.setValue(0);
97
103
  Animated.sequence(
98
104
  [10, -9, 7, -5, 3, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 45, useNativeDriver: true }))
99
105
  ).start();
100
- }, [error, shake]);
106
+ }, [error, shake, reduce]);
101
107
 
102
108
  const change = (raw: string) => {
103
109
  // No maxLength on the input, so pasting "123-456" or a longer SMS snippet still fills every cell.
@@ -152,6 +158,7 @@ export function OtpInput({
152
158
  variant={variant}
153
159
  w={cell}
154
160
  h={cellH}
161
+ reduce={reduce}
155
162
  />
156
163
  </React.Fragment>
157
164
  ))}
@@ -176,7 +183,7 @@ export function OtpInput({
176
183
  importantForAccessibility="yes"
177
184
  style={[styles.hidden, webNoOutline]}
178
185
  />
179
- {message ? <Message text={message} kind={state} /> : null}
186
+ {message ? <Message text={message} kind={state} reduce={reduce} /> : null}
180
187
  </View>
181
188
  );
182
189
  }
@@ -191,6 +198,7 @@ function Cell({
191
198
  variant,
192
199
  w,
193
200
  h,
201
+ reduce,
194
202
  }: {
195
203
  index: number;
196
204
  char?: string;
@@ -201,6 +209,7 @@ function Cell({
201
209
  variant: OtpInputVariant;
202
210
  w: number;
203
211
  h: number;
212
+ reduce: boolean;
204
213
  }) {
205
214
  const { colors, radius, font: kitFont } = useKit();
206
215
  const ff = kitFont.family ? { fontFamily: kitFont.family } : null;
@@ -214,17 +223,22 @@ function Cell({
214
223
  const ring = useRef(new Animated.Value(0)).current;
215
224
 
216
225
  useEffect(() => {
217
- if (char) {
226
+ if (char && reduce) {
227
+ // Reduce Motion: the digit appears without the pop.
228
+ pop.setValue(1);
229
+ } else if (char) {
218
230
  pop.setValue(0.3);
219
231
  Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
220
232
  } else {
221
233
  Animated.timing(pop, { toValue: 0, duration: 110, useNativeDriver: true }).start();
222
234
  }
223
- }, [char, pop]);
235
+ }, [char, pop, reduce]);
224
236
 
225
237
  useEffect(() => {
226
238
  const to = active ? 1 : 0;
227
- Animated.spring(halo, { toValue: to, useNativeDriver: true, ...motion.snappy }).start();
239
+ // Reduce Motion: the halo only fades (its scale is pinned below). The caret keeps blinking.
240
+ if (reduce) Animated.timing(halo, { toValue: to, duration: 150, useNativeDriver: true }).start();
241
+ else Animated.spring(halo, { toValue: to, useNativeDriver: true, ...motion.snappy }).start();
228
242
  Animated.timing(ring, { toValue: to, duration: motion.fast, useNativeDriver: false }).start();
229
243
  if (!active) return;
230
244
  caret.setValue(1);
@@ -238,17 +252,17 @@ function Cell({
238
252
  );
239
253
  blink.start();
240
254
  return () => blink.stop();
241
- }, [active, halo, ring, caret]);
255
+ }, [active, halo, ring, caret, reduce]);
242
256
 
243
257
  useEffect(() => {
244
- if (state !== 'success') return;
258
+ if (state !== 'success' || reduce) return;
245
259
  pulse.setValue(0);
246
260
  Animated.sequence([
247
261
  Animated.delay(index * 55),
248
262
  Animated.timing(pulse, { toValue: 1, duration: 140, easing: Easing.out(Easing.quad), useNativeDriver: true }),
249
263
  Animated.spring(pulse, { toValue: 0, useNativeDriver: true, ...motion.bouncy }),
250
264
  ]).start();
251
- }, [state, index, pulse]);
265
+ }, [state, index, pulse, reduce]);
252
266
 
253
267
  const underline = variant === 'underline';
254
268
  const pill = variant === 'pill';
@@ -289,7 +303,7 @@ function Cell({
289
303
  borderRadius: underline ? 10 : r + 4,
290
304
  backgroundColor: alpha(tone, 0.18),
291
305
  opacity: halo,
292
- transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.86, 1] }) }],
306
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.86, 1] }) }],
293
307
  },
294
308
  ]}
295
309
  />
@@ -306,7 +320,7 @@ function Cell({
306
320
  {secure ? (
307
321
  <View style={{ width: dot, height: dot, borderRadius: dot / 2, backgroundColor: fg }} />
308
322
  ) : (
309
- <Text style={[styles.char, ff, { color: fg, fontSize: font, lineHeight: Math.round(font * 1.2) }]}>{char}</Text>
323
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.char, ff, { color: fg, fontSize: font, lineHeight: Math.round(font * 1.2) }]}>{char}</Text>
310
324
  )}
311
325
  </Animated.View>
312
326
  ) : active ? (
@@ -317,8 +331,8 @@ function Cell({
317
331
  );
318
332
  }
319
333
 
320
- /** Helper / error / success line that fades and slides in whenever its text changes. */
321
- function Message({ text, kind }: { text: string; kind: CellState }) {
334
+ /** Helper / error / success line that fades and slides in whenever its text changes (only fades with Reduce Motion). */
335
+ function Message({ text, kind, reduce }: { text: string; kind: CellState; reduce: boolean }) {
322
336
  const { colors, font } = useKit();
323
337
  const ff = font.family ? { fontFamily: font.family } : null;
324
338
  const enter = useRef(new Animated.Value(0)).current;
@@ -326,12 +340,13 @@ function Message({ text, kind }: { text: string; kind: CellState }) {
326
340
 
327
341
  useEffect(() => {
328
342
  enter.setValue(0);
329
- Animated.timing(enter, { toValue: 1, duration: motion.normal, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
330
- draw.setValue(0);
331
- if (kind === 'success') {
343
+ Animated.timing(enter, { toValue: 1, duration: reduce ? 150 : motion.normal, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
344
+ // Reduce Motion: the tick is drawn at once.
345
+ draw.setValue(reduce ? 1 : 0);
346
+ if (kind === 'success' && !reduce) {
332
347
  Animated.timing(draw, { toValue: 1, duration: 360, delay: 120, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
333
348
  }
334
- }, [text, kind, enter, draw]);
349
+ }, [text, kind, enter, draw, reduce]);
335
350
 
336
351
  const color = kind === 'error' ? colors.danger : kind === 'success' ? colors.success : colors.mutedForeground;
337
352
  return (
@@ -339,7 +354,7 @@ function Message({ text, kind }: { text: string; kind: CellState }) {
339
354
  accessibilityLiveRegion="polite"
340
355
  style={[
341
356
  styles.message,
342
- { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] },
357
+ { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }] },
343
358
  ]}
344
359
  >
345
360
  {kind === 'error' ? (
@@ -366,6 +381,22 @@ function Message({ text, kind }: { text: string; kind: CellState }) {
366
381
  );
367
382
  }
368
383
 
384
+ function useReduceMotion() {
385
+ const [reduce, setReduce] = useState(false);
386
+ useEffect(() => {
387
+ let alive = true;
388
+ AccessibilityInfo.isReduceMotionEnabled()
389
+ .then((r) => alive && setReduce(r))
390
+ .catch(() => {});
391
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
392
+ return () => {
393
+ alive = false;
394
+ sub.remove();
395
+ };
396
+ }, []);
397
+ return reduce;
398
+ }
399
+
369
400
  const styles = StyleSheet.create({
370
401
  root: { alignSelf: 'stretch' },
371
402
  disabled: { opacity: 0.5 },
@@ -377,6 +408,6 @@ const styles = StyleSheet.create({
377
408
  sep: { alignItems: 'center', justifyContent: 'center' },
378
409
  dash: { width: 10, height: 2, borderRadius: 1, opacity: 0.6 },
379
410
  message: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 6 },
380
- messageText: { fontSize: 12.5, lineHeight: 17, fontWeight: '400' },
411
+ messageText: { flexShrink: 1, fontSize: 12.5, lineHeight: 17, fontWeight: '400' },
381
412
  hidden: { position: 'absolute', width: 1, height: 1, opacity: 0 },
382
413
  });
@@ -1,5 +1,6 @@
1
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,
@@ -85,6 +86,9 @@ export const FLOATING_INPUT_SIZES = {
85
86
  md: { height: 54, font: 16, floated: 12.5, padX: 16, icon: 18, radius: 0 },
86
87
  lg: { height: 64, font: 18, floated: 13.5, padX: 18, icon: 20, radius: 4 },
87
88
  };
89
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
90
+ // content text scales all the way.
91
+ const FONT_CAP = 1.35;
88
92
  const HALO = 4;
89
93
  /** Font size of the static label used when `floatingLabel` is false. */
90
94
  const LABEL_ABOVE_FONT = { sm: 13, md: 14, lg: 14.5 };
@@ -166,6 +170,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
166
170
  ref
167
171
  ) {
168
172
  const { colors, radius, font } = useKit();
173
+ const reduce = useReduceMotion();
169
174
  const ff = font.family ? { fontFamily: font.family } : null;
170
175
  const S = FLOATING_INPUT_SIZES[size];
171
176
  // The user's border colour only replaces the resting one; numeric height / radius drive the layout maths.
@@ -200,29 +205,36 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
200
205
  const check = useRef(new Animated.Value(ok ? 1 : 0)).current; // native
201
206
 
202
207
  useEffect(() => {
203
- Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
204
- }, [floating, float]);
208
+ if (reduce) float.setValue(floating ? 1 : 0);
209
+ else Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
210
+ }, [floating, float, reduce]);
205
211
 
206
212
  useEffect(() => {
207
213
  Animated.timing(focus, { toValue: isFocused ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
208
- Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
209
- }, [isFocused, focus, glow]);
214
+ // Reduce Motion: halo and indicator bar only fade (their scales are pinned below).
215
+ if (reduce) Animated.timing(glow, { toValue: isFocused ? 1 : 0, duration: 150, useNativeDriver: true }).start();
216
+ else Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
217
+ }, [isFocused, focus, glow, reduce]);
210
218
 
211
219
  useEffect(() => {
212
- Animated.spring(clear, { toValue: clearable && text && editable ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
213
- }, [clearable, text, editable, clear]);
220
+ const to = clearable && text && editable ? 1 : 0;
221
+ if (reduce) Animated.timing(clear, { toValue: to, duration: 150, useNativeDriver: true }).start();
222
+ else Animated.spring(clear, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
223
+ }, [clearable, text, editable, clear, reduce]);
214
224
 
215
225
  useEffect(() => {
216
- Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
217
- }, [ok, check]);
226
+ if (reduce) Animated.timing(check, { toValue: ok ? 1 : 0, duration: 150, useNativeDriver: true }).start();
227
+ else Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
228
+ }, [ok, check, reduce]);
218
229
 
219
230
  useEffect(() => {
220
- if (!error) return;
231
+ // Reduce Motion: no shake; the red border and message still flag the error.
232
+ if (!error || reduce) return;
221
233
  shake.setValue(0);
222
234
  Animated.sequence(
223
235
  [10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))
224
236
  ).start();
225
- }, [error, shake]);
237
+ }, [error, shake, reduce]);
226
238
 
227
239
  const setText = (next: string) => {
228
240
  if (value === undefined) setInner(next);
@@ -310,23 +322,24 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
310
322
  borderRadius: corner + HALO,
311
323
  backgroundColor: active,
312
324
  opacity: glow.interpolate({ inputRange: [0, 1], outputRange: [0, 0.18], extrapolate: 'clamp' }),
313
- transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
325
+ transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.97, 1] }) }],
314
326
  },
315
327
  ]}
316
328
  />
317
329
  )}
318
330
  <Pressable onPress={() => inputRef.current?.focus()} disabled={!editable} accessible={false}>
319
- <Animated.View style={[styles.field, boxStyle, { height: HEIGHT }, userField, { borderColor }]}>
331
+ <Animated.View style={[styles.field, boxStyle, { minHeight: HEIGHT }, userField, { borderColor }]}>
320
332
  {left ? <View style={[styles.left, { left: underline ? 2 : S.padX - 2 }, underline && { top: contentTop }]}>{left}</View> : null}
321
333
  <View style={[styles.row, { paddingLeft: leftPad, paddingRight: rightPad, paddingTop: contentTop }]}>
322
334
  {prefix ? (
323
- <Animated.Text style={[styles.affix, styles.prefix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, prefixStyle, { opacity: affixOpacity }]}>
335
+ <Animated.Text maxFontSizeMultiplier={FONT_CAP} style={[styles.affix, styles.prefix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, prefixStyle, { opacity: affixOpacity }]}>
324
336
  {prefix}
325
337
  </Animated.Text>
326
338
  ) : null}
327
339
  <TextInput
328
340
  ref={inputRef}
329
341
  accessibilityLabel={label}
342
+ maxFontSizeMultiplier={FONT_CAP}
330
343
  {...props}
331
344
  maxLength={maxLength}
332
345
  editable={editable}
@@ -347,7 +360,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
347
360
  style={[styles.input, ff, { color: colors.foreground, fontSize: S.font }, inputStyle, NO_WEB_OUTLINE]}
348
361
  />
349
362
  {suffix ? (
350
- <Animated.Text style={[styles.affix, styles.suffix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, suffixStyle, { opacity: affixOpacity }]}>
363
+ <Animated.Text maxFontSizeMultiplier={FONT_CAP} style={[styles.affix, styles.suffix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, suffixStyle, { opacity: affixOpacity }]}>
351
364
  {suffix}
352
365
  </Animated.Text>
353
366
  ) : null}
@@ -358,6 +371,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
358
371
  style={[styles.labelWrap, { left: leftPad, right: right ? adorn : S.padX - 4, top: contentTop, height: HEIGHT - contentTop }, labelStyle]}
359
372
  >
360
373
  <Animated.Text
374
+ maxFontSizeMultiplier={FONT_CAP}
361
375
  numberOfLines={1}
362
376
  style={[
363
377
  styles.label,
@@ -380,7 +394,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
380
394
  ) : null}
381
395
  {clearable ? (
382
396
  <Animated.View
383
- style={[styles.slot, { right: edge, top: contentTop, opacity: clear, transform: [{ scale: clear }] }]}
397
+ style={[styles.slot, { right: edge, top: contentTop, opacity: clear, transform: [{ scale: reduce ? 1 : clear }] }]}
384
398
  pointerEvents={text && editable ? 'box-none' : 'none'}
385
399
  >
386
400
  <Pressable
@@ -408,8 +422,8 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
408
422
  top: contentTop,
409
423
  opacity: check.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
410
424
  transform: [
411
- { scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
412
- { rotate: check.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'] }) },
425
+ { scale: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.3, 1] }) },
426
+ { rotate: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? '0deg' : '-30deg', '0deg'] }) },
413
427
  ],
414
428
  },
415
429
  ]}
@@ -423,10 +437,14 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
423
437
  {outlined ? null : (
424
438
  <>
425
439
  <View pointerEvents="none" style={[styles.baseLine, { height: underline ? 1.5 : 1, backgroundColor: rest }]} />
426
- {/* Grows out from the centre on focus. */}
440
+ {/* Grows out from the centre on focus (fades in with Reduce Motion). */}
427
441
  <Animated.View
428
442
  pointerEvents="none"
429
- style={[styles.bar, { backgroundColor: active, transform: [{ scaleX: glowClamped }] }]}
443
+ style={[
444
+ styles.bar,
445
+ { backgroundColor: active },
446
+ reduce ? { opacity: glowClamped } : { transform: [{ scaleX: glowClamped }] },
447
+ ]}
430
448
  />
431
449
  </>
432
450
  )}
@@ -436,10 +454,11 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
436
454
  {message || showCounter ? (
437
455
  <View style={[styles.footer, underline && styles.footerFlush]}>
438
456
  <View style={styles.messageSlot}>
439
- {message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} /> : null}
457
+ {message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} reduce={reduce} /> : null}
440
458
  </View>
441
459
  {showCounter ? (
442
460
  <Text
461
+ maxFontSizeMultiplier={FONT_CAP}
443
462
  style={[
444
463
  styles.count,
445
464
  ff,
@@ -459,13 +478,27 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
459
478
 
460
479
  type MessageTone = 'error' | 'success' | 'helper';
461
480
 
462
- /** Helper / error / success line. Error and success slide up and fade in. */
463
- function FieldMessage({ text, tone, color, style }: { text: string; tone: MessageTone; color: string; style?: StyleProp<TextStyle> }) {
481
+ /** Helper / error / success line. Error and success slide up and fade in (only fade with Reduce Motion). */
482
+ function FieldMessage({
483
+ text,
484
+ tone,
485
+ color,
486
+ style,
487
+ reduce,
488
+ }: {
489
+ text: string;
490
+ tone: MessageTone;
491
+ color: string;
492
+ style?: StyleProp<TextStyle>;
493
+ reduce: boolean;
494
+ }) {
464
495
  const { font } = useKit();
465
496
  const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
466
497
  useEffect(() => {
467
- if (tone !== 'helper') Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
468
- }, [tone, enter]);
498
+ if (tone === 'helper') return;
499
+ if (reduce) Animated.timing(enter, { toValue: 1, duration: 150, useNativeDriver: true }).start();
500
+ else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
501
+ }, [tone, enter, reduce]);
469
502
 
470
503
  return (
471
504
  <Animated.View
@@ -473,7 +506,7 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
473
506
  styles.message,
474
507
  {
475
508
  opacity: enter.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
476
- transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }],
509
+ transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }],
477
510
  },
478
511
  ]}
479
512
  >
@@ -510,11 +543,27 @@ function flattenView(style: StyleProp<ViewStyle>): ViewStyle {
510
543
  return style as ViewStyle;
511
544
  }
512
545
 
546
+ function useReduceMotion() {
547
+ const [reduce, setReduce] = useState(false);
548
+ useEffect(() => {
549
+ let alive = true;
550
+ AccessibilityInfo.isReduceMotionEnabled()
551
+ .then((r) => alive && setReduce(r))
552
+ .catch(() => {});
553
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
554
+ return () => {
555
+ alive = false;
556
+ sub.remove();
557
+ };
558
+ }, []);
559
+ return reduce;
560
+ }
561
+
513
562
  const styles = StyleSheet.create({
514
563
  disabled: { opacity: 0.5 },
515
564
  halo: { position: 'absolute', top: -HALO, left: -HALO, right: -HALO, bottom: -HALO },
516
565
  field: { justifyContent: 'center' },
517
- row: { flexDirection: 'row', alignItems: 'center', height: '100%' },
566
+ row: { flexDirection: 'row', alignItems: 'center', flexGrow: 1 },
518
567
  input: { flex: 1, alignSelf: 'stretch', paddingVertical: 0, paddingHorizontal: 0 },
519
568
  affix: { fontWeight: '400' },
520
569
  prefix: { marginRight: 4 },
@@ -1,5 +1,5 @@
1
1
  import React, { forwardRef, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
2
- import { Animated, Pressable, StyleSheet, Text, View, type TextInput } from 'react-native';
2
+ import { AccessibilityInfo, Animated, Pressable, StyleSheet, Text, View, type TextInput } from 'react-native';
3
3
  import Svg, { Circle, Path } from 'react-native-svg';
4
4
 
5
5
  import { FloatingInput, type FloatingInputProps } from '@/components/ui/floating-input';
@@ -34,6 +34,10 @@ export interface PasswordInputProps extends Omit<FloatingInputProps, 'secureText
34
34
  mismatchMessage?: string;
35
35
  }
36
36
 
37
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
38
+ // content text scales all the way.
39
+ const FONT_CAP = 1.35;
40
+
37
41
  const LEVEL_LABELS = ['Too short', 'Weak', 'Okay', 'Strong', 'Very strong'];
38
42
  const ICON = { sm: 18, md: 22, lg: 24 };
39
43
 
@@ -70,6 +74,7 @@ export const PasswordInput = forwardRef<ComponentRef<typeof TextInput>, Password
70
74
  ref
71
75
  ) {
72
76
  const { colors } = useKit();
77
+ const reduce = useReduceMotion();
73
78
  const [hidden, setHidden] = useState(true);
74
79
  const [inner, setInner] = useState(defaultValue ?? '');
75
80
  const text = value ?? inner;
@@ -84,17 +89,21 @@ export const PasswordInput = forwardRef<ComponentRef<typeof TextInput>, Password
84
89
 
85
90
  const eye = useRef(new Animated.Value(hidden ? 0 : 1)).current; // native: 1 = visible
86
91
  useEffect(() => {
87
- Animated.spring(eye, { toValue: hidden ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
88
- }, [hidden, eye]);
92
+ if (reduce) Animated.timing(eye, { toValue: hidden ? 0 : 1, duration: 150, useNativeDriver: true }).start();
93
+ else Animated.spring(eye, { toValue: hidden ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
94
+ }, [hidden, eye, reduce]);
89
95
 
90
96
  const iconColor = iconColorProp ?? colors.mutedForeground;
91
97
  const iconSize = ICON[size];
92
98
  const fade = (on: 0 | 1) => ({
93
99
  opacity: eye.interpolate({ inputRange: [0, 1], outputRange: on ? [0, 1] : [1, 0], extrapolate: 'clamp' }),
94
- transform: [
95
- { scale: eye.interpolate({ inputRange: [0, 1], outputRange: on ? [0.6, 1] : [1, 0.6] }) },
96
- { rotate: eye.interpolate({ inputRange: [0, 1], outputRange: on ? ['-45deg', '0deg'] : ['0deg', '45deg'] }) },
97
- ],
100
+ // Reduce Motion: a plain cross-fade, no spin or scale.
101
+ transform: reduce
102
+ ? []
103
+ : [
104
+ { scale: eye.interpolate({ inputRange: [0, 1], outputRange: on ? [0.6, 1] : [1, 0.6] }) },
105
+ { rotate: eye.interpolate({ inputRange: [0, 1], outputRange: on ? ['-45deg', '0deg'] : ['0deg', '45deg'] }) },
106
+ ],
98
107
  });
99
108
 
100
109
  return (
@@ -137,8 +146,8 @@ export const PasswordInput = forwardRef<ComponentRef<typeof TextInput>, Password
137
146
  </Pressable>
138
147
  }
139
148
  />
140
- {strength && !confirming && text.length > 0 ? <Meter level={level} /> : null}
141
- {rules && !confirming ? <Checklist rules={rules} value={text} /> : null}
149
+ {strength && !confirming && text.length > 0 ? <Meter level={level} reduce={reduce} /> : null}
150
+ {rules && !confirming ? <Checklist rules={rules} value={text} reduce={reduce} /> : null}
142
151
  </View>
143
152
  );
144
153
  });
@@ -151,7 +160,7 @@ const EYE_OFF =
151
160
  'M2 2l20 20';
152
161
 
153
162
  /** Four segments that spring-fill left to right; the colour slides with the score. */
154
- function Meter({ level }: { level: number }) {
163
+ function Meter({ level, reduce }: { level: number; reduce: boolean }) {
155
164
  const { colors, dark, font } = useKit();
156
165
  const palette = [colors.mutedForeground, colors.danger, colors.warning, colors.success, colors.success];
157
166
  const fills = useRef([0, 1, 2, 3].map(() => new Animated.Value(0))).current; // native: scaleX
@@ -159,16 +168,24 @@ function Meter({ level }: { level: number }) {
159
168
  const enter = useRef(new Animated.Value(0)).current; // native
160
169
 
161
170
  useEffect(() => {
162
- Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
171
+ if (reduce) enter.setValue(1);
172
+ else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
174
  }, [enter]);
164
175
 
165
176
  useEffect(() => {
177
+ if (reduce) {
178
+ // Segments appear filled at once; the colour still fades.
179
+ fills.forEach((a, i) => a.setValue(i < level ? 1 : 0));
180
+ Animated.timing(tone, { toValue: level, duration: 150, useNativeDriver: false }).start();
181
+ return;
182
+ }
166
183
  Animated.stagger(
167
184
  50,
168
185
  fills.map((a, i) => Animated.spring(a, { toValue: i < level ? 1 : 0, useNativeDriver: true, ...motion.snappy }))
169
186
  ).start();
170
187
  Animated.timing(tone, { toValue: level, duration: 220, useNativeDriver: false }).start();
171
- }, [level, fills, tone]);
188
+ }, [level, fills, tone, reduce]);
172
189
 
173
190
  const color = tone.interpolate({ inputRange: [0, 1, 2, 3, 4], outputRange: palette });
174
191
  const track = dark ? colors.muted : colors.border;
@@ -199,27 +216,28 @@ function Meter({ level }: { level: number }) {
199
216
  </View>
200
217
  ))}
201
218
  </View>
202
- <Text style={[styles.level, font.family ? { fontFamily: font.family } : null, { color: palette[level] }]}>{LEVEL_LABELS[level]}</Text>
219
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.level, font.family ? { fontFamily: font.family } : null, { color: palette[level] }]}>{LEVEL_LABELS[level]}</Text>
203
220
  </Animated.View>
204
221
  );
205
222
  }
206
223
 
207
- function Checklist({ rules, value }: { rules: PasswordRequirement[]; value: string }) {
224
+ function Checklist({ rules, value, reduce }: { rules: PasswordRequirement[]; value: string; reduce: boolean }) {
208
225
  return (
209
226
  <View style={styles.checklist} accessibilityRole="list">
210
227
  {rules.map((r) => (
211
- <Requirement key={r.label} label={r.label} met={r.test(value)} />
228
+ <Requirement key={r.label} label={r.label} met={r.test(value)} reduce={reduce} />
212
229
  ))}
213
230
  </View>
214
231
  );
215
232
  }
216
233
 
217
- function Requirement({ label, met }: { label: string; met: boolean }) {
234
+ function Requirement({ label, met, reduce }: { label: string; met: boolean; reduce: boolean }) {
218
235
  const { colors, dark, font } = useKit();
219
236
  const tick = useRef(new Animated.Value(met ? 1 : 0)).current; // native
220
237
  useEffect(() => {
221
- Animated.spring(tick, { toValue: met ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
222
- }, [met, tick]);
238
+ if (reduce) Animated.timing(tick, { toValue: met ? 1 : 0, duration: 150, useNativeDriver: true }).start();
239
+ else Animated.spring(tick, { toValue: met ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
240
+ }, [met, tick, reduce]);
223
241
 
224
242
  return (
225
243
  <View
@@ -237,7 +255,7 @@ function Requirement({ label, met }: { label: string; met: boolean }) {
237
255
  StyleSheet.absoluteFill,
238
256
  {
239
257
  opacity: tick.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
240
- transform: [{ scale: tick.interpolate({ inputRange: [0, 1], outputRange: [0.2, 1] }) }],
258
+ transform: reduce ? [] : [{ scale: tick.interpolate({ inputRange: [0, 1], outputRange: [0.2, 1] }) }],
241
259
  },
242
260
  ]}
243
261
  >
@@ -258,6 +276,22 @@ function Requirement({ label, met }: { label: string; met: boolean }) {
258
276
  );
259
277
  }
260
278
 
279
+ function useReduceMotion() {
280
+ const [reduce, setReduce] = useState(false);
281
+ useEffect(() => {
282
+ let alive = true;
283
+ AccessibilityInfo.isReduceMotionEnabled()
284
+ .then((r) => alive && setReduce(r))
285
+ .catch(() => {});
286
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
287
+ return () => {
288
+ alive = false;
289
+ sub.remove();
290
+ };
291
+ }, []);
292
+ return reduce;
293
+ }
294
+
261
295
  const styles = StyleSheet.create({
262
296
  meter: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 10, marginHorizontal: 4 },
263
297
  bars: { flex: 1, flexDirection: 'row', gap: 6 },