@expokit/cli 0.0.0-stage → 0.2.0

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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -2
  3. package/bin/expo-kit.js +1204 -0
  4. package/bin/lib.mjs +343 -0
  5. package/bin/mcp.mjs +338 -0
  6. package/package.json +44 -4
  7. package/registry/_docs/accordion.json +99 -0
  8. package/registry/_docs/action-sheet.json +110 -0
  9. package/registry/_docs/alert.json +127 -0
  10. package/registry/_docs/avatar.json +203 -0
  11. package/registry/_docs/badge.json +117 -0
  12. package/registry/_docs/bottom-nav.json +119 -0
  13. package/registry/_docs/bottom-sheet.json +158 -0
  14. package/registry/_docs/bullet.json +24 -0
  15. package/registry/_docs/button-3d.json +122 -0
  16. package/registry/_docs/button.json +115 -0
  17. package/registry/_docs/calendar.json +182 -0
  18. package/registry/_docs/card-stack.json +108 -0
  19. package/registry/_docs/card.json +111 -0
  20. package/registry/_docs/carousel.json +102 -0
  21. package/registry/_docs/checkbox.json +93 -0
  22. package/registry/_docs/checkout.json +102 -0
  23. package/registry/_docs/chip-group.json +116 -0
  24. package/registry/_docs/color-swatch.json +66 -0
  25. package/registry/_docs/confetti.json +82 -0
  26. package/registry/_docs/coupon.json +176 -0
  27. package/registry/_docs/cube-pager.json +84 -0
  28. package/registry/_docs/dashboard.json +120 -0
  29. package/registry/_docs/date-picker.json +249 -0
  30. package/registry/_docs/dialog.json +162 -0
  31. package/registry/_docs/drawer.json +171 -0
  32. package/registry/_docs/fab.json +120 -0
  33. package/registry/_docs/flip-card.json +111 -0
  34. package/registry/_docs/floating-input.json +191 -0
  35. package/registry/_docs/gift-card.json +108 -0
  36. package/registry/_docs/glass-button.json +118 -0
  37. package/registry/_docs/glass-nav-bar.json +78 -0
  38. package/registry/_docs/glass-search-bar.json +90 -0
  39. package/registry/_docs/glass-segmented.json +66 -0
  40. package/registry/_docs/glass-sheet.json +108 -0
  41. package/registry/_docs/glass-tab-bar.json +76 -0
  42. package/registry/_docs/glass.json +78 -0
  43. package/registry/_docs/inbox.json +72 -0
  44. package/registry/_docs/isometric-bar.json +72 -0
  45. package/registry/_docs/kit.json +11 -0
  46. package/registry/_docs/kpi-card.json +11 -0
  47. package/registry/_docs/like-button.json +72 -0
  48. package/registry/_docs/liquidglass-tab.json +27 -0
  49. package/registry/_docs/list-item.json +200 -0
  50. package/registry/_docs/loyalty-card.json +108 -0
  51. package/registry/_docs/morph-button.json +108 -0
  52. package/registry/_docs/number-ticker.json +96 -0
  53. package/registry/_docs/offers.json +78 -0
  54. package/registry/_docs/onboarding.json +66 -0
  55. package/registry/_docs/otp-input.json +134 -0
  56. package/registry/_docs/pagination-dots.json +88 -0
  57. package/registry/_docs/parallax-layers.json +84 -0
  58. package/registry/_docs/parallax-scroll.json +186 -0
  59. package/registry/_docs/password-input.json +92 -0
  60. package/registry/_docs/payment-card.json +153 -0
  61. package/registry/_docs/pixel-bar.json +66 -0
  62. package/registry/_docs/polar-area.json +78 -0
  63. package/registry/_docs/profile.json +84 -0
  64. package/registry/_docs/progress-steps.json +92 -0
  65. package/registry/_docs/progress.json +132 -0
  66. package/registry/_docs/radio-group.json +92 -0
  67. package/registry/_docs/rating.json +90 -0
  68. package/registry/_docs/ring-carousel.json +102 -0
  69. package/registry/_docs/scratch-card.json +90 -0
  70. package/registry/_docs/search-bar.json +140 -0
  71. package/registry/_docs/search-results.json +120 -0
  72. package/registry/_docs/segmented-control.json +103 -0
  73. package/registry/_docs/select.json +252 -0
  74. package/registry/_docs/settings.json +72 -0
  75. package/registry/_docs/shimmer-button.json +78 -0
  76. package/registry/_docs/sign-in.json +198 -0
  77. package/registry/_docs/skeleton.json +142 -0
  78. package/registry/_docs/slide-to-confirm.json +84 -0
  79. package/registry/_docs/slider.json +157 -0
  80. package/registry/_docs/spin-wheel.json +114 -0
  81. package/registry/_docs/spinner.json +91 -0
  82. package/registry/_docs/stepper.json +168 -0
  83. package/registry/_docs/swipe-deck.json +102 -0
  84. package/registry/_docs/swipeable-row.json +72 -0
  85. package/registry/_docs/switch.json +104 -0
  86. package/registry/_docs/tabs.json +105 -0
  87. package/registry/_docs/tag-input.json +128 -0
  88. package/registry/_docs/textarea.json +175 -0
  89. package/registry/_docs/ticket-card.json +174 -0
  90. package/registry/_docs/ticket-shape.json +11 -0
  91. package/registry/_docs/tilt-card.json +141 -0
  92. package/registry/_docs/time-picker.json +253 -0
  93. package/registry/_docs/timeline-chart.json +30 -0
  94. package/registry/_docs/timeline.json +156 -0
  95. package/registry/_docs/toast.json +161 -0
  96. package/registry/_docs/treemap.json +30 -0
  97. package/registry/_docs/waterfall.json +36 -0
  98. package/registry/_docs/wheel-list.json +102 -0
  99. package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
  100. package/registry/accordion/files/src/lib/kit.tsx +219 -0
  101. package/registry/accordion.json +17 -0
  102. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
  103. package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
  104. package/registry/action-sheet.json +18 -0
  105. package/registry/alert/files/src/components/ui/alert.tsx +654 -0
  106. package/registry/alert/files/src/components/ui/button.tsx +274 -0
  107. package/registry/alert/files/src/lib/kit.tsx +219 -0
  108. package/registry/alert.json +20 -0
  109. package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
  110. package/registry/avatar/files/src/lib/kit.tsx +219 -0
  111. package/registry/avatar.json +18 -0
  112. package/registry/badge/files/src/components/ui/badge.tsx +316 -0
  113. package/registry/badge/files/src/lib/kit.tsx +219 -0
  114. package/registry/badge.json +17 -0
  115. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
  116. package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
  117. package/registry/bottom-nav.json +18 -0
  118. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
  119. package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
  120. package/registry/bottom-sheet.json +17 -0
  121. package/registry/bullet.json +14 -0
  122. package/registry/button/files/src/components/ui/button.tsx +274 -0
  123. package/registry/button/files/src/lib/kit.tsx +219 -0
  124. package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
  125. package/registry/button-3d/files/src/lib/kit.tsx +219 -0
  126. package/registry/button-3d.json +18 -0
  127. package/registry/button.json +18 -0
  128. package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
  129. package/registry/calendar/files/src/lib/kit.tsx +219 -0
  130. package/registry/calendar.json +17 -0
  131. package/registry/card/files/src/components/ui/card.tsx +300 -0
  132. package/registry/card/files/src/lib/kit.tsx +219 -0
  133. package/registry/card-stack.json +15 -0
  134. package/registry/card.json +17 -0
  135. package/registry/carousel.json +15 -0
  136. package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
  137. package/registry/checkbox/files/src/lib/kit.tsx +219 -0
  138. package/registry/checkbox.json +17 -0
  139. package/registry/checkout.json +26 -0
  140. package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
  141. package/registry/chip-group/files/src/lib/kit.tsx +219 -0
  142. package/registry/chip-group.json +17 -0
  143. package/registry/color-swatch.json +17 -0
  144. package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
  145. package/registry/confetti/files/src/lib/kit.tsx +219 -0
  146. package/registry/confetti.json +15 -0
  147. package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
  148. package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
  149. package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
  150. package/registry/coupon/files/src/lib/kit.tsx +219 -0
  151. package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
  152. package/registry/coupon.json +23 -0
  153. package/registry/cube-pager.json +15 -0
  154. package/registry/dashboard.json +32 -0
  155. package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
  156. package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
  157. package/registry/date-picker/files/src/lib/kit.tsx +219 -0
  158. package/registry/date-picker.json +19 -0
  159. package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
  160. package/registry/dialog/files/src/lib/kit.tsx +219 -0
  161. package/registry/dialog.json +18 -0
  162. package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
  163. package/registry/drawer/files/src/lib/kit.tsx +219 -0
  164. package/registry/drawer.json +18 -0
  165. package/registry/fab.json +18 -0
  166. package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
  167. package/registry/flip-card/files/src/lib/kit.tsx +219 -0
  168. package/registry/flip-card.json +15 -0
  169. package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
  170. package/registry/floating-input/files/src/lib/kit.tsx +219 -0
  171. package/registry/floating-input.json +17 -0
  172. package/registry/gift-card.json +21 -0
  173. package/registry/glass-button.json +22 -0
  174. package/registry/glass-nav-bar.json +21 -0
  175. package/registry/glass-search-bar.json +21 -0
  176. package/registry/glass-segmented.json +22 -0
  177. package/registry/glass-sheet.json +22 -0
  178. package/registry/glass-tab-bar.json +22 -0
  179. package/registry/glass.json +19 -0
  180. package/registry/inbox.json +30 -0
  181. package/registry/isometric-bar.json +14 -0
  182. package/registry/kit/files/src/lib/kit.tsx +219 -0
  183. package/registry/kit.json +11 -0
  184. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
  185. package/registry/kpi-card.json +14 -0
  186. package/registry/like-button.json +18 -0
  187. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
  188. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
  189. package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
  190. package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
  191. package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
  192. package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
  193. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
  194. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
  195. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
  196. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
  197. package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
  198. package/registry/liquidglass-tab.json +23 -0
  199. package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
  200. package/registry/list-item/files/src/lib/kit.tsx +219 -0
  201. package/registry/list-item.json +17 -0
  202. package/registry/loyalty-card.json +23 -0
  203. package/registry/morph-button.json +18 -0
  204. package/registry/number-ticker.json +15 -0
  205. package/registry/offers.json +24 -0
  206. package/registry/onboarding.json +17 -0
  207. package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
  208. package/registry/otp-input/files/src/lib/kit.tsx +219 -0
  209. package/registry/otp-input.json +17 -0
  210. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
  211. package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
  212. package/registry/pagination-dots.json +15 -0
  213. package/registry/parallax-layers.json +15 -0
  214. package/registry/parallax-scroll.json +17 -0
  215. package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
  216. package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
  217. package/registry/password-input/files/src/lib/kit.tsx +219 -0
  218. package/registry/password-input.json +19 -0
  219. package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
  220. package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
  221. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
  222. package/registry/payment-card/files/src/lib/kit.tsx +219 -0
  223. package/registry/payment-card.json +21 -0
  224. package/registry/pixel-bar.json +14 -0
  225. package/registry/polar-area.json +14 -0
  226. package/registry/profile.json +17 -0
  227. package/registry/progress/files/src/components/ui/progress.tsx +883 -0
  228. package/registry/progress/files/src/lib/kit.tsx +219 -0
  229. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
  230. package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
  231. package/registry/progress-steps.json +18 -0
  232. package/registry/progress.json +17 -0
  233. package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
  234. package/registry/radio-group/files/src/lib/kit.tsx +219 -0
  235. package/registry/radio-group.json +18 -0
  236. package/registry/rating.json +18 -0
  237. package/registry/ring-carousel.json +15 -0
  238. package/registry/scratch-card.json +18 -0
  239. package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
  240. package/registry/search-bar/files/src/lib/kit.tsx +219 -0
  241. package/registry/search-bar.json +17 -0
  242. package/registry/search-results.json +30 -0
  243. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
  244. package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
  245. package/registry/segmented-control.json +18 -0
  246. package/registry/select/files/src/components/ui/select.tsx +1398 -0
  247. package/registry/select/files/src/lib/kit.tsx +219 -0
  248. package/registry/select.json +17 -0
  249. package/registry/settings.json +26 -0
  250. package/registry/shimmer-button.json +17 -0
  251. package/registry/sign-in.json +26 -0
  252. package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
  253. package/registry/skeleton/files/src/lib/kit.tsx +219 -0
  254. package/registry/skeleton.json +17 -0
  255. package/registry/slide-to-confirm.json +18 -0
  256. package/registry/slider/files/src/components/ui/slider.tsx +716 -0
  257. package/registry/slider/files/src/lib/kit.tsx +219 -0
  258. package/registry/slider.json +17 -0
  259. package/registry/spin-wheel.json +18 -0
  260. package/registry/spinner/files/src/components/ui/spinner.tsx +335 -0
  261. package/registry/spinner/files/src/lib/kit.tsx +219 -0
  262. package/registry/spinner.json +17 -0
  263. package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
  264. package/registry/stepper/files/src/lib/kit.tsx +219 -0
  265. package/registry/stepper.json +17 -0
  266. package/registry/swipe-deck.json +15 -0
  267. package/registry/swipeable-row.json +15 -0
  268. package/registry/switch/files/src/components/ui/switch.tsx +344 -0
  269. package/registry/switch/files/src/lib/kit.tsx +219 -0
  270. package/registry/switch.json +18 -0
  271. package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
  272. package/registry/tabs/files/src/lib/kit.tsx +219 -0
  273. package/registry/tabs.json +17 -0
  274. package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
  275. package/registry/tag-input/files/src/lib/kit.tsx +219 -0
  276. package/registry/tag-input.json +17 -0
  277. package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
  278. package/registry/textarea/files/src/lib/kit.tsx +219 -0
  279. package/registry/textarea.json +17 -0
  280. package/registry/ticket-card.json +24 -0
  281. package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
  282. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
  283. package/registry/ticket-shape.json +17 -0
  284. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
  285. package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
  286. package/registry/tilt-card.json +17 -0
  287. package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
  288. package/registry/time-picker/files/src/lib/kit.tsx +219 -0
  289. package/registry/time-picker.json +17 -0
  290. package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
  291. package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
  292. package/registry/timeline/files/src/lib/kit.tsx +219 -0
  293. package/registry/timeline-chart.json +14 -0
  294. package/registry/timeline.json +20 -0
  295. package/registry/toast/files/src/components/ui/button.tsx +274 -0
  296. package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
  297. package/registry/toast/files/src/lib/kit.tsx +219 -0
  298. package/registry/toast.json +20 -0
  299. package/registry/treemap.json +14 -0
  300. package/registry/waterfall.json +14 -0
  301. package/registry/wheel-list.json +17 -0
  302. package/schema/components.schema.json +13 -0
