@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
@@ -1,6 +1,8 @@
1
1
  import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
5
+ Easing,
4
6
  PanResponder,
5
7
  Pressable,
6
8
  StyleSheet,
@@ -98,6 +100,9 @@ type FlatProps = CalendarBaseProps & {
98
100
  onChange?: (value: CalendarValue) => void;
99
101
  };
100
102
 
103
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
104
+ // content text scales all the way.
105
+ const FONT_CAP = 1.35;
101
106
  const CELL_H = 48;
102
107
  const CIRCLE = 38;
103
108
  const CIRCLE_TOP = 2;
@@ -205,6 +210,7 @@ export function Calendar(props: CalendarProps) {
205
210
  const ff = font.family ? { fontFamily: font.family } : null;
206
211
  const tint = accent ?? colors.accent;
207
212
  const fmt = useFormatters(locale);
213
+ const reduce = useReduceMotion();
208
214
 
209
215
  const [inner, setInner] = useState<CalendarValue>(defaultValue ?? null);
210
216
  const current = value !== undefined ? value : inner;
@@ -252,7 +258,11 @@ export function Calendar(props: CalendarProps) {
252
258
  setLeaving({ month: prev, dir });
253
259
  slide.stopAnimation();
254
260
  slide.setValue(0);
255
- Animated.spring(slide, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start(({ finished }) => {
261
+ // Reduce Motion: a short cross-fade in place (the translate interpolations are flat).
262
+ const anim = reduce
263
+ ? Animated.timing(slide, { toValue: 1, duration: 150, easing: Easing.bezier(0.32, 0.72, 0, 1), useNativeDriver: true })
264
+ : Animated.spring(slide, { toValue: 1, useNativeDriver: true, ...motion.gentle });
265
+ anim.start(({ finished }) => {
256
266
  if (finished) setLeaving(null);
257
267
  });
258
268
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -267,7 +277,8 @@ export function Calendar(props: CalendarProps) {
267
277
  if (next !== 'days' && view === 'days') setPickYear(visible.getFullYear());
268
278
  setView(next);
269
279
  viewAnim.setValue(0);
270
- Animated.spring(viewAnim, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
280
+ if (reduce) Animated.timing(viewAnim, { toValue: 1, duration: 150, useNativeDriver: true }).start();
281
+ else Animated.spring(viewAnim, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
271
282
  };
272
283
 
273
284
  /* selection */
@@ -332,11 +343,13 @@ export function Calendar(props: CalendarProps) {
332
343
  manyKeys: many ? many.map(toDateKey).join('|') : undefined,
333
344
  tint,
334
345
  fullLabel: fmt.full,
346
+ reduce,
335
347
  onDayPress,
336
348
  };
337
349
 
338
350
  const W = width || 320;
339
- const dir = dirRef.current;
351
+ // Reduce Motion: months swap with a fade only, no sideways slide.
352
+ const dir = reduce ? 0 : dirRef.current;
340
353
  const prevDisabled = view === 'days' ? monthIndex(visible) - 1 < minM : view === 'months' ? pickYear * 12 - 1 < minM : false;
341
354
  const nextDisabled = view === 'days' ? monthIndex(visible) + 1 > maxM : view === 'months' ? (pickYear + 1) * 12 > maxM : false;
342
355
  const decadeStart = Math.floor(pickYear / 12) * 12;
@@ -381,7 +394,7 @@ export function Calendar(props: CalendarProps) {
381
394
  >
382
395
  {title}
383
396
  </Animated.Text>
384
- {monthPicker ? <Chevron turned={view !== 'days'} color={view === 'days' ? colors.mutedForeground : tint} /> : null}
397
+ {monthPicker ? <Chevron turned={view !== 'days'} color={view === 'days' ? colors.mutedForeground : tint} reduce={reduce} /> : null}
385
398
  </Pressable>
386
399
  <View style={styles.navs}>
387
400
  {showJump ? (
@@ -392,7 +405,9 @@ export function Calendar(props: CalendarProps) {
392
405
  hitSlop={4}
393
406
  style={({ pressed }) => [styles.todayBtn, { borderRadius: radius.capsule(32), backgroundColor: pressed ? colors.secondary : colors.muted }]}
394
407
  >
395
- <Text style={[styles.todayText, ff, { color: tint }]}>Today</Text>
408
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.todayText, ff, { color: tint }]}>
409
+ Today
410
+ </Text>
396
411
  </Pressable>
397
412
  ) : null}
398
413
  <NavButton dir={-1} disabled={prevDisabled} onPress={() => step(-1)} />
@@ -404,12 +419,12 @@ export function Calendar(props: CalendarProps) {
404
419
  <Animated.View
405
420
  style={{
406
421
  opacity: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
407
- transform: [{ scale: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [1.04, 1] }) }],
422
+ transform: [{ scale: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 1.04, 1] }) }],
408
423
  }}
409
424
  >
410
425
  <View style={styles.weekRow} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
411
426
  {weekdays.map((w) => (
412
- <Text key={w.long} numberOfLines={1} style={[styles.weekday, ff, { color: colors.mutedForeground }]}>
427
+ <Text key={w.long} numberOfLines={1} maxFontSizeMultiplier={FONT_CAP} style={[styles.weekday, ff, { color: colors.mutedForeground }]}>
413
428
  {w.short}
414
429
  </Text>
415
430
  ))}
@@ -422,7 +437,7 @@ export function Calendar(props: CalendarProps) {
422
437
  styles.leaving,
423
438
  {
424
439
  opacity: slide.interpolate({ inputRange: [0, 0.7], outputRange: [1, 0], extrapolate: 'clamp' }),
425
- transform: [{ translateX: slide.interpolate({ inputRange: [0, 1], outputRange: [0, -leaving.dir * W * 0.6] }) }],
440
+ transform: [{ translateX: slide.interpolate({ inputRange: [0, 1], outputRange: [0, reduce ? 0 : -leaving.dir * W * 0.6] }) }],
426
441
  },
427
442
  ]}
428
443
  >
@@ -446,7 +461,7 @@ export function Calendar(props: CalendarProps) {
446
461
  { minHeight: gridH ? gridH + 28 : undefined },
447
462
  {
448
463
  opacity: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
449
- transform: [{ scale: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) }],
464
+ transform: [{ scale: viewAnim.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.94, 1] }) }],
450
465
  },
