@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,11 +1,14 @@
1
1
  import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
5
+ Easing,
4
6
  PanResponder,
5
7
  Pressable,
6
8
  StyleSheet,
7
9
  Text,
8
10
  View,
11
+ useWindowDimensions,
9
12
  type AccessibilityActionEvent,
10
13
  type GestureResponderEvent,
11
14
  type StyleProp,
@@ -110,13 +113,22 @@ const SIZES: Record<SliderSize, { track: number; stepped: number; thumb: number;
110
113
  md: { track: 4, stepped: 8, thumb: 26, bar: 46, icon: 20, font: 13, hit: 44 },
111
114
  lg: { track: 6, stepped: 10, thumb: 30, bar: 58, icon: 24, font: 15, hit: 52 },
112
115
  };
116
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
117
+ // content text scales all the way.
118
+ const FONT_CAP = 1.35;
113
119
  const BUBBLE_H = 28;
114
120
  const BUBBLE_GAP = 10;
115
121
  const BUBBLE_W = 120;
116
122
  /** Below this many points per step the thumb follows the finger and snaps on release. */
117
123
  const SNAP_PX = 14;
124
+ const SMOOTH = Easing.bezier(0.32, 0.72, 0, 1);
118
125
 
119
126
  const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
127
+ /** Native-driven move to `toValue`: a snappy spring, or a short ease when Reduce Motion is on. */
128
+ const glide = (v: Animated.Value, toValue: number, reduce: boolean) =>
129
+ reduce
130
+ ? Animated.timing(v, { toValue, duration: 150, easing: SMOOTH, useNativeDriver: true })
131
+ : Animated.spring(v, { toValue, useNativeDriver: true, ...motion.snappy });
120
132
  const decimals = (n: number) => (String(n).split('.')[1] ?? '').length;
121
133
 