@@ -0,0 +1,1050 @@
1
+ import * as Haptics from 'expo-haptics';
2
+ import React, { useEffect, useId, useRef, useState } from 'react';
3
+ import {
4
+ AccessibilityInfo,
5
+ Animated,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ type LayoutChangeEvent,
11
+ type StyleProp,
12
+ type ViewStyle,
13
+ } from 'react-native';
14
+ import Svg, { Circle, Defs, LinearGradient, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
15
+
16
+ import { alpha, motion, readableOn, useKit } from '@/lib/kit';
17
+
18
+ /** The five looks. */
19
+ export type BottomNavLook = 'minimal' | 'floating' | 'pill' | 'center-fab' | 'indicator';
20
+
21
+ /**
22
+ * @deprecated Retired looks. They still render, as the closest kept look:
23
+ * `glass`, `neumorphic`, `dock`, `curved` → `floating`; `gradient`, `outline` → `pill`.
24
+ */
25
+ export type BottomNavLegacyVariant = 'glass' | 'neumorphic' | 'gradient' | 'outline' | 'curved' | 'dock';
26
+
27
+ export type BottomNavVariant = BottomNavLook | BottomNavLegacyVariant;
28
+
29
+ const LEGACY: Record<BottomNavLegacyVariant, BottomNavLook> = {
30
+ glass: 'floating',
31
+ neumorphic: 'floating',
32
+ dock: 'floating',
33
+ curved: 'floating',
34
+ gradient: 'pill',
35
+ outline: 'pill',
36
+ };
37
+
38
+ export type BottomNavIconName =
39
+ | 'home'
40
+ | 'explore'
41
+ | 'search'
42
+ | 'projects'
43
+ | 'saved'
44
+ | 'profile'
45
+ | 'inbox'
46
+ | 'activity'
47
+ | 'analytics'
48
+ | 'message'
49
+ | 'work'
50
+ | 'browse'
51
+ | 'library'
52
+ | 'music'
53
+ | 'create';
54
+
55
+ export interface BottomNavIconProps {
56
+ color: string;
57
+ size: number;
58
+ active: boolean;
59
+ }
60
+
61
+ export interface BottomNavItem {
62
+ key: string;
63
+ label: string;
64
+ /**
65
+ * A built-in icon name, or a render function for your own icon. Render functions are called
66
+ * twice (outline with `active: false`, filled with `active: true`) so the bar can morph between them.
67
+ */
68
+ icon: BottomNavIconName | ((props: BottomNavIconProps) => React.ReactNode);
69
+ /** `true` shows a dot; a number shows a count (99+ above 99). */
70
+ badge?: boolean | number;
71
+ /**
72
+ * A primary action (e.g. Create) instead of a destination: drawn as a solid accent button and never
73
+ * becomes the active tab. Pressing it calls `onAction`.
74
+ */
75
+ action?: boolean;
76
+ accessibilityLabel?: string;
77
+ }
78
+
79
+ export interface BottomNavProps {
80
+ items: BottomNavItem[];
81
+ value?: string;
82
+ defaultValue?: string;
83
+ onValueChange?: (key: string) => void;
84
+ /** Called with the item's key when an `action` item is pressed. */
85
+ onAction?: (key: string) => void;
86
+ variant?: BottomNavVariant;
87
+ /** Active colour and the action button fill. */
88
+ accent?: string;
89
+ /** Opt-in two-colour gradient for the action button (default: none — a solid accent fill). */
90
+ gradient?: [string, string];
91
+ /** Show labels. `floating` and `pill` show only the active label; the others show all. */
92
+ showLabels?: boolean;
93
+ /** Extra bottom padding, e.g. `useSafeAreaInsets().bottom` when the bar sits at the screen edge. */
94
+ bottomInset?: number;
95
+ /** Show the iOS home-indicator line (default: on for `minimal`). */
96
+ homeIndicator?: boolean;
97
+ /** Selection tick on tab change, light impact on the action button (default true). */
98
+ haptics?: boolean;
99
+ style?: StyleProp<ViewStyle>;
100
+ }
101
+
102
+ const ROW_H = 60;
103
+ const PAD_X = 6;
104
+ const PAD_TOP = 6;
105
+ const FAB = 58;
106
+ const ICON = 24;
107
+ const DOT = 5;
108
+ const PILL_H = 42;
109
+ /** Flex weight of the active item in the `pill` look (the others are 1). */
110
+ const PILL_GROW = 2.3;
111
+ // Liquid move: the edge in the direction of travel leads, the other one trails behind.
112
+ const LEAD = { damping: 22, stiffness: 320, mass: 0.7 };
113
+ const TRAIL = { damping: 20, stiffness: 150, mass: 0.9 };
114
+
115
+ /**
116
+ * Bottom navigation in five looks — minimal dot, floating capsule, expanding pill, notched centre
117
+ * FAB and top indicator with a spotlight. A liquid indicator stretches between tabs (leading edge
118
+ * first, trailing edge catching up), icons morph from outline to filled with a spring and a lift,
119
+ * badges pop, the solid accent action button glows softly, and every change ticks a haptic.
120
+ */
121
+ export function BottomNav({
122
+ items,
123
+ value,
124
+ defaultValue,
125
+ onValueChange,
126
+ onAction,
127
+ variant = 'minimal',
128
+ accent,
129
+ gradient,
130
+ showLabels = true,
131
+ bottomInset = 0,
132
+ homeIndicator,
133
+ haptics = true,
134
+ style,
135
+ }: BottomNavProps) {
136
+ const { colors, radius, dark } = useKit();
137
+ const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
138
+ const reduce = useReduceMotion();
139
+ const look: BottomNavLook = variant in LEGACY ? LEGACY[variant as BottomNavLegacyVariant] : (variant as BottomNavLook);
140
+ const tint = accent ?? colors.accent;
141
+ const fabFrom = gradient?.[0] ?? tint;
142
+ const fabTo = gradient?.[1] ?? fabFrom;
143
+
144
+ const firstTab = items.findIndex((i) => !i.action);
145
+ const [inner, setInner] = useState(defaultValue ?? items[firstTab]?.key);
146
+ const active = value ?? inner;
147
+ const found = items.findIndex((i) => i.key === active && !i.action);
148
+ const index = found >= 0 ? found : firstTab;
149
+ const actionIndex = items.findIndex((i) => i.action);
150
+
151
+ // Measured: the row of slots (indicator geometry) and the whole bar (centre-fab notch).
152
+ const [rowW, setRowW] = useState(0);
153
+ const [bar, setBar] = useState({ w: 0, h: 0 });
154
+ const n = Math.max(1, items.length);
155
+ const slot = rowW / n;
156
+
157
+ // Indicator edges (JS driver: they set left / width) and the slot offset (native: spotlight).
158
+ const lead = useRef(new Animated.Value(0)).current;
159
+ const trail = useRef(new Animated.Value(0)).current;
160
+ const x = useRef(new Animated.Value(0)).current;
161
+ const placed = useRef(false);
162
+ const last = useRef(index);
163
+
164
+ const edges = (i: number): [number, number] => {
165
+ if (look === 'pill') {
166
+ const unit = rowW / (n - 1 + PILL_GROW);
167
+ return [i * unit + 2, (i + PILL_GROW) * unit - 2];
168
+ }
169
+ if (look === 'floating') return [i * slot + 4, (i + 1) * slot - 4];
170
+ if (look === 'indicator') return [i * slot + slot * 0.24, (i + 1) * slot - slot * 0.24];
171
+ const c = (i + 0.5) * slot;
172
+ return [c - DOT / 2, c + DOT / 2];
173
+ };
174
+
175
+ useEffect(() => {
176
+ if (!rowW || index < 0) return;
177
+ const [L, R] = edges(index);
178
+ const to = index * slot;
179
+ if (!placed.current) {
180
+ lead.setValue(L);
181
+ trail.setValue(R);
182
+ x.setValue(to);
183
+ placed.current = true;
184
+ last.current = index;
185
+ return;
186
+ }
187
+ const right = index > last.current;
188
+ last.current = index;
189
+ if (reduce) {
190
+ // Functional, not decorative: a short glide with no stretch.
191
+ const t = { duration: 160, useNativeDriver: false };
192
+ Animated.parallel([
193
+ Animated.timing(lead, { toValue: L, ...t }),
194
+ Animated.timing(trail, { toValue: R, ...t }),
195
+ Animated.timing(x, { toValue: to, duration: 160, useNativeDriver: true }),
196
+ ]).start();
197
+ return;
198
+ }
199
+ Animated.parallel([
200
+ Animated.spring(lead, { toValue: L, useNativeDriver: false, ...(right ? TRAIL : LEAD) }),
201
+ Animated.spring(trail, { toValue: R, useNativeDriver: false, ...(right ? LEAD : TRAIL) }),
202
+ Animated.spring(x, { toValue: to, useNativeDriver: true, ...motion.snappy }),
203
+ ]).start();
204
+ // edges() only reads rowW / look / n, all listed.
205
+ // eslint-disable-next-line react-hooks/exhaustive-deps
206
+ }, [index, rowW, look, n]);
207
+
208
+ const onRowLayout = (e: LayoutChangeEvent) => {
209
+ const w = e.nativeEvent.layout.width;
210
+ if (Math.abs(w - rowW) > 0.5) setRowW(w);
211
+ };
212
+ const onBarLayout = (e: LayoutChangeEvent) => {
213
+ const { width, height } = e.nativeEvent.layout;
214
+ if (Math.abs(width - bar.w) > 0.5 || Math.abs(height - bar.h) > 0.5) setBar({ w: width, h: height });
215
+ };
216
+
217
+ const press = (item: BottomNavItem) => {
218
+ if (item.action) {
219
+ if (haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
220
+ onAction?.(item.key);
221
+ return;
222
+ }
223
+ if (item.key === active) return;
224
+ if (haptics) Haptics.selectionAsync().catch(() => {});
225
+ if (value === undefined) setInner(item.key);
226
+ onValueChange?.(item.key);
227
+ };
228
+
229
+ // ---- palette ---------------------------------------------------------------------------------
230
+ const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);
231
+ const surface = look === 'floating' && dark ? colors.popover : colors.card;
232
+ const idle = colors.mutedForeground;
233
+ const showHome = homeIndicator ?? (look === 'minimal' || variant === 'gradient');
234
+ const notched = look === 'center-fab' && actionIndex >= 0;
235
+ // Transparent band above the drawn bar that holds the FAB, so it stays inside the bar's bounds
236
+ // (Android only delivers touches inside them).
237
+ const overhang = notched ? FAB / 2 : 0;
238
+ const floatR = radius.capsule(ROW_H + PAD_TOP * 2);
239
+
240
+ // ---- indicator (behind the icons) ------------------------------------------------------------
241
+ let indicator: React.ReactNode = null;
242
+ if (rowW && index >= 0) {
243
+ const width = Animated.subtract(trail, lead);
244
+ if (look === 'minimal' || look === 'center-fab') {
245
+ indicator = (
246
+ <Animated.View
247
+ pointerEvents="none"
248
+ style={[styles.abs, { top: ROW_H - 6, height: DOT, borderRadius: radius.capsule(DOT), backgroundColor: tint, left: lead, width }]}
249
+ />
250
+ );
251
+ } else if (look === 'pill') {
252
+ indicator = (
253
+ <Animated.View
254
+ pointerEvents="none"
255
+ style={[
256
+ styles.abs,
257
+ {
258
+ top: (ROW_H - PILL_H) / 2,
259
+ height: PILL_H,
260
+ left: lead,
261
+ width,
262
+ borderRadius: radius.capsule(PILL_H),
263
+ backgroundColor: alpha(tint, dark ? 0.2 : 0.11),
264
+ borderWidth: StyleSheet.hairlineWidth,
265
+ borderColor: alpha(tint, dark ? 0.3 : 0.18),
266
+ },
267
+ ]}
268
+ />
269
+ );
270
+ } else if (look === 'floating') {
271
+ indicator = (
272
+ <Animated.View
273
+ pointerEvents="none"
274
+ style={[
275
+ styles.abs,
276
+ {
277
+ top: 4,
278
+ bottom: 4,
279
+ left: lead,
280
+ width,
281
+ borderRadius: Math.max(radius.scale(10), floatR - PAD_TOP - 4),
282
+ backgroundColor: alpha(tint, dark ? 0.18 : 0.1),
283
+ },
284
+ ]}
285
+ />
286
+ );
287
+ } else {
288
+ const H = ROW_H + PAD_TOP;
289
+ indicator = (
290
+ <>
291
+ {/* Spotlight: a cone of accent light falling from the bar. Native slide. */}
292
+ <Animated.View pointerEvents="none" style={[styles.abs, { left: 0, top: -PAD_TOP, width: slot, height: H, transform: [{ translateX: x }] }]}>
293
+ <Svg width={slot} height={H}>
294
+ <Defs>
295
+ <LinearGradient id={`bnSpot${uid}`} x1="0" y1="0" x2="0" y2="1">
296
+ <Stop offset="0" stopColor={tint} stopOpacity={dark ? 0.26 : 0.16} />
297
+ <Stop offset="1" stopColor={tint} stopOpacity={0} />
298
+ </LinearGradient>
299
+ </Defs>
300
+ <Path d={`M${slot * 0.24} 0 H${slot * 0.76} L${slot * 0.98} ${H} H${slot * 0.02} Z`} fill={`url(#bnSpot${uid})`} />
301
+ </Svg>
302
+ </Animated.View>
303
+ {/* The liquid line on the top edge. JS-driven edges, separate view. */}
304
+ <Animated.View
305
+ pointerEvents="none"
306
+ style={[
307
+ styles.abs,
308
+ {
309
+ top: -PAD_TOP,
310
+ height: 3,
311
+ left: lead,
312
+ width,
313
+ borderBottomLeftRadius: radius.capsule(6),
314
+ borderBottomRightRadius: radius.capsule(6),
315
+ backgroundColor: tint,
316
+ },
317
+ ]}
318
+ />
319
+ </>
320
+ );
321
+ }
322
+ }
323
+
324
+ // ---- centre FAB in its notch (absolute, so its whole face stays inside the bar's bounds) -----
325
+ let centre: React.ReactNode = null;
326
+ if (notched && rowW) {
327
+ const item = items[actionIndex];
328
+ centre = (
329
+ <CentreAction
330
+ item={item}
331
+ left={PAD_X + actionIndex * slot}
332
+ width={slot}
333
+ height={overhang + PAD_TOP + ROW_H}
334
+ labelTop={overhang + PAD_TOP + 37}
335
+ from={fabFrom}
336
+ to={fabTo}
337
+ labelColor={idle}
338
+ showLabel={showLabels}
339
+ onPress={() => press(item)}
340
+ />
341
+ );
342
+ }
343
+
344
+ const row = (
345
+ <View style={styles.row} onLayout={onRowLayout}>
346
+ {indicator}
347
+ {items.map((item, i) => (
348
+ <NavButton
349
+ key={item.key}
350
+ item={item}
351
+ active={i === index && !item.action}
352
+ look={look}
353
+ tint={tint}
354
+ idle={idle}
355
+ surface={surface}
356
+ fab={[fabFrom, fabTo]}
357
+ showLabels={showLabels}
358
+ reduce={reduce}
359
+ onPress={() => press(item)}
360
+ />
361
+ ))}
362
+ </View>
363
+ );
364
+
365
+ const home = showHome ? (
366
+ <View pointerEvents="none" style={[styles.home, { borderRadius: radius.capsule(5), backgroundColor: alpha(colors.foreground, 0.85) }]} />
367
+ ) : null;
368
+
369
+ if (look === 'floating') {
370
+ // Two shadows: a wide soft one on the outer view, a tight contact one on the bar itself.
371
+ return (
372
+ <View
373
+ style={[
374
+ styles.floatOuter,
375
+ { marginBottom: 12 + bottomInset, borderRadius: floatR, backgroundColor: surface, shadowOpacity: dark ? 0.55 : 0.1 },
376
+ style,
377
+ ]}
378
+ >
379
+ <View
380
+ accessibilityRole="tablist"
381
+ style={[
382
+ styles.bar,
383
+ styles.floatInner,
384
+ {
385
+ paddingBottom: showHome ? 16 : PAD_TOP,
386
+ borderRadius: floatR,
387
+ backgroundColor: surface,
388
+ borderColor: hairline,
389
+ shadowOpacity: dark ? 0.35 : 0.06,
390
+ },
391
+ ]}
392
+ >
393
+ <Svg pointerEvents="none" style={StyleSheet.absoluteFill} width="100%" height="100%">
394
+ <Defs>
395
+ <LinearGradient id={`bnSheen${uid}`} x1="0" y1="0" x2="0" y2="1">
396
+ <Stop offset="0" stopColor={colors.foreground} stopOpacity={dark ? 0.06 : 0} />
397
+ <Stop offset="1" stopColor={colors.foreground} stopOpacity={dark ? 0 : 0.025} />
398
+ </LinearGradient>
399
+ </Defs>
400
+ <Rect width="100%" height="100%" rx={floatR} fill={`url(#bnSheen${uid})`} />
401
+ </Svg>
402
+ {row}
403
+ {home}
404
+ </View>
405
+ </View>
406
+ );
407
+ }
408
+
409
+ const barStyle: ViewStyle[] = [
410
+ styles.bar,
411
+ { paddingTop: PAD_TOP + overhang, paddingBottom: bottomInset + (showHome ? 18 : PAD_TOP) },
412
+ ];
413
+ if (notched) barStyle.push(styles.visible);
414
+ else barStyle.push({ backgroundColor: colors.card, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: hairline });
415
+ if (look === 'indicator') barStyle.push(styles.clip);
416
+
417
+ return (
418
+ <View accessibilityRole="tablist" onLayout={notched ? onBarLayout : undefined} style={[...barStyle, style]}>
419
+ {notched && bar.w && rowW ? (
420
+ <BarShape
421
+ d={notchPath(bar.w, bar.h, PAD_X + (actionIndex + 0.5) * slot, FAB / 2 + 7, FAB / 2 + 4, overhang)}
422
+ fill={colors.card}
423
+ stroke={hairline}
424
+ />
425
+ ) : null}
426
+ {row}
427
+ {centre}
428
+ {home}
429
+ </View>
430
+ );
431
+ }
432
+
433
+ interface NavButtonProps {
434
+ item: BottomNavItem;
435
+ active: boolean;
436
+ look: BottomNavLook;
437
+ tint: string;
438
+ idle: string;
439
+ surface: string;
440
+ fab: [string, string];
441
+ showLabels: boolean;
442
+ reduce: boolean;
443
+ onPress: () => void;
444
+ }
445
+
446
+ function NavButton({ item, active, look, tint, idle, surface, fab, showLabels, reduce, onPress }: NavButtonProps) {
447
+ const press = useRef(new Animated.Value(1)).current; // press squish (native)
448
+ const pop = useRef(new Animated.Value(active ? 1 : 0)).current; // active state (native)
449
+ const grow = useRef(new Animated.Value(active ? 1 : 0)).current; // pill width (JS — layout)
450
+ const [labelW, setLabelW] = useState(0);
451
+
452
+ useEffect(() => {
453
+ if (reduce) {
454
+ Animated.timing(pop, { toValue: active ? 1 : 0, duration: 140, useNativeDriver: true }).start();
455
+ if (look === 'pill') Animated.timing(grow, { toValue: active ? 1 : 0, duration: 140, useNativeDriver: false }).start();
456
+ return;
457
+ }
458
+ Animated.spring(pop, { toValue: active ? 1 : 0, useNativeDriver: true, ...(active ? motion.bouncy : motion.snappy) }).start();
459
+ if (look === 'pill') Animated.spring(grow, { toValue: active ? 1 : 0, useNativeDriver: false, ...LEAD }).start();
460
+ }, [active, look, reduce, pop, grow]);
461
+
462
+ const pressTo = (v: number) =>
463
+ Animated.spring(press, { toValue: v, useNativeDriver: true, ...(v < 1 ? motion.snappy : motion.bouncy) }).start();
464
+ const a11y = item.accessibilityLabel ?? (typeof item.badge === 'number' ? `${item.label}, ${item.badge} new` : item.label);
465
+ const pressable = {
466
+ onPress,
467
+ onPressIn: () => pressTo(0.88),
468
+ onPressOut: () => pressTo(1),
469
+ accessibilityLabel: a11y,
470
+ };
471
+
472
+ // ---- primary action ----
473
+ if (item.action) {
474
+ // Centre-fab draws its FAB over the notch (see CentreAction); keep the slot.
475
+ if (look === 'center-fab') return <View style={styles.slot} />;
476
+ const size = look === 'floating' ? 46 : 40;
477
+ return (
478
+ <Pressable {...pressable} accessibilityRole="button" hitSlop={4} style={styles.slot}>
479
+ <Animated.View style={{ transform: [{ scale: press }] }}>
480
+ <FabFace size={size} from={fab[0]} to={fab[1]} glow={false} pressed={press}>
481
+ {renderIcon(item.icon, { color: readableOn(fab[0]), size: 22, active: true })}
482
+ </FabFace>
483
+ </Animated.View>
484
+ </Pressable>
485
+ );
486
+ }
487
+
488
+ const badge = item.badge ? <Badge value={item.badge} ring={surface} reduce={reduce} /> : null;
489
+ const icon = <MorphIcon icon={item.icon} size={ICON} idle={idle} active={tint} pop={pop} />;
490
+ const fadeIn = pop.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' });
491
+ const tab = { ...pressable, accessibilityRole: 'tab' as const, accessibilityState: { selected: active } };
492
+
493
+ // ---- pill: the active tab widens into a capsule and its label slides in ----
494
+ if (look === 'pill') {
495
+ return (
496
+ <Animated.View style={{ flex: grow.interpolate({ inputRange: [0, 1], outputRange: [1, PILL_GROW] }) }}>
497
+ <Pressable {...tab} style={styles.slot}>
498
+ <Animated.View style={[styles.pillRow, { transform: [{ scale: press }] }]}>
499
+ <Animated.View style={{ transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [1, 1.04] }) }] }}>
500
+ {icon}
501
+ {badge}
502
+ </Animated.View>
503
+ {showLabels ? (
504
+ <Animated.View
505
+ style={{
506
+ width: grow.interpolate({ inputRange: [0, 1], outputRange: [0, labelW + 1] }),
507
+ marginLeft: grow.interpolate({ inputRange: [0, 1], outputRange: [0, 7] }),
508
+ overflow: 'hidden',
509
+ }}
510
+ >
511
+ <Animated.Text
512
+ numberOfLines={1}
513
+ style={[
514
+ styles.pillLabel,
515
+ {
516
+ width: labelW + 1,
517
+ color: tint,
518
+ opacity: fadeIn,
519
+ transform: [{ translateX: pop.interpolate({ inputRange: [0, 1], outputRange: [-8, 0] }) }],
520
+ },
521
+ ]}
522
+ >
523
+ {item.label}
524
+ </Animated.Text>
525
+ </Animated.View>
526
+ ) : null}
527
+ </Animated.View>
528
+ {/* Invisible measurer: the label's natural width, so it never ellipsizes while growing. */}
529
+ {showLabels ? (
530
+ <View pointerEvents="none" style={styles.measure} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
531
+ <Text style={[styles.pillLabel, styles.selfStart]} onLayout={(e) => setLabelW(Math.ceil(e.nativeEvent.layout.width))}>
532
+ {item.label}
533
+ </Text>
534
+ </View>
535
+ ) : null}
536
+ </Pressable>
537
+ </Animated.View>
538
+ );
539
+ }
540
+
541
+ // ---- floating: icon lifts and the active label rises in beneath it ----
542
+ if (look === 'floating') {
543
+ return (
544
+ <Pressable {...tab} style={styles.slot}>
545
+ <Animated.View style={{ transform: [{ scale: press }] }}>
546
+ <Animated.View
547
+ style={{
548
+ transform: [
549
+ { translateY: pop.interpolate({ inputRange: [0, 1], outputRange: [0, showLabels ? -8 : -1] }) },
550
+ { scale: pop.interpolate({ inputRange: [0, 1], outputRange: [1, showLabels ? 1 : 1.08] }) },
551
+ ],
552
+ }}
553
+ >
554
+ {icon}
555
+ {badge}
556
+ </Animated.View>
557
+ </Animated.View>
558
+ {showLabels ? (
559
+ <Animated.Text
560
+ numberOfLines={1}
561
+ style={[
562
+ styles.label,
563
+ styles.floatLabel,
564
+ { color: tint, opacity: fadeIn, transform: [{ translateY: pop.interpolate({ inputRange: [0, 1], outputRange: [6, 0] }) }] },
565
+ ]}
566
+ >
567
+ {item.label}
568
+ </Animated.Text>
569
+ ) : null}
570
+ </Pressable>
571
+ );
572
+ }
573
+
574
+ // ---- minimal / indicator / centre-fab: icon over label ----
575
+ return (
576
+ <Pressable {...tab} style={styles.slot}>
577
+ <Animated.View style={[styles.center, { transform: [{ scale: press }] }]}>
578
+ <Animated.View
579
+ style={{
580
+ transform: [
581
+ { translateY: pop.interpolate({ inputRange: [0, 1], outputRange: [0, -2] }) },
582
+ { scale: pop.interpolate({ inputRange: [0, 1], outputRange: [1, 1.08] }) },
583
+ ],
584
+ }}
585
+ >
586
+ {icon}
587
+ {badge}
588
+ </Animated.View>
589
+ {showLabels ? (
590
+ <Text numberOfLines={1} style={[styles.label, { color: active ? tint : idle }]}>
591
+ {item.label}
592
+ </Text>
593
+ ) : null}
594
+ </Animated.View>
595
+ </Pressable>
596
+ );
597
+ }
598
+
599
+ /** Outline and filled icons stacked: the outline fades out while the fill springs up from inside. */
600
+ function MorphIcon({
601
+ icon,
602
+ size,
603
+ idle,
604
+ active,
605
+ pop,
606
+ }: {
607
+ icon: BottomNavItem['icon'];
608
+ size: number;
609
+ idle: string;
610
+ active: string;
611
+ pop: Animated.Value;
612
+ }) {
613
+ return (
614
+ <View style={{ width: size, height: size }}>
615
+ <Animated.View style={[StyleSheet.absoluteFill, { opacity: pop.interpolate({ inputRange: [0, 1], outputRange: [1, 0], extrapolate: 'clamp' }) }]}>
616
+ {renderIcon(icon, { color: idle, size, active: false })}
617
+ </Animated.View>
618
+ <Animated.View
619
+ style={[
620
+ StyleSheet.absoluteFill,
621
+ {
622
+ opacity: pop.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
623
+ transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.55, 1] }) }],
624
+ },
625
+ ]}
626
+ >
627
+ {renderIcon(icon, { color: active, size, active: true })}
628
+ </Animated.View>
629
+ </View>
630
+ );
631
+ }
632
+
633
+ function CentreAction({
634
+ item,
635
+ left,
636
+ width,
637
+ height,
638
+ labelTop,
639
+ from,
640
+ to,
641
+ labelColor,
642
+ showLabel,
643
+ onPress,
644
+ }: {
645
+ item: BottomNavItem;
646
+ left: number;
647
+ width: number;
648
+ height: number;
649
+ labelTop: number;
650
+ from: string;
651
+ to: string;
652
+ labelColor: string;
653
+ showLabel: boolean;
654
+ onPress: () => void;
655
+ }) {
656
+ const press = useRef(new Animated.Value(1)).current;
657
+ const pressTo = (v: number) =>
658
+ Animated.spring(press, { toValue: v, useNativeDriver: true, ...(v < 1 ? motion.snappy : motion.bouncy) }).start();
659
+ return (
660
+ <Pressable
661
+ accessibilityRole="button"
662
+ accessibilityLabel={item.accessibilityLabel ?? item.label}
663
+ onPress={onPress}
664
+ onPressIn={() => pressTo(0.9)}
665
+ onPressOut={() => pressTo(1)}
666
+ style={[styles.abs, styles.centreCol, { left, top: 0, width, height }]}
667
+ >
668
+ <Animated.View style={{ transform: [{ scale: press }] }}>
669
+ <FabFace size={FAB} from={from} to={to} glow pressed={press}>
670
+ {renderIcon(item.icon, { color: readableOn(from), size: 28, active: true })}
671
+ </FabFace>
672
+ </Animated.View>
673
+ {showLabel ? (
674
+ <Text numberOfLines={1} style={[styles.label, styles.abs, { top: labelTop, color: labelColor }]}>
675
+ {item.label}
676
+ </Text>
677
+ ) : null}
678
+ </Pressable>
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Action button: a solid `from` fill (or a `from` → `to` diagonal when an opt-in gradient is given),
684
+ * a faint top sheen, a hairline rim and an optional same-colour glow.
685
+ */
686
+ function FabFace({
687
+ size,
688
+ from,
689
+ to,
690
+ glow,
691
+ pressed,
692
+ children,
693
+ }: {
694
+ size: number;
695
+ from: string;
696
+ to: string;
697
+ glow: boolean;
698
+ pressed: Animated.Value;
699
+ children: React.ReactNode;
700
+ }) {
701
+ const { radius } = useKit();
702
+ const id = 'bnFab' + useId().replace(/[^a-zA-Z0-9]/g, '');
703
+ const r = radius.capsule(size);
704
+ const G = size * 2;
705
+ const twoTone = to.toLowerCase() !== from.toLowerCase();
706
+ return (
707
+ <View style={{ width: size, height: size }}>
708
+ {glow ? (
709
+ // Soft accent glow (works on Android too, where coloured shadows don't). Brightens on press.
710
+ <Animated.View
711
+ pointerEvents="none"
712
+ style={[
713
+ styles.abs,
714
+ {
715
+ left: -size / 2,
716
+ top: -size / 2 + size * 0.16,
717
+ width: G,
718
+ height: G,
719
+ opacity: pressed.interpolate({ inputRange: [0.9, 1], outputRange: [1, 0.7], extrapolate: 'clamp' }),
720
+ },
721
+ ]}
722
+ >
723
+ <Svg width={G} height={G}>
724
+ <Defs>
725
+ <RadialGradient id={`${id}g`} cx="50%" cy="50%" r="50%">
726
+ <Stop offset="0" stopColor={from} stopOpacity={0.4} />
727
+ <Stop offset="0.5" stopColor={from} stopOpacity={0.12} />
728
+ <Stop offset="1" stopColor={from} stopOpacity={0} />
729
+ </RadialGradient>
730
+ </Defs>
731
+ <Circle cx={size} cy={size} r={size} fill={`url(#${id}g)`} />
732
+ </Svg>
733
+ </Animated.View>
734
+ ) : null}
735
+ <View style={[styles.fab, { width: size, height: size, borderRadius: r, backgroundColor: from, shadowColor: from }]}>
736
+ <Svg width={size} height={size} style={StyleSheet.absoluteFill}>
737
+ <Defs>
738
+ {twoTone ? (
739
+ <LinearGradient id={`${id}f`} x1="0" y1="0" x2="1" y2="1">
740
+ <Stop offset="0" stopColor={from} />
741
+ <Stop offset="1" stopColor={to} />
742
+ </LinearGradient>
743
+ ) : null}
744
+ <LinearGradient id={`${id}s`} x1="0" y1="0" x2="0" y2="1">
745
+ <Stop offset="0" stopColor="#ffffff" stopOpacity={0.12} />
746
+ <Stop offset="0.55" stopColor="#ffffff" stopOpacity={0} />
747
+ </LinearGradient>
748
+ </Defs>
749
+ {twoTone ? <Rect width={size} height={size} rx={r} fill={`url(#${id}f)`} /> : null}
750
+ <Rect width={size} height={size} rx={r} fill={`url(#${id}s)`} />
751
+ <Rect x={0.5} y={0.5} width={size - 1} height={size - 1} rx={Math.max(0, r - 0.5)} fill="none" stroke="#ffffff" strokeOpacity={0.22} />
752
+ </Svg>
753
+ <View style={[StyleSheet.absoluteFill, styles.center]}>{children}</View>
754
+ </View>
755
+ </View>
756
+ );
757
+ }
758
+
759
+ /** Dot or count. Pops in with a spring when it appears or its value changes. */
760
+ function Badge({ value, ring, reduce }: { value: true | number; ring: string; reduce: boolean }) {
761
+ const { colors, radius } = useKit();
762
+ const s = useRef(new Animated.Value(reduce ? 1 : 0.3)).current;
763
+ useEffect(() => {
764
+ if (reduce) {
765
+ s.setValue(1);
766
+ return;
767
+ }
768
+ s.setValue(0.3);
769
+ Animated.spring(s, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
770
+ }, [value, reduce, s]);
771
+ const dot = value === true;
772
+ const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : '';
773
+ return (
774
+ <Animated.View
775
+ pointerEvents="none"
776
+ style={[
777
+ styles.badge,
778
+ dot ? [styles.badgeDot, { borderRadius: radius.capsule(10) }] : [styles.badgeCount, { borderRadius: radius.capsule(19) }],
779
+ {
780
+ backgroundColor: colors.danger,
781
+ borderColor: ring,
782
+ opacity: s.interpolate({ inputRange: [0.3, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
783
+ transform: [{ scale: s }],
784
+ },
785
+ ]}
786
+ >
787
+ {!dot && <Text style={[styles.badgeText, { color: colors.dangerForeground }]}>{text}</Text>}
788
+ </Animated.View>
789
+ );
790
+ }
791
+
792
+ /** Bar outline with a smooth dip of radius `r` and depth `depth` centred on `cx`, top edge at `y`. */
793
+ function notchPath(w: number, h: number, cx: number, r: number, depth: number, y: number) {
794
+ const x1 = cx - r - 14;
795
+ const x2 = cx + r + 14;
796
+ return [
797
+ `M0 ${y}`,
798
+ `H${x1}`,
799
+ `C${x1 + 11} ${y} ${cx - r + 1} ${y + depth} ${cx} ${y + depth}`,
800
+ `C${cx + r - 1} ${y + depth} ${x2 - 11} ${y} ${x2} ${y}`,
801
+ `H${w} V${h} H0 Z`,
802
+ ].join(' ');
803
+ }
804
+
805
+ function BarShape({ d, fill, stroke }: { d: string; fill: string; stroke: string }) {
806
+ return (
807
+ <Svg pointerEvents="none" style={StyleSheet.absoluteFill} width="100%" height="100%">
808
+ <Path d={d} fill={fill} stroke={stroke} strokeWidth={1} />
809
+ </Svg>
810
+ );
811
+ }
812
+
813
+ function useReduceMotion() {
814
+ const [reduce, setReduce] = useState(false);
815
+ useEffect(() => {
816
+ let alive = true;
817
+ AccessibilityInfo.isReduceMotionEnabled()
818
+ .then((v) => alive && setReduce(v))
819
+ .catch(() => {});
820
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
821
+ return () => {
822
+ alive = false;
823
+ sub.remove();
824
+ };
825
+ }, []);
826
+ return reduce;
827
+ }
828
+
829
+ function renderIcon(icon: BottomNavItem['icon'], props: BottomNavIconProps) {
830
+ return typeof icon === 'function' ? icon(props) : <BottomNavIcon name={icon} {...props} />;
831
+ }
832
+
833
+ /** The built-in icon set (24pt grid, 1.8 stroke). Active icons fill their main shape. */
834
+ export function BottomNavIcon({ name, color, size = 24, active = false }: { name: BottomNavIconName } & Partial<BottomNavIconProps> & { color: string }) {
835
+ const s = { stroke: color, strokeWidth: active ? 2 : 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
836
+ const f = active ? { ...s, fill: color } : s;
837
+ let body: React.ReactNode;
838
+ switch (name) {
839
+ case 'home':
840
+ body = <Path d="M3.5 10.2 12 3.5l8.5 6.7V19a1.5 1.5 0 0 1-1.5 1.5h-4.2v-5.6H9.2v5.6H5A1.5 1.5 0 0 1 3.5 19z" {...f} />;
841
+ break;
842
+ case 'explore':
843
+ body = (
844
+ <>
845
+ <Circle cx="12" cy="12" r="8.6" {...s} />
846
+ <Path d="m15.4 8.6-1.9 4.9-4.9 1.9 1.9-4.9z" {...f} />
847
+ </>
848
+ );
849
+ break;
850
+ case 'search':
851
+ body = (
852
+ <>
853
+ <Circle cx="11" cy="11" r="6.6" {...s} />
854
+ <Path d="m16 16 4.4 4.4" {...s} />
855
+ </>
856
+ );
857
+ break;
858
+ case 'projects':
859
+ body = <Path d="M3.5 7.2A1.7 1.7 0 0 1 5.2 5.5h3.9l2 2.1h7.7a1.7 1.7 0 0 1 1.7 1.7v8.9a1.7 1.7 0 0 1-1.7 1.7H5.2a1.7 1.7 0 0 1-1.7-1.7z" {...f} />;
860
+ break;
861
+ case 'saved':
862
+ body = <Path d="M6.5 4.8A1.3 1.3 0 0 1 7.8 3.5h8.4a1.3 1.3 0 0 1 1.3 1.3v15.7L12 16.8l-5.5 3.7z" {...f} />;
863
+ break;
864
+ case 'profile':
865
+ body = (
866
+ <>
867
+ <Circle cx="12" cy="8" r="3.9" {...f} />
868
+ <Path d="M4.5 20.2a7.5 7.5 0 0 1 15 0z" {...f} />
869
+ </>
870
+ );
871
+ break;
872
+ case 'inbox':
873
+ body = (
874
+ <>
875
+ <Path d="M6.2 16V11a5.8 5.8 0 0 1 11.6 0v5l1.6 2.1H4.6z" {...f} />
876
+ <Path d="M10 20.7h4" {...s} />
877
+ </>
878
+ );
879
+ break;
880
+ case 'activity':
881
+ body = <Path d="M3 12.5h3.8l2.6-6.8 4.8 13 2.6-6.2H21" {...s} />;
882
+ break;
883
+ case 'analytics':
884
+ body = <Path d="M5 20v-6.5M12 20V5M19 20v-9.5" {...s} strokeWidth={active ? 3 : 2.2} />;
885
+ break;
886
+ case 'message':
887
+ body = <Path d="M12 3.8a8.2 8.2 0 0 0-7.1 12.3L3.8 20.2l4.2-1.1A8.2 8.2 0 1 0 12 3.8z" {...f} />;
888
+ break;
889
+ case 'work':
890
+ body = (
891
+ <>
892
+ <Rect x="3.5" y="7.2" width="17" height="12.6" rx="2" {...f} />
893
+ <Path d="M9 7.2V5.6A1.6 1.6 0 0 1 10.6 4h2.8A1.6 1.6 0 0 1 15 5.6v1.6" {...s} />
894
+ </>
895
+ );
896
+ break;
897
+ case 'browse':
898
+ body = (
899
+ <>
900
+ <Rect x="3.8" y="3.8" width="7" height="7" rx="1.8" {...f} />
901
+ <Rect x="13.2" y="3.8" width="7" height="7" rx="1.8" {...s} />
902
+ <Rect x="3.8" y="13.2" width="7" height="7" rx="1.8" {...s} />
903
+ <Rect x="13.2" y="13.2" width="7" height="7" rx="1.8" {...f} />
904
+ </>
905
+ );
906
+ break;
907
+ case 'library':
908
+ body = (
909
+ <>
910
+ <Rect x="3.8" y="4" width="4.4" height="16" rx="1.2" {...f} />
911
+ <Rect x="9.8" y="4" width="4.4" height="16" rx="1.2" {...s} />
912
+ <Path d="m16 5.4 3.6-.9 1.4 15-3.6.9z" {...f} />
913
+ </>
914
+ );
915
+ break;
916
+ case 'music':
917
+ body = (
918
+ <>
919
+ <Path d="M9 18V5.5l10-2v12.5" {...s} />
920
+ <Circle cx="6.5" cy="18" r="2.6" {...f} />
921
+ <Circle cx="16.5" cy="16" r="2.6" {...f} />
922
+ </>
923
+ );
924
+ break;
925
+ case 'create':
926
+ body = <Path d="M12 5v14M5 12h14" {...s} strokeWidth={2.4} />;
927
+ break;
928
+ }
929
+ return (
930
+ <Svg width={size} height={size} viewBox="0 0 24 24">
931
+ {body}
932
+ </Svg>
933
+ );
934
+ }
935
+
936
+ const styles = StyleSheet.create({
937
+ bar: {
938
+ paddingHorizontal: PAD_X,
939
+ paddingTop: PAD_TOP,
940
+ },
941
+ visible: {
942
+ overflow: 'visible',
943
+ },
944
+ clip: {
945
+ overflow: 'hidden',
946
+ },
947
+ floatOuter: {
948
+ marginHorizontal: 16,
949
+ shadowColor: '#000',
950
+ shadowRadius: 24,
951
+ shadowOffset: { width: 0, height: 12 },
952
+ elevation: 10,
953
+ },
954
+ floatInner: {
955
+ borderWidth: StyleSheet.hairlineWidth,
956
+ shadowColor: '#000',
957
+ shadowRadius: 3,
958
+ shadowOffset: { width: 0, height: 1 },
959
+ },
960
+ row: {
961
+ flexDirection: 'row',
962
+ height: ROW_H,
963
+ },
964
+ abs: {
965
+ position: 'absolute',
966
+ },
967
+ slot: {
968
+ flex: 1,
969
+ alignItems: 'center',
970
+ justifyContent: 'center',
971
+ minHeight: 44,
972
+ },
973
+ center: {
974
+ alignItems: 'center',
975
+ justifyContent: 'center',
976
+ },
977
+ centreCol: {
978
+ alignItems: 'center',
979
+ },
980
+ label: {
981
+ fontSize: 11,
982
+ fontWeight: '400',
983
+ marginTop: 4,
984
+ letterSpacing: 0.1,
985
+ },
986
+ floatLabel: {
987
+ position: 'absolute',
988
+ left: 0,
989
+ right: 0,
990
+ top: ROW_H / 2 + 3,
991
+ marginTop: 0,
992
+ textAlign: 'center',
993
+ },
994
+ pillRow: {
995
+ flexDirection: 'row',
996
+ alignItems: 'center',
997
+ height: PILL_H,
998
+ },
999
+ pillLabel: {
1000
+ fontSize: 13.5,
1001
+ fontWeight: '400',
1002
+ letterSpacing: -0.1,
1003
+ },
1004
+ measure: {
1005
+ position: 'absolute',
1006
+ width: 400,
1007
+ opacity: 0,
1008
+ },
1009
+ selfStart: {
1010
+ alignSelf: 'flex-start',
1011
+ },
1012
+ fab: {
1013
+ shadowOpacity: 0.4,
1014
+ shadowRadius: 12,
1015
+ shadowOffset: { width: 0, height: 6 },
1016
+ elevation: 6,
1017
+ },
1018
+ home: {
1019
+ position: 'absolute',
1020
+ bottom: 6,
1021
+ alignSelf: 'center',
1022
+ width: 120,
1023
+ height: 4,
1024
+ },
1025
+ badge: {
1026
+ position: 'absolute',
1027
+ borderWidth: 2,
1028
+ },
1029
+ badgeDot: {
1030
+ top: -1,
1031
+ right: -2,
1032
+ width: 10,
1033
+ height: 10,
1034
+ },
1035
+ badgeCount: {
1036
+ top: -6,
1037
+ right: -11,
1038
+ minWidth: 19,
1039
+ height: 19,
1040
+ paddingHorizontal: 4,
1041
+ alignItems: 'center',
1042
+ justifyContent: 'center',
1043
+ },
1044
+ badgeText: {
1045
+ fontSize: 10,
1046
+ fontWeight: '400',
1047
+ lineHeight: 12,
1048
+ fontVariant: ['tabular-nums'],
1049
+ },
1050
+ });