451
466
  ]}
452
467
  >
@@ -488,7 +503,9 @@ export function Calendar(props: CalendarProps) {
488
503
  },
489
504
  ]}
490
505
  >
491
- <Text style={[styles.pickText, ff, { color: selected ? readableOn(tint) : isNow ? tint : colors.foreground }]}>{label}</Text>
506
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.pickText, ff, { color: selected ? readableOn(tint) : isNow ? tint : colors.foreground }]}>
507
+ {label}
508
+ </Text>
492
509
  </View>
493
510
  )}
494
511
  </Pressable>
@@ -520,6 +537,7 @@ interface MonthGridProps {
520
537
  manyKeys?: string;
521
538
  tint: string;
522
539
  fullLabel: (d: Date) => string;
540
+ reduce: boolean;
523
541
  onDayPress: (d: Date) => void;
524
542
  }
525
543
 
@@ -541,6 +559,7 @@ const MonthGrid = memo(function MonthGrid({
541
559
  manyKeys,
542
560
  tint,
543
561
  fullLabel,
562
+ reduce,
544
563
  onDayPress,
545
564
  }: MonthGridProps) {
546
565
  const weeks = useMemo(() => buildWeeks(month, weekStartsOn, fixedWeeks), [month, weekStartsOn, fixedWeeks]);
@@ -595,6 +614,7 @@ const MonthGrid = memo(function MonthGrid({
595
614
  highlight={highlightedDates?.[key]}
596
615
  tile={tiles && inMonth && !inBand}
597
616
  tint={tint}
617
+ reduce={reduce}
598
618
  onPress={onDayPress}
599
619
  />
600
620
  );
@@ -620,6 +640,7 @@ interface DayCellProps {
620
640
  highlight?: string;
621
641
  tile: boolean;
622
642
  tint: string;
643
+ reduce: boolean;
623
644
  onPress: (d: Date) => void;
624
645
  }
625
646
 
@@ -638,6 +659,7 @@ const DayCell = memo(function DayCell({
638
659
  highlight,
639
660
  tile,
640
661
  tint,
662
+ reduce,
641
663
  onPress,
642
664
  }: DayCellProps) {
643
665
  const { colors, radius, font } = useKit();
@@ -646,8 +668,10 @@ const DayCell = memo(function DayCell({
646
668
  const pop = useRef(new Animated.Value(selected ? 1 : 0)).current; // native: circle scale + opacity
647
669
  const bandIn = useRef(new Animated.Value(band === 'none' ? 0 : 1)).current; // native: band opacity
648
670
  useEffect(() => {
649
- Animated.spring(pop, { toValue: selected ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
650
- }, [selected, pop]);
671
+ // Reduce Motion: the selection fills in with a quick fade, no scale pop.
672
+ if (reduce) Animated.timing(pop, { toValue: selected ? 1 : 0, duration: 150, useNativeDriver: true }).start();
673
+ else Animated.spring(pop, { toValue: selected ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
674
+ }, [selected, pop, reduce]);
651
675
  useEffect(() => {
652
676
  Animated.timing(bandIn, { toValue: band === 'none' ? 0 : 1, duration: motion.normal, useNativeDriver: true }).start();
653
677
  }, [band, bandIn]);
@@ -714,12 +738,13 @@ const DayCell = memo(function DayCell({
714
738
  borderRadius: r,
715
739
  backgroundColor: tint,
716
740
  opacity: pop.interpolate({ inputRange: [0, 0.4], outputRange: [0, 1], extrapolate: 'clamp' }),
717
- transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.45, 1] }) }],
741
+ transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.45, 1] }) }],
718
742
  },
719
743
  ]}
720
744
  />
721
745
  <View pointerEvents="none" style={styles.label}>
722
746
  <Text
747
+ maxFontSizeMultiplier={FONT_CAP}
723
748
  style={[
724
749
  styles.day,
725
750
  ff,
@@ -772,11 +797,15 @@ function NavButton({ dir, disabled, onPress }: { dir: 1 | -1; disabled: boolean;
772
797
  );
773
798
  }
774
799
 
775
- function Chevron({ turned, color }: { turned: boolean; color: string }) {
800
+ function Chevron({ turned, color, reduce }: { turned: boolean; color: string; reduce: boolean }) {
776
801
  const turn = useRef(new Animated.Value(turned ? 1 : 0)).current; // native
777
802
  useEffect(() => {
803
+ if (reduce) {
804
+ turn.setValue(turned ? 1 : 0);
805
+ return;
806
+ }
778
807
  Animated.spring(turn, { toValue: turned ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
779
- }, [turned, turn]);
808
+ }, [turned, turn, reduce]);
780
809
  return (
781
810
  <Animated.View style={{ transform: [{ rotate: turn.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] }) }] }}>
782
811
  <Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
@@ -786,6 +815,22 @@ function Chevron({ turned, color }: { turned: boolean; color: string }) {
786
815
  );
787
816
  }
788
817
 
818
+ function useReduceMotion() {
819
+ const [reduce, setReduce] = useState(false);
820
+ useEffect(() => {
821
+ let alive = true;
822
+ AccessibilityInfo.isReduceMotionEnabled()
823
+ .then((r) => alive && setReduce(r))
824
+ .catch(() => {});
825
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
826
+ return () => {
827
+ alive = false;
828
+ sub.remove();
829
+ };
830
+ }, []);
831
+ return reduce;
832
+ }
833
+
789
834
  const styles = StyleSheet.create({
790
835
  root: { alignSelf: 'stretch' },
791
836
  header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10, minHeight: 40 },
@@ -793,7 +838,7 @@ const styles = StyleSheet.create({
793
838
  title: { fontSize: 17, fontWeight: '400', letterSpacing: -0.3, flexShrink: 1 },
794
839
  navs: { flexDirection: 'row', alignItems: 'center', gap: 6 },
795
840
  nav: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
796
- todayBtn: { height: 32, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },
841
+ todayBtn: { minHeight: 32, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },
797
842
  todayText: { fontSize: 13, fontWeight: '400' },
798
843
  weekRow: { flexDirection: 'row', marginBottom: 6 },
799
844
  weekday: { flex: 1, textAlign: 'center', fontSize: 13, fontWeight: '400', letterSpacing: 0.2 },
@@ -821,6 +866,6 @@ const styles = StyleSheet.create({
821
866
  dot: { width: 5, height: 5, borderRadius: 2.5 },
822
867
  pickGrid: { flexDirection: 'row', flexWrap: 'wrap', alignContent: 'center', paddingTop: 4 },
823
868
  pickCell: { width: '33.333%', height: 56, alignItems: 'center', justifyContent: 'center' },
824
- pickInner: { minWidth: 76, height: 40, paddingHorizontal: 14, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5 },
869
+ pickInner: { minWidth: 76, minHeight: 40, paddingHorizontal: 14, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5 },
825
870
  pickText: { fontSize: 15, fontWeight: '400' },
826
871
  });
@@ -1,5 +1,6 @@
1
- import React, { createContext, useContext, useId, useRef, useState, type ComponentRef } from 'react';
1
+ import React, { createContext, useContext, useEffect, useId, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Image,
5
6
  Platform,
@@ -67,6 +68,7 @@ export function Card({
67
68
  const interactive = !!(onPress || onLongPress || pressable);
68
69
  const corner = radius.scale(18);
69
70
  const gid = 'card' + useId().replace(/[^a-zA-Z0-9]/g, '');
71
+ const reduce = useReduceMotion();
70
72
 
71
73
  const scale = useRef(new Animated.Value(1)).current;
72
74
  const glow = useRef(new Animated.Value(0)).current;
@@ -87,15 +89,17 @@ export function Card({
87
89
 
88
90
  const border = (surfaces[variant].borderWidth as number | undefined) ?? 0;
89
91
 
92
+ // Reduce Motion: no press squish and the spotlight doesn't grow; the glow still fades in and out.
90
93
  const pressIn = (e: GestureResponderEvent) => {
91
- Animated.spring(scale, { toValue: 0.975, useNativeDriver: true, ...motion.snappy }).start();
94
+ if (!reduce) Animated.spring(scale, { toValue: 0.975, useNativeDriver: true, ...motion.snappy }).start();
92
95
  if (highlight === 'none') return;
93
96
  moveSpot(e);
94
97
  Animated.spring(glow, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();
95
98
  };
96
99
  const pressOut = () => {
97
- Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
98
- if (highlight !== 'none') Animated.timing(glow, { toValue: 0, duration: 420, useNativeDriver: true }).start();
100
+ if (reduce) scale.setValue(1);
101
+ else Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
102
+ if (highlight !== 'none') Animated.timing(glow, { toValue: 0, duration: reduce ? 150 : 420, useNativeDriver: true }).start();
99
103
  };
100
104
  // Touch coordinates are relative to whichever child was hit, so work from page coordinates.
101
105
  const clipRef = useRef<ComponentRef<typeof View>>(null);
@@ -125,7 +129,7 @@ export function Card({
125
129
  transform: [
126
130
  { translateX: spot.x },
127
131
  { translateY: spot.y },
128
- { scale: glow.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) },
132
+ { scale: glow.interpolate({ inputRange: [0, 1], outputRange: reduce ? [1, 1] : [0.4, 1] }) },
129
133
  ],
130
134
  },
131
135
  ]}
@@ -288,6 +292,22 @@ function shadow(opacity: number, y: number, blur: number, elevation = 2): ViewSt
288
292
  return { shadowColor: '#000', shadowOpacity: opacity, shadowRadius: blur / 2, shadowOffset: { width: 0, height: y }, elevation };
289
293
  }
290
294
 
295
+ function useReduceMotion() {
296
+ const [reduce, setReduce] = useState(false);
297
+ useEffect(() => {
298
+ let alive = true;
299
+ AccessibilityInfo.isReduceMotionEnabled()
300
+ .then((r) => alive && setReduce(r))
301
+ .catch(() => {});
302
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
303
+ return () => {
304
+ alive = false;
305
+ sub.remove();
306
+ };
307
+ }, []);
308
+ return reduce;
309
+ }
310
+
291
311
  const styles = StyleSheet.create({
292
312
  clip: { overflow: 'hidden' },
293
313
  spot: { position: 'absolute', top: 0, left: 0 },
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
- import { Animated, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { AccessibilityInfo, Animated, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import Svg, { Path } from 'react-native-svg';
4
4
 
5
5
  import { alpha, useKit } from '@/lib/kit';
@@ -39,6 +39,7 @@ export function Checkbox({
39
39
  style,
40
40
  }: CheckboxProps) {
41
41
  const { colors, radius, font } = useKit();
42
+ const reduce = useReduceMotion();
42
43
  const ff = font.family ? { fontFamily: font.family } : null;
43
44
  const tint = accent ?? colors.accent;
44
45
  const corner = shape === 'circle' ? size / 2 : Math.min(size / 2, radius.sm + 1);
@@ -50,12 +51,17 @@ export function Checkbox({
50
51
  const burst = useRef(new Animated.Value(0)).current;
51
52
 
52
53
  useEffect(() => {
54
+ // Reduce Motion: a short fill fade, no burst ring and no press squish.
55
+ if (reduce) {
56
+ Animated.timing(progress, { toValue: on ? 1 : 0, duration: 150, useNativeDriver: false }).start();
57
+ return;
58
+ }
53
59
  Animated.spring(progress, { toValue: on ? 1 : 0, useNativeDriver: false, speed: 14, bounciness: 0 }).start();
54
60
  if (on) {
55
61
  burst.setValue(0);
56
62
  Animated.timing(burst, { toValue: 1, duration: 450, useNativeDriver: true }).start();
57
63
  }
58
- }, [on, progress, burst]);
64
+ }, [on, progress, burst, reduce]);
59
65
 
60
66
  const toggle = () => {
61
67
  if (checked === undefined) setInner(!on);
@@ -72,8 +78,8 @@ export function Checkbox({
72
78
  accessibilityState={{ checked: on, disabled }}
73
79
  disabled={disabled}
74
80
  onPress={toggle}
75
- onPressIn={() => Animated.spring(press, { toValue: 0.82, useNativeDriver: true, speed: 50 }).start()}
76
- onPressOut={() => Animated.spring(press, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 0 }).start()}
81
+ onPressIn={() => !reduce && Animated.spring(press, { toValue: 0.82, useNativeDriver: true, speed: 50, bounciness: 0 }).start()}
82
+ onPressOut={() => !reduce && Animated.spring(press, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 0 }).start()}
77
83
  style={[styles.row, { opacity: disabled ? 0.5 : 1 }, style]}
78
84
  >
79
85
  <Animated.View style={{ transform: [{ scale: press }] }}>
@@ -139,6 +145,22 @@ export function Checkbox({
139
145
  );
140
146
  }
141
147
 
148
+ function useReduceMotion() {
149
+ const [reduce, setReduce] = useState(false);
150
+ useEffect(() => {
151
+ let alive = true;
152
+ AccessibilityInfo.isReduceMotionEnabled()
153
+ .then((r) => alive && setReduce(r))
154
+ .catch(() => {});
155
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
156
+ return () => {
157
+ alive = false;
158
+ sub.remove();
159
+ };
160
+ }, []);
161
+ return reduce;
162
+ }
163
+
142
164
  const styles = StyleSheet.create({
143
165
  row: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
144
166
  box: { borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Pressable,
5
6
  ScrollView,
@@ -56,6 +57,10 @@ export interface ChipGroupProps {
56
57
  style?: StyleProp<ViewStyle>;
57
58
  }
58
59
 
60
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
61
+ // content text scales all the way.
62
+ const FONT_CAP = 1.35;
63
+
59
64
  const SIZES: Record<ChipGroupSize, { h: number; px: number; font: number; check: number }> = {
60
65
  sm: { h: 30, px: 12, font: 13, check: 14 },
61
66
  md: { h: 36, px: 14, font: 14.5, check: 16 },
@@ -84,6 +89,7 @@ export function ChipGroup({
84
89
  style,
85
90
  }: ChipGroupProps) {
86
91
  const { colors } = useKit();
92
+ const reduce = useReduceMotion();
87
93
  const tint = accent ?? colors.accent;
88
94
  const items: ChipOption[] = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
89
95
  const [inner, setInner] = useState<string[]>(defaultValue);
@@ -114,6 +120,7 @@ export function ChipGroup({
114
120
  showCheck={showCheck}
115
121
  multiple={multiple}
116
122
  disabled={disabled || o.disabled}
123
+ reduce={reduce}
117
124
  />
118
125
  ));
119
126
 
@@ -148,9 +155,10 @@ interface ChipProps {
148
155
  showCheck: boolean;
149
156
  multiple: boolean;
150
157
  disabled?: boolean;
158
+ reduce: boolean;
151
159
  }
152
160
 
153
- function Chip({ option, active, onPress, variant, size, tint, showCheck, multiple, disabled }: ChipProps) {
161
+ function Chip({ option, active, onPress, variant, size, tint, showCheck, multiple, disabled, reduce }: ChipProps) {
154
162
  const { colors, radius, font } = useKit();
155
163
  const ff = font.family ? { fontFamily: font.family } : null;
156
164
  // p: colours, check width + stroke (JS driver). press: squish (native, on the outer view).
@@ -158,11 +166,15 @@ function Chip({ option, active, onPress, variant, size, tint, showCheck, multipl
158
166
  const press = useRef(new Animated.Value(1)).current;
159
167
 
160
168
  useEffect(() => {
161
- Animated.spring(p, { toValue: active ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
162
- }, [active, p]);
169
+ // Reduce Motion: the chip swaps state at once (no widening, no check draw).
170
+ if (reduce) p.setValue(active ? 1 : 0);
171
+ else Animated.spring(p, { toValue: active ? 1 : 0, useNativeDriver: false, ...motion.snappy }).start();
172
+ }, [active, p, reduce]);
163
173
 
164
- const pressTo = (to: number) =>
174
+ const pressTo = (to: number) => {
175
+ if (reduce) return;
165
176
  Animated.spring(press, { toValue: to, useNativeDriver: true, ...(to < 1 ? motion.snappy : motion.bouncy) }).start();
177
+ };
166
178
 
167
179
  const look: Record<ChipGroupVariant, { bg: [string, string]; border: [string, string]; fg: [string, string] }> = {
168
180
  solid: {
@@ -201,7 +213,7 @@ function Chip({ option, active, onPress, variant, size, tint, showCheck, multipl
201
213
  style={[
202
214
  styles.chip,
203
215
  {
204
- height: size.h,
216
+ minHeight: size.h,
205
217
  borderRadius: radius.capsule(size.h),
206
218
  paddingHorizontal: size.px - 1,
207
219
  backgroundColor: p.interpolate({ ...range, outputRange: l.bg }),
@@ -238,6 +250,7 @@ function Chip({ option, active, onPress, variant, size, tint, showCheck, multipl
238
250
  {option.label ? (
239
251
  <Animated.Text
240
252
  numberOfLines={1}
253
+ maxFontSizeMultiplier={FONT_CAP}
241
254
  style={[styles.label, ff, { fontSize: size.font, color: p.interpolate({ ...range, outputRange: l.fg }) }]}
242
255
  >
243
256
  {option.label}
@@ -245,6 +258,7 @@ function Chip({ option, active, onPress, variant, size, tint, showCheck, multipl
245
258
  ) : null}
246
259
  {option.count !== undefined ? (
247
260
  <Animated.Text
261
+ maxFontSizeMultiplier={FONT_CAP}
248
262
  style={[
249
263
  styles.count,
250
264
  ff,
@@ -260,6 +274,22 @@ function Chip({ option, active, onPress, variant, size, tint, showCheck, multipl
260
274
  );
261
275
  }
262
276
 
277
+ function useReduceMotion() {
278
+ const [reduce, setReduce] = useState(false);
279
+ useEffect(() => {
280
+ let alive = true;
281
+ AccessibilityInfo.isReduceMotionEnabled()
282
+ .then((r) => alive && setReduce(r))
283
+ .catch(() => {});
284
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
285
+ return () => {
286
+ alive = false;
287
+ sub.remove();
288
+ };
289
+ }, []);
290
+ return reduce;
291
+ }
292
+
263
293
  const styles = StyleSheet.create({
264
294
  row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
265
295
  wrap: { flexWrap: 'wrap' },