122
134
  /**
@@ -163,9 +175,12 @@ export function Slider(props: SliderProps) {
163
175
 
164
176
  const { colors, radius, dark, font } = useKit();
165
177
  const ff = font.family ? { fontFamily: font.family } : null;
178
+ const reduce = useReduceMotion();
166
179
  const tint = accent ?? colors.accent;
167
180
  const onTint = accent ? readableOn(accent) : colors.accentForeground;
168
181
  const sz = SIZES[size];
182
+ // Text scale the capped labels actually render at, for the label row's height.
183
+ const fontScale = Math.min(useWindowDimensions().fontScale, FONT_CAP);
169
184
 
170
185
  const T = isBar ? 0 : sz.thumb; // thumb diameter
171
186
  const inset = T / 2; // thumb centre travels [inset, w - inset]
@@ -211,29 +226,24 @@ export function Slider(props: SliderProps) {
211
226
  const held = useRef(new Animated.Value(0)).current;
212
227
 
213
228
  // Everything the (once-created) PanResponder reads, kept fresh every render.
214
- const live = useRef({ vals, w, inset, step, span, min, range, isBar, T, disabled, controlled, snap, ratioOf, fmt, out, onValueChange, onSlidingStart, onSlidingComplete });
215
- live.current = { vals, w, inset, step, span, min, range, isBar, T, disabled, controlled, snap, ratioOf, fmt, out, onValueChange, onSlidingStart, onSlidingComplete };
229
+ const live = useRef({ vals, w, inset, step, span, min, range, isBar, T, disabled, controlled, reduce, snap, ratioOf, fmt, out, onValueChange, onSlidingStart, onSlidingComplete });
230
+ live.current = { vals, w, inset, step, span, min, range, isBar, T, disabled, controlled, reduce, snap, ratioOf, fmt, out, onValueChange, onSlidingStart, onSlidingComplete };
216
231
  const drag = useRef({ dragging: false, thumb: 0, startX: 0, origin: 0, relative: false, moved: false, cur: [] as number[], target: [-1, -1] });
217
232
 
218
233
  // Follow value changes from outside (controlled updates, a11y, icon taps) when nobody is dragging.
219
234
  const key = vals.join(',');
220
235
  useEffect(() => {
221
236
  if (drag.current.dragging) return;
222
- vals.forEach((v, i) => Animated.spring(ratios[i], { toValue: ratioOf(v), useNativeDriver: true, ...motion.snappy }).start());
237
+ vals.forEach((v, i) => glide(ratios[i], ratioOf(v), reduce).start());
223
238
  // eslint-disable-next-line react-hooks/exhaustive-deps
224
239
  }, [key, min, max]);
225
240
 
241
+ // With Reduce Motion these only fade (the scale / lift outputs below are flattened).
226
242
  useEffect(() => {
227
- press.forEach((p, i) => Animated.spring(p, { toValue: active === i ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start());
228
- bubble.forEach((b, i) =>
229
- Animated.spring(b, {
230
- toValue: bubbleShown || (showValue === 'drag' && active === i) ? 1 : 0,
231
- useNativeDriver: true,
232
- ...motion.snappy,
233
- }).start()
234
- );
235
- Animated.spring(held, { toValue: active !== null ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
236
- }, [active, bubbleShown, showValue, press, bubble, held]);
243
+ press.forEach((p, i) => glide(p, active === i ? 1 : 0, reduce).start());
244
+ bubble.forEach((b, i) => glide(b, bubbleShown || (showValue === 'drag' && active === i) ? 1 : 0, reduce).start());
245
+ glide(held, active !== null ? 1 : 0, reduce).start();
246
+ }, [active, bubbleShown, showValue, press, bubble, held, reduce]);
237
247
 
238
248
  const pan = useMemo(() => {
239
249
  const usableOf = () => Math.max(1, live.current.w - 2 * live.current.inset);
@@ -249,7 +259,7 @@ export function Slider(props: SliderProps) {
249
259
  const coarse = !!L.step && (L.step / L.span) * usableOf() >= SNAP_PX;
250
260
  const target = coarse ? L.ratioOf(snapped) : L.ratioOf(v);
251
261
  if (animate || coarse) {
252
- if (target !== d.target[i]) Animated.spring(ratios[i], { toValue: target, useNativeDriver: true, ...motion.snappy }).start();
262
+ if (target !== d.target[i]) glide(ratios[i], target, L.reduce).start();
253
263
  } else {
254
264
  ratios[i].setValue(target);
255
265
  }
@@ -269,7 +279,7 @@ export function Slider(props: SliderProps) {
269
279
  const d = drag.current;
270
280
  if (!d.dragging) return;
271
281
  if (seek) moveTo(d.startX, true);
272
- Animated.spring(ratios[d.thumb], { toValue: L.ratioOf(d.cur[d.thumb]), useNativeDriver: true, ...motion.snappy }).start();
282
+ glide(ratios[d.thumb], L.ratioOf(d.cur[d.thumb]), L.reduce).start();
273
283
  d.dragging = false;
274
284
  d.target = [-1, -1];
275
285
  if (!L.controlled) setInner(d.cur);
@@ -350,17 +360,18 @@ export function Slider(props: SliderProps) {
350
360
  };
351
361
  }, [w, inset, usable, range, slotW, ratios]);
352
362
 
363
+ // Reduce Motion: no swell, no pop-up — the halo and tooltip just fade.
353
364
  const anim = useMemo(
354
365
  () => ({
355
- thumb: press.map((p) => [{ scale: p.interpolate({ inputRange: [0, 1], outputRange: [1, 1.14] }) }]),
356
- halo: press.map((p) => [{ scale: p.interpolate({ inputRange: [0, 1], outputRange: [1, 1.75] }) }]),
366
+ thumb: press.map((p) => [{ scale: p.interpolate({ inputRange: [0, 1], outputRange: [1, reduce ? 1 : 1.14] }) }]),
367
+ halo: press.map((p) => [{ scale: p.interpolate({ inputRange: [0, 1], outputRange: [1, reduce ? 1 : 1.75] }) }]),
357
368
  bubble: bubble.map((b) => [
358
- { translateY: b.interpolate({ inputRange: [0, 1], outputRange: [6, 0] }) },
359
- { scale: b.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }) },
369
+ { translateY: b.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 6, 0] }) },
370
+ { scale: b.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.6, 1] }) },
360
371
  ]),
361
- held: [{ scale: held.interpolate({ inputRange: [0, 1], outputRange: [1, 1.025] }) }],
372
+ held: [{ scale: held.interpolate({ inputRange: [0, 1], outputRange: [1, reduce ? 1 : 1.025] }) }],
362
373
  }),
363
- [press, bubble, held]
374
+ [press, bubble, held, reduce]
364
375
  );
365
376
 
366
377
  // ---- marks ----------------------------------------------------------------------------------
@@ -413,7 +424,7 @@ export function Slider(props: SliderProps) {
413
424
  <View style={[styles.barContent, { width: w, height: trackH, paddingHorizontal: Math.round(trackH * 0.32) }]}>
414
425
  <View>{icon !== undefined ? drawIcon(icon, iconColor) : null}</View>
415
426
  {showValue === 'always' ? (
416
- <Text numberOfLines={1} selectable={false} style={[styles.barValue, ff, { color: textColor, fontSize: sz.font + 1 }, labelStyle]}>
427
+ <Text numberOfLines={1} selectable={false} maxFontSizeMultiplier={FONT_CAP} style={[styles.barValue, ff, { color: textColor, fontSize: sz.font + 1 }, labelStyle]}>
417
428
  {range ? `${fmt(vals[0])} – ${fmt(vals[1])}` : fmt(vals[0])}
418
429
  </Text>
419
430
  ) : null}
@@ -425,6 +436,7 @@ export function Slider(props: SliderProps) {
425
436
  key={key}
426
437
  numberOfLines={1}
427
438
  selectable={false}
439
+ maxFontSizeMultiplier={FONT_CAP}
428
440
  style={[styles.markLabel, place, ff, { fontSize: sz.font - 1, color: lit ? colors.foreground : colors.mutedForeground }, labelStyle]}
429
441
  >
430
442
  {text}
@@ -570,7 +582,7 @@ export function Slider(props: SliderProps) {
570
582
  ]}
571
583
  >
572
584
  <View style={[styles.bubble, { backgroundColor: colors.foreground, borderRadius: radius.capsule(BUBBLE_H) }]}>
573
- <Text numberOfLines={1} selectable={false} style={[styles.bubbleText, ff, { color: colors.background, fontSize: sz.font }, labelStyle]}>
585
+ <Text numberOfLines={1} selectable={false} maxFontSizeMultiplier={FONT_CAP} style={[styles.bubbleText, ff, { color: colors.background, fontSize: sz.font }, labelStyle]}>
574
586
  {fmt(v)}
575
587
  </Text>
576
588
  </View>
@@ -583,7 +595,7 @@ export function Slider(props: SliderProps) {
583
595
  </View>
584
596
 
585
597
  {hasMarkLabels || minMax ? (
586
- <View pointerEvents="none" style={[styles.labels, { height: sz.font + 8 }]}>
598
+ <View pointerEvents="none" style={[styles.labels, { height: Math.round(sz.font * fontScale) + 8 }]}>
587
599
  {hasMarkLabels
588
600
  ? markList.map((m) => {
589
601
  if (!m.label || !w) return null;
@@ -672,6 +684,22 @@ export function SliderIcon({ name, size = 20, color }: { name: SliderIconName; s
672
684
  );
673
685
  }
674
686
 
687
+ function useReduceMotion() {
688
+ const [reduce, setReduce] = useState(false);
689
+ useEffect(() => {
690
+ let alive = true;
691
+ AccessibilityInfo.isReduceMotionEnabled()
692
+ .then((r) => alive && setReduce(r))
693
+ .catch(() => {});
694
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
695
+ return () => {
696
+ alive = false;
697
+ sub.remove();
698
+ };
699
+ }, []);
700
+ return reduce;
701
+ }
702
+
675
703
  const styles = StyleSheet.create({
676
704
  root: { alignSelf: 'stretch' },
677
705
  row: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
@@ -700,7 +728,7 @@ const styles = StyleSheet.create({
700
728
  },
701
729
  bubbleWrap: { position: 'absolute', width: BUBBLE_W, alignItems: 'center', transformOrigin: 'bottom' },
702
730
  bubble: {
703
- height: BUBBLE_H,
731
+ minHeight: BUBBLE_H,
704
732
  paddingHorizontal: 10,
705
733
  minWidth: 36,
706
734
  alignItems: 'center',
@@ -6,6 +6,7 @@ import {
6
6
  Pressable,
7
7
  StyleSheet,
8
8
  View,
9
+ useWindowDimensions,
9
10
  type LayoutChangeEvent,
10
11
  type StyleProp,
11
12
  type TextStyle,
@@ -67,6 +68,9 @@ const SIZES: Record<StatefulButtonSize, { h: number; px: number; font: number; i
67
68
  lg: { h: 54, px: 22, font: 17, icon: 20, gap: 9 },
68
69
  };
69
70
  const STAGGER = 22;
71
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
72
+ // content text scales all the way.
73
+ const FONT_CAP = 1.35;
70
74
  const END = 1e6;
71
75
  // Connected scripts (Arabic, Indic, Thai…) roll as one piece instead of letter by letter.
72
76
  const CONNECTED = /[֐-ࣿऀ-෿฀-໿ༀ-࿿က-႟ក-៿יִ-﷿ﹰ-]/;
@@ -131,6 +135,7 @@ function Letters({ text, style, lh, space, anims, onLayout }: LettersProps) {
131
135
  <View key={i} style={{ height: lh, overflow: 'hidden' }}>
132
136
  <Animated.Text
133
137
  numberOfLines={1}
138
+ maxFontSizeMultiplier={FONT_CAP}
134
139
  style={[style, anims?.[i] && { opacity: anims[i].opacity, transform: [{ translateY: anims[i].translateY }] }]}
135
140
  >
136
141
  {ch}
@@ -252,8 +257,11 @@ export function StatefulButton({
252
257
  const reduce = useReduceMotion();
253
258
  const s = SIZES[size];
254
259
  const fontSize = StyleSheet.flatten(textStyle)?.fontSize ?? s.font;
255
- const lh = Math.round(fontSize * 1.3);
256
- const space = fontSize * 0.28;
260
+ // Letter boxes clip to one line, so they grow with the (capped) text size; the Text's own lineHeight scales itself.
261
+ const textScale = Math.min(Math.max(useWindowDimensions().fontScale || 1, 1), FONT_CAP);
262
+ const lineHeight = Math.round(fontSize * 1.3);
263
+ const lh = Math.ceil(lineHeight * textScale);
264
+ const space = fontSize * 0.28 * textScale;
257
265
 
258
266
  const [inner, setInner] = useState<StatefulButtonState>('idle');
259
267
  const current = state ?? inner;
@@ -421,7 +429,7 @@ export function StatefulButton({
421
429
  setWidths((prev) => (prev[text] === w ? prev : { ...prev, [text]: w }));
422
430
  };
423
431
 
424
- const textBase: StyleProp<TextStyle> = [styles.label, { fontSize: s.font, fontFamily: font.family }, textStyle, { lineHeight: lh }];
432
+ const textBase: StyleProp<TextStyle> = [styles.label, { fontSize: s.font, fontFamily: font.family }, textStyle, { lineHeight }];
425
433
  // Press feedback: a light wash on dark fills, a faint shade on light or transparent ones.
426
434
  const wash = variant === 'primary' && readableOn(look[0].bg) === '#ffffff' ? alpha('#ffffff', 0.14) : alpha(colors.foreground, 0.07);
427
435
  const corner = pill ? radius.capsule(s.h) : radius.md;
@@ -444,7 +452,7 @@ export function StatefulButton({
444
452
  style={[
445
453
  styles.surface,
446
454
  {
447
- height: s.h,
455
+ minHeight: s.h,
448
456
  minWidth: s.h,
449
457
  paddingHorizontal: s.px,
450
458
  borderRadius: corner,
@@ -1,10 +1,12 @@
1
1
  import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import {
3
+ AccessibilityInfo,
3
4
  Animated,
4
5
  Pressable,
5
6
  StyleSheet,
6
7
  Text,
7
8
  View,
9
+ useWindowDimensions,
8
10
  type AccessibilityActionEvent,
9
11
  type LayoutChangeEvent,
10
12
  type StyleProp,
@@ -65,6 +67,10 @@ export interface StepperProps {
65
67
  valueStyle?: StyleProp<TextStyle>;
66
68
  }
67
69
 
70
+ // Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
71
+ // content text scales all the way.
72
+ const FONT_CAP = 1.35;
73
+
68
74
  /** Every dimension in one table so the sizes read clearly different. */
69
75
  const SIZES: Record<StepperSize | 'xs', { btn: number; font: number; icon: number; num: number }> = {
70
76
  xs: { btn: 28, font: 15, icon: 12, num: 26 },
@@ -120,6 +126,7 @@ export function Stepper({
120
126
  }: StepperProps) {
121
127
  const { colors, radius, font } = useKit();
122
128
  const ff = font.family ? { fontFamily: font.family } : null;
129
+ const reduce = useReduceMotion();
123
130
  const variant: StepperVariant = variantProp in LEGACY ? LEGACY[variantProp as StepperLegacyVariant] : (variantProp as StepperVariant);
124
131
  const vertical = variant !== 'cart' && (orientation ? orientation === 'vertical' : variantProp === 'vertical');
125
132
  const tint = accent ?? colors.accent;
@@ -134,9 +141,11 @@ export function Stepper({
134
141
  const [track, setTrack] = useState({ v: current, dir: 1 });
135
142
  if (track.v !== current) setTrack({ v: current, dir: current > track.v ? 1 : -1 });
136
143
 
137
- // Press past a limit: the number nudges toward the wall and springs back.
144
+ // Press past a limit: the number nudges toward the wall and springs back (not with Reduce Motion;
145
+ // the dimmed button already says it's the limit).
138
146
  const bump = useRef(new Animated.Value(0)).current;
139
147
  const nudge = (dir: 1 | -1) => {
148
+ if (reduce) return;
140
149
  Animated.sequence([
141
150
  Animated.timing(bump, { toValue: dir * 4, duration: 60, useNativeDriver: true }),
142
151
  Animated.spring(bump, { toValue: 0, useNativeDriver: true, ...motion.bouncy }),
@@ -238,6 +247,7 @@ export function Stepper({
238
247
  limit={dir === 1 ? atMax : atMin}
239
248
  disabled={!!disabled}
240
249
  label={a11y ?? `${dir === 1 ? 'Increase' : 'Decrease'}${title ? ` ${title}` : ''}`}
250
+ reduce={reduce}
241
251
  style={buttonStyle}
242
252
  onPressIn={() => startRepeat(dir)}
243
253
  onPressOut={stopRepeat}
@@ -246,9 +256,9 @@ export function Stepper({
246
256
 
247
257
  const number = (
248
258
  <View style={styles.roll}>
249
- <RollingText text={text} dir={track.dir} style={numStyle} />
259
+ <RollingText text={text} dir={track.dir} style={numStyle} reduce={reduce} />
250
260
  {suffixText ? (
251
- <Text style={[styles.num, styles.suffix, numStyle, { fontSize: fontSize * 0.82, color: isCart ? alpha(onCart, 0.8) : colors.mutedForeground }]}>
261
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, styles.suffix, numStyle, { fontSize: fontSize * 0.82, color: isCart ? alpha(onCart, 0.8) : colors.mutedForeground }]}>
252
262
  {suffixText}
253
263
  </Text>
254
264
  ) : null}
@@ -287,6 +297,7 @@ export function Stepper({
287
297
  addLabel={addLabel}
288
298
  bump={bump}
289
299
  disabled={!!disabled}
300
+ reduce={reduce}
290
301
  dec={button(-1, removes ? ICON.trash : ICON.minus, removes ? `Remove${title ? ` ${title}` : ''}` : undefined)}
291
302
  inc={button(1)}
292
303
  onAdd={() => startRepeat(1)}
@@ -300,7 +311,7 @@ export function Stepper({
300
311
  styles.value,
301
312
  vertical
302
313
  ? { minHeight: s.num, minWidth: s.btn, paddingVertical: 2 }
303
- : { minWidth: s.num, height: s.btn, paddingHorizontal: variant === 'outline' ? 8 : 4 },
314
+ : { minWidth: s.num, minHeight: s.btn, paddingHorizontal: variant === 'outline' ? 8 : 4 },
304
315
  { transform: [vertical ? { translateY: Animated.multiply(bump, -1) } : { translateX: bump }] },
305
316
  ]}
306
317
  >
@@ -340,6 +351,7 @@ function CartControl({
340
351
  addLabel,
341
352
  bump,
342
353
  disabled,
354
+ reduce,
343
355
  dec,
344
356
  inc,
345
357
  onAdd,
@@ -358,6 +370,7 @@ function CartControl({
358
370
  addLabel?: string;
359
371
  bump: Animated.Value;
360
372
  disabled: boolean;
373
+ reduce: boolean;
361
374
  dec: React.ReactNode;
362
375
  inc: React.ReactNode;
363
376
  onAdd: () => void;
@@ -380,18 +393,20 @@ function CartControl({
380
393
 
381
394
  const measured = textW > 0 && (!addLabel || addW > 0);
382
395
  useEffect(() => {
383
- // Snap until the rulers have reported; after that every change springs.
384
- if (!settled.current) {
396
+ // Snap until the rulers have reported; after that every change springs (or snaps, with Reduce Motion).
397
+ if (!settled.current || reduce) {
385
398
  width.setValue(target);
386
399
  settled.current = measured;
387
400
  return;
388
401
  }
389
402
  Animated.spring(width, { toValue: target, useNativeDriver: false, ...motion.snappy }).start();
390
- }, [target, measured, width]);
403
+ }, [target, measured, width, reduce]);
391
404
 
392
405
  useEffect(() => {
393
- Animated.spring(open, { toValue: collapsed ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
394
- }, [collapsed, open]);
406
+ // Reduce Motion: − and the number only fade in (their slide is flattened below).
407
+ if (reduce) Animated.timing(open, { toValue: collapsed ? 0 : 1, duration: 150, useNativeDriver: true }).start();
408
+ else Animated.spring(open, { toValue: collapsed ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
409
+ }, [collapsed, open, reduce]);
395
410
 
396
411
  const measure = (set: (n: number) => void) => (e: LayoutChangeEvent) => set(Math.ceil(e.nativeEvent.layout.width));
397
412
 
@@ -407,15 +422,15 @@ function CartControl({
407
422
  {/* Off-screen rulers inside a wide box, so nothing squeezes them. */}
408
423
  <View pointerEvents="none" style={[styles.ruler, styles.hidden]}>
409
424
  <View onLayout={measure(setTextW)} style={styles.row}>
410
- <Text style={[styles.num, numStyle]}>{text}</Text>
411
- {suffixText ? <Text style={[styles.num, styles.suffix, numStyle, { fontSize: (numStyle.fontSize ?? s.font) * 0.82 }]}>{suffixText}</Text> : null}
425
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, numStyle]}>{text}</Text>
426
+ {suffixText ? <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, styles.suffix, numStyle, { fontSize: (numStyle.fontSize ?? s.font) * 0.82 }]}>{suffixText}</Text> : null}
412
427
  </View>
413
428
  </View>
414
429
 
415
430
  <View style={[styles.cartRow, { width: openW }]} pointerEvents="box-none">
416
431
  <Animated.View
417
432
  pointerEvents={collapsed ? 'none' : 'auto'}
418
- style={[styles.row, { opacity: open, transform: [{ translateX: open.interpolate({ inputRange: [0, 1], outputRange: [s.btn * 0.5, 0] }) }] }]}
433
+ style={[styles.row, { opacity: open, transform: [{ translateX: open.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : s.btn * 0.5, 0] }) }] }]}
419
434
  >
420
435
  {dec}
421
436
  <Animated.View style={[styles.value, { width: valueW, height: s.btn, transform: [{ translateX: bump }] }]}>{number}</Animated.View>
@@ -441,8 +456,8 @@ function CartControl({
441
456
  onPressOut={onAddEnd}
442
457
  style={({ pressed }) => [styles.add, { height: s.btn, paddingHorizontal: Math.round(s.btn * 0.36) }, pressed && { backgroundColor: alpha(fg, 0.16) }]}
443
458
  >
444
- <Glyph d={ICON.plus} size={s.icon} color={fg} />
445
- <Text style={[styles.addText, ff, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
459
+ <Glyph d={ICON.plus} size={s.icon} color={fg} reduce={reduce} />
460
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.addText, ff, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
446
461
  </Pressable>
447
462
  </Animated.View>
448
463
  </View>
@@ -470,6 +485,7 @@ function StepButton({
470
485
  limit,
471
486
  disabled,
472
487
  label,
488
+ reduce,
473
489
  style,
474
490
  onPressIn,
475
491
  onPressOut,
@@ -485,6 +501,7 @@ function StepButton({
485
501
  limit: boolean;
486
502
  disabled: boolean;
487
503
  label: string;
504
+ reduce: boolean;
488
505
  style?: StyleProp<ViewStyle>;
489
506
  onPressIn: () => void;
490
507
  onPressOut: () => void;
@@ -492,7 +509,11 @@ function StepButton({
492
509
  // Scale and dim share one native-driven view; nothing JS-driven touches it.
493
510
  const scale = useRef(new Animated.Value(1)).current;
494
511
  const dim = useRef(new Animated.Value(limit ? 0.32 : 1)).current;
495
- const spring = (to: number) => Animated.spring(scale, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
512
+ // Reduce Motion: no press squeeze; the pressed background still shows the press.
513
+ const spring = (to: number) => {
514
+ if (reduce) scale.setValue(1);
515
+ else Animated.spring(scale, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
516
+ };
496
517
 
497
518
  useEffect(() => {
498
519
  Animated.timing(dim, { toValue: limit ? 0.32 : 1, duration: motion.fast, useNativeDriver: true }).start();
@@ -533,22 +554,23 @@ function StepButton({
533
554
  style,
534
555
  ]}
535
556
  >
536
- <Glyph d={glyph} size={icon} color={fg} />
557
+ <Glyph d={glyph} size={icon} color={fg} reduce={reduce} />
537
558
  </Pressable>
538
559
  </Animated.View>
539
560
  );
540
561
  }
541
562
 
542
563
  /** Stroked icon; pops in when its shape changes (− ↔ trash). */
543
- function Glyph({ d, size, color }: { d: string; size: number; color: string }) {
564
+ function Glyph({ d, size, color, reduce }: { d: string; size: number; color: string; reduce: boolean }) {
544
565
  const pop = useRef(new Animated.Value(1)).current;
545
566
  const prev = useRef(d);
546
567
  useEffect(() => {
547
568
  if (prev.current === d) return;
548
569
  prev.current = d;
570
+ if (reduce) return;
549
571
  pop.setValue(0.55);
550
572
  Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
551
- }, [d, pop]);
573
+ }, [d, pop, reduce]);
552
574
  const trash = d === ICON.trash;
553
575
  return (
554
576
  <Animated.View style={{ transform: [{ scale: pop }] }}>
@@ -559,37 +581,48 @@ function Glyph({ d, size, color }: { d: string; size: number; color: string }) {
559
581
  );
560
582
  }
561
583
 
562
- /** Text where every changed character slides out and its replacement slides in. */
563
- function RollingText({ text, dir, style }: { text: string; dir: number; style: TextStyle }) {
584
+ /** Text where every changed character slides out and its replacement slides in (swaps at once with Reduce Motion). */
585
+ function RollingText({ text, dir, style, reduce }: { text: string; dir: number; style: TextStyle; reduce: boolean }) {
564
586
  const chars = [...text];
565
587
  return (
566
588
  <View style={styles.roll}>
567
589
  {chars.map((c, i) => (
568
590
  // Keyed from the right so the ones digit stays the ones digit when the number grows.
569
- <RollingChar key={chars.length - i} char={c} dir={dir} style={style} />
591
+ <RollingChar key={chars.length - i} char={c} dir={dir} style={style} reduce={reduce} />
570
592
  ))}
571
593
  </View>
572
594
  );
573
595
  }
574
596
 
575
- function RollingChar({ char, dir, style }: { char: string; dir: number; style: TextStyle }) {
597
+ function RollingChar({ char, dir, style, reduce }: { char: string; dir: number; style: TextStyle; reduce: boolean }) {
576
598
  const [pair, setPair] = useState<{ cur: string; prev: string | null; dir: number }>({ cur: char, prev: null, dir });
577
599
  if (pair.cur !== char) setPair({ cur: char, prev: pair.cur, dir });
578
600
  const t = useRef(new Animated.Value(1)).current;
579
- const h = (style.lineHeight as number) ?? 24;
601
+ // The digit window follows the text's own (capped) Dynamic Type scale, so a scaled digit isn't cropped.
602
+ const { fontScale } = useWindowDimensions();
603
+ const h = ((style.lineHeight as number) ?? 24) * Math.min(fontScale, FONT_CAP);
580
604
 
605
+ // Each change rolls once (a Reduce Motion toggle alone doesn't replay it).
606
+ const played = useRef(pair);
581
607
  useLayoutEffect(() => {
582
- if (pair.prev === null) return;
608
+ if (pair.prev === null || played.current === pair) return;
609
+ played.current = pair;
610
+ if (reduce) {
611
+ // At 1 the old character is gone and the new one is in place: the final value, no roll.
612
+ t.setValue(1);
613
+ return;
614
+ }
583
615
  t.setValue(0);
584
616
  Animated.spring(t, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
585
- }, [pair, t]);
617
+ }, [pair, t, reduce]);
586
618
 
587
619
  const d = pair.dir * h * 0.8;
588
620
  return (
589
621
  <View style={[styles.charBox, { height: h }]}>
590
- <Text style={[styles.num, style, styles.hidden]}>{pair.cur}</Text>
622
+ <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.num, style, styles.hidden]}>{pair.cur}</Text>
591
623
  {pair.prev !== null ? (
592
624
  <Animated.Text
625
+ maxFontSizeMultiplier={FONT_CAP}
593
626
  style={[
594
627
  styles.num,
595
628
  styles.layer,
@@ -607,6 +640,7 @@ function RollingChar({ char, dir, style }: { char: string; dir: number; style: T
607
640
  </Animated.Text>
608
641
  ) : null}
609
642
  <Animated.Text
643
+ maxFontSizeMultiplier={FONT_CAP}
610
644
  style={[
611
645
  styles.num,
612
646
  styles.layer,
@@ -623,6 +657,22 @@ function RollingChar({ char, dir, style }: { char: string; dir: number; style: T
623
657
  );
624
658
  }
625
659
 
660
+ function useReduceMotion() {
661
+ const [reduce, setReduce] = useState(false);
662
+ useEffect(() => {
663
+ let alive = true;
664
+ AccessibilityInfo.isReduceMotionEnabled()
665
+ .then((r) => alive && setReduce(r))
666
+ .catch(() => {});
667
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
668
+ return () => {
669
+ alive = false;
670
+ sub.remove();
671
+ };
672
+ }, []);
673
+ return reduce;
674
+ }
675
+
626
676
  const styles = StyleSheet.create({
627
677
  // Hugs its content in a column without hijacking the cross-axis alignment of a row.
628
678
  self: { alignItems: 'flex-start', justifyContent: 'center' },