@box-kite/react 0.0.1 → 2.0.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 (280) hide show
  1. package/.claude/rules/box-kite-rules.md +65 -0
  2. package/.claude/skills/box-kite/SKILL.md +138 -0
  3. package/.claude/skills/box-kite/references/components.md +235 -0
  4. package/.claude/skills/box-kite/references/extending.md +42 -0
  5. package/.claude/skills/box-kite/references/patterns.md +27 -0
  6. package/.claude/skills/box-kite/references/styling.md +222 -0
  7. package/.cursor/rules/box-kite.mdc +73 -0
  8. package/AGENTS.md +102 -0
  9. package/BOX_AI_CONTEXT.md +2804 -0
  10. package/BOX_KITE_AI_CONTEXT.md +2804 -0
  11. package/LICENSE +21 -0
  12. package/README.md +744 -7
  13. package/a11y.cjs +1 -0
  14. package/a11y.d.ts +15 -0
  15. package/a11y.mjs +4 -0
  16. package/anchor.cjs +1 -0
  17. package/anchor.d.ts +14 -0
  18. package/anchor.mjs +3 -0
  19. package/behavior.cjs +1 -0
  20. package/behavior.mjs +277 -0
  21. package/box.cjs +1 -0
  22. package/box.d.ts +46 -0
  23. package/box.mjs +23 -0
  24. package/client.cjs +1 -0
  25. package/client.mjs +164 -0
  26. package/components/accordion.cjs +1 -0
  27. package/components/accordion.d.ts +114 -0
  28. package/components/accordion.mjs +251 -0
  29. package/components/baseSvg.cjs +1 -0
  30. package/components/baseSvg.d.ts +5 -0
  31. package/components/baseSvg.mjs +21 -0
  32. package/components/button.cjs +1 -0
  33. package/components/button.d.ts +19 -0
  34. package/components/button.mjs +19 -0
  35. package/components/chart.cjs +1 -0
  36. package/components/chart.d.ts +83 -0
  37. package/components/chart.mjs +236 -0
  38. package/components/checkbox.cjs +1 -0
  39. package/components/checkbox.d.ts +36 -0
  40. package/components/checkbox.mjs +38 -0
  41. package/components/combobox/comboboxChips.d.ts +21 -0
  42. package/components/combobox/comboboxListbox.d.ts +47 -0
  43. package/components/combobox.cjs +1 -0
  44. package/components/combobox.d.ts +121 -0
  45. package/components/combobox.mjs +563 -0
  46. package/components/dataGrid/components/dataGridAggregateValue.d.ts +10 -0
  47. package/components/dataGrid/components/dataGridBody.d.ts +7 -0
  48. package/components/dataGrid/components/dataGridBottomBar.d.ts +6 -0
  49. package/components/dataGrid/components/dataGridCell.d.ts +16 -0
  50. package/components/dataGrid/components/dataGridCellEditor.d.ts +15 -0
  51. package/components/dataGrid/components/dataGridCellPlaceholder.d.ts +14 -0
  52. package/components/dataGrid/components/dataGridCellRowDetail.d.ts +6 -0
  53. package/components/dataGrid/components/dataGridCellRowSelection.d.ts +6 -0
  54. package/components/dataGrid/components/dataGridCellText.d.ts +6 -0
  55. package/components/dataGrid/components/dataGridCellTree.d.ts +16 -0
  56. package/components/dataGrid/components/dataGridColumnFilter.d.ts +9 -0
  57. package/components/dataGrid/components/dataGridColumnGroups.d.ts +7 -0
  58. package/components/dataGrid/components/dataGridContent.d.ts +6 -0
  59. package/components/dataGrid/components/dataGridDataSourceError.d.ts +12 -0
  60. package/components/dataGrid/components/dataGridDetailRow.d.ts +8 -0
  61. package/components/dataGrid/components/dataGridEmptyColumns.d.ts +6 -0
  62. package/components/dataGrid/components/dataGridExport.d.ts +10 -0
  63. package/components/dataGrid/components/dataGridFilterCell.d.ts +11 -0
  64. package/components/dataGrid/components/dataGridFilterRow.d.ts +6 -0
  65. package/components/dataGrid/components/dataGridFooter.d.ts +11 -0
  66. package/components/dataGrid/components/dataGridGlobalFilter.d.ts +6 -0
  67. package/components/dataGrid/components/dataGridGroupRow.d.ts +8 -0
  68. package/components/dataGrid/components/dataGridHeader.d.ts +6 -0
  69. package/components/dataGrid/components/dataGridHeaderCell.d.ts +9 -0
  70. package/components/dataGrid/components/dataGridHeaderCellContextMenu.d.ts +11 -0
  71. package/components/dataGrid/components/dataGridHeaderCellResizer.d.ts +14 -0
  72. package/components/dataGrid/components/dataGridLoader.d.ts +11 -0
  73. package/components/dataGrid/components/dataGridPagination.d.ts +6 -0
  74. package/components/dataGrid/components/dataGridRow.d.ts +8 -0
  75. package/components/dataGrid/components/dataGridTopBar.d.ts +6 -0
  76. package/components/dataGrid/components/dataGridTopBarContextMenu.d.ts +6 -0
  77. package/components/dataGrid/contracts/dataGridContract.d.ts +671 -0
  78. package/components/dataGrid/export/gridExport.d.ts +10 -0
  79. package/components/dataGrid/gridNavigationContext.d.ts +32 -0
  80. package/components/dataGrid/models/aggregateCellModel.d.ts +34 -0
  81. package/components/dataGrid/models/aggregationModel.d.ts +32 -0
  82. package/components/dataGrid/models/cellKeys.d.ts +11 -0
  83. package/components/dataGrid/models/cellModel.d.ts +44 -0
  84. package/components/dataGrid/models/columnModel.d.ts +160 -0
  85. package/components/dataGrid/models/columnVisibilityModel.d.ts +24 -0
  86. package/components/dataGrid/models/dataSourceModel.d.ts +177 -0
  87. package/components/dataGrid/models/detailRowModel.d.ts +22 -0
  88. package/components/dataGrid/models/editModel.d.ts +77 -0
  89. package/components/dataGrid/models/exportModel.d.ts +51 -0
  90. package/components/dataGrid/models/filterModel.d.ts +22 -0
  91. package/components/dataGrid/models/gridModel.d.ts +293 -0
  92. package/components/dataGrid/models/groupRowCellModel.d.ts +34 -0
  93. package/components/dataGrid/models/groupRowModel.d.ts +68 -0
  94. package/components/dataGrid/models/headerCellModel.d.ts +83 -0
  95. package/components/dataGrid/models/paginationModel.d.ts +32 -0
  96. package/components/dataGrid/models/rowModel.d.ts +46 -0
  97. package/components/dataGrid/models/sourceGroupRowModel.d.ts +45 -0
  98. package/components/dataGrid/models/sourceLevel.d.ts +71 -0
  99. package/components/dataGrid/models/sourceRowModel.d.ts +29 -0
  100. package/components/dataGrid/models/sourceTreeRowModel.d.ts +23 -0
  101. package/components/dataGrid/models/treeModel.d.ts +94 -0
  102. package/components/dataGrid/models/treeRow.d.ts +22 -0
  103. package/components/dataGrid/models/treeRowModel.d.ts +50 -0
  104. package/components/dataGrid/models/viewportModel.d.ts +44 -0
  105. package/components/dataGrid/useGrid.d.ts +8 -0
  106. package/components/dataGrid/useGridNavigation.d.ts +21 -0
  107. package/components/dataGrid.cjs +1 -0
  108. package/components/dataGrid.d.ts +4 -0
  109. package/components/dataGrid.mjs +4907 -0
  110. package/components/dialog.cjs +1 -0
  111. package/components/dialog.d.ts +151 -0
  112. package/components/dialog.mjs +223 -0
  113. package/components/dropdown/dropdownContext.d.ts +44 -0
  114. package/components/dropdown/dropdownItems.d.ts +22 -0
  115. package/components/dropdown/dropdownRowRenderer.d.ts +8 -0
  116. package/components/dropdown/dropdownSearch.d.ts +21 -0
  117. package/components/dropdown/utils.d.ts +1 -0
  118. package/components/dropdown.cjs +1 -0
  119. package/components/dropdown.d.ts +77 -0
  120. package/components/dropdown.mjs +491 -0
  121. package/components/flex.cjs +1 -0
  122. package/components/flex.d.ts +6 -0
  123. package/components/flex.mjs +16 -0
  124. package/components/form.cjs +1 -0
  125. package/components/form.d.ts +10 -0
  126. package/components/form.mjs +58 -0
  127. package/components/grid.cjs +1 -0
  128. package/components/grid.d.ts +6 -0
  129. package/components/grid.mjs +16 -0
  130. package/components/icon.cjs +1 -0
  131. package/components/icon.d.ts +37 -0
  132. package/components/icon.mjs +41 -0
  133. package/components/menu.cjs +1 -0
  134. package/components/menu.d.ts +234 -0
  135. package/components/menu.mjs +422 -0
  136. package/components/overlay.cjs +1 -0
  137. package/components/overlay.d.ts +47 -0
  138. package/components/overlay.mjs +52 -0
  139. package/components/popover.cjs +1 -0
  140. package/components/popover.d.ts +100 -0
  141. package/components/popover.mjs +69 -0
  142. package/components/presence.cjs +1 -0
  143. package/components/presence.d.ts +36 -0
  144. package/components/presence.mjs +11 -0
  145. package/components/progress.cjs +1 -0
  146. package/components/progress.d.ts +47 -0
  147. package/components/progress.mjs +28 -0
  148. package/components/radioButton.cjs +1 -0
  149. package/components/radioButton.d.ts +32 -0
  150. package/components/radioButton.mjs +32 -0
  151. package/components/radioGroup.cjs +1 -0
  152. package/components/radioGroup.d.ts +51 -0
  153. package/components/radioGroup.mjs +83 -0
  154. package/components/select.cjs +1 -0
  155. package/components/select.d.ts +51 -0
  156. package/components/select.mjs +36 -0
  157. package/components/semantics.cjs +1 -0
  158. package/components/semantics.d.ts +39 -0
  159. package/components/semantics.mjs +17 -0
  160. package/components/slider.cjs +1 -0
  161. package/components/slider.d.ts +98 -0
  162. package/components/slider.mjs +250 -0
  163. package/components/svg.cjs +1 -0
  164. package/components/svg.d.ts +65 -0
  165. package/components/svg.mjs +141 -0
  166. package/components/switch.cjs +1 -0
  167. package/components/switch.d.ts +29 -0
  168. package/components/switch.mjs +25 -0
  169. package/components/tabs.cjs +1 -0
  170. package/components/tabs.d.ts +87 -0
  171. package/components/tabs.mjs +324 -0
  172. package/components/textarea.cjs +1 -0
  173. package/components/textarea.d.ts +41 -0
  174. package/components/textarea.mjs +33 -0
  175. package/components/textbox.cjs +1 -0
  176. package/components/textbox.d.ts +38 -0
  177. package/components/textbox.mjs +33 -0
  178. package/components/toaster.cjs +1 -0
  179. package/components/toaster.d.ts +106 -0
  180. package/components/toaster.mjs +295 -0
  181. package/components/tooltip.cjs +1 -0
  182. package/components/tooltip.d.ts +98 -0
  183. package/components/tooltip.mjs +110 -0
  184. package/components/visuallyHidden.cjs +1 -0
  185. package/components/visuallyHidden.d.ts +6 -0
  186. package/components/visuallyHidden.mjs +23 -0
  187. package/docs/a11y.md +199 -0
  188. package/docs/anchor.md +139 -0
  189. package/docs/components.md +48 -0
  190. package/docs/index.md +11 -0
  191. package/docs/props.md +1777 -0
  192. package/effects.cjs +1 -0
  193. package/effects.mjs +6 -0
  194. package/forms.cjs +1 -0
  195. package/forms.mjs +21 -0
  196. package/gridExport.cjs +2 -0
  197. package/gridExport.mjs +244 -0
  198. package/icons/dotsIcon.d.ts +2 -0
  199. package/icons/expandIcon.d.ts +2 -0
  200. package/icons/groupingIcon.d.ts +2 -0
  201. package/icons/pinIcon.d.ts +2 -0
  202. package/icons/searchIcon.d.ts +2 -0
  203. package/icons/sortIcon.d.ts +2 -0
  204. package/identity.cjs +1 -0
  205. package/identity.mjs +12 -0
  206. package/keyboard.cjs +1 -0
  207. package/keyboard.mjs +30 -0
  208. package/layer.cjs +1 -0
  209. package/layer.mjs +127 -0
  210. package/motion.cjs +1 -0
  211. package/motion.mjs +66 -0
  212. package/package.json +327 -7
  213. package/placement.cjs +1 -0
  214. package/placement.mjs +180 -0
  215. package/platform.cjs +1 -0
  216. package/platform.mjs +45 -0
  217. package/react/a11y/callbacks.d.ts +12 -0
  218. package/react/a11y/useControllableState.d.ts +25 -0
  219. package/react/a11y/useDismiss.d.ts +25 -0
  220. package/react/a11y/useFocusReturn.d.ts +18 -0
  221. package/react/a11y/useRovingFocus.d.ts +84 -0
  222. package/react/anchor/useAnchorPosition.d.ts +79 -0
  223. package/react/animation/usePresence.d.ts +32 -0
  224. package/react/boxClassNames.d.ts +25 -0
  225. package/react/boxProps.d.ts +49 -0
  226. package/react/boxTagProps.d.ts +7 -0
  227. package/react/clientRuntime.d.ts +11 -0
  228. package/react/dialog/useScrollLock.d.ts +12 -0
  229. package/react/effects.d.ts +10 -0
  230. package/react/forms/labelledControl.d.ts +17 -0
  231. package/react/forms/placeholderProp.d.ts +13 -0
  232. package/react/hooks/usePortalContainer.d.ts +6 -0
  233. package/react/hooks/useVirtualization.d.ts +32 -0
  234. package/react/hooks/useVisibility.d.ts +9 -0
  235. package/react/identity/useIdentifier.d.ts +17 -0
  236. package/react/popover/usePopoverLayer.d.ts +87 -0
  237. package/react/reactTypes.d.ts +10 -0
  238. package/react/resolveStyles.d.ts +18 -0
  239. package/react/styleElements.d.ts +3 -0
  240. package/react/svg/attributesInProps.d.ts +4 -0
  241. package/react/svg/svgNaming.d.ts +15 -0
  242. package/react/theme/theme.d.ts +26 -0
  243. package/react/theme/themeContext.d.ts +7 -0
  244. package/react/useStyles.d.ts +22 -0
  245. package/react-shared.cjs +1 -0
  246. package/react-shared.mjs +122 -0
  247. package/rolldown-runtime.cjs +1 -0
  248. package/rsc.cjs +1 -0
  249. package/rsc.d.ts +27 -0
  250. package/rsc.mjs +18 -0
  251. package/ssg.cjs +1 -0
  252. package/ssg.d.ts +24 -0
  253. package/ssg.mjs +25 -0
  254. package/utils/accordion/accordionUtils.d.ts +48 -0
  255. package/utils/anchor/anchorUtils.d.ts +79 -0
  256. package/utils/animation/animationUtils.d.ts +29 -0
  257. package/utils/array/arrayUtils.d.ts +15 -0
  258. package/utils/array/lazyList.d.ts +10 -0
  259. package/utils/box/boxUtils.d.ts +5 -0
  260. package/utils/chart/chartPalette.d.ts +15 -0
  261. package/utils/chart/chartUtils.d.ts +92 -0
  262. package/utils/combobox/comboboxUtils.d.ts +89 -0
  263. package/utils/dom/domUtils.d.ts +32 -0
  264. package/utils/environment/environmentUtils.d.ts +28 -0
  265. package/utils/export/csvUtils.d.ts +23 -0
  266. package/utils/export/downloadUtils.d.ts +12 -0
  267. package/utils/export/xlsxUtils.d.ts +56 -0
  268. package/utils/export/zipUtils.d.ts +26 -0
  269. package/utils/fn/fnUtils.d.ts +4 -0
  270. package/utils/form/formUtils.d.ts +8 -0
  271. package/utils/keyboard/typeaheadUtils.d.ts +31 -0
  272. package/utils/memo.d.ts +10 -0
  273. package/utils/menu/menuUtils.d.ts +25 -0
  274. package/utils/object/objectUtils.d.ts +13 -0
  275. package/utils/slider/sliderUtils.d.ts +67 -0
  276. package/utils/string/fuzzySearch.d.ts +27 -0
  277. package/utils/string/stringUtils.d.ts +4 -0
  278. package/utils/tabs/tabsUtils.d.ts +75 -0
  279. package/utils/toast/toastStore.d.ts +73 -0
  280. package/utils/virtual/virtualUtils.d.ts +47 -0
@@ -0,0 +1,2804 @@
1
+ # @box-kite/react - AI Assistant Context
2
+
3
+ Runtime CSS-in-JS library. `Box` component accepts 221 CSS props and generates CSS classes at runtime. Same prop values share a single class.
4
+
5
+ ---
6
+
7
+ ## CRITICAL RULES
8
+
9
+ ### Rule #1: NEVER Use Inline Styles
10
+
11
+ Always use Box props. If a prop doesn't exist, create it with `Box.extend()` (see Extension System) — or,
12
+ for a one-off property, write it in `css={{ … }}`: the typed escape hatch, which is still a class and
13
+ never a `style` attribute (see "The escape hatch").
14
+
15
+ | Inline Style (WRONG) | Box Prop (CORRECT) |
16
+ | --------------------------------------------- | ---------------------------------------------- |
17
+ | `style={{ width: "100%" }}` | `width="fit"` |
18
+ | `style={{ width: "100vw" }}` | `width="fit-screen"` |
19
+ | `style={{ height: "100%" }}` | `height="fit"` |
20
+ | `style={{ height: "100vh" }}` | `height="fit-screen"` |
21
+ | `style={{ minHeight: "100vh" }}` | `minHeight="fit-screen"` |
22
+ | `style={{ maxWidth: "1200px" }}` | `maxWidth={300}` (300/4=75rem=1200px) |
23
+ | `style={{ alignItems: "center" }}` | `ai="center"` |
24
+ | `style={{ justifyContent: "space-between" }}` | `jc="between"` |
25
+ | `style={{ flexDirection: "column" }}` | `d="column"` |
26
+ | `style={{ pointerEvents: "none" }}` | `pointerEvents="none"` |
27
+ | `style={{ cursor: "pointer" }}` | `cursor="pointer"` |
28
+ | `style={{ overflow: "hidden" }}` | `overflow="hidden"` |
29
+ | `style={{ position: "relative" }}` | `position="relative"` |
30
+ | `style={{ zIndex: 10 }}` | `zIndex={10}` |
31
+ | `style={{ opacity: 0.5 }}` | `opacity={0.5}` |
32
+ | `sm={{ style: { maxWidth: 600 } }}` | `sm={{ maxWidth: 150 }}` (150/4=37.5rem=600px) |
33
+
34
+ **`style` is top-level only** — it is NOT supported inside breakpoints, pseudo-classes, or theme objects. Always use Box props for responsive/conditional styles; `css` nests anywhere a prop does (`md={{ css: { objectPosition: 'center' } }}` compiles to a rule).
35
+
36
+ ### Rule #2: ALWAYS Use Component Shortcuts
37
+
38
+ NEVER use `<Box tag="...">` when a component exists. NEVER use `<Box display="flex/grid">`.
39
+
40
+ | Instead of... | Use... | Import from |
41
+ | ------------------------------------ | -------------------------------- | ---------------------- |
42
+ | `<Box display="flex">` | `<Flex>` | `components/flex` |
43
+ | `<Box display="grid">` | `<Grid>` | `components/grid` |
44
+ | `<Box tag="button">` | `<Button>` | `components/button` |
45
+ | `<Box tag="input">` | `<Textbox>` | `components/textbox` |
46
+ | `<Box tag="textarea">` | `<Textarea>` | `components/textarea` |
47
+ | `<Box tag="a/img/label">` | `<Link>/<Img>/<Label>` | `components/semantics` |
48
+ | `<Box tag="h1/h2/h3/h4/h5/h6">` | `<H1>/<H2>/.../<H6>` | `components/semantics` |
49
+ | `<Box tag="p/span">` | `<P>/<Span>` | `components/semantics` |
50
+ | `<Box tag="ul/ol/li">` | `<Ul>/<Ol>/<Li>` | `components/semantics` |
51
+ | `<Box tag="nav/header/footer/main">` | `<Nav>/<Header>/<Footer>/<Main>` | `components/semantics` |
52
+ | `<Box tag="section/article/aside">` | `<Section>/<Article>/<Aside>` | `components/semantics` |
53
+ | `<Box tag="svg/path/circle/rect">` | `<Svg>/<Path>/<Circle>/<Rect>` | `components/svg` |
54
+ | `<Box tag="dialog">` | `<Dialog>/<AlertDialog>` | `components/dialog` |
55
+ | a menu button and its menu | `<Menu>` | `components/menu` |
56
+ | a tablist over one panel at a time | `<Tabs>` | `components/tabs` |
57
+ | a message sent from anywhere | `<Toaster>` + `toast()` | `components/toaster` |
58
+ | a lucide/Tabler icon, styled | `<Icon>` | `components/icon` |
59
+
60
+ All imports from `@box-kite/react/components/...`. Semantics also export: `Mark`, `Figure`, `Figcaption`, `Details`, `Summary`, `MenuList` (the semantic `<menu>`; the menu **button** is `Menu` from `components/menu`), `Time`.
61
+
62
+ ---
63
+
64
+ ## Numeric Value Formatters
65
+
66
+ **#1 source of confusion.** Different props have different dividers:
67
+
68
+ | Prop Category | Divider | Example | CSS Output |
69
+ | -------------------------------------------------------------------------------------- | ------- | -------------------------- | ------------------------------ |
70
+ | Spacing (`p`, `m`, `gap`, `px`, `py`, `mx`, `my`, etc.) | 4 | `p={4}` | `padding: 1rem` (16px) |
71
+ | Font size (`fontSize`) | **16** | `fontSize={14}` | `font-size: 0.875rem` (14px) |
72
+ | Width/Height (numeric) | 4 | `width={20}` | `width: 5rem` (80px) |
73
+ | Border width (`b`, `bx`, `by`, `bt`, `br`, `bb`, `bl`, `bs`, `be`) | none | `b={1}` | `border-width: 1px` |
74
+ | Border radius (`borderRadius`, `borderRadiusTop`, …) | 4 | `borderRadius={2}` | `border-radius: 0.5rem` (8px) |
75
+ | Line height (`lineHeight`) | none | `lineHeight={24}` | `line-height: 24px` |
76
+ | SVG lengths (`strokeWidth`, `strokeDasharray`, `r`, `cx`, …) | none | `strokeWidth={2}` | `stroke-width: 2` (user units) |
77
+ | Times (`animationDuration`, `animationDelay`, `transitionDuration`, `transitionDelay`) | none | `animationDuration={1100}` | `animation-duration: 1100ms` |
78
+
79
+ ```tsx
80
+ // fontSize: divider 16 → value maps directly to px
81
+ fontSize={12} // 12px fontSize={14} // 14px fontSize={16} // 16px
82
+ fontSize={18} // 18px fontSize={24} // 24px fontSize={32} // 32px
83
+
84
+ // Spacing: divider 4 → value/4 = rem
85
+ p={1} // 4px p={2} // 8px p={3} // 12px p={4} // 16px p={6} // 24px p={8} // 32px
86
+
87
+ // Width/Height/min/max: divider 4 → value/4 = rem (NOT direct pixels)
88
+ width={20} // 5rem = 80px height={10} // 2.5rem = 40px height={20} // 5rem = 80px
89
+ maxWidth={300} // 75rem = 1200px minHeight={50} // 12.5rem = 200px
90
+ height="fit" // 100% height="fit-screen" // 100vh width="1/2" // 50%
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Prop Reference
96
+
97
+ ### Spacing
98
+
99
+ | Prop | CSS Property |
100
+ | ----------------------------------------------------------- | ------------------------------------------------------------------------------- |
101
+ | `p` / `px` / `py` / `pt` / `pr` / `pb` / `pl` / `ps` / `pe` | padding (all / inline axis / block axis / one physical side / one logical side) |
102
+ | `m` / `mx` / `my` / `mt` / `mr` / `mb` / `ml` / `ms` / `me` | margin (all / inline axis / block axis / one physical side / one logical side) |
103
+ | `gap` | gap (flexbox/grid) |
104
+
105
+ ### Logical properties and RTL
106
+
107
+ Every axis prop was already logical — `mx` is `margin-inline`, `px` is `padding-inline`, `insetX` is `inset-inline`. These are the two **sides** of that axis, so one stylesheet serves both reading directions: the browser resolves them from `dir`, and nothing re-renders.
108
+
109
+ | Physical | Logical | CSS |
110
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
111
+ | `pl` / `pr` | `ps` / `pe` | `padding-inline-start` / `padding-inline-end` |
112
+ | `ml` / `mr` | `ms` / `me` | `margin-inline-start` / `margin-inline-end` |
113
+ | `bl` / `br` | `bs` / `be` | `border-inline-start-width` / `border-inline-end-width` |
114
+ | `left` / `right` | `insetStart` / `insetEnd` | `inset-inline-start` / `inset-inline-end` |
115
+ | `borderRadiusLeft` / `borderRadiusRight` | `borderRadiusStart` / `borderRadiusEnd` | the two corners on that inline side |
116
+ | `borderRadiusTopLeft` … `borderRadiusBottomRight` | `borderRadiusStartStart` / `borderRadiusStartEnd` / `borderRadiusEndEnd` / `borderRadiusEndStart` | one corner; **block axis first, inline axis second** |
117
+
118
+ `textAlign` takes `'start'` and `'end'` beside `'left'`/`'right'`. The dividers are unchanged: `ps`/`pe`/`ms`/`me`/`insetStart`/`insetEnd` and the radii are the ÷4 spacing scale, `bs`/`be` are direct px.
119
+
120
+ ```tsx
121
+ // A notification card whose accent bar stays on the side the text starts from
122
+ <Flex props={{ dir }} ps={4} pe={3} py={3} bs={4} borderStyle="solid" borderColor="indigo-500" borderRadiusEnd={2}>
123
+ <Box flex1>Your order is on its way</Box>
124
+ <Box rtl={{ flip: 'xAxis' }}>→</Box>
125
+ </Flex>
126
+ ```
127
+
128
+ `rtl` and `ltr` are ordinary pseudo-class keys for what the logical props cannot express — an arrow that has to point the other way, a shadow that has to fall the other way. They compile to `:dir(rtl)` / `:dir(ltr)` rather than Tailwind's `[dir="rtl"] &`: the direction belongs to **this** element, so a `<bdi>` or a `dir="auto"` that flipped one paragraph is seen. Two consequences: with no `dir` anywhere the document is left-to-right, so `ltr` matches (it is a state, not an attribute you have to write); and the `dir` attribute itself goes in `props`, where every attribute goes.
129
+
130
+ ```tsx
131
+ <Box props={{ dir: 'rtl' }} rtl={{ flip: 'xAxis' }} not={{ rtl: { ms: 2 } }} md={{ rtl: { textAlign: 'end' } }} />
132
+ ```
133
+
134
+ **The pre-built components come with it**, so one `dir` is the whole change:
135
+
136
+ - **The arrow keys follow the reading order.** `useRovingFocus` reads the element's _resolved_ direction when a sideways arrow arrives, so in a right-to-left list or grid `ArrowLeft` moves to the **next** item — which is what APG asks for. Nothing is configured; a `dir` anywhere above the list is enough, and a vertical list pays nothing. Tab, Home and End never flip.
137
+ - **A DataGrid column pins to `'START'` or `'END'`** of the inline axis rather than to a screen side, so it stays where the reading begins under either direction; `'LEFT'`/`'RIGHT'` are the older spelling of those two. `align` gained `'start'`/`'end'`, the resize handle grows a column towards the reading end, and the column menu's _Pin Left_ reads _Pin Right_ when that is the side it would pin to.
138
+ - **A popup keeps the direction it was declared in.** `Overlay` stays in the subtree it was declared in — the top layer inherits normally — so `Tooltip`, the `Dropdown` popup and the grid's column menu simply inherit it. Only on the portal fallback, where the container is a child of the body and nothing of the declaration site reaches it, does it read the direction off its anchor and write it back on as `dir`.
139
+ - **What stays physical, on purpose**: the grid's loading bar sweeps the same way in both. (`Overlay` no longer positions in page coordinates at all — a `side` is logical, so a layer beside its anchor mirrors with the reading order.)
140
+
141
+ ### Layout
142
+
143
+ | Prop | CSS Property | Values |
144
+ | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
145
+ | `display` | display | `'flex'`, `'block'`, `'inline'`, `'grid'`, `'none'`, `'inline-flex'`, etc. |
146
+ | `d` | flex-direction | `'row'`, `'column'`, `'row-reverse'`, `'column-reverse'` |
147
+ | `wrap` | flex-wrap | `'wrap'`, `'nowrap'`, `'wrap-reverse'` |
148
+ | `ai` | align-items | `'center'`, `'start'`, `'end'`, `'stretch'`, `'baseline'` |
149
+ | `jc` | justify-content | `'center'`, `'start'`, `'end'`, `'between'`, `'around'`, `'evenly'` |
150
+ | `justifyItems` / `placeItems` / `placeContent` / `alignContent` / `alignSelf` / `justifySelf` | justify-items / place-items / place-content / align-content / align-self / justify-self | `'center'`, `'start'`, `'end'`, `'stretch'`, `'baseline'`, `'self-start'`, `'self-end'`. **Every alignment prop also takes the overflow-safe forms** — `'safe center'`, `'safe start'`, `'safe end'` and the `'unsafe …'` twins: `safe center` centres until the content stops fitting and then aligns to `start`, where plain `center` overflows both edges and the half above the scrollable origin cannot be reached |
151
+ | `aspectRatio` | aspect-ratio | `'square'` (1/1), `'video'` (16/9), a ratio written compactly (`'4/3'`), a number (`aspectRatio={2}`), or `'auto'` |
152
+ | `flex` / `grow` / `shrink` | flex / flex-grow / flex-shrink | number or string |
153
+ | `container` | container(-type) | `true`, or a name — makes the element a query container (see `cq` below) |
154
+
155
+ ### Sizing
156
+
157
+ | Prop | CSS Property | Accepts |
158
+ | --------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
159
+ | `width` / `height` | width / height | number (rem/4), `'auto'`, `'fit'` (100%), `'fit-screen'` (100vw/vh), fractions (`'1/2'`, `'1/3'`, `'2/3'`, `'1/4'`, `'3/4'`), percentages (`'33%'`) |
160
+ | `minWidth` / `maxWidth` / `minHeight` / `maxHeight` | min/max sizing | number or string |
161
+
162
+ All sizing, spacing, and positioning props also accept percentage strings: `p="5%"`, `top="10%"`, `gap="2%"`.
163
+
164
+ ### Visual
165
+
166
+ | Prop | CSS Property | Notes |
167
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
168
+ | `bgColor` / `color` / `borderColor` | background-color / color / border-color | Tailwind's OKLCH palette, 26 families × 11 steps: `'gray-50'`..`'gray-950'`, same for slate/zinc/neutral/stone/mauve/mist/olive/taupe/red/orange/amber/yellow/lime/green/emerald/teal/cyan/sky/blue/indigo/violet/purple/fuchsia/pink/rose. Also `'white'`, `'black'`, `'transparent'`, `'currentColor'`. Any of them takes an **opacity modifier** — `bgColor="blue-500/40"`. Plus the CSS **system colours** (`'Canvas'`, `'CanvasText'`, `'ButtonFace'`, `'ButtonText'`, `'Highlight'`, `'HighlightText'`, `'GrayText'`, `'LinkText'`) — keywords rather than tokens, and the one palette a forced-colors mode keeps |
169
+ | `b` / `bx` / `by` / `bt` / `br` / `bb` / `bl` / `bs` / `be` | border-width | direct px |
170
+ | `borderRadius` | border-radius | divider 4 (spacing scale) |
171
+ | `borderStyle` | border-style | `'solid'`, `'dashed'`, `'dotted'`, `'none'` |
172
+ | `fontSize` | font-size | divider 16 |
173
+ | `fontWeight` | font-weight | `400`, `500`, `600`, `700`, etc. |
174
+ | `lineHeight` | line-height | direct px |
175
+ | `textAlign` / `textDecoration` / `textTransform` / `whiteSpace` / `textOverflow` | text properties | string values |
176
+ | `overflow` | overflow | `'hidden'`, `'auto'`, `'scroll'`, `'visible'` |
177
+ | `position` | position | `'relative'`, `'absolute'`, `'fixed'`, `'sticky'` |
178
+ | `top` / `right` / `bottom` / `left` / `inset` / `insetX` / `insetY` / `insetStart` / `insetEnd` | positioning offsets | number (rem/4), `'auto'`, fractions (`'1/2'`, `'-1/3'`), percentages. `insetX`/`insetY` are the logical shorthands `inset-inline`/`inset-block`, the way `mx`/`my` are `margin-inline`/`margin-block` |
179
+ | `zIndex` | z-index | number |
180
+ | `shadow` / `insetShadow` / `ring` / `insetRing` | box-shadow (four stacking layers) | `shadow`: `'xxs'`..`'xxl'` on Tailwind's scale, the presets `'small'`/`'medium'`/`'large'`, or `'none'`. `insetShadow`: `'xxs'`/`'xs'`/`'sm'`. `ring`/`insetRing`: a width in px. Each is its own layer, so they stack. `shadowColor`/`insetShadowColor`/`ringColor`/`insetRingColor` recolour one. See _Gradients, shadows and rings_ |
181
+ | `textShadow` / `textShadowColor` | text-shadow | `'xxs'`..`'lg'` or `'none'`; the colour is a separate prop, as with the box shadows |
182
+ | `bgGradient` | background-image | A gradient as a record: `{ linear: 'r', colors: ['blue-500', 'pink-500'] }`. Same property as `bgImage` — use one | |
183
+ | `blur` / `brightness` / `contrast` / `grayscale` / `hueRotate` / `invert` / `saturate` / `sepia` | filter (nine stacking functions) | A number is the function's own unit — `%` for the six that take one, `deg` for `hueRotate`, `px` for `blur`, which also takes `'xs'`..`'xxxl'`. `'none'` clears one function. See _Filters, backdrop filters and masks_ |
184
+ | `dropShadow` / `dropShadowColor` | filter: drop-shadow() | `'xs'`..`'xxl'` or `'none'`. Cast by the shape, not the box — the ninth filter function, not a fifth shadow layer |
185
+ | `backdropBlur` / `backdropBrightness` / `backdropContrast` / `backdropGrayscale` / `backdropHueRotate` / `backdropInvert` / `backdropOpacity` / `backdropSaturate` / `backdropSepia` | backdrop-filter | The same nine on what is behind the element. Same property as `backdropFilter` — use one |
186
+ | `maskImage` | mask-image | The gradient record `bgGradient` takes, `'url(#id)'`, `'var(--name)'` or `'none'`. One mask, not a stack |
187
+ | `bgClip` | background-clip | `'border'`, `'padding'`, `'content'`, `'text'` — `text` needs `color="transparent"` beside it |
188
+ | `opacity` | opacity | number |
189
+ | `accentColor` / `caretColor` | accent-color / caret-color | The palette, its opacity modifier and the system colours. `accentColor` tints the parts of a native control the page does not draw — a checkbox's tick, a radio's dot, a range track, a progress bar — without `appearance="none"` and a rebuild, and it inherits, so a form can set it once |
190
+ | `colorScheme` | color-scheme | `'light'`, `'dark'`, `'light dark'`, `'only light'`, `'only dark'`, `'normal'`. What native UI follows: scrollbars, form controls, the spellcheck underline. A dark theme that leaves it alone gets light scrollbars over a dark page |
191
+ | `fieldSizing` | field-sizing | `'content'` — a control that grows with what is typed into it, no JavaScript measuring anything — or `'fixed'` |
192
+ | `scrollbarGutter` / `scrollbarWidth` / `scrollbarColor` | scrollbar-gutter / -width / -color | `'stable'` / `'stable both-edges'` reserve the scrollbar's space before there is one, so nothing shifts when content overflows. `scrollbarWidth`: `'auto'`/`'thin'`/`'none'`; `scrollbarColor`: `[thumb, track]` |
193
+ | `willChange` | will-change | `'transform'`, `'opacity'`, `'filter'`, `'scroll-position'`, `'contents'`, `'auto'`. A hint with a real cost (a promoted layer holds memory), so it belongs on the few elements that animate, not on a list |
194
+ | `cursor` / `pointerEvents` / `userSelect` | misc | string values. For `transition`, `animation` and the transform props see _Animation and transitions_ below |
195
+
196
+ ### Anchor positioning — a floating layer with no JavaScript
197
+
198
+ CSS Anchor Positioning as six props, so a tooltip, popup or badge is placed by the browser instead of measured in an effect. The anchor names itself; the layer says which anchor and which cell of the 3×3 grid around it:
199
+
200
+ ```tsx
201
+ <Button anchorName="menu-trigger">Options</Button>
202
+
203
+ <Box position="absolute" positionAnchor="menu-trigger" positionArea="block-end span-all" positionTryFallbacks="flip-block" mt={2}>
204
+ …
205
+ </Box>
206
+ ```
207
+
208
+ | Prop | CSS | Values |
209
+ | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
210
+ | `anchorName` | `anchor-name` | a name with or without `--` (`"trigger"` → `--trigger`), or `'none'`. Global to the document, so one instance needs one name |
211
+ | `positionAnchor` | `position-anchor` | the same name, or `'auto'` / `'none'`. Needs `position="absolute"` (or `"fixed"`) beside it |
212
+ | `positionArea` | `position-area` | one or two keywords, **block axis first**, both from one family: `'block-end center'`, `'top left'`, `'center inline-end'`, `'span-all'` |
213
+ | `positionTryFallbacks` | `position-try-fallbacks` | `'flip-block'`, `'flip-inline'`, `'flip-start'`, space-combined into one candidate, comma-separated into a list tried in order, or `'none'` |
214
+ | `positionTryOrder` | `position-try-order` | `'normal'` (first fit), `'most-width'`, `'most-height'`, `'most-block-size'`, `'most-inline-size'` |
215
+ | `positionVisibility` | `position-visibility` | `'always'`, `'anchors-visible'`, `'no-overflow'`, `'anchors-visible no-overflow'` |
216
+
217
+ `justifySelf` and `alignSelf` also take `'anchor-center'`. Offset a layer from its anchor with an ordinary margin — there is no offset prop, because `mt={2}` already is one.
218
+
219
+ The keyword families do not mix: physical (`top`/`bottom` × `left`/`right`), logical (`block-start`/`block-end` × `inline-start`/`inline-end`), their `self-` twins, the `start`/`end` shorthand, its `self-` twin, and the `y-*` × `x-*` pair — with `center` and `span-all` neutral in all of them. So `'block-end inline-start'` is a value and `'top inline-start'` is not; a mixed pair emits no rule and no class, as does an unknown flip keyword or a name that is not an identifier.
220
+
221
+ **Two traps, both measured in Chrome 152 rather than read off the spec:**
222
+
223
+ - **A candidate position has to fit on _both_ axes to be taken**, so an overflow the flips cannot fix disqualifies every one of them and the layer silently stays where it started. `positionArea="block-end center"` with a layer wider than its anchor overflows the centre column, and `flip-block` then does nothing at all. Span the axis being kept — `"block-end span-all"` — and it flips.
224
+ - **`positionVisibility` hides at paint time**, so the layer keeps its box and its computed `visibility` still reads `visible` (`checkVisibility()` agrees). Only painting and hit-testing stop, so a test asserting it needs a screenshot.
225
+
226
+ Chrome 125+, Firefox 147+, Safari 26+. Where it is missing the layer renders unpositioned, so the portable path is `useAnchorPosition` (which measures instead) or `Overlay`, which is that hook plus the top layer.
227
+
228
+ A length can also come off the anchor. Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one, so a layer sizes and places itself against its anchor with nothing measured:
229
+
230
+ ```tsx
231
+ <Box position="fixed" positionAnchor="menu-trigger" minWidth="anchor-size(width)" maxHeight="anchor-size(height, 20rem)" />
232
+ <Box position="fixed" top="anchor(bottom)" insetStart="anchor(left)" />
233
+ ```
234
+
235
+ `anchor-size()` takes an axis (`width`, `height`, `block`, `inline`, `self-block`, `self-inline`), `anchor()` an edge (`top`, `right`, `bottom`, `left`, `start`, `end`, `self-start`, `self-end`, `center`, `inside`, `outside`) or a percentage along one. Both take an optional anchor name first — with the same optional `--` — and an optional fallback after a comma. A bare `anchor()` is rejected (CSS needs the edge); a bare `anchor-size()` is not (it means the layer's own axis).
236
+
237
+ ### `useAnchorPosition` — the placement as one hook
238
+
239
+ `@box-kite/react/anchor` turns a side and an alignment into the props above, and measures instead where the browser has no anchor positioning. It renders nothing and owns no state beyond that.
240
+
241
+ ```tsx
242
+ import { useAnchorPosition } from '@box-kite/react/anchor';
243
+
244
+ const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true });
245
+
246
+ <Button {...anchorProps} onClick={toggle}>
247
+ Options
248
+ </Button>;
249
+ {
250
+ isOpen && (
251
+ <Box {...layerProps} component="menu">
252
+ …
253
+ </Box>
254
+ );
255
+ }
256
+ ```
257
+
258
+ | Option | Default | What it means |
259
+ | ------------ | ---------- | ------------------------------------------------------------------------------------------------------------------- |
260
+ | `side` | `'bottom'` | `'top'`/`'bottom'` are the block axis, `'start'`/`'end'` the inline one — so a side mirrors in a right-to-left page |
261
+ | `align` | `'center'` | which of the anchor's edges to line up with on the other axis |
262
+ | `offset` | `0` | the gap, on the ÷4 spacing scale — emitted as the margin on the side facing the anchor, and a flip flips it too |
263
+ | `flip` | `true` | `positionTryFallbacks`: the side's own axis, then the alignment's, then both |
264
+ | `matchWidth` | `false` | `minWidth="anchor-size(width)"` |
265
+ | `name` | generated | the anchor's name; one per instance unless you pass one |
266
+ | `anchor` | — | an anchor handed to the hook rather than one it spreads props onto — the name is written on in an effect |
267
+ | `trackSide` | `false` | whether `side` in the result reports where the layer ended up, which costs one read after layout |
268
+
269
+ `css` in the result says which path ran: `true` is the browser placing the layer with no JavaScript at all, `false` is the measured fallback (flip to the opposite side, then shift along the other axis to stay in the viewport). `side` is where the layer actually is — the requested side unless `trackSide` is on and a flip moved it.
270
+
271
+ **Three more things worth knowing, all measured:**
272
+
273
+ - **The layer is `position: fixed`**, so it escapes every `overflow: hidden` ancestor without a portal — but not a _transformed_ ancestor, which is a fixed element's containing block, and not the page's stacking order. `Overlay` is this hook plus the browser's top layer, which beats both, and is still the answer when the layer has to come out on top of everything.
274
+ - **Three fallbacks, not one.** A candidate has to fit on _both_ axes, so a lone `flip-block` does nothing at all for a layer that overflows the _cross_ axis — the browser leaves it pressed against the edge of the viewport. `flip` therefore offers the side's axis, the alignment's, and both, and a menu aligned to its trigger's end mirrors to the other end rather than running off the page.
275
+ - **Which side the browser chose costs the entrance.** `trackSide` reads the _used_ `position-area`, and that read is the style resolution `@starting-style` computes its before-change style from — so a class that depends on the answer lands after the entrance has already been decided. The exit runs long afterwards and can use it (`dropdown.items` has `closedUp` and no `up`).
276
+ - **A used `position-area` is not the value that went in.** Chrome 152 drops a `span-all` half (`block-end span-all` reads back `block-end`) and collapses a value naming both axes into the `start`/`end` shorthand, where position names the axis: `block-end span-inline-end` reads back `end span-end`, and `start span-end` once it has flipped.
277
+ - **A flip is sticky, and in the top layer it is never re-evaluated on scroll at all.** Once the browser takes one it keeps it until the layer is laid out afresh, which is what stops it oscillating as the page scrolls: hiding the layer and showing it again re-evaluates. So a popup that mounts when it opens always picks the side that fits, while one that stays mounted keeps the side it first chose. For an element in the **top layer** — which is every pre-built layer: `Overlay`, and so `Tooltip`, the `Dropdown` popup and the DataGrid menu, plus `Popover` — Chrome does not re-evaluate on scroll even once the side has run out of room, so a layer open across a long scroll slides past the viewport edge. Measured in Chrome 152 against an otherwise identical non-top-layer element, which does flip; only leaving and re-entering the top layer re-arms it.
278
+ - **The anchor's name is an inline style, not a prop.** An identity is per instance, so a class for it would be a rule per instance that is never freed — the same reason a sparkline's `d` is an attribute while its stroke is a class. Everything shared — the area, the flip, the margin, the width — is an ordinary prop.
279
+
280
+ ### Colours, and the opacity modifier
281
+
282
+ The palette is **Tailwind 4.3's, in OKLCH**: twenty-six families of eleven steps (`50`–`950`) — the five
283
+ neutrals (`slate`, `gray`, `zinc`, `neutral`, `stone`), the four Tailwind 4.3 added (`mauve`, `mist`,
284
+ `olive`, `taupe`) and seventeen hues (`red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`,
285
+ `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`). Each token is a
286
+ CSS variable declared in `:root` the first time something uses it, so a colour you never write costs
287
+ nothing.
288
+
289
+ Any colour value takes a slash and a percentage:
290
+
291
+ ```tsx
292
+ <Box bgColor="blue-500/40" borderColor="black/10" color="currentColor/60" />
293
+ <Box theme={{ dark: { bgColor: 'sky-400/25' } }} hover={{ bgColor: 'blue-500/60' }} />
294
+ <Flex vars={{ 'color-grid': 'slate-500/20' }} />
295
+ ```
296
+
297
+ - It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`. The mix wraps the
298
+ **variable**, so a themed token is still themed and every element asking for the same value shares
299
+ one class. `oklab` because mixing towards transparency in a polar space drags the hue along.
300
+ - Not `opacity`, which fades the element, its text and its children. The modifier fades one declaration.
301
+ - Works on `color`, `bgColor`, `borderColor`, `outlineColor`, `fill`, `stroke` and on a `vars` entry —
302
+ and on a variable declared through `Box.extend()` (`bgColor="brand/30"`).
303
+ - A token the palette does not have, or a percentage outside 0–100, produces **no rule and no class
304
+ name** — the same silence every unmatched value gets.
305
+
306
+ ### Gradients, shadows and rings
307
+
308
+ A gradient is a **value**, written as a record — so its stops are palette tokens, it takes the opacity
309
+ modifier, it is themed, and two elements asking for the same one share a class.
310
+
311
+ ```tsx
312
+ // The key names the kind and carries its geometry; `colors` are the stops in order.
313
+ <Box bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }} />
314
+ <Box bgGradient={{ linear: 135, colors: ['blue-500/40', 'transparent'] }} />
315
+ <Box bgGradient={{ radial: 'circle', at: 'top left', colors: [['sky-500', '20%'], 'indigo-900'] }} />
316
+ <Box bgGradient={{ conic: 45, colors: ['red-500', 'yellow-500', 'red-500'] }} />
317
+
318
+ // Interpolation is what keeps two stops out of the grey middle sRGB runs them through.
319
+ <Box bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'], interpolate: 'oklch' }} />
320
+ ```
321
+
322
+ - `linear` takes `t`/`tr`/`r`/`br`/`b`/`bl`/`l`/`tl` or a number of degrees; `radial` takes
323
+ `'circle'`/`'ellipse'`/`true`; `conic` takes a start angle or `true`. Exactly one of the three.
324
+ - `at` centres a `radial` or `conic` gradient (not a `linear` one, which runs in a direction).
325
+ - `interpolate`: `'srgb'`, `'hsl'`, `'oklab'`, `'oklch'`, `'hsl-longer'`, `'oklch-longer'`.
326
+ - A stop is any colour value — a token, `blue-500/40`, a system colour, `var(--chart-1)` — or a
327
+ `[colour, position]` pair. **Two stops minimum**, and the record is judged whole: one bad stop, one
328
+ unknown key, two kinds at once, and the whole value emits no rule and no class name.
329
+ - It writes `background-image`, so `bgGradient` and `bgImage` are the same property.
330
+
331
+ **Four shadows stack**, because each sets its own custom property and all four write one composed
332
+ `box-shadow`. A ring and an elevation coexist instead of the last one winning:
333
+
334
+ ```tsx
335
+ <Box shadow="md" ring={2} ringColor="indigo-500" />
336
+ <Box insetShadow="sm" insetRing={1} shadow="lg" shadowColor="blue-500/40" />
337
+ <P textShadow="sm" textShadowColor="black/20" />
338
+ ```
339
+
340
+ - `shadow`: `xxs` `xs` `sm` `md` `lg` `xl` `xxl`, Tailwind's elevation scale — plus the three original
341
+ presets `small`/`medium`/`large`, which carry their own colour. `insetShadow`: `xxs`/`xs`/`sm`.
342
+ - `ring`/`insetRing` are a **width in px**, not a scale: `ring={2}`. Unlike `outline` a ring joins the
343
+ shadow stack, follows `borderRadius` and costs no layout. `currentColor` unless recoloured.
344
+ - `none` on `shadow` or `insetShadow` — and `0` on a ring — clears **just that layer**.
345
+ - A colour prop shows nothing on its own, the way `borderColor` does with no border width.
346
+ - `transition="shadow"` covers `box-shadow` and `text-shadow` both.
347
+
348
+ ### Filters, backdrop filters and masks
349
+
350
+ **Nine filter functions stack** the way the shadows do: `filter` is one property whose value is a list,
351
+ so each prop sets a layer of its own and all nine write the same composed declaration.
352
+
353
+ ```tsx
354
+ <Box blur="sm" grayscale={100} />
355
+ <Box brightness={110} saturate={150} hueRotate={90} />
356
+ <Box dropShadow="lg" dropShadowColor="indigo-500/40" />
357
+ <Box backdropBlur="sm" backdropSaturate={180} bgColor="white/20" />
358
+ ```
359
+
360
+ - `blur`, `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate`, `sepia`,
361
+ `dropShadow`. **A number is the function's own unit** — a percentage for the six that take one,
362
+ degrees for `hueRotate`, px for `blur`.
363
+ - `blur` takes Tailwind's scale too: `xs` (4px) `sm` (8px) `md` (12px) `lg` (16px) `xl` (24px)
364
+ `xxl` (40px) `xxxl` (64px).
365
+ - `dropShadow` is `xs`..`xxl` with `dropShadowColor`. It is cast by the **shape** — an SVG path, the
366
+ opaque part of a transparent PNG — where `shadow` draws a rectangle round the box.
367
+ - `none` clears **just that function**, the way it clears just one shadow layer.
368
+ - The nine `backdrop*` props are the same functions on `backdrop-filter`, which is the glassmorphism
369
+ half. `backdropOpacity` exists and `backdropDropShadow` does not — neither means anything on the
370
+ other side. The older `backdropFilter` prop writes that property directly: use one or the other.
371
+ - `transition="filter"` covers `filter` and `backdrop-filter` both.
372
+
373
+ **A mask is the gradient grammar again.** `maskImage` reads a gradient's alpha channel to decide which
374
+ of the element's pixels are painted, so a fade to `transparent` is the whole edge-fade recipe:
375
+
376
+ ```tsx
377
+ <Box maskImage={{ linear: 'b', colors: ['black', ['black', '55%'], 'transparent'] }} />
378
+ <Box maskImage="url(#frame)" />
379
+ ```
380
+
381
+ - The same record `bgGradient` takes and judged the same way, plus `url(#id)`/`var(--name)` and
382
+ `none`. One mask, not a stack.
383
+
384
+ **`bgClip="text"` is what turns a gradient into type**, and it needs `color="transparent"` beside it
385
+ or the text paints over its own background:
386
+
387
+ ```tsx
388
+ <H1 color="transparent" bgClip="text" bgGradient={{ linear: 'r', colors: ['violet-500', 'cyan-400'] }}>
389
+ Painted by the background
390
+ </H1>
391
+ ```
392
+
393
+ - `border` / `padding` / `content` / `text`.
394
+
395
+ ### Custom properties (`vars`)
396
+
397
+ The one prop whose declaration _names_ come from its value, for styling markup this library never
398
+ rendered — a chart library, a third-party widget, a subtree with its own tokens:
399
+
400
+ ```tsx
401
+ <Flex vars={{ 'color-revenue': 'sky-500', 'chart-gap': '4px', rows: 3 }} theme={{ dark: { vars: { 'color-revenue': 'sky-400' } } }}>
402
+ {/* --color-revenue, --chart-gap and --rows are inherited by everything in here */}
403
+ </Flex>
404
+ ```
405
+
406
+ - A **colour token** resolves to the variable behind it (`--color-revenue: var(--sky-500)`), and one
407
+ carrying an opacity modifier to the mix that applies it (`slate-500/20`), so it
408
+ follows the palette. Every other value is written out as it stands: a length, a number, a
409
+ `var(--x)`/`url(#x)` reference, a colour of your own. Names may carry a leading `--` or not.
410
+ - It is an ordinary prop: it nests in `theme`, `hover`, `md` and a group selector like all the others,
411
+ and it lands in a **class** — so two subtrees declaring the same variables share one rule, and
412
+ nothing needs a `<style>` tag or an `id` to scope it. It renders in a Server Component.
413
+ - A name that is not a CSS identifier, or a value containing `;` or a brace, is skipped — that entry
414
+ only, not the whole record, so one bad name cannot take a palette down with it.
415
+ - Not the same thing as `Box.extend({ variables })`, which declares tokens globally on `:root` before
416
+ the first render. `vars` is per element, per theme, per breakpoint.
417
+
418
+ ### The escape hatch (`css`)
419
+
420
+ The 5% the typed set does not cover, without an inline style: a style object compiled through the
421
+ same pipeline as every other prop — one class per object, shared between elements writing the same
422
+ one, nested wherever a prop nests, rendered on a server.
423
+
424
+ ```tsx
425
+ <Box css={{ mixBlendMode: 'multiply', scrollSnapType: 'x mandatory' }} hover={{ css: { isolation: 'isolate' } }} />
426
+ <Img css={{ objectPosition: 'top' }} md={{ css: { objectPosition: 'center' } }} />
427
+ ```
428
+
429
+ - **Property names are camelCase**, as React spells them (`WebkitLineClamp` → `-webkit-line-clamp`,
430
+ `msOverflowStyle` → `-ms-overflow-style`); a hyphenated name or a `--custom` one is written as it
431
+ stands. Names and keyword values are typed by `csstype`, so a misspelt property is a compile error.
432
+ - **Values are CSS, written out as they stand** — a number too, so a length wants its unit
433
+ (`width: '100px'`; the type refuses `width: 100`) while `zIndex`, `opacity`, `flexGrow` and
434
+ `lineHeight` take theirs. A colour token resolves the way a `vars` value does
435
+ (`outlineColor: 'sky-500'` → `var(--sky-500)`, `'rose-400/60'` → the mix), so the hatch stays themed.
436
+ - **It is sorted last**: on one element a `css` declaration wins over the typed prop naming the same
437
+ property. It is the override, and the cascade says so.
438
+ - **It nests like any prop** — `hover`, `md`, `theme`, `dataAttr`, `before`, `startingStyle`, a
439
+ `Box.keyframes()` step. What it does _not_ take is a selector or a nested rule: those are the
440
+ nesting keys' job, around it.
441
+ - A value carrying `;` or a brace, or a name that is not a property name, is dropped — that entry
442
+ only. A data URI's `;` is written `%3B`.
443
+ - **Governance**: reach for a typed prop first; for a property used more than once, `Box.extend()`
444
+ (five lines, indistinguishable from a built-in afterwards); `css` is for the one-off. An ESLint rule
445
+ that flags or forbids it per team policy is planned; until it ships, `grep -rn "css={{"` is the audit.
446
+
447
+ ### SVG
448
+
449
+ Twenty-three SVG properties, on any Box, plus twenty element components in `components/svg` — `Svg`, `G`, `Defs`, `Path`, `Circle`, `Ellipse`, `Rect`, `Line`, `Polyline`, `Polygon`, `SvgText`, `TSpan`, `LinearGradient`, `RadialGradient`, `Stop`, `ClipPath`, `Mask`, `Use`, `SvgSymbol`, `Marker`. **Never `<Box tag="path">`.** For an icon from somebody else's set, `<Icon>` (`components/icon`) — see below. (`BaseSvg` is deprecated: it is `Svg` with a 24×24 preset.)
450
+
451
+ | Prop | CSS Property | Accepts |
452
+ | ------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
453
+ | `fill` / `stroke` | fill / stroke | any colour variable, or `'none'` |
454
+ | `fillOpacity` / `strokeOpacity` | fill-opacity / stroke-opacity | `0`–`1` in tenths, same scale as `opacity` |
455
+ | `fillRule` | fill-rule | `'nonzero'`, `'evenodd'` |
456
+ | `strokeWidth` | stroke-width | number, **user units — no divider** (`strokeWidth={2}` → `2`) |
457
+ | `strokeLinecap` | stroke-linecap | `'butt'`, `'round'`, `'square'` |
458
+ | `strokeLinejoin` | stroke-linejoin | `'miter'`, `'round'`, `'bevel'` |
459
+ | `strokeMiterlimit` | stroke-miterlimit | number, 1 or greater |
460
+ | `strokeDasharray` | stroke-dasharray | number (`{12}` = dash 12, gap 12) or the pattern as a string (`"12 4"`) |
461
+ | `strokeDashoffset` | stroke-dashoffset | number, or a percentage of the path length (`'40%'`) |
462
+ | `paintOrder` | paint-order | `'normal'`, `'fill'`, `'stroke'`, `'markers'` |
463
+ | `vectorEffect` | vector-effect | `'none'`, `'non-scaling-stroke'` |
464
+ | `shapeRendering` | shape-rendering | `'auto'`, `'optimizeSpeed'`, `'crispEdges'`, `'geometricPrecision'` |
465
+ | `textAnchor` | text-anchor | `'start'`, `'middle'`, `'end'` — which part of a label sits on its `x` |
466
+ | `dominantBaseline` | dominant-baseline | `'auto'`, `'alphabetic'`, `'central'`, `'middle'`, `'hanging'`, `'text-top'`, `'text-bottom'`, `'ideographic'`, `'mathematical'` |
467
+ | `cx` / `cy` | cx / cy | number in user units, or a percentage — `<circle>`, `<ellipse>` |
468
+ | `r` | r | number in user units, or a percentage — `<circle>` |
469
+ | `rx` / `ry` | rx / ry | number, a percentage, or `'auto'` — `<ellipse>` radii, `<rect>` corners |
470
+ | `x` / `y` | x / y | number in user units, or a percentage — `<rect>`, `<image>`, `<use>`, `<foreignObject>`, nested `<svg>`. NOT `<text>` |
471
+
472
+ ```tsx
473
+ import { Circle, Path, Rect, Svg, SvgText } from '@box-kite/react/components/svg';
474
+
475
+ // Every property except vectorEffect is inherited, so set it once on the <svg>.
476
+ <Svg viewBox="0 0 200 48" width="200px" fill="none" stroke="violet-500" strokeWidth={3} strokeLinecap="round">
477
+ <Path d="M8 40 L56 12 L104 34 L152 8 L192 24" />
478
+ </Svg>
479
+
480
+ // Draw-on animation: the transition is already there (--svgTransitionTime), and
481
+ // prefers-reduced-motion zeroes it, so this is the whole thing.
482
+ <Svg fill="none" stroke="violet-500" strokeWidth={3} strokeDasharray={320} strokeDashoffset={320} hover={{ strokeDashoffset: 0 }}>
483
+ <Path d="M8 40 L56 12 L104 34 L152 8 L192 24" />
484
+ </Svg>
485
+
486
+ // Geometry is CSS, so a shape moves with no JavaScript. Text is placed by the two text props.
487
+ <Svg viewBox="0 0 96 96" width="96px" label="Three quarters" fill="none" textAnchor="middle" dominantBaseline="central">
488
+ <Circle cx={48} cy={48} r={38} stroke="indigo-600" strokeWidth={10} hover={{ r: 40 }} />
489
+ <SvgText x={48} y={48} fontSize={20} fill="slate-700">
490
+ 75%
491
+ </SvgText>
492
+ </Svg>
493
+
494
+ // A bar: y is the CSS geometry prop, width/height are the rect's own attributes.
495
+ <Rect x={16} y={32} width={24} height={64} rx={3} fill="sky-600" />
496
+ ```
497
+
498
+ **Nine things to get right:**
499
+
500
+ 1. **No divider on SVG lengths.** `strokeWidth`, `strokeDasharray`, `strokeDashoffset` and `strokeMiterlimit` pass the number through unchanged — they are measured in the coordinate system the `viewBox` sets up.
501
+ 2. **Inheritance does the work.** Put `fill`/`stroke`/`strokeWidth` on the `<svg>`, not on every shape. The exception is a shape carrying its own `fill=` attribute — a presentation attribute on an element beats a value inherited from its parent, so style that shape directly.
502
+ 3. **`vectorEffect` and `dominantBaseline` are not inherited**, so their rules target the element _and_ its descendants (`.cls, .cls *`). `vectorEffect="non-scaling-stroke"` on the `<svg>` keeps a hairline one pixel wide at any scale; `dominantBaseline="central"` on the `<svg>` reaches every label inside it.
503
+ 4. **Geometry belongs to the shape, and it transitions.** `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y` are real CSS in SVG 2, so a gauge or a growing bar is a pseudo-class and no JavaScript. They are not inherited and should not be — set them on the shape.
504
+ 5. **Each component settles the names SVG and Box both use, for its own element.** On `Path`, `d` is path data (not `flexDirection`); on `Rect`, `width`/`height` are user units (not the ÷4 layout scale); on `SvgText` and `TSpan`, `x`/`y`/`dx`/`dy` are attributes, because the CSS geometry properties do not apply to text; on `RadialGradient`, `cx`/`cy`/`r` are attributes, because they do not apply to a gradient either — while on `Circle` the same names stay CSS and transition. `transform` is a prop on every shape and group (`transform="rotate(-90 48 48)"` carries its own centre, which the CSS `rotate` prop cannot).
505
+ 6. **`Svg` sizes with attributes and names itself with `label`.** `viewBox`, `preserveAspectRatio`, `width` and `height` are the SVG attributes (`width="100%"`, `width={200}`), so the ÷4 layout `width`/`height` are not available on it. No `label` means `aria-hidden` — decoration, which is what most SVG is; `label` makes it `role="img"` with that name. A role or `aria-*` of your own in `props` wins over both.
506
+ 7. **An icon from a set is not SVG you draw — it is `<Icon>`.** `<Icon size={5} color="amber-500" label="Sunny"><Sun /></Icon>` from `components/icon`, wrapping one element from lucide, Tabler, react-icons, or a raw `<svg>`. `size` is the ÷4 scale (`size={6}` is 24px, the default) and lands in the _class_, where a CSS declaration outranks the `width`/`height` attributes the icon set writes — which is why `Icon` needs to know no set's API. `strokeWidth` is the same: an ordinary Box prop, so it can change on hover. No `label` means `aria-hidden`; a `label` means `role="img"`. **Reach for `Svg` rather than wrapping one in `Icon`** — `Svg` takes these props directly. (Wrapping one does work: `Icon` asks the child which convention it follows and routes `role`/`aria-label` into `props` for a component of ours. It is still a layer nobody needs.)
507
+ 8. **A paint server is a value, not an attribute.** `fill`, `stroke` and `clipPath` take `url(#id)` (a `<LinearGradient>`, a pattern, a `<ClipPath>` the document defines) and `var(--name)` (a variable somebody else declared) beside the colour tokens — so `fill="url(#sky)"` and `clipPath="url(#frame)"`, and both can differ per theme, on `hover` and per breakpoint. Only those two shapes are accepted: anything else emits no rule at all rather than a broken declaration. A gradient stop is still themed the same way — `<Stop stopColor="currentColor" color="amber-300" />`.
508
+ 9. **An icon outside lucide comes through the same `<Icon>`.** Iconify carries 300,000+ icons in 200-plus sets, and the choice is only about _when_ the icon becomes markup. One icon: copy its SVG and paste it into an `<Icon>` — no dependency, renders on a server. A set: `unplugin-icons` compiles `~icons/<set>/<name>` into a component at build time from an `@iconify-json/<set>` devDependency (`npm i -D unplugin-icons @iconify-json/<set> @svgr/core @svgr/plugin-jsx`, the plugin with `{ compiler: 'jsx', jsx: 'react' }`, and `/// <reference types="unplugin-icons/types/react" />` in a `.d.ts`) — nothing fetched at runtime, server-renders. A name that is data (from a CMS, from a user): `@iconify/react`'s `<Icon icon="set:name"/>` inside our `<Icon>` — it fetches in the browser, so it is a client component and the server sends no icon. **Turbopack runs no unplugin**, so under Next.js 16 the build-time recipe needs `next build --webpack`; the other two work as they are.
509
+
510
+ ---
511
+
512
+ ### Animation and transitions
513
+
514
+ Every Box already transitions `all` its properties over `--transitionTime` (0.25s), which is why a
515
+ `hover` colour fades without being asked. On top of that:
516
+
517
+ | Prop | CSS Property | Notes |
518
+ | ----------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
519
+ | `animation` | animation | One of four presets: `'spin'`, `'pulse'`, `'bounce'`, `'ping'`, or `'none'`. Keyframes come with the engine |
520
+ | `animationName` | animation-name | A sequence registered with `Box.keyframes()`, a preset name, or a name from any stylesheet |
521
+ | `animationDuration` / `animationDelay` | ms | **Milliseconds, no divider**: `animationDuration={1100}` → `1100ms` |
522
+ | `animationIterationCount` | number | A number or `'infinite'` |
523
+ | `animationDirection` / `animationFillMode` / `animationPlayState` | keywords | `'normal'`/`'reverse'`/`'alternate'`/`'alternate-reverse'`; `'none'`/`'forwards'`/`'backwards'`/`'both'`; `'running'`/`'paused'` |
524
+ | `animationTimingFunction` / `transitionTimingFunction` | timing function | Keywords, the four springs (`'spring'`, `'spring-gentle'`, `'spring-bouncy'`, `'spring-snappy'`), or a curve: `'cubic-bezier(0.4, 0, 0.6, 1)'`, `'steps(4, end)'`, `'linear(0, 0.5, 1)'` |
525
+ | `transition` | transition-property | `'all'`, `'none'`, or a group: `'colors'`, `'opacity'`, `'shadow'`, `'transform'`, `'size'`, `'filter'` |
526
+ | `transitionDuration` / `transitionDelay` | ms | Milliseconds, same as the animation pair. Both duration props also take a spring name — that spring's settling time |
527
+ | `transitionBehavior` | transition-behavior | `'normal'` or `'allow-discrete'`, which lets `display`, `overlay` and `content-visibility` transition — the property that makes an element animate _out_ without unmounting |
528
+ | `interpolateSize` | interpolate-size | `'numeric-only'` or `'allow-keywords'`, which is what makes `height: auto` animate. Inherited, so it belongs on a container. Chromium-only; elsewhere the size snaps |
529
+ | `startingStyle` | `@starting-style` | A nested block of plain props: what they start from the first time the element is styled. `startingStyle={{ opacity: 0, translateY: 2 }}` is an entrance with no JavaScript |
530
+ | `translateX` / `translateY` / `rotate` / `scale` / `flip` | transform longhands | Each writes its own CSS property, so they compose. `scale={1.05}` is unitless; `rotate={45}` is degrees |
531
+
532
+ ```tsx
533
+ // A preset: no registration, and it stops under prefers-reduced-motion on its own
534
+ <Icon size={6} animation="spin"><Loader2 /></Icon>
535
+
536
+ // A sequence of your own — the steps are Box props, so the ÷4 scale and colour tokens apply
537
+ Box.keyframes({
538
+ 'slide-in': {
539
+ from: { opacity: 0, translateY: 3 }, // 3 → 0.75rem
540
+ to: { opacity: 1, translateY: 0 },
541
+ },
542
+ });
543
+
544
+ <Box animationName="slide-in" animationDuration={450} animationTimingFunction="ease-out" animationFillMode="backwards" />
545
+
546
+ // Narrow what transitions, and compose transforms on hover
547
+ <Box transition="transform" transitionDuration={300} hover={{ translateX: 2, translateY: -2, scale: 1.05 }} />
548
+
549
+ // A spring: the same name on both props, because a spring is a curve *and* a settling time
550
+ <Box transition="transform" transitionTimingFunction="spring-bouncy" transitionDuration="spring-bouncy" hover={{ scale: 1.1 }} />
551
+
552
+ // One of your own — sampled once, at module scope
553
+ const wobble = Box.spring({ stiffness: 120, damping: 8 });
554
+
555
+ <Box transition="transform" transitionTimingFunction={wobble.easing} transitionDuration={wobble.duration} hover={{ translateY: -2 }} />
556
+
557
+ // An entrance: what a just-mounted element starts from. No state, no effect, no library.
558
+ <Box p={4} startingStyle={{ opacity: 0, translateY: 2 }} transitionDuration={280} />
559
+
560
+ // Both directions, by hiding rather than unmounting: display flips at the *end* of the transition
561
+ <Box
562
+ display={open ? 'block' : 'none'}
563
+ opacity={open ? 1 : 0}
564
+ transitionBehavior="allow-discrete"
565
+ startingStyle={{ opacity: 0 }}
566
+ transitionDuration={260}
567
+ />
568
+
569
+ // Or really unmounting, once the exit has run: <Presence> holds the node until its CSS is finished
570
+ <Presence present={open}>
571
+ {({ present, ref, props }) => (
572
+ <Box
573
+ ref={ref}
574
+ props={props}
575
+ opacity={present ? 1 : 0}
576
+ translateY={present ? 0 : -2}
577
+ startingStyle={{ opacity: 0, translateY: -2 }}
578
+ transitionDuration={320}
579
+ />
580
+ )}
581
+ </Presence>
582
+
583
+ // height: auto, animated — the container opts its subtree in
584
+ <Box interpolateSize="allow-keywords">
585
+ <Box height={expanded ? 'auto' : 0} overflow="hidden" transition="size" transitionDuration={300}>
586
+ …
587
+ </Box>
588
+ </Box>
589
+ ```
590
+
591
+ - **`Box.keyframes({ name: { from, '50%', to } })`** registers sequences; stop keys are `'from'`,
592
+ `'to'` or a percentage string. Registration is free — a sequence is written into the stylesheet the
593
+ first time a rule names it, and exactly once, so an unused preset costs nothing. It reaches static
594
+ output through `getStyles()` and rides the base `<style>` element in element mode, so a Server
595
+ Component can animate with no client JavaScript.
596
+ - **A preset already respects reduced motion.** Preset durations are multiples of `--transitionTime`,
597
+ which the base stylesheet zeroes under `prefers-reduced-motion`. The moment you name a duration in
598
+ milliseconds you own that decision: say `motionReduce={{ animationName: 'none' }}` yourself.
599
+ - **`animation` is declared before the longhands**, so `animationDuration` and friends override
600
+ whatever a preset chose.
601
+ - **The transform props are CSS longhands, not one `transform` declaration.** `translateX` and
602
+ `translateY` each set a custom property and both compose into one `translate`, which still
603
+ transitions (a `var()` is substituted before the browser compares the two states) and still
604
+ animates (the base stylesheet registers both axes with `@property`, without which a keyframe
605
+ moving them would jump rather than interpolate). `rotate` and `scale` are their own properties
606
+ too. The one collision left: `flip` and `scale` both write `scale`.
607
+ - **A spring is a sampled curve, so it is two props.** The four presets are a damped oscillator
608
+ sampled into `linear()`: `spring` (540ms, barely a bounce), `spring-gentle` (660ms),
609
+ `spring-bouncy` (880ms, 20% past the target), `spring-snappy` (420ms). Name one on the timing
610
+ function and the same one on the duration — the duration is the spring's settling time, counted in
611
+ `--transitionTime` units, so reduced motion stops a spring too. For a spring of your own,
612
+ `Box.spring()` takes `stiffness`, `damping`, `mass` and `velocity` and returns `{ easing, duration }`.
613
+ - **What a sampled spring is not**: the curve is fixed once it is a string, so an interrupted
614
+ transition restarts rather than carrying its velocity across — fine for hover, a panel or a toggle,
615
+ not for a drag (that is framer-motion's job). `linear()` is missing in roughly one browser in
616
+ eight, so every curve is written with an `ease-out` declaration underneath it.
617
+ - **`startingStyle` is nesting, not a value.** It takes plain props — no breakpoint, pseudo-class or
618
+ theme _inside_ it; those nest around it instead — `md: { startingStyle: { … } }`, and the same inside a
619
+ `theme`. Every rule it writes sorts after the ordinary rules **and is emitted `!important`**, because
620
+ the browser computes the before-change style from the whole cascade: source order settles a tie in
621
+ specificity and nothing else, so a starting rule at `.x` would lose to the value it starts from at
622
+ `.dark .x` or `.x[data-state="open"]` and nothing would transition at all. The importance reaches
623
+ nothing but the before-change style. A browser without `@starting-style` drops the one rule and shows
624
+ the element finished. `Tooltip` and the `Dropdown` popup already carry one.
625
+ - **An exit needs someone to hold the node.** `@starting-style` runs when an element is first
626
+ rendered — mounted, or shown from `display: none`. Going the other way, React unmounts the node
627
+ immediately and there is nothing left to animate. Two answers: hide it instead — `display` plus
628
+ `transitionBehavior="allow-discrete"` flips `display` at the _end_ of the transition — or, when the
629
+ node really has to leave, `<Presence>`.
630
+ - **`<Presence present>` (`components/presence`) is the exit.** It keeps rendering its child with
631
+ `present: false` until the child's own CSS says the transition is over, then lets React remove it. A
632
+ render prop, handed `{ present, state, ref, props }`: `ref` goes on the element that carries the
633
+ transition (it is the one whose computed style the wait is measured from), `props` is
634
+ `{ 'data-state': 'open' | 'closed' }`. The wait is the element's computed `transition-duration` /
635
+ `animation-duration`, not a `transitionend` listener — which fires once per property with no way to
636
+ know how many are coming. So a reader on `prefers-reduced-motion` measures `0s` and the node leaves in
637
+ the same commit, with nothing to configure. `Tooltip`, the `Dropdown` popup and the DataGrid column
638
+ menu are all built on it, and each expresses its exit as a `closed` variant in its component styles — plus `up`/`closedUp` on `dropdown.items`, since a popup that opened upward has to collapse upward.
639
+ - **`Box.configure({ transition })`** changes what the base class transitions for the whole engine:
640
+ a group name, or `false` to declare nothing at all and leave transitions entirely to the props.
641
+ Call it before the first render.
642
+
643
+ ---
644
+
645
+ ## Pseudo-Classes, Breakpoints & Themes
646
+
647
+ ```tsx
648
+ // Pseudo-classes: hover, focus (:focus-within), focusVisible, hasFocus, active, valid, invalid,
649
+ // optional, disabled, checked, indeterminate, required, selected, hasChecked, hasRequired,
650
+ // hasDisabled, hasValid, hasInvalid — plus the eight states the browser tracks and nothing
651
+ // else can see: visited, target, open, placeholderShown, autofill, inRange, outOfRange, inert
652
+ // — plus rtl and ltr, the element's own resolved text direction
653
+ <Box bgColor="blue-500" hover={{ bgColor: 'blue-600' }} disabled={{ opacity: 0.5 }} />
654
+
655
+ // `open` is a <details>/<dialog> with the attribute, a popover, or a <select>'s picker; `inert`
656
+ // matches the whole inert subtree; `visited` takes colour properties only (the privacy rule)
657
+ <Box open={{ rotate: 180 }} placeholderShown={{ color: 'gray-400' }} inRange={{ borderColor: 'emerald-500' }} />
658
+
659
+ // Responsive breakpoints (mobile-first): sm(640) md(768) lg(1024) xl(1280) xxl(1536)
660
+ <Box p={2} md={{ p: 4 }} lg={{ p: 6 }} />
661
+
662
+ // Combine: breakpoints can nest pseudo-classes
663
+ <Box bgColor="white" hover={{ bgColor: 'gray-100' }} md={{ bgColor: 'gray-50', hover: { bgColor: 'gray-200' } }} />
664
+ ```
665
+
666
+ ### State variants — `dataAttr`, `ariaAttr`, `has`, `not`, `nth`
667
+
668
+ A fourth kind of nesting: a selector fragment on the element's **own** compound selector, so a state your code sets — a menu that is open, a row that is selected, a step that is loading — is styled in CSS instead of with a ternary in the markup. The record _key_ is the selector.
669
+
670
+ | Prop | Key | Selector it builds |
671
+ | ---------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
672
+ | `dataAttr` | `'state=open'` / `'loading'` | `[data-state="open"]` / `[data-loading]` |
673
+ | `ariaAttr` | `'selected'` / `'sort=ascending'` | `[aria-selected="true"]` — a bare key means `="true"` — / `[aria-sort="ascending"]` |
674
+ | `has` | `':checked'` / `'img[alt]'` | `:has(:checked)` / `:has(img[alt])` |
675
+ | `not` | a **state**: `hover`, `checked`, `'data-loading'`… | `:not(:hover)` / `:not([data-loading])` |
676
+ | `nth` | `'first'`, `'last'`, `'only'`, `'odd'`, `'even'`, `'3'`, `'2n+1'`, `'-n+3'`, `'last 2'` | `:first-child` / `:nth-child(2n+1)` / `:nth-last-child(2)` |
677
+
678
+ ```tsx
679
+ // The attribute goes in `props`, where every attribute goes; the styling goes in `dataAttr`.
680
+ <Box
681
+ props={{ 'data-state': state }}
682
+ dataAttr={{ 'state=busy': { bgColor: 'amber-500' }, 'state=done': { bgColor: 'emerald-500' } }}
683
+ />
684
+
685
+ // A bare aria key means ="true", so the attribute that makes the tab list correct is the one that colours it
686
+ <Button props={{ role: 'tab', 'aria-selected': isCurrent }} ariaAttr={{ selected: { bgColor: 'indigo-500' } }} />
687
+
688
+ // A container reacting to its own contents, and the inverse of a state
689
+ <Box has={{ 'input:checked': { borderColor: 'indigo-500' } }} />
690
+ <Box group={{ 'deck/hover': { not: { hover: { opacity: 0.5 } } } }} />
691
+
692
+ // A position among siblings: striping a list and dropping the last divider, with no index in the markup
693
+ <Box bb={1} nth={{ odd: { bgColor: 'slate-500/10' }, 'last 1': { bb: 0 } }} />
694
+
695
+ // Everything else nests around them, in either direction — the class name is built from the set
696
+ // rather than the order, so these two resolve to one class and one rule
697
+ <Box md={{ dataAttr: { 'state=open': { hover: { color: 'red-500' } } } }} />
698
+ <Box md={{ hover: { dataAttr: { 'state=open': { color: 'red-500' } } } }} />
699
+ ```
700
+
701
+ - **A variant needs no cascade rank of its own**: `.a[data-state="open"]` is 0,2,0 against a plain class's 0,1,0, so it already outranks the rule it overrides.
702
+ - **A key the grammar rejects drops its whole block** — no rule and no class name, the same failure mode as an unmatched prop value. An attribute name that is not one, a value carrying a quote, an unbalanced `:has()`.
703
+ - **The library sets two attributes itself**: `data-state="open" | "closed"` on whatever `<Presence>` is holding, so `Tooltip`, the `Dropdown` popup and the DataGrid column menu all carry it; and `data-theme` on the element `Box.Theme` writes to.
704
+
705
+ ### Somebody else's state — `group` and `peer`
706
+
707
+ Every key above is about the element itself. `group` is about an **ancestor** and `peer` about a preceding **sibling**: the record key is a state, on the default class (`group`/`peer` — the names Tailwind uses) or on one you name, `'card/hover'`. The state vocabulary is `not`'s, so any pseudo-class works and a `data-`/`aria-` prefix reaches an attribute the ancestor carries.
708
+
709
+ ```tsx
710
+ // `.group:hover .x` — the ancestor carries the class itself
711
+ <Flex className="card">
712
+ <Box opacity={0} group={{ 'card/hover': { opacity: 1 } }}>Edit</Box>
713
+ </Flex>
714
+
715
+ // `.peer:checked ~ .x` — what styles a label from the input before it, with no JavaScript
716
+ <Checkbox className="agree" label={<Box peer={{ 'agree/checked': { color: 'emerald-500' } }}>I agree</Box>} />
717
+
718
+ // A state this library never sets, on the ancestor
719
+ <Box group={{ 'row/data-state=open': { rotate: 90 }, 'row/aria-selected': { bgColor: 'sky-100' } }} />
720
+
721
+ // The element's own states still belong to the element: `.card:hover .x:hover`
722
+ <Box group={{ 'card/hover': { opacity: 0.6, hover: { opacity: 1 } } }} />
723
+ ```
724
+
725
+ - `hoverGroup`, `focusGroup`, `activeGroup`, `disabledGroup` and `selectedGroup` are the older spelling of the same thing — `hoverGroup={{ card: … }}` is `group={{ 'card/hover': … }}` and compiles to the same class. They still work; new states go on `group`.
726
+ - **The group name is validated**: it lands in rule text as a class selector, so it has to be a CSS identifier. A key the grammar rejects drops its whole block, no rule and no class name.
727
+ - A group means nothing in `Box.addGlobalStyles` — `<html>` has no ancestor to name — so it is dropped there. A theme is the exception: its class is on the same element (`html.dark`).
728
+
729
+ ### Pseudo-elements — `before`, `after`, `placeholder`, `selection`, `marker`…
730
+
731
+ A fifth kind of nesting, and the only one CSS allows **one** of: a pseudo-element is a slot, not a list, and it is appended last to whatever the other keys build.
732
+
733
+ | Prop | Element | Notes |
734
+ | --------------------------- | --------------------------------------- | --------------------------------------------------------------------------- |
735
+ | `before` / `after` | `::before` / `::after` | Generate a box, so they come with `content` (below) |
736
+ | `placeholder` | `::placeholder` | On `Textbox`/`Textarea` a **string** is the attribute, an object the styles |
737
+ | `selection` | `::selection` | Names descendants too — the text may be in a child |
738
+ | `marker` | `::marker` | Same: write it on the `<Ul>`, it reaches the `<Li>` |
739
+ | `firstLine` / `firstLetter` | `::first-line` / `-letter` | Typography only, per CSS |
740
+ | `backdrop` / `fileButton` | `::backdrop` / `::file-selector-button` | A `<dialog>`/popover, and an `<input type="file">` |
741
+
742
+ ```tsx
743
+ // A ::before with no `content` generates no box at all, so declaring one supplies content: '' —
744
+ // and the element exists in exactly the states you styled it in.
745
+ <Box position="relative" before={{ position: 'absolute', inset: 0, bgColor: 'indigo-500' }} />
746
+ <Box hover={{ before: { width: 'fit' } }} /> // .x:hover::before, content included
747
+
748
+ // content: a keyword, text (quoted for you), or CSS you wrote
749
+ <Box before={{ content: 'empty' }} /> // content: ''
750
+ <Box before={{ content: 'New' }} /> // content: "New"
751
+ <Box after={{ content: 'attr(data-count)' }} /> // attr(), counter(), url(), var(), image-set()
752
+ <Box after={{ content: '"Step " counter(step)' }} /> // a sequence has to be written as CSS
753
+ <Box before={{ content: 'none' }} /> // off again
754
+
755
+ // Everything nests around them, and the element still lands last and on the target
756
+ <Box md={{ dataAttr: { 'state=open': { after: { opacity: 1 } } } }} /> // @media … .x[data-state="open"]::after
757
+ <Box group={{ 'card/hover': { before: { opacity: 1 } } }} /> // .card:hover .x::before
758
+
759
+ // On an input the name means both things; both at once puts the text where attributes go
760
+ <Textbox props={{ placeholder: 'Search…' }} placeholder={{ color: 'slate-400', fontStyle: 'italic' }} />
761
+ ```
762
+
763
+ - **One per compound selector**: nesting a second is a type error, and a merged component style that manages it anyway is dropped rather than emitting `::before::after`, which matches nothing.
764
+ - **`content` is the one prop whose value is text**, so text is quoted and escaped, a value written as CSS is scanned instead (every quote closed, parentheses balanced, no `;`, `}` or `@` outside a string), and one that fails produces no rule and no class name.
765
+ - `placeholderStyles` is the old name for `placeholder` and still works.
766
+
767
+ ### Device and accessibility preferences
768
+
769
+ Five more media keys, shaped exactly like a breakpoint — they nest pseudo-classes, themes and group selectors the same way, and they win the cascade against every breakpoint.
770
+
771
+ | Prop | Media query | When it applies |
772
+ | --------------- | -------------------------------- | -------------------------------------------------- |
773
+ | `pointerCoarse` | `pointer: coarse` | A finger or a pen — a bigger target, and no hover |
774
+ | `pointerFine` | `pointer: fine` | A mouse or a trackpad |
775
+ | `motionReduce` | `prefers-reduced-motion: reduce` | The user asked their OS for less motion |
776
+ | `forcedColors` | `forced-colors: active` | A forced-colors mode is on (Windows High Contrast) |
777
+ | `contrastMore` | `prefers-contrast: more` | The user asked their OS for more contrast |
778
+
779
+ The pointer pair ranks **below** the three preferences: what the pointer can do is a fact about the device, and what the reader asked for still wins where both apply.
780
+
781
+ ```tsx
782
+ // Reduced motion is already the default: every Box transitions on --transitionTime, and the
783
+ // preference sets that variable to 0s. Nothing to opt into — declare motionReduce only to
784
+ // replace a movement with something still, or to keep a transition you decided is safe.
785
+ <Box motionReduce={{ transform: 'none' }} />
786
+
787
+ // A component that named its own duration is the one case the default cannot reach.
788
+ <Box transitionDuration={150} motionReduce={{ transition: 'none' }} />
789
+
790
+ // Forced colors throw away background-color and color, so anything whose only edge was a fill
791
+ // (or a shadow, also dropped) needs a border — borders survive.
792
+ <Box bgColor="gray-900" color="gray-50" forcedColors={{ b: 1 }} />
793
+
794
+ // They nest like a breakpoint does
795
+ <Box contrastMore={{ borderColor: 'black', hover: { borderColor: 'blue-700' }, theme: { dark: { borderColor: 'white' } } }} />
796
+ ```
797
+
798
+ **They do not nest inside a breakpoint, or inside each other** — one rule lives in one `@media` block, so `md={{ motionReduce: {...} }}` is a type error rather than a query that silently drops half of what was asked.
799
+
800
+ ### Container queries — `cq`
801
+
802
+ The same question a breakpoint asks, addressed to the element's own container: the card is wide in a page and narrow in a sidebar, and the viewport says nothing about which. `container` makes an element a container, `cq` queries one.
803
+
804
+ | Prop | CSS | Notes |
805
+ | --------------- | ------------------------------ | --------------------------------------------------------------------------------- |
806
+ | `container` | `container-type` / `container` | `true` → `inline-size`; a name → `container: <name> / inline-size` |
807
+ | `containerName` | `container-name` | The longhand, for a name beside `containerType="size"` |
808
+ | `containerType` | `container-type` | `'inline-size'`, `'size'` (both axes, needs its own block size), `'normal'` (off) |
809
+ | `cq` | `@container` | Keyed by size: `md`, its complement `maxMd`, or `'name/md'` for a named container |
810
+
811
+ | Size | Query | Complement |
812
+ | ----- | -------------------------- | ---------------------------------- |
813
+ | `xs` | `(min-width: 20rem)` 320px | `maxXs` → `not (min-width: 20rem)` |
814
+ | `sm` | `(min-width: 24rem)` 384px | `maxSm` |
815
+ | `md` | `(min-width: 28rem)` 448px | `maxMd` |
816
+ | `lg` | `(min-width: 32rem)` 512px | `maxLg` |
817
+ | `xl` | `(min-width: 36rem)` 576px | `maxXl` |
818
+ | `xxl` | `(min-width: 42rem)` 672px | `maxXxl` |
819
+
820
+ ```tsx
821
+ // A card that lays itself out from the space it was given — the window never comes into it
822
+ <Flex container>
823
+ <Flex d="column" cq={{ sm: { d: 'row', ai: 'center' } }} gap={4} p={4}>…</Flex>
824
+ </Flex>
825
+
826
+ // A name, for when a card sits inside a card. `cq` queries the *nearest* container by default.
827
+ <Flex container="page">
828
+ <Flex container="card">
829
+ <Box cq={{ md: { fontSize: 16 }, 'page/xl': { fontSize: 18 }, maxSm: { display: 'none' } }} />
830
+ </Flex>
831
+ </Flex>
832
+
833
+ // Everything a breakpoint nests, nests here too
834
+ <Box cq={{ md: { hover: { color: 'indigo-500' }, theme: { dark: { bgColor: 'slate-800' } }, before: { content: 'Wide' } } }} />
835
+ ```
836
+
837
+ - **The cascade puts a container query after every breakpoint and before every preference** — the element's own space is the more local statement, and neither is a reason to override `motionReduce`. Sizes ascend, the `max` keys descend, so the narrower one wins where two overlap.
838
+ - **One at-rule block per rule**, so `cq` does not nest inside a breakpoint and a breakpoint does not nest inside `cq` — a type error, the same way two breakpoints are.
839
+ - **The container name is validated** because it lands in an at-rule prelude: anything that is not a CSS identifier, or a word the prelude uses (`not`, `and`, `or`), drops the whole block — no rule and no class name.
840
+ - `container` costs something: an inline-size container's width no longer depends on its contents. That is what makes the query possible, and the reason not to declare it everywhere.
841
+
842
+ ### Theme System
843
+
844
+ ```tsx
845
+ import Box from '@box-kite/react';
846
+
847
+ // Setup: wrap app in Box.Theme (auto-detects via prefers-color-scheme)
848
+ <Box.Theme> {/* auto light/dark detection */}
849
+ <Box.Theme theme="dark"> {/* explicit theme, ignores system pref */}
850
+ <Box.Theme theme="dark" use="global"> {/* applies class + data-theme to <html> */}
851
+ <Box.Theme storageKey="app-theme"> {/* persists user choice to localStorage */}
852
+
853
+ // Hook: read + set theme programmatically (must be within Box.Theme)
854
+ const [theme, setTheme] = Box.useTheme();
855
+ setTheme('dark'); // set explicit theme (persists to localStorage if storageKey provided)
856
+ setTheme(null); // reset to system auto-detection (clears localStorage)
857
+
858
+ // Supports any custom theme name — not limited to 'light'/'dark'
859
+ <Box.Theme theme="high-contrast">
860
+
861
+ // Theme-aware styles — nests with pseudo-classes and breakpoints
862
+ <Box
863
+ bgColor="white" color="gray-900"
864
+ hover={{ bgColor: 'gray-100' }}
865
+ theme={{ dark: { bgColor: 'gray-900', color: 'gray-100', hover: { bgColor: 'gray-700' } } }}
866
+ />
867
+ ```
868
+
869
+ **Props**: `theme?` (string — explicit theme name), `use?` (`'global'`|`'local'`, default `'local'`), `storageKey?` (string — localStorage key for persistence), `globalStyles?` (BoxStyleProps — app-wide styles on `<html>`, only with `use="global"`).
870
+ **DOM**: Sets `data-theme` attribute and theme class on wrapper (local) or `document.documentElement` (global). Cleaned up on unmount.
871
+
872
+ #### globalStyles — App-wide styles on `<html>`
873
+
874
+ ```tsx
875
+ // Style the root document scrollbar (and any other html-level CSS). Only takes effect when use="global".
876
+ <Box.Theme
877
+ use="global"
878
+ globalStyles={{
879
+ scrollbarColor: ['violet-500', 'transparent'],
880
+ scrollbarWidth: 'thin',
881
+ theme: {
882
+ dark: { scrollbarColor: ['violet-700', 'gray-900'] },
883
+ light: { scrollbarColor: ['violet-300', 'gray-100'] },
884
+ },
885
+ }}
886
+ >
887
+ <App />
888
+ </Box.Theme>
889
+ ```
890
+
891
+ Accepts the same shape as Box style props (including theme-keyed values, pseudo-classes, breakpoints). Rules are emitted directly on `html` — useful for inheritable CSS like `scrollbar-color`/`scrollbar-width`/`fontFamily`/`color`. Group selectors (`group`/`peer`) are not supported here since `<html>` has no ancestor to name.
892
+
893
+ ---
894
+
895
+ ## Component System
896
+
897
+ ```tsx
898
+ // Component + variant props apply registered styles from Box.components()
899
+ <Box component="card" variant="bordered">
900
+ <Box component="card.header">Title</Box>
901
+ <Box component="card.body">Content</Box>
902
+ </Box>
903
+ ```
904
+
905
+ ### Box.components() — Define Custom Component Styles
906
+
907
+ ```tsx
908
+ Box.components({
909
+ card: {
910
+ styles: { display: 'flex', d: 'column', p: 4, bgColor: 'white', borderRadius: 8, shadow: 'medium' },
911
+ variants: {
912
+ bordered: { b: 1, borderColor: 'gray-200', shadow: 'none' },
913
+ elevated: { shadow: 'large' },
914
+ },
915
+ children: {
916
+ header: { styles: { fontSize: 18, fontWeight: 600, mb: 3 } },
917
+ body: { styles: { flex: 1 } },
918
+ },
919
+ },
920
+ });
921
+ ```
922
+
923
+ ### Component Inheritance with `extends`
924
+
925
+ ```tsx
926
+ Box.components({
927
+ subgrid: {
928
+ extends: 'datagrid', // inherits all styles, variants, and children; deep-merges overrides
929
+ styles: { b: 0, borderRadius: 0, shadow: 'none' },
930
+ children: { header: { children: { cell: { styles: { fontSize: 12 } } } } },
931
+ },
932
+ });
933
+ ```
934
+
935
+ ---
936
+
937
+ ## Extension System
938
+
939
+ ### Box.extend() — Add New Props, Colors, Variables
940
+
941
+ ```tsx
942
+ import Box from '@box-kite/react';
943
+
944
+ export const { extendedProps, extendedPropTypes } = Box.extend(
945
+ { 'brand-primary': '#ff6600', 'brand-secondary': '#0066ff' }, // CSS variables
946
+ {
947
+ // New props — a CSS property the registry does not have (`aspectRatio` is built in now)
948
+ imageRendering: [
949
+ {
950
+ values: ['auto', 'pixelated', 'crisp-edges'] as const,
951
+ styleName: 'image-rendering',
952
+ },
953
+ ],
954
+ },
955
+ {
956
+ // Extend existing props with new values
957
+ bgColor: [
958
+ {
959
+ values: ['brand-primary', 'brand-secondary'] as const,
960
+ styleName: 'background-color',
961
+ valueFormat: (value, getVariable) => getVariable(value),
962
+ },
963
+ ],
964
+ color: [
965
+ {
966
+ values: ['brand-primary', 'brand-secondary'] as const,
967
+ styleName: 'color',
968
+ valueFormat: (value, getVariable) => getVariable(value),
969
+ },
970
+ ],
971
+ },
972
+ );
973
+ ```
974
+
975
+ ### Per-Property Values (Multi-styleName)
976
+
977
+ When `styleName` is an array, `valueFormat` is called once per CSS property with `styleName` as the third argument. Use for typography presets or any design token spanning multiple CSS properties:
978
+
979
+ ```tsx
980
+ Box.extend(
981
+ {
982
+ 'text-display-lg-size': '36px',
983
+ 'text-display-lg-weight': '700',
984
+ 'text-display-lg-line-height': '1.2',
985
+ 'text-display-lg-letter-spacing': '-0.02em',
986
+ },
987
+ {
988
+ textStyle: [
989
+ {
990
+ values: ['display-lg', 'display-sm'] as const,
991
+ styleName: ['font-size', 'font-weight', 'line-height', 'letter-spacing'],
992
+ valueFormat: (value, getVariable, styleName) => {
993
+ const suffix = { 'font-size': 'size', 'font-weight': 'weight', 'line-height': 'line-height', 'letter-spacing': 'letter-spacing' };
994
+ return getVariable(`text-${value}-${suffix[styleName!]}`);
995
+ },
996
+ },
997
+ ],
998
+ },
999
+ {},
1000
+ );
1001
+ // <Box textStyle="display-lg" /> → sets all 4 CSS properties
1002
+ ```
1003
+
1004
+ ### TypeScript Type Augmentation
1005
+
1006
+ ```typescript
1007
+ // types.d.ts — Generic approach (recommended)
1008
+ import { ExtractComponentsAndVariants, ExtractBoxStyles } from '@box-kite/core/types';
1009
+ import { components } from './boxComponents';
1010
+ import { extendedPropTypes, extendedProps } from './boxExtends';
1011
+
1012
+ declare module '@box-kite/core/types' {
1013
+ namespace Augmented {
1014
+ interface BoxProps extends ExtractBoxStyles<typeof extendedProps> {}
1015
+ interface BoxPropTypes extends ExtractBoxStyles<typeof extendedPropTypes> {}
1016
+ interface ComponentsTypes extends ExtractComponentsAndVariants<typeof components> {}
1017
+ }
1018
+ }
1019
+
1020
+ // Manual approach (simple cases):
1021
+ declare module '@box-kite/core/types' {
1022
+ namespace Augmented {
1023
+ interface BoxPropTypes {
1024
+ bgColor: 'brand-primary' | 'brand-secondary';
1025
+ }
1026
+ interface ComponentsTypes {
1027
+ card: 'bordered' | 'elevated';
1028
+ }
1029
+ }
1030
+ }
1031
+ ```
1032
+
1033
+ ---
1034
+
1035
+ ## Common Patterns
1036
+
1037
+ ```tsx
1038
+ import Flex from '@box-kite/react/components/flex';
1039
+ import Grid from '@box-kite/react/components/grid';
1040
+ import Button from '@box-kite/react/components/button';
1041
+ import Textbox from '@box-kite/react/components/textbox';
1042
+
1043
+ // Flex layout
1044
+ <Flex d="column" gap={4} ai="center" jc="between">{children}</Flex>
1045
+ <Flex inline gap={2}>Inline flex</Flex>
1046
+
1047
+ // Responsive stack
1048
+ <Flex d="column" gap={2} md={{ d: 'row', gap: 4 }}>{children}</Flex>
1049
+
1050
+ // Grid
1051
+ <Grid gridCols={3} gap={4}>{items.map(i => <Box key={i.id}>{i.content}</Box>)}</Grid>
1052
+
1053
+ // Card
1054
+ <Box p={4} bgColor="white" borderRadius={8} shadow="medium">{content}</Box>
1055
+
1056
+ // Button with states
1057
+ <Button px={4} py={2} bgColor="blue-500" color="white" borderRadius={6}
1058
+ hover={{ bgColor: 'blue-600' }} disabled={{ opacity: 0.5, cursor: 'not-allowed' }}>Click</Button>
1059
+
1060
+ // Input
1061
+ <Textbox placeholder="Enter..." width="fit" px={3} py={2} b={1} borderColor="gray-300"
1062
+ borderRadius={6} focus={{ borderColor: 'blue-500', outline: 'none' }} />
1063
+
1064
+ // Truncated text
1065
+ <Box overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">Long text...</Box>
1066
+
1067
+ // Overlay
1068
+ <Box position="fixed" top={0} left={0} right={0} bottom={0} bgColor="black" opacity={0.5} zIndex={50} />
1069
+ ```
1070
+
1071
+ **Floating layers**: `Overlay` (`components/overlay`) anchors a layer to a trigger (`anchor`, `side`, `align`,
1072
+ `offset`, `flip`, `matchWidth` — the vocabulary `useAnchorPosition` and `Tooltip` share) and renders it
1073
+ **where it was declared, in the browser's top layer** (`popover="manual"`, shown on mount), so it escapes
1074
+ `overflow: hidden`, clipped and transformed ancestors and the stacking order while still inheriting the
1075
+ theme, the custom properties and the direction around it. `manual` because it owns no dismissal — and no
1076
+ ARIA and no open state either: it is positioning only. **Never declare one inside its trigger**: a
1077
+ listbox or a menu inside a `<button>` is unreachable content, which the portal used to hide by moving it
1078
+ elsewhere. **One cost comes with the top layer**: the layer keeps the side it chose when it opened, because
1079
+ Chrome never re-evaluates `position-try-fallbacks` on scroll for a top-layer element (measured in 152) — so
1080
+ a layer left open while the page scrolls slides past the viewport edge rather than flipping. Every open
1081
+ picks the right side; close a layer if the page can scroll far underneath it. Where the browser has no
1082
+ Popover API it is portalled into `#box-kite-portal` instead, and that path measures, so it flips. For a
1083
+ _panel a user interacts with_, use `Popover` (below); for a _description of a control_, use `Tooltip`.
1084
+ Both are that layer with a pattern on it.
1085
+
1086
+ ### Styling an element Box cannot render (`useClassNames`)
1087
+
1088
+ An icon from a set, a `motion.div`, a router's `NavLink`, a third-party chart: there is no `tag` that
1089
+ renders one, and the only styling channel they offer is `className`. `useClassNames` resolves the
1090
+ same props Box would and gives you the class list to put on them.
1091
+
1092
+ ```tsx
1093
+ import { useClassNames } from '@box-kite/react';
1094
+
1095
+ const { className, styles } = useClassNames({ color: 'sky-500', hover: { color: 'sky-300' } });
1096
+
1097
+ <>
1098
+ {styles}
1099
+ <NavLink to="/" className={className} />
1100
+ </>;
1101
+ ```
1102
+
1103
+ `styles` is defined in element mode only (the CSS as hoistable `<style>` elements); elsewhere it is
1104
+ undefined and rendering it costs nothing, so write the line either way. Pass `{ svg: true }` as a
1105
+ second argument for an element inside an `<svg>`. `<Icon>` is this hook plus that flag — reach for
1106
+ `Icon` for an icon and this only for everything else.
1107
+
1108
+ ### Group and Peer
1109
+
1110
+ ```tsx
1111
+ <Flex className="card-row" gap={2}>
1112
+ <Box opacity={0} group={{ 'card-row/hover': { opacity: 1 } }}>
1113
+ Actions
1114
+ </Box>
1115
+ </Flex>
1116
+ ```
1117
+
1118
+ ### Server-Side Rendering
1119
+
1120
+ ```tsx
1121
+ import { getStyles, resetStyles } from '@box-kite/react/ssg';
1122
+
1123
+ const html = renderToString(<App />); // any React server renderer
1124
+ const cssString = getStyles(); // the CSS for what was just rendered
1125
+ // <style id="box-kite-styles">{cssString}</style> in the document head
1126
+ resetStyles(); // reset before the next request
1127
+ ```
1128
+
1129
+ Or in one call, which injects the styles into the rendered `<head>` and resets afterwards:
1130
+
1131
+ ```tsx
1132
+ import { renderToStaticMarkup } from '@box-kite/react/ssg';
1133
+
1134
+ const { html, styles } = renderToStaticMarkup(<App />);
1135
+ ```
1136
+
1137
+ No DOM is needed: with no `document` in the process the engine collects CSS in memory. Sequential
1138
+ requests are independent and identical markup gets identical class names.
1139
+
1140
+ ---
1141
+
1142
+ ## React Server Components (React 19)
1143
+
1144
+ Box renders in a Server Component with no `'use client'` and no configuration — the `react-server`
1145
+ export condition resolves to a build that calls no hook and touches no DOM. Its CSS travels with its
1146
+ markup as `<style href precedence>` elements, which React 19 hoists into `<head>` and dedupes.
1147
+
1148
+ ```tsx
1149
+ // app/page.tsx — a Server Component
1150
+ import Flex from '@box-kite/react/components/flex';
1151
+ import { H1 } from '@box-kite/react/components/semantics';
1152
+
1153
+ export default function Page() {
1154
+ return (
1155
+ <Flex d="column" gap={2} p={6} bgColor="slate-50" sm={{ p: 8 }}>
1156
+ <H1 fontSize={24}>Rendered on the server</H1>
1157
+ </Flex>
1158
+ );
1159
+ }
1160
+ ```
1161
+
1162
+ Client components in the same app should use the same path, so their CSS is in the HTML too — one
1163
+ call, in a module the root layout imports:
1164
+
1165
+ ```tsx
1166
+ 'use client';
1167
+ import Box from '@box-kite/react';
1168
+
1169
+ Box.configure({ sink: 'element' }); // React 19 only
1170
+ ```
1171
+
1172
+ - `Box.configure({ sink, classNames })` — `sink`: `'cssom'` (browser default), `'textContent'`
1173
+ (tests, readable rules), `'string'` (server), `'element'` (React 19 style elements);
1174
+ `classNames`: `'hashed'` (default), `'readable'`, `'stable'` (content-hashed — the default in
1175
+ element mode so server and client agree). Call it before the first render.
1176
+ - In element mode rules live in CSS cascade layers, so **your own unlayered CSS always wins** over
1177
+ Box props, and declaring `Box.extend()` props before the first render matters.
1178
+ - The hook-free pre-built components render on the server too: `Flex`, `Grid`, `Button`,
1179
+ `Textbox`, `Textarea`, `RadioButton`, `Icon`, the SVG elements (`Svg`, `Path`, `Circle`, …) and the semantic tags (`H1`, `P`, `Link`, …).
1180
+ Import them straight into a Server Component.
1181
+ - `Dropdown`, `Tooltip`, `Overlay`, `DataGrid`, `Checkbox`, `Switch`, `RadioGroup`, `Select` and `Form` hold state, so their chunks
1182
+ ship a `'use client'` banner: a Server Component may import one, but it becomes a client
1183
+ boundary and its CSS is in the HTML only if a client module already ran
1184
+ `Box.configure({ sink: 'element' })`.
1185
+ - Client-only, so use them inside `'use client'`: hover-callback children
1186
+ (`{({ isHover }) => …}`) and `Box.Theme`. Theme _styles_ (`theme={{ dark: { … } }}`) work in a
1187
+ server component — put the theme class on `<html>`.
1188
+ - React 18 cannot hoist these elements; keep the default sink there.
1189
+
1190
+ ---
1191
+
1192
+ ## Behaviour primitives (`@box-kite/react/a11y`)
1193
+
1194
+ Five client hooks for building accessible components — the mechanics, not the ARIA. 2.2 KB gzipped
1195
+ and no styling engine behind them.
1196
+
1197
+ ```tsx
1198
+ import { useControllableState, useDismiss, useFocusReturn, useIdentifier, useRovingFocus } from '@box-kite/react/a11y';
1199
+ import VisuallyHidden from '@box-kite/react/components/visuallyHidden';
1200
+
1201
+ const id = useIdentifier('menu'); // stable id: `${id}-trigger`, `${id}-listbox`
1202
+ const [open, setOpen] = useControllableState({ value: props.open, defaultValue: false, onChange: props.onOpenChange });
1203
+
1204
+ const roving = useRovingFocus({ count: items.length, textOf: (i) => items[i].label, onSelect: (i) => choose(i) });
1205
+ // roving.activeIndex · roving.onKeyDown (on the focused element) · roving.itemProps(i) · roving.activeItem()
1206
+
1207
+ useDismiss({ enabled: open, inside: [triggerRef, popupRef], onDismiss: (reason, event) => setOpen(false, { reason, event }) });
1208
+ useFocusReturn({ enabled: open, returnTo: triggerRef });
1209
+ ```
1210
+
1211
+ - Every change carries a reason: `onChange(value, { reason, event })` — `'escape'`,
1212
+ `'outside-pointer'`, or one you name. The setter takes an updater and its identity is stable.
1213
+ - `useDismiss`: pass the **trigger** in `inside` as well as the popup, or pressing the trigger of an
1214
+ open popup dismisses and reopens in one gesture. Escape reaches the innermost layer only.
1215
+ - `useRovingFocus`: arrows per `orientation` (`'vertical'` default), Home/End, typeahead when
1216
+ `textOf` is given, Enter/Space to select, disabled items skipped. `focusItems: false` keeps DOM
1217
+ focus where it is for an `aria-activedescendant` pattern.
1218
+ - `useIdentifier`: React's `useId` without the punctuation, so the id also works as a CSS selector.
1219
+ - `VisuallyHidden`: screen-reader-only content — clipped, so it stays in the accessibility tree
1220
+ (`display: none` would not). It is a Box: `tag`, `component` and every style prop work.
1221
+ - The entry is client-only (`'use client'`); `visuallyHidden` renders on a server.
1222
+
1223
+ Full reference: `docs/a11y-primitives.md`.
1224
+
1225
+ ---
1226
+
1227
+ ## Form controls: Checkbox, Switch, RadioGroup
1228
+
1229
+ All three render **real native inputs**, so focus, the checked state, the disabled state and form
1230
+ submission come from the platform. What the components add is the part the platform cannot guess.
1231
+
1232
+ ```tsx
1233
+ import Checkbox from '@box-kite/react/components/checkbox';
1234
+ import Switch from '@box-kite/react/components/switch';
1235
+ import RadioGroup from '@box-kite/react/components/radioGroup';
1236
+ import RadioButton from '@box-kite/react/components/radioButton';
1237
+
1238
+ <Checkbox label="Accept the terms" name="terms" />
1239
+ <Checkbox label="Select all rows" name="rows" indeterminate />
1240
+ <Switch label="Email notifications" name="notify" defaultChecked />
1241
+
1242
+ <RadioGroup label="Plan" name="plan" defaultValue="free" onValueChange={(plan, { reason }) => setPlan(plan)}>
1243
+ <RadioGroup.Item value="free" label="Free" />
1244
+ <RadioGroup.Item value="pro" label="Pro" />
1245
+ <RadioGroup.Item value="team" label="Team" disabled />
1246
+ </RadioGroup>
1247
+
1248
+ <RadioButton name="plan" value="free" label="Free" /> {/* on its own, outside a group */}
1249
+ ```
1250
+
1251
+ **`label` is the important prop.** It renders the text inside a `<label>` that wraps the input, so
1252
+ the association needs no `htmlFor`/`id` pair and the whole row is a click target. Leave it out and
1253
+ nothing wraps the input — the markup is what it always was. `labelProps` styles that `<label>`;
1254
+ every other Box prop styles the control itself.
1255
+
1256
+ | Component | Prop | What it does |
1257
+ | ------------ | ------------------------ | -------------------------------------------------------------------------------------------- |
1258
+ | all three | `label` / `labelProps` | The wrapping `<label>` and its styles. |
1259
+ | `Checkbox` | `indeterminate` | Sets the DOM property **and** `aria-checked="mixed"`, which is what is announced. |
1260
+ | `Switch` | — | `role="switch"` over the same input; Space **and** Enter toggle, Enter does not submit. |
1261
+ | `RadioGroup` | `label` | `role="radiogroup"` named by it — a set of radios with nothing over it is a flat list. |
1262
+ | `RadioGroup` | `name` | The field every item submits under. Generated when left out. |
1263
+ | `RadioGroup` | `value` / `defaultValue` | The selected item's value (a string — the platform's own model for a radio). |
1264
+ | `RadioGroup` | `onValueChange` | `(value, { reason, event })` — `click` or `keyboard`. `onChange` is the deprecated spelling. |
1265
+ | `RadioGroup` | `orientation` | `vertical` (default) or `horizontal`. Both arrow pairs navigate either way. |
1266
+
1267
+ Arrow keys move **and select** inside a group, wrapping at both ends and skipping disabled items;
1268
+ Tab enters the group once and leaves it once, because a native radio set is already a single tab
1269
+ stop and the component does not fight that.
1270
+
1271
+ `checked`, `disabled` and `indeterminate` are each both a state and a pseudo-class style prop. Pass
1272
+ a boolean for the state, or the tuple `[state, styles]` for both:
1273
+ `checked={[on, { bgColor: 'emerald-500' }]}`.
1274
+
1275
+ ---
1276
+
1277
+ ## Form Component
1278
+
1279
+ A `<form>` that reads its own fields when it submits, so a simple form needs no state per input.
1280
+
1281
+ ```tsx
1282
+ import Form from '@box-kite/react/components/form';
1283
+ import Textbox from '@box-kite/react/components/textbox';
1284
+
1285
+ interface Credentials {
1286
+ email: string;
1287
+ password: string;
1288
+ }
1289
+
1290
+ <Form<Credentials> p={4} onSubmit={(values, e) => signIn(values)}>
1291
+ <Textbox name="email" type="email" />
1292
+ <Textbox name="password" type="password" />
1293
+ <Button type="submit">Sign in</Button>
1294
+ </Form>;
1295
+ ```
1296
+
1297
+ `onSubmit(values, event)` runs after the event's own `preventDefault()`, so nothing navigates. The
1298
+ object is built from the form's elements, and a field is in it only if it has a `name`:
1299
+
1300
+ | In the markup | In `values` |
1301
+ | --------------------------------- | -------------------------------------------------------- |
1302
+ | one named input | its `value` as a string |
1303
+ | one named checkbox or radio | its `checked` **boolean** |
1304
+ | several elements sharing a `name` | an array — the checked values, or every value for inputs |
1305
+ | `name="address.city"` | nested: `{ address: { city } }` |
1306
+ | `name="lines[0].qty"` | nested through an array: `{ lines: [{ qty }] }` |
1307
+
1308
+ The type argument is the shape you expect back; it is not checked against the fields, so keep it
1309
+ next to the markup. Everything else is a Box prop, and `props` takes the `<form>` attributes
1310
+ (`onSubmit` and `ref` are the component's own).
1311
+
1312
+ ---
1313
+
1314
+ ## Popover Component
1315
+
1316
+ ```tsx
1317
+ import Popover from '@box-kite/react/components/popover';
1318
+
1319
+ <Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}>
1320
+ <Checkbox label="Only mine" />
1321
+ </Popover>;
1322
+ ```
1323
+
1324
+ A panel anchored to a trigger, on the platform's **Popover API**. The `trigger` is a render prop (the
1325
+ `ref` and the ARIA have to land on the control itself) and the **children are the panel's content** —
1326
+ the other way round from `Tooltip`, whose `content` is a short description and whose `children` are the
1327
+ trigger. The trigger must be a **button**: the toggle is the browser's own `popovertarget`.
1328
+
1329
+ **There is no portal.** The panel is in the top layer, so it paints above every stacking context and
1330
+ outside every clipped ancestor — and because it is never moved, it keeps the theme, the custom
1331
+ properties, the direction and the tab order it was declared in. Use `Popover` rather than `Overlay`
1332
+ whenever the layer is a panel a user interacts with; `Overlay` is in the same top layer, but it is
1333
+ positioning only and light-dismisses nothing.
1334
+
1335
+ | Prop | Default | What it does |
1336
+ | ---------------------- | ------------------- | ----------------------------------------------------------------------------------- |
1337
+ | `trigger` | — | Render prop given `{ ref, props }`. Spread onto a **button**. |
1338
+ | `children` | — | The panel's content. Rendered whether or not it is open. |
1339
+ | `label` / `labelledBy` | — | The panel's accessible name. `role="dialog"` has none of its own — give it one. |
1340
+ | `open` / `defaultOpen` | — | Controlled / uncontrolled open state. |
1341
+ | `onOpenChange` | — | `(open, { reason, event })` — `trigger`, `escape`, `outside-pointer`, `imperative`. |
1342
+ | `side` / `align` | `bottom` / `center` | Which side of the trigger the panel sits on, and how it lines up. |
1343
+ | `offset` | `2` | The gap, on the ÷4 scale — 8px. |
1344
+ | `flip` | `true` | Whether a side with no room is swapped for its opposite. |
1345
+ | `matchWidth` | `false` | Whether the panel is at least as wide as its trigger. |
1346
+ | `autoFocus` | `true` | Whether opening moves focus into the panel. An `autofocus` inside wins. |
1347
+
1348
+ What the component supplies, because the `popover` attribute supplies none of it: `role="dialog"`, the
1349
+ name, and `aria-expanded`/`aria-haspopup="dialog"`/`aria-controls` on the trigger. What the **platform**
1350
+ supplies, so it is not reimplemented: the top layer, light dismiss on Escape and on an outside press,
1351
+ and focus return when the panel is hidden.
1352
+
1353
+ Three traps, all measured in Chrome 152:
1354
+
1355
+ - **The panel is always rendered**; closed is `display: none`, not unmounted. So the exit is a CSS
1356
+ transition (`transitionBehavior="allow-discrete"`, already in the component styles) and **not**
1357
+ `<Presence>` — nothing leaves the DOM. Gate expensive children yourself: `{open ? <Heavy /> : null}`.
1358
+ - **A close cannot be refused.** `beforetoggle` is cancelable opening and not closing, so a controlled
1359
+ `<Popover open>` hears about a light dismiss only after it has happened; keep `open` true and the
1360
+ component re-shows the panel.
1361
+ - **Never put an `onClick` toggle on the trigger.** Light dismiss closes on `pointerdown`, so the
1362
+ `click` after it reads "closed" and reopens the panel — pressing the trigger would never close it.
1363
+ That is exactly why the toggle is handed to `popovertarget`.
1364
+
1365
+ Where the browser has no Popover API the panel falls back to a portalled `Overlay` with `useDismiss`
1366
+ and `useFocusReturn`. Same props, same styling; what is lost is what a portal costs.
1367
+
1368
+ ---
1369
+
1370
+ ## Dialog Component
1371
+
1372
+ ```tsx
1373
+ import Dialog, { AlertDialog } from '@box-kite/react/components/dialog';
1374
+
1375
+ <Dialog trigger={(t) => <Button {...t}>Rename</Button>}>
1376
+ <Dialog.Title>Rename this view</Dialog.Title>
1377
+ <Dialog.Description>The name is only shown to you.</Dialog.Description>
1378
+ <Textbox name="name" props={{ 'aria-label': 'Name' }} />
1379
+ </Dialog>;
1380
+ ```
1381
+
1382
+ A real `<dialog>` shown with **`showModal()`**, which is where the whole pattern comes from: the top
1383
+ layer, the `::backdrop`, an inert page behind it, Escape, focus containment and focus return are all the
1384
+ browser's. There is no focus trap here, no `aria-hidden` sweep over the page and no `z-index` — measured
1385
+ in Chrome 152, a modal dialog paints over a `position: fixed` sibling with `z-index: 9999`.
1386
+
1387
+ | Prop | Default | What it does |
1388
+ | ---------------------- | ------- | ----------------------------------------------------------------------------------- |
1389
+ | `trigger` | — | Render prop given `{ ref, props }`. Optional — open it from anywhere instead. |
1390
+ | `children` | — | The dialog's content. Rendered whether or not it is open. |
1391
+ | `open` / `defaultOpen` | — | Controlled / uncontrolled open state. |
1392
+ | `onOpenChange` | — | `(open, { reason, event })` — `trigger`, `escape`, `outside-pointer`, `imperative`. |
1393
+ | `label` / `labelledBy` | — | The name, when no `Dialog.Title` supplies one. |
1394
+ | `describedBy` | — | The description, when no `Dialog.Description` supplies one. |
1395
+ | `modal` | `true` | `showModal()`. `false` is `show()`: no backdrop, nothing inert. |
1396
+ | `dismissible` | `true` | Whether a press outside closes it. |
1397
+ | `lockScroll` | `modal` | Whether the page behind it stops scrolling. |
1398
+ | `initialFocus` | — | Where focus lands, instead of the first focusable thing inside. |
1399
+
1400
+ `Dialog.Title` and `Dialog.Description` are how it is named: rendering a title writes `aria-labelledby`
1401
+ on the dialog and rendering a description writes `aria-describedby`. **Neither attribute is written when
1402
+ the part is absent**, so a reference never dangles — and a dialog showing no heading wants `label`,
1403
+ `labelledBy` or `describedBy` instead. Both are Boxes (`dialog.title`, `dialog.description` in the style
1404
+ tree), and `AlertDialog` carries the same two.
1405
+
1406
+ ```tsx
1407
+ const cancel = useRef(null);
1408
+
1409
+ <AlertDialog initialFocus={cancel} trigger={(t) => <Button {...t}>Delete</Button>}>
1410
+ <AlertDialog.Title>Delete this view?</AlertDialog.Title>
1411
+ <AlertDialog.Description>Nothing here can be undone.</AlertDialog.Description>
1412
+ <Button bgColor="rose-600">Delete</Button>
1413
+ <Button ref={cancel}>Cancel</Button>
1414
+ </AlertDialog>;
1415
+ ```
1416
+
1417
+ `AlertDialog` is `role="alertdialog"` — the content is an alert rather than a panel, so the name and the
1418
+ description are announced together on open. It is always modal and **never dismissed by a press
1419
+ outside**; Escape still closes it, because a keyboard user must always have a way out. `initialFocus` is
1420
+ APG's requirement that focus land on the least destructive action, so a deletion cannot be confirmed by
1421
+ reflex. It takes every `Dialog` prop but `modal`, `dismissible` and `lockScroll`, which are not choices
1422
+ it gets to make.
1423
+
1424
+ Four traps, all measured in Chrome 152:
1425
+
1426
+ - **The dialog is always rendered**; closed is `display: none`, not unmounted — the same shape as
1427
+ `Popover`. The exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in the
1428
+ component styles, and it covers the `::backdrop` too) and **not** `<Presence>`. Gate expensive
1429
+ children yourself: `{open ? <Heavy /> : null}`.
1430
+ - **A close cannot be refused.** `cancel` is cancelable, but the browser has already closed the dialog
1431
+ by the time `onOpenChange` runs, so a controlled `<Dialog open>` hears about a dismissal afterwards
1432
+ and keeping `open` true shows it again. `dismissible={false}` is how a decision is made unavoidable.
1433
+ - **For a modal dialog the backdrop _is_ the dialog element.** A press on it targets the `<dialog>`, so
1434
+ every containment test calls it inside. The platform's `closedby="any"` gets this right and the
1435
+ component writes it; where the browser has not got it yet, the press is measured against the dialog's
1436
+ own box instead.
1437
+ - **The platform does not stop the page scrolling** (measured: `overflow` stays `visible` while a modal
1438
+ dialog is open). That is what `lockScroll` is — an `overflow: hidden` class on `<html>`, held by a
1439
+ counter so an inner dialog closing cannot unlock the page under an outer one. The scrollbar's width
1440
+ leaves the page as it is applied, so a document that must not shift wants `scrollbarGutter="stable"`;
1441
+ iOS Safari scrolls anyway.
1442
+
1443
+ The style tree says almost nothing about position or size on purpose: the UA stylesheet centres a modal
1444
+ dialog in the viewport and caps it at `calc(100% - 6px - 2em)`. The **one** exception is `margin: auto`,
1445
+ which `Box.components('dialog')` has to declare because `._b` sets `margin: 0` and outranks the UA rule
1446
+ — without it the dialog sits in the corner. `display: none` while closed is declared for the same
1447
+ reason.
1448
+
1449
+ ---
1450
+
1451
+ ## Menu Component
1452
+
1453
+ ```tsx
1454
+ import Menu from '@box-kite/react/components/menu';
1455
+
1456
+ <Menu trigger={(t) => <Button {...t}>Actions</Button>}>
1457
+ <Menu.Item onSelect={duplicate}>Duplicate</Menu.Item>
1458
+ <Menu.Item disabled>Move</Menu.Item>
1459
+ <Menu.Separator />
1460
+ <Menu.Group label="View">
1461
+ <Menu.CheckboxItem checked={compact} onCheckedChange={setCompact}>
1462
+ Compact rows
1463
+ </Menu.CheckboxItem>
1464
+ </Menu.Group>
1465
+ <Menu.RadioGroup label="Sort by" value={sort} onValueChange={setSort}>
1466
+ <Menu.RadioItem value="name">Name</Menu.RadioItem>
1467
+ <Menu.RadioItem value="date">Date added</Menu.RadioItem>
1468
+ </Menu.RadioGroup>
1469
+ <Menu.Sub label="Share">
1470
+ <Menu.Item onSelect={copyLink}>Copy link</Menu.Item>
1471
+ </Menu.Sub>
1472
+ </Menu>;
1473
+ ```
1474
+
1475
+ APG's menu button on the platform's own Popover API, and **a submenu is a popover nested inside its
1476
+ menu**. The top layer, light dismiss and the toggle are the browser's: the menu paints over every
1477
+ stacking context with no portal and no `z-index`, Escape closes the innermost menu first — one layer per
1478
+ press — and a press outside closes the lot. What the component adds is the ARIA, the keyboard, and the
1479
+ two focus moves the platform does not make.
1480
+
1481
+ | Prop | Default | What it does |
1482
+ | ---------------------- | ---------- | ---------------------------------------------------------------------------------------------------- |
1483
+ | `trigger` | — | Render prop given `{ ref, props }`. It has to be a **button**. |
1484
+ | `children` | — | The items. Rendered whether or not the menu is open. |
1485
+ | `open` / `defaultOpen` | — | Controlled / uncontrolled open state. |
1486
+ | `onOpenChange` | — | `(open, { reason, event })` — `select`, `tab`, `trigger`, `escape`, `outside-pointer`, `imperative`. |
1487
+ | `label` / `labelledBy` | trigger | The menu's name. Left out, it takes its trigger's, which is what APG asks for. |
1488
+ | `side` | `'bottom'` | `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page). |
1489
+ | `align` | `'start'` | Which of the trigger's edges to line the menu up with. |
1490
+ | `offset` | `1` | The gap, on the ÷4 scale — 4px. |
1491
+ | `flip` | `true` | Whether a side with no room may be swapped for its opposite. |
1492
+ | `matchWidth` | `false` | Whether the menu is at least as wide as its trigger. |
1493
+
1494
+ | Part | Props | Role |
1495
+ | ------------------- | ------------------------------------------------------------------------------------------- | ---------------------------- |
1496
+ | `Menu.Item` | `onSelect`, `disabled`, `closeOnSelect` (default `true`) | `menuitem` |
1497
+ | `Menu.CheckboxItem` | `checked`/`defaultChecked`/`onCheckedChange`, `disabled`, `closeOnSelect` (default `false`) | `menuitemcheckbox` |
1498
+ | `Menu.RadioGroup` | `label`, `value`/`defaultValue`/`onValueChange` | `group` |
1499
+ | `Menu.RadioItem` | `value`, `disabled`, `closeOnSelect` (default `false`) | `menuitemradio` |
1500
+ | `Menu.Group` | `label` — names the group through `aria-labelledby` | `group` |
1501
+ | `Menu.Separator` | every Box prop | `separator` |
1502
+ | `Menu.Sub` | `label` (its item's content), `disabled`, `itemProps`, `side`/`align`/`offset`/`flip` | `menuitem` + a nested `menu` |
1503
+
1504
+ `Menu.Sub` renders both halves: the item that opens it — `aria-haspopup="menu"`, `aria-expanded`, a
1505
+ chevron — and the menu beside it, declared inside the menu it belongs to, which is what nests the two
1506
+ popovers. Its defaults are `side="end"` and `offset={0}`, so a submenu abuts the menu it came out of, and
1507
+ they nest as deeply as the markup does.
1508
+
1509
+ | Key | What it does |
1510
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
1511
+ | Enter / Space | On the trigger, opens with the first item focused. On an item, chooses it. On a submenu's item, opens it. |
1512
+ | Down / Up | On the trigger, opens at the first or last item. Inside, moves between items and **wraps**. |
1513
+ | Home / End | The first or the last item. |
1514
+ | Right / Left | Opens the submenu of the focused item, and closes the submenu focus is in — the **reading order**, so the two swap in a right-to-left menu. |
1515
+ | Escape | Closes the innermost menu, and puts focus back on what opened it. |
1516
+ | Tab | Closes the menu and moves focus on, as APG asks. |
1517
+ | A printable character | Typeahead. A longer buffer narrows; the same letter again cycles through the items starting with it. |
1518
+
1519
+ Four things to know, all measured in Chrome 152:
1520
+
1521
+ - **A disabled item is `aria-disabled` and stays focusable.** APG asks for that, so a keyboard user can
1522
+ find out the item is there at all; the `disabled` attribute would take it out of the keyboard's reach
1523
+ and silence it. The arrows land on it and activating it does nothing.
1524
+ - **A command closes the menu and a state does not.** `Menu.Item` closes on select, because choosing a
1525
+ command ends the visit; a checkbox or a radio item does not, so several boxes can be ticked in one go.
1526
+ `closeOnSelect` swaps either default.
1527
+ - **The menu is always rendered**; closed is `display: none`, not unmounted — the same shape as
1528
+ `Popover` and `Dialog`. The exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in
1529
+ the component styles) and **not** `<Presence>`. Gate expensive items yourself:
1530
+ `{open ? <Items /> : null}`.
1531
+ - **The platform returns focus for the outermost layer only.** Closing a nested popover drops focus to
1532
+ `<body>`, so a submenu puts focus back on its own item itself — and opening one moves focus into it,
1533
+ hover included, which is what keeps the highlight and the keyboard in the same place. The top layer's
1534
+ one cost applies as it does to `Popover`: a menu **keeps the side it opened on**.
1535
+
1536
+ The style tree is `menu` with `menu.item` (plus a `sub` variant), `menu.group`, `menu.label`,
1537
+ `menu.separator`, and `menu.indicator`, `menu.check`, `menu.dot` and `menu.arrow` for the four marks a
1538
+ menu draws — all of them borders and a radius rather than an asset, since the library ships no icons.
1539
+ The highlight is drawn on `:focus` because in a menu focus **is** the highlight: the pointer moves it, so
1540
+ hover and the keyboard cannot disagree.
1541
+
1542
+ **The semantic `<menu>` element is `MenuList`** (`components/semantics`) now. `Menu` is still exported
1543
+ there and still works, but it is deprecated: two exports of that name are one import away from the wrong
1544
+ component.
1545
+
1546
+ ---
1547
+
1548
+ ## Tabs Component
1549
+
1550
+ ```tsx
1551
+ import Tabs from '@box-kite/react/components/tabs';
1552
+
1553
+ <Tabs defaultValue="overview" onValueChange={(value, { reason }) => log(value, reason)}>
1554
+ <Tabs.List label="Project">
1555
+ <Tabs.Tab value="overview">Overview</Tabs.Tab>
1556
+ <Tabs.Tab value="activity">Activity</Tabs.Tab>
1557
+ <Tabs.Tab value="audit" disabled>
1558
+ Audit log
1559
+ </Tabs.Tab>
1560
+ </Tabs.List>
1561
+ <Tabs.Panel value="overview">Who is on it, and what is left.</Tabs.Panel>
1562
+ <Tabs.Panel value="activity">What changed this week.</Tabs.Panel>
1563
+ </Tabs>;
1564
+ ```
1565
+
1566
+ APG's tabs pattern, where **selection follows focus**: one arrow key moves to a tab and shows its panel.
1567
+ `activation="manual"` splits the two — the arrows move, Enter or Space chooses — which is what a panel
1568
+ too expensive to render on the way past needs.
1569
+
1570
+ | Prop | Default | What it does |
1571
+ | --------------- | -------------- | ------------------------------------------------------------------------------------------- |
1572
+ | `value` | — | Controlled selection. |
1573
+ | `defaultValue` | — | Which tab starts selected. Left out, nothing is selected and no panel is shown. |
1574
+ | `onValueChange` | — | `(value, { reason, event })` — `click` or `keyboard`. |
1575
+ | `orientation` | `'horizontal'` | `vertical` turns the axis, moves the arrows to Down/Up and the indicator to the inline end. |
1576
+ | `activation` | `'automatic'` | `manual` moves focus without selecting. |
1577
+ | `loop` | `true` | Whether the arrows wrap at the ends. |
1578
+ | `keepMounted` | `false` | Render every panel rather than only the selected one, hiding the rest. |
1579
+ | `indicator` | `'static'` | `sliding` is one element for the whole list, travelling between tabs. |
1580
+
1581
+ | Part | Props | Role |
1582
+ | ------------- | -------------------------------------- | ------------------------------------------------- |
1583
+ | `Tabs.List` | `label` / `labelledBy`, every Box prop | `tablist` + `aria-orientation` |
1584
+ | `Tabs.Tab` | `value`, `disabled`, every Box prop | `tab` + `aria-selected`, `aria-controls` |
1585
+ | `Tabs.Panels` | `children`, every Box prop | none — the optional box that resizes to its panel |
1586
+ | `Tabs.Panel` | `value`, every Box prop | `tabpanel` + `aria-labelledby`, `tabindex="0"` |
1587
+
1588
+ | Key | What it does |
1589
+ | ------------- | ---------------------------------------------------------------------------------------------------- |
1590
+ | Tab | Enters the list once, landing on the selected tab; again, leaves it for the panel. |
1591
+ | Right / Left | The next and previous tab in a horizontal list, wrapping and stepping over disabled tabs. |
1592
+ | Down / Up | The same in a vertical list. The off-axis pair is left to the page, so a list does not eat a scroll. |
1593
+ | Home / End | The first and last selectable tab. |
1594
+ | Enter / Space | Chooses the focused tab. Only `activation="manual"` needs them. |
1595
+
1596
+ The arrows follow the **reading order**, so in a right-to-left page ArrowLeft is the _next_ tab, read off
1597
+ the element's own resolved direction rather than an ancestor's `dir`.
1598
+
1599
+ Two things move, and both are opt-in. `indicator="sliding"` swaps each tab's own border for one element
1600
+ for the whole list, which animates between tabs because it _is_ the same element; wrapping the panels in a
1601
+ `Tabs.Panels` gives that container the height of the panel on screen, so panels of different heights
1602
+ transition instead of jumping.
1603
+
1604
+ ```tsx
1605
+ <Tabs defaultValue="summary" indicator="sliding">
1606
+ <Tabs.List label="Release">
1607
+ <Tabs.Tab value="summary">Summary</Tabs.Tab>
1608
+ <Tabs.Tab value="changelog">Changelog</Tabs.Tab>
1609
+ </Tabs.List>
1610
+ <Tabs.Panels>
1611
+ <Tabs.Panel value="summary">One line, and the box is one line tall.</Tabs.Panel>
1612
+ <Tabs.Panel value="changelog">Four, and it grows to fit them.</Tabs.Panel>
1613
+ </Tabs.Panels>
1614
+ </Tabs>
1615
+ ```
1616
+
1617
+ Both are measured, which is where their three surprises come from. The travelling indicator **appears
1618
+ once the widget has run**, so until then the tabs keep drawing their own border — what a prerendered page
1619
+ paints, in exactly the same place, so the handover is invisible rather than a page with no indicator at
1620
+ all. The container **watches the panel and never itself**, since its own height is what it writes, and
1621
+ watching the panel catches a reflow as well as a switch. And both ride `--transitionTime`, so
1622
+ `prefers-reduced-motion` computes each to `0s` with no opt-out. The container is clipped **only while its
1623
+ height is travelling**: at rest it is exactly as tall as its panel, so a permanent clip would cut the
1624
+ focus ring off anything sitting at that edge — and `overflow` clips both axes whatever is asked for,
1625
+ computing a `visible` companion to a clipped axis up to `auto`.
1626
+
1627
+ Four things to know:
1628
+
1629
+ - **A disabled tab is not selectable and the arrows step over it** — the opposite of `Menu.Item`, which
1630
+ APG asks stay reachable. Selection follows focus here, so a tab focus could reach but selection could
1631
+ not would leave the widget with no state to be in.
1632
+ - **Only the selected panel is rendered.** An unmounted panel costs nothing and gets an entrance from
1633
+ `startingStyle` for free, but it loses whatever state it held — `keepMounted` renders them all and
1634
+ hides the rest, which is what a half-filled form needs. A hidden panel carries the `hidden` attribute
1635
+ **and** a `display: none` of its own, since every Box carries `display: block` and any author rule
1636
+ outranks the UA's `[hidden]` one.
1637
+ - **The tabs are read off the DOM**, not out of a registry, so a tab wrapped in a layout of your own,
1638
+ rendered from a list or put behind a condition navigates like any other — and a nested set of tabs
1639
+ belongs to its own list rather than the one around it.
1640
+ - **The tab sequence follows the selection**, so a controlled `value` changed elsewhere on the page moves
1641
+ the keyboard's entry point with it. Under `activation="manual"` it follows the focus instead, which is
1642
+ what APG's own example does.
1643
+
1644
+ The style tree is `tabs` with `tabs.list`, `tabs.tab` and `tabs.panel` beneath it, plus
1645
+ `tabs.indicator` for the travelling bar and `tabs.panels` for the resizing container. The selected state is
1646
+ `aria-selected`: inside a style tree it is the bare `selected` key, and **on a tab it is
1647
+ `ariaAttr={{ selected: … }}`** — a top-level `selected` _prop_ writes the attribute the component owns
1648
+ rather than nesting styles under it, which is the rule for `selected`, `disabled` and `checked` alike.
1649
+ The indicator is a border rather than a background: a forced-colors mode keeps one and throws the other
1650
+ away, so selection would otherwise read identically on and off.
1651
+
1652
+ ---
1653
+
1654
+ ## Accordion & Collapsible
1655
+
1656
+ ```tsx
1657
+ import Accordion, { Collapsible } from '@box-kite/react/components/accordion';
1658
+
1659
+ <Accordion defaultValue={['shipping']} onValueChange={(open, { reason }) => log(open, reason)}>
1660
+ <Accordion.Item value="shipping">
1661
+ <Accordion.Trigger>Shipping</Accordion.Trigger>
1662
+ <Accordion.Panel>Two to four working days, tracked.</Accordion.Panel>
1663
+ </Accordion.Item>
1664
+ <Accordion.Item value="returns">
1665
+ <Accordion.Trigger>Returns</Accordion.Trigger>
1666
+ <Accordion.Panel>Thirty days, in the packaging it came in.</Accordion.Panel>
1667
+ </Accordion.Item>
1668
+ </Accordion>;
1669
+ ```
1670
+
1671
+ APG's accordion. **The height animation is a class, not a measurement**: the panel sits in a grid of one
1672
+ row whose track runs `1fr` to `0fr`, so there is no `ResizeObserver`, no measured pixel and nothing
1673
+ written per instance — and content that grows while a panel is open grows with it.
1674
+
1675
+ | Prop | Default | What it does |
1676
+ | --------------- | ------- | ------------------------------------------------------------------------- |
1677
+ | `value` | — | Controlled: the values of every panel standing open, a `string[]`. |
1678
+ | `defaultValue` | — | Which panels start open. Left out, all of them are closed. |
1679
+ | `onValueChange` | — | `(open, { reason, event })` — `reason` is `'trigger'`. |
1680
+ | `multiple` | `false` | Whether several panels may stand open at once. |
1681
+ | `loop` | `true` | Whether Down and Up wrap at the ends. |
1682
+ | `level` | `3` | Which heading each header sits in. It has to fit the outline of the page. |
1683
+
1684
+ | Part | Props | Role |
1685
+ | ------------------- | ----------------------------------- | --------------------------------------------------- |
1686
+ | `Accordion.Item` | `value`, `disabled`, every Box prop | none — it owns the pair of ids |
1687
+ | `Accordion.Trigger` | `level`, `arrow`, every Box prop | `<button aria-expanded aria-controls>` in a heading |
1688
+ | `Accordion.Panel` | every Box prop | `region` + `aria-labelledby` |
1689
+
1690
+ | Key | What it does |
1691
+ | ------------- | --------------------------------------------------------------------------------------------- |
1692
+ | Tab | Every header is its own tab stop, and so is anything inside an open panel. |
1693
+ | Enter / Space | Opens the focused section, or closes it. |
1694
+ | Down / Up | The next and previous header, wrapping and stepping over disabled ones. Inert inside a panel. |
1695
+ | Home / End | The first and last header. |
1696
+
1697
+ An accordion is **not** a composite widget, so nothing here is a roving tabindex — that is the one thing
1698
+ to unlearn from `Tabs`, where the whole list is a single tab stop.
1699
+
1700
+ **A closed panel is hidden with `visibility`, and is always rendered.** Content behind a zero-height
1701
+ track is still laid out, still tabbable and still read out, so it has to be hidden by something —
1702
+ and `visibility` is the one that is animatable, flipping to hidden only once the track has closed and
1703
+ back the instant it opens. So there is no `@starting-style` and no `transitionBehavior="allow-discrete"`
1704
+ here, a server-rendered open panel does not animate itself open on load, a half-filled form in a panel
1705
+ survives being shut, and children too expensive to render closed are yours to gate:
1706
+
1707
+ ```tsx
1708
+ <Accordion.Panel>{open ? <ExpensiveThing /> : null}</Accordion.Panel>
1709
+ ```
1710
+
1711
+ One panel stands open at a time unless `multiple`, and closing the open one is always allowed — so an
1712
+ accordion can stand with everything shut and there is no second prop for it. A `disabled` item is the
1713
+ `disabled` attribute, so the browser takes the header out of the tab sequence and the arrows step over it.
1714
+ The panel is a `role="region"` named by its header; past about six panels the landmarks are noise, which
1715
+ is APG's own caveat, and `props={{ role: undefined }}` drops it.
1716
+
1717
+ `Collapsible` is a **named export of the same module**: the same mechanism with no heading, no group and
1718
+ no arrow keys. The trigger is a render prop and the children are the content, the way `<Popover>` reads.
1719
+
1720
+ ```tsx
1721
+ <Collapsible defaultOpen trigger={(trigger) => <Button {...trigger}>What is in the box?</Button>}>
1722
+ <P>A kite, and the string for it.</P>
1723
+ </Collapsible>
1724
+ ```
1725
+
1726
+ Styling is the `accordion` tree — `accordion.item`, `accordion.heading`, `accordion.trigger`,
1727
+ `accordion.arrow`, `accordion.panel` — plus `collapsible` for the lone one. A header's open state is its
1728
+ own `aria-expanded`, so `ariaAttr={{ expanded: … }}` styles it and no variant is involved:
1729
+
1730
+ ```tsx
1731
+ <Accordion.Trigger py={4} ariaAttr={{ expanded: { color: 'emerald-600' } }}>
1732
+ Shipping
1733
+ </Accordion.Trigger>
1734
+ ```
1735
+
1736
+ Two parts are not yours: `accordion.track`, the grid whose row animates, and `accordion.clip` inside it,
1737
+ the bare grid item that clips. Bare is the point — **padding cannot be squeezed**, so a grid item carrying
1738
+ any floors the `0fr` track at exactly that much, leaving a permanent stub and a stretch of the transition
1739
+ where the height has stopped but the text is still painted (measured in Chrome, bug #142). The panel you
1740
+ pad is inside the clip, so its padding costs the track nothing; that padding is also the room a focus ring
1741
+ on something at the panel's edge needs, since the clip is permanent.
1742
+
1743
+ ---
1744
+
1745
+ ## Slider & Progress
1746
+
1747
+ ```tsx
1748
+ import Slider from '@box-kite/react/components/slider';
1749
+ import Progress from '@box-kite/react/components/progress';
1750
+
1751
+ <Slider label="Volume" defaultValue={40} onValueChange={(value) => setVolume(value)} />;
1752
+ <Slider label="Price" defaultValue={[20, 80]} thumbLabels={['Lowest', 'Highest']} format={(value) => `${value} lei`} />;
1753
+ <Progress label="Upload" value={62} />;
1754
+ <Progress label="Preparing" />;
1755
+ ```
1756
+
1757
+ APG's slider and its multi-thumb sibling, plus the `role="progressbar"` beside them. **A number in is a
1758
+ number out**: `defaultValue={40}` is one thumb whose `onValueChange` hands back a `number`, and
1759
+ `defaultValue={[20, 80]}` is a range whose handler takes a `number[]`. The value's own shape is how many
1760
+ thumbs there are, so there is no second prop saying which kind this is and nothing to narrow at the call
1761
+ site.
1762
+
1763
+ | Prop (`Slider`) | Default | What it does |
1764
+ | --------------- | -------------- | ----------------------------------------------------------------------------- |
1765
+ | `value` | — | Controlled. A `number` is one thumb, a `number[]` is one per entry. |
1766
+ | `defaultValue` | `min` | What it starts at, and what decides how many thumbs there are. |
1767
+ | `onValueChange` | — | `(value, { reason, event })` on every step. `reason` is `pointer`/`keyboard`. |
1768
+ | `onValueCommit` | — | The same, once, when the interaction ends. The place for a request. |
1769
+ | `min` / `max` | `0` / `100` | The range. |
1770
+ | `step` | `1` | The grid every value lands on, counted from `min`. `0` is continuous. |
1771
+ | `largeStep` | `step * 10` | What PageUp and PageDown move by. |
1772
+ | `orientation` | `'horizontal'` | `'vertical'` counts up from the bottom edge, in either reading order. |
1773
+ | `disabled` | `false` | `aria-disabled`, so the thumb stays readable and in the tab order. |
1774
+ | `label` | — | Names the thumb on one, and the `role="group"` on a range. |
1775
+ | `labelledBy` | — | The same, naming an element that already says it. |
1776
+ | `thumbLabels` | — | One name per thumb. A range wants them — `label` names the group. |
1777
+ | `format` | — | `(value) => string`, written as `aria-valuetext`. |
1778
+ | `name` | — | Submits: one hidden input per thumb, so a range posts two values. |
1779
+
1780
+ | Prop (`Progress`) | Default | What it does |
1781
+ | ---------------------- | ----------- | ---------------------------------------------------------------- |
1782
+ | `value` | — | Leave it out for indeterminate: it reports no position at all. |
1783
+ | `min` / `max` | `0` / `100` | What "full" means. |
1784
+ | `label` / `labelledBy` | — | What names it. A bar with neither is a percentage about nothing. |
1785
+ | `format` | — | `(value) => string`, written as `aria-valuetext`. |
1786
+
1787
+ | Key | What it does |
1788
+ | ----------------- | ------------------------------------------------------------------------------- |
1789
+ | Tab | Every thumb is its own tab stop, so a range is two of them. |
1790
+ | Right / Up | One step towards the maximum. In a right-to-left page **Left** is the increase. |
1791
+ | Left / Down | One step towards the minimum. |
1792
+ | PageUp / PageDown | `largeStep`. |
1793
+ | Home / End | The minimum and the maximum themselves, on the step grid or off it. |
1794
+
1795
+ **Neither is the native element, and that is the one place the platform loses.** Every other control here
1796
+ is a real form element with a role over it — a `Switch` is a checkbox, a `RadioGroup` is a set of radios.
1797
+ But `<input type="range">` cannot hold two thumbs, and it and `<progress>` both draw themselves with
1798
+ vendor pseudo-elements (`::-webkit-slider-thumb`, `::-moz-range-track`) that no typed prop reaches, so a
1799
+ styled one is a rewrite of every part. `name` is what keeps the half worth keeping: the value submits.
1800
+
1801
+ **The position is an inline style, and it is the only thing here that is not a class.** A thumb's offset
1802
+ is a per-frame value, so a class for it would be a rule per frame of a drag, written into the stylesheet
1803
+ and never freed — the same exception `useAnchorPosition`'s anchor name and the travelling `Tabs`
1804
+ indicator take. Where the value is data rather than an animation, a `ProgressRing` (`components/chart`)
1805
+ rounds its fraction to half a percent and puts it in a shared class instead: nobody drags a ring.
1806
+
1807
+ **No `value` on a `Progress` is a state, not a zero.** `aria-valuenow` is omitted entirely rather than
1808
+ reported as `0` — a position nobody measured is the one thing a reader must not be told — and the bar
1809
+ sweeps instead. The sweep names its duration in milliseconds, so it is outside what `--transitionTime`
1810
+ zeroes and stops itself under `prefers-reduced-motion`.
1811
+
1812
+ **A press travels; a nudge does not.** A press on the track is the one move the eye has to follow, so the
1813
+ thumb animates the whole way. Every other move — a drag, an arrow key, a held arrow — is the `tracking`
1814
+ variant instead: 60ms `ease-out` on both the thumb and the fill, which must both carry it or they come
1815
+ apart. **Short rather than off, because off is exact and steps**: a value on a grid can only be at its grid
1816
+ positions, so a 1-in-100 slider moves 3.2px at a time on a 320px track. **Eased out rather than linear or
1817
+ eased**: a repeat restarts the transition every 33ms, so only its first half is ever seen, and on this curve
1818
+ that half is the straight part — it glides while held and still decelerates on the last transition, the one
1819
+ allowed to finish. Linear rode 1.4 steps behind the value and stopped at full speed; this rides 0.9 behind
1820
+ and lands at 0.6 of cruising. An arrow key taking the 250ms travel for one
1821
+ 3.2px step was itself the lag: a tap arrives in 60ms now. `step` is the real dial:
1822
+ `step={0}` is continuous and interpolates nothing. And **a controlled slider re-renders whatever component
1823
+ owns its `useState`**, thirty times a second while an arrow is held — the slider costs 0.3ms a keystroke, so
1824
+ keep the value beside it rather than at the top of a page, or use `defaultValue` with `onValueCommit`.
1825
+
1826
+ **It mirrors for free.** The fill and the thumbs are placed with `inset-inline-start` and centred with a
1827
+ logical margin, so a right-to-left page draws the minimum on the right with nothing declared twice and no
1828
+ re-render. The half that is not free is the keyboard: the sideways arrows swap, read off the element's
1829
+ resolved direction when one arrives, and Up/Down never do.
1830
+
1831
+ Styling is `slider`/`slider.track`/`slider.fill`/`slider.thumb`, every part carrying the `vertical`
1832
+ variant, plus `progress`/`progress.fill`, whose `indeterminate` variant is the sweep. `Progress` renders
1833
+ on a **server** — no state, no effect, no measurement — so a page paints a real figure before any
1834
+ JavaScript arrives.
1835
+
1836
+ ```tsx
1837
+ Box.components({
1838
+ slider: {
1839
+ children: {
1840
+ fill: { styles: { bgGradient: { linear: 'r', colors: ['sky-400', 'indigo-500'] } } },
1841
+ thumb: { styles: { borderColor: 'sky-500' } },
1842
+ },
1843
+ },
1844
+ });
1845
+ ```
1846
+
1847
+ ---
1848
+
1849
+ ## Toaster (`toast()` + `<Toaster>`)
1850
+
1851
+ ```tsx
1852
+ import Toaster, { toast } from '@box-kite/react/components/toaster';
1853
+
1854
+ // once, near the root of the app
1855
+ <Toaster position="bottom-end" limit={3} />;
1856
+
1857
+ // then from anywhere at all — an event handler, a fetch, a module with no React in it
1858
+ toast.success('Saved');
1859
+ toast.error('Could not save', { action: { label: 'Retry', onClick: save } });
1860
+ toast('Row deleted', { description: 'Ada Lovelace, added in March.', action: { label: 'Undo', onClick: restore } });
1861
+ toast.promise(save(), { loading: 'Saving…', success: (saved) => `Saved as ${saved.name}`, error: 'Could not save' });
1862
+ ```
1863
+
1864
+ Messages the app sends rather than renders. The store behind `toast()` is framework-free, so a call made
1865
+ **before** the viewport mounts is queued rather than lost, and `<Toaster>` only draws what is there.
1866
+
1867
+ ### The imperative API
1868
+
1869
+ | Call | What it does |
1870
+ | ---------------------------------------------- | ------------------------------------------------------------------------------- |
1871
+ | `toast(message, options?)` | Adds one and returns its id. `message` is any `ReactNode`. |
1872
+ | `toast.success` / `error` / `warning` / `info` | The same with a `kind`. `error` is the assertive one — `role="alert"`. |
1873
+ | `toast.loading(message, options?)` | `duration: Infinity` by default: a spinner that timed out would report nothing. |
1874
+ | `toast.promise(promise, messages, options?)` | One toast for the whole call. The promise comes back untouched. |
1875
+ | `toast.update(id, message, options?)` | Changes one already on screen. An unknown id is ignored rather than added. |
1876
+ | `toast.dismiss(id?)` | Starts the exit for one, or for all of them with no id. |
1877
+
1878
+ An `options.id` that is already on screen is an **update**, not a second toast — which is exactly how
1879
+ `toast.promise` turns into one toast rather than two.
1880
+
1881
+ | Option | Default | What it does |
1882
+ | ------------- | ------------- | -------------------------------------------------------------------------------------------------------------------- |
1883
+ | `id` | generated | Name it to update it later. |
1884
+ | `kind` | `'default'` | `success` / `error` / `warning` / `info` / `loading`. Decides the accent and the role. |
1885
+ | `description` | — | A second line under the message. Any `ReactNode`. |
1886
+ | `duration` | `<Toaster>`'s | **Milliseconds.** `Infinity` waits for a dismissal. |
1887
+ | `action` | — | `{ label, onClick, closeOnClick }` — one button, which dismisses the toast it answered unless `closeOnClick: false`. |
1888
+ | `dismissible` | `true` | Whether the close button is drawn. |
1889
+ | `onDismiss` | — | `(reason)` — `'timeout'` / `'close'` / `'action'` / `'imperative'`. |
1890
+
1891
+ ### The viewport
1892
+
1893
+ | Prop | Default | What it does |
1894
+ | --------------- | ------------------------- | ------------------------------------------------------------------------------- |
1895
+ | `position` | `'bottom-end'` | Six corners. The inline half is logical, so `start`/`end` mirror with the page. |
1896
+ | `limit` | `3` | How many are on screen. The rest **queue**, timers and all. |
1897
+ | `duration` | `5000` | Milliseconds for a toast that names none of its own. |
1898
+ | `label` | `'Notifications'` | Names the region. It is never labelled visually. |
1899
+ | `closeLabel` | `'Close'` | Names the close button. |
1900
+ | `overflowLabel` | `` (n) => `+${n} more` `` | What the counter at the far end of the stack says. |
1901
+ | `hotkey` | `'F6'` | Moves focus to the stack. Takes `'alt+t'` and the like, or `false`. |
1902
+ | `store` | the default one | A second, independent stack — or a test that must not share state. |
1903
+
1904
+ **The limit is a queue, not a cap.** Past it a toast waits _with its timer unstarted_, so nothing expires
1905
+ that was never on screen.
1906
+
1907
+ ### Four things to know, all measured in Chrome 153
1908
+
1909
+ 1. **The region exists before there is anything in it.** The viewport is a `<section>` carrying
1910
+ `aria-live="polite"` from the moment it mounts — a live region inserted together with its content is
1911
+ not reliably announced. An error toast is `role="alert"`, which is assertive and is the one
1912
+ announcement pattern every screen reader implements; **nothing else carries a region of its own**,
1913
+ because the nearest region to a change is the one that speaks, and a second would only take the
1914
+ first's head start away.
1915
+ 2. **The top layer, and no portal.** `popover="manual"`, shown as it mounts, so it paints over every
1916
+ stacking context and outside every clipped or transformed ancestor while staying where it was
1917
+ declared — it inherits the theme, the custom properties and the direction around it. `manual` rather
1918
+ than `auto` because a stack of messages owns no dismissal: a press outside has to reach the page. The
1919
+ viewport takes **no pointer events at all** and the toasts take them back, or a corner-sized fixed
1920
+ strip would swallow every press in its gaps.
1921
+ 3. **Four things stop the clock**: the pointer over the stack, focus inside it, a background tab, and a
1922
+ dismissal. A paused timer resumes where it left off rather than restarting. WCAG 2.2.1, and the only
1923
+ reason a toast is allowed to carry a control at all.
1924
+ 4. **A toast never takes focus.** `F6` moves focus to the viewport, Tab walks the toasts in the order
1925
+ they are on screen, and Escape dismisses the toast focus is in — handing focus back where it came
1926
+ from once the stack is empty.
1927
+
1928
+ ### Styling
1929
+
1930
+ ```tsx
1931
+ Box.components({
1932
+ toaster: {
1933
+ children: {
1934
+ toast: { styles: { borderRadius: 4, shadow: 'large' } },
1935
+ action: { styles: { borderColor: 'indigo-500' } },
1936
+ },
1937
+ },
1938
+ });
1939
+ ```
1940
+
1941
+ Parts: `toaster` (the viewport), `toaster.toast` — the kind is a **variant** on it, as is `fromTop` —
1942
+ `toaster.message`, `toaster.description`, `toaster.action`, `toaster.close` and `toaster.overflow`. The
1943
+ accent bar the kind paints is never the only signal: the message itself is what says what happened.
1944
+
1945
+ The viewport's own style tree has to answer the UA's `[popover]` rule, which is worth knowing before
1946
+ overriding it: `inset: 0` leaves a corner-pinned stack over-constrained (`top`/`left` win and it lands in
1947
+ the wrong corner) and `overflow: auto` makes it a scroll container, so all four sides are declared `auto`
1948
+ and `overflow: visible` before a `position` variant names the two it wants.
1949
+
1950
+ ---
1951
+
1952
+ ## Tooltip Component
1953
+
1954
+ ```tsx
1955
+ import Tooltip from '@box-kite/react/components/tooltip';
1956
+
1957
+ <Tooltip content="Deletes the row for good">{(trigger) => <Button {...trigger}>Delete</Button>}</Tooltip>;
1958
+ ```
1959
+
1960
+ The child is a **render prop**, not an element: what it receives (a `ref`, plus `aria-describedby`
1961
+ and the pointer/focus handlers in `props`) has to land on the control itself. One spread on a Box
1962
+ component — `<Button {...trigger}>` — or, on a plain element,
1963
+ `<button ref={trigger.ref} {...trigger.props}>`. The `ref` is what the bubble is positioned against,
1964
+ so the tooltip sits under the trigger and adds nothing to the layout.
1965
+
1966
+ | Prop | Default | What it does |
1967
+ | ---------------------- | ------------------- | ---------------------------------------------------------------------------------- |
1968
+ | `content` | — | The description. Nothing renders while it is empty. |
1969
+ | `open` / `defaultOpen` | — | Controlled / uncontrolled open state. |
1970
+ | `onOpenChange` | — | `(open, { reason, event })` — `hover`, `focus`, `pointer-leave`, `blur`, `escape`. |
1971
+ | `openDelay` | `300` | Hover dwell before it appears. Focus ignores it and shows immediately. |
1972
+ | `closeDelay` | `150` | Grace period after the pointer leaves, so it can travel onto the tooltip. |
1973
+ | `side` / `align` | `bottom` / `center` | Which side of the trigger the bubble sits on, and how it lines up. |
1974
+ | `offset` | `1` | The gap, on the ÷4 scale — 4px, near enough for the pointer to cross. |
1975
+ | `flip` | `true` | Whether a side with no room is swapped for its opposite. |
1976
+
1977
+ Every other Box prop styles the bubble, over the built-in `tooltip` component style. What the
1978
+ component guarantees, so you do not wire it: `role="tooltip"`, `aria-describedby` on the trigger only
1979
+ while it is open, opening on hover **and** focus, Escape to dismiss with focus left where it is (and
1980
+ no re-show until the pointer leaves and returns), the pointer able to move onto the tooltip, and no
1981
+ auto-hide timer — the three WCAG 1.4.13 rules.
1982
+
1983
+ The bubble is an `Overlay`, so it sits in the browser's **top layer** with no portal: it paints over
1984
+ every stacking context and out of every clipped ancestor, and it inherits the theme, the custom
1985
+ properties and the direction around it. Its popover type is `manual` on purpose — light dismiss would
1986
+ close the bubble behind the component's back, and WCAG 1.4.13 asks for a dismissal that _lasts_, which
1987
+ is stricter than what the platform does for free. `<Presence>` still owns the mount, so a closed
1988
+ tooltip renders nothing at all.
1989
+
1990
+ ---
1991
+
1992
+ ## Chart primitives (`components/chart`)
1993
+
1994
+ Four small drawings built from the `components/svg` elements, server-safe, in one entry. **Not a
1995
+ chart library**: no axes, no legends, no data transformations. What they give you is that a chart is
1996
+ a Box — its colour, size, dark mode, hover state and breakpoints are the props you already know.
1997
+ For a real chart with axes and a tooltip, theme Recharts instead.
1998
+
1999
+ ```tsx
2000
+ import { Gauge, MiniDonut, ProgressRing, Sparkline } from '@box-kite/react/components/chart';
2001
+
2002
+ // A sparkline stretches to whatever box it is in and keeps its line one width thick.
2003
+ <Sparkline data={[4, 9, 6, 12, 10, 15]} width="7rem" color="sky-500" />
2004
+ <Sparkline data={[4, 9, 6, 12, 10, 15]} variant="area" fillOpacity={0.2} color="violet-500" />
2005
+ <Sparkline data={[4, 9, 6, 12]} variant="bar" min={0} max={20} color="emerald-500" />
2006
+
2007
+ // A ring or a dial: the arc eases between values with no animation code.
2008
+ <ProgressRing value={0.62} color="emerald-500" label="62% complete" />
2009
+ <Gauge value={0.4} sweep={180} start={270} color="rose-500">
2010
+ <SvgText x={50} y={48} textAnchor="middle" fontSize={22} fill="rose-500" stroke="none">40%</SvgText>
2011
+ </Gauge>
2012
+
2013
+ // One segment per value, each its share of the whole. No total needed.
2014
+ <MiniDonut data={[5, 3, 2]} colors={['sky-500', 'emerald-500', 'var(--chart-3)']} />
2015
+
2016
+ // A gradient fill is a value now, so it takes a theme and a hover like any other paint.
2017
+ <Sparkline data={[4, 9, 6, 12]} variant="area" stroke="url(#trend)" fill="url(#trend)">
2018
+ <Defs>
2019
+ <LinearGradient id="trend" x1="0" y1="0" x2="1" y2="0">
2020
+ <Stop offset="0%" stopColor="currentColor" color="sky-500" />
2021
+ <Stop offset="100%" stopColor="currentColor" color="violet-500" />
2022
+ </LinearGradient>
2023
+ </Defs>
2024
+ </Sparkline>
2025
+ ```
2026
+
2027
+ | Prop | Component | Default | What it is |
2028
+ | ----------------- | ------------------------ | ----------------------- | -------------------------------------------------------------------------------------------------- |
2029
+ | `data` | `Sparkline`, `MiniDonut` | — | The numbers. A sparkline reads them oldest-first; a donut draws each as its share of the whole |
2030
+ | `variant` | `Sparkline` | `'line'` | `'line'`, `'area'` (the line with the space beneath it filled) or `'bar'` |
2031
+ | `min` / `max` | `Sparkline` | the data's own ends | Fix the value axis, so a column of rows is comparable instead of each being scaled to itself |
2032
+ | `value` | `ProgressRing`, `Gauge` | — | How full the arc is, 0–1. Rounded to half a percent (see below) and clamped |
2033
+ | `thickness` | all but `Sparkline` | `10` (`20` for a donut) | The ring's width, out of the 100-unit box the drawing is computed in |
2034
+ | `trackOpacity` | `ProgressRing`, `Gauge` | `0.2` | The unfilled part, as the same colour faded — so the track follows the theme too |
2035
+ | `sweep` / `start` | `Gauge` | `270` / `225` | How far round the dial goes and where it begins, in degrees clockwise from twelve o'clock |
2036
+ | `colors` | `MiniDonut` | six tokens | One paint per segment, cycled. Anything `fill` takes, including `var(--chart-1)` |
2037
+ | `label` | all | — | Names the drawing: `role="img"` with this text. Without one it is `aria-hidden` |
2038
+ | `children` | all | — | SVG content drawn after the chart — an `SvgText` in the middle of a ring, a `Defs` with a gradient |
2039
+
2040
+ Everything else is an `Svg`'s: `width`/`height` are the SVG attributes (`width="7rem"`,
2041
+ `width={200}`, and a sparkline defaults to `width="100%"`), the paint is inherited by the shapes
2042
+ inside (the default stroke is `currentColor`, so `color="sky-500"` recolours any of them), and every
2043
+ Box prop, pseudo-class, breakpoint and theme works.
2044
+
2045
+ **Four things to get right:**
2046
+
2047
+ 1. **Naming is `Svg`'s rule, and a chart is where it matters.** A sparkline beside the number it
2048
+ summarises is decoration — leave it unnamed, or a screen reader reads the row twice. A chart that
2049
+ _is_ the data needs a `label` saying what the shape shows: `label="Revenue, rising 12% over six
2050
+ months"`, not `label="Chart"`.
2051
+ 2. **Shape is an attribute, paint is a class.** A sparkline's geometry is the `d` attribute, which
2052
+ the styling engine never sees, so ten thousand different shapes generate no CSS at all; the
2053
+ colour and the width are style props, so the rows share one rule each. That is what makes one
2054
+ sparkline per row of a virtualized grid affordable.
2055
+ 3. **A ring's value is rounded to half a percent.** A dash length has to be a style prop to be able
2056
+ to transition, so it lands in a class name — rounding caps a column of percentages at a couple of
2057
+ hundred rules instead of one per row, and half a percent of a 48px ring is a third of a pixel.
2058
+ 4. **A sparkline is the one primitive not drawn to scale.** It is `preserveAspectRatio="none"` and
2059
+ `vectorEffect="non-scaling-stroke"`, so it fills a cell of unknown width and the line stays one
2060
+ width thick. Both are ordinary props — `vectorEffect="none"` opts out. The rings are drawn to
2061
+ scale and stay circular.
2062
+
2063
+ In a DataGrid, a cell renderer is a component: define it outside the render, or every scroll
2064
+ remounts the column.
2065
+
2066
+ ### Theming a chart library — `ChartContainer`
2067
+
2068
+ The bridge for the chart you did not write. It declares the variables a chart reads, so the chart
2069
+ names no colour at all and its dark mode belongs to the page:
2070
+
2071
+ ```tsx
2072
+ import { ChartContainer } from '@box-kite/react/components/chart';
2073
+ import { Area, AreaChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts';
2074
+
2075
+ <ChartContainer
2076
+ series={['revenue', 'cost']}
2077
+ vars={{ 'chart-grid': 'slate-200', 'chart-label': 'slate-500' }}
2078
+ theme={{ dark: { vars: { 'chart-grid': 'slate-800', 'chart-label': 'slate-400' } } }}
2079
+ height={60}
2080
+ >
2081
+ <ResponsiveContainer width="100%" height="100%">
2082
+ <AreaChart data={months}>
2083
+ <CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" vertical={false} />
2084
+ <XAxis dataKey="month" stroke="var(--chart-label)" fontSize={12} />
2085
+ <YAxis stroke="var(--chart-label)" fontSize={12} width={36} />
2086
+ <Area dataKey="revenue" stroke="var(--color-revenue)" fill="var(--color-revenue)" fillOpacity={0.15} />
2087
+ <Area dataKey="cost" stroke="var(--color-cost)" fill="var(--color-cost)" fillOpacity={0.15} />
2088
+ </AreaChart>
2089
+ </ResponsiveContainer>
2090
+ </ChartContainer>;
2091
+ ```
2092
+
2093
+ | Prop | What it is |
2094
+ | -------- | ---------------------------------------------------------------------------------------------------------- |
2095
+ | `series` | `['revenue', 'cost']` takes the palette in order, or `{ revenue: 'emerald-600' }` names a paint per series |
2096
+
2097
+ Everything else is a Box prop, which is the point:
2098
+
2099
+ - It always declares `--chart-1` … `--chart-6` (the six `MiniDonut` cycles through) and redeclares
2100
+ them one step lighter under the dark theme. A series with no paint of its own reads a slot, wrapping
2101
+ round after six.
2102
+ - Replacing a slot needs no prop for it: `vars={{ 'chart-1': 'teal-600' }}` and
2103
+ `theme={{ dark: { vars: { 'chart-1': 'teal-400' } } }}` merge with the defaults rather than replacing
2104
+ them, and any other name a chart reads (`--chart-grid`, `--chart-label`) is declared the same way.
2105
+ - `--color-<series>` and `--chart-n` are deliberately the names the ecosystem uses, so a chart copied
2106
+ from shadcn's charts works unchanged. Unlike that recipe there is no `<style>` tag per chart and no
2107
+ `id` to scope it with: the variables are in a class, so two tiles with the same series share one rule.
2108
+ - A series name becomes part of a custom-property name, so it has to be a CSS identifier. A Recharts
2109
+ dot-path `dataKey` (`user.name`) is skipped — that series only, not the rest of the palette.
2110
+ - It adds no role, no ARIA and no styling of its own: it is a `<div>` with variables on it, and what is
2111
+ inside owns its own semantics. Size it like any Box (`height={60}` is 15rem) — Recharts
2112
+ `ResponsiveContainer` measures the parent.
2113
+
2114
+ ```tsx
2115
+ function TrendCell({ cell }: { cell: CellModel<Row> }) {
2116
+ return (
2117
+ <Flex px={3} ai="center" height="fit">
2118
+ <Sparkline data={cell.row.data.trend} color="emerald-500" />
2119
+ </Flex>
2120
+ );
2121
+ }
2122
+
2123
+ <DataGrid data={rows} def={{ rowKey: 'id', columns: [{ key: 'trend', header: 'Last 12 months', width: 180, Cell: TrendCell }] }} />;
2124
+ ```
2125
+
2126
+ ---
2127
+
2128
+ ## Key Reminders for AI Assistants
2129
+
2130
+ 1. **NEVER `style={{ }}`** — use Box props. Missing prop? Use `Box.extend()`, or `css={{ … }}` for a one-off — the typed escape hatch compiles to a class, never a `style` attribute
2131
+ 2. **NEVER `<Box tag="...">` for common elements** — use `<Button>`, `<Link>`, `<H1>`, `<P>`, `<Nav>`, `<Svg>`, `<Path>`, `<Circle>`, etc.
2132
+ 3. **NEVER `<Box display="flex/grid">`** — use `<Flex>` / `<Grid>`
2133
+ 4. **fontSize divider is 16** (not 4). `fontSize={14}` → 14px
2134
+ 5. **Spacing divider is 4**. `p={4}` → 16px (1rem)
2135
+ 6. **Border width and lineHeight are direct px**. `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
2136
+ 7. **Colors are Tailwind's OKLCH palette**: `'gray-500'`, `'blue-600'` — 26 families × 11 steps, and an opacity modifier on any of them: `bgColor="blue-500/40"` (a `color-mix`, not `opacity`)
2137
+ 8. **Breakpoints are mobile-first**: base → sm → md → lg → xl → xxl
2138
+ 9. **Theme styles nest**: `theme={{ dark: { hover: { ... } } }}`
2139
+ 10. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`. `data-*` and `aria-*` go there too — a `data-state` written at the top level typechecks and is then dropped
2140
+ 11. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
2141
+ 12. **Box is memoized** with `React.memo`
2142
+ 13. **`style` is top-level only** — never inside breakpoints (`sm={{ style: ... }}`), pseudo-classes, or theme objects. `css` is the one that nests: `sm={{ css: { objectPosition: 'top' } }}` is a rule
2143
+ 14. **A CSS variable is a prop**: `vars={{ 'color-x': 'sky-500' }}` declares `--color-x` for everything inside — and a third-party chart goes in `<ChartContainer series={['revenue']}>` so it names no colour at all
2144
+ 15. **All sizing props use divider 4** — `width`, `height`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight` are NOT direct pixels. `height={10}` = 2.5rem = 40px
2145
+ 16. **A state your own code sets is a nested prop too**: `dataAttr={{ 'state=open': { … } }}` → `[data-state="open"]`, `ariaAttr={{ selected: { … } }}` → `[aria-selected="true"]`, `has={{ ':checked': { … } }}`, `not={{ hover: { … } }}`, `nth={{ odd: { … } }}`. The attribute itself still goes in `props`
2146
+ 17. **Somebody else's state is `group`/`peer`**: `group={{ 'card/hover': { … } }}` → `.card:hover .x`, `peer={{ 'agree/checked': { … } }}` → `.agree:checked ~ .x`. The ancestor or sibling carries the class via `className`; `hoverGroup` and its four siblings are the older spelling
2147
+
2148
+ ---
2149
+
2150
+ ## DataGrid Component
2151
+
2152
+ ```tsx
2153
+ import DataGrid from '@box-kite/react/components/dataGrid';
2154
+
2155
+ <DataGrid
2156
+ data={users}
2157
+ def={{
2158
+ rowKey: 'id',
2159
+ title: 'Users',
2160
+ topBar: true,
2161
+ bottomBar: true,
2162
+ globalFilter: true,
2163
+ rowSelection: { pinned: true },
2164
+ showRowNumber: { pinned: true },
2165
+ rowHeight: 40,
2166
+ visibleRowsCount: 15, // or 'all' to disable virtualization
2167
+ columns: [
2168
+ { key: 'name', header: 'Name', filterable: true },
2169
+ { key: 'age', header: 'Age', width: 80, align: 'right', filterable: { type: 'number' } },
2170
+ { key: 'email', header: 'Email', width: 250, filterable: true },
2171
+ { key: 'status', header: 'Status', filterable: { type: 'multiselect' } },
2172
+ { key: 'country', header: 'Country', pin: 'END' },
2173
+ {
2174
+ key: 'actions',
2175
+ header: '',
2176
+ pin: 'END',
2177
+ width: 80,
2178
+ sortable: false,
2179
+ resizable: false,
2180
+ Cell: ({ cell }) => <Button onClick={() => edit(cell.row.data)}>Edit</Button>,
2181
+ },
2182
+ ],
2183
+ rowDetail: { content: (user) => <UserDetails user={user} />, height: 'auto', expandOnRowClick: true, expandColumnHeader: 'Details' },
2184
+ contextMenu: { sort: true, pin: true, group: false },
2185
+ resizerStyle: 'hover',
2186
+ }}
2187
+ onSelectedRowKeysChange={(keys, { reason }) => console.log(keys, reason)}
2188
+ />;
2189
+ ```
2190
+
2191
+ ### DataGridProps
2192
+
2193
+ Box style props too: `DataGridProps` extends them, so `<DataGrid b={1} borderRadius={2} p={4}>` styles
2194
+ the element wrapping the bars, the rows and the pager. Every callback below is a `ChangeHandler` —
2195
+ `(value, { reason }) => void`, the shape every component in the library reports a change under.
2196
+
2197
+ | Prop | Type | Description |
2198
+ | --------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
2199
+ | `data` | `TRow[]` | Row data (required) |
2200
+ | `def` | `GridDefinition` | Grid config (required) |
2201
+ | `component` | `string` | Style tree name (default: `'datagrid'`) |
2202
+ | `loading` | `boolean` | Loading state |
2203
+ | `filters` | `((row) => boolean)[]` | External predicate filters (applied before column/global filters) |
2204
+ | `page` / `pageSize` / `onPaginationChange` | `number` / `number` / `({ page, pageSize }, { reason })` | Controlled pagination (1-indexed). Reason: `page`/`page-size`/`sort`/`filter`/`clear` |
2205
+ | `onSortingChange` | `({ columnKey, direction } \| undefined, { reason })` | Sort. `undefined` once cleared; reason: `sort`/`clear` |
2206
+ | `onServerStateChange` | `(state, { reason })` | Unified: `{ page, pageSize, sortColumn, sortDirection, columnFilters, globalFilterValue }` |
2207
+ | `onSelectedRowKeysChange` | `(keys, { reason })` | Every selected key; reason: `select`/`deselect`/`select-all`/`clear` |
2208
+ | `expandedRowKeys` / `onExpandedRowKeysChange` | `Key[]` / `(keys, { reason })` | Controlled expanded rows; reason: `expand`/`collapse` |
2209
+ | `globalFilterValue` / `onGlobalFilterChange` | `string` / `(value, { reason })` | Controlled global filter; reason: `filter`/`clear` |
2210
+ | `columnFilters` / `onColumnFiltersChange` | `ColumnFilters` / `(filters, { reason })` | Controlled column filters; reason: `filter`/`clear` |
2211
+ | `onCellEditsChange` | `(edits, { reason })` | Every accepted edit, oldest first; reason: `edit`/`clear` |
2212
+
2213
+ `onSelectionChange(event)`, `onSortChange(key, dir)`, `onPageChange(page, size)` and
2214
+ `onPageSizeChange(size)` are the pre-contract spellings of the four above. They still fire; the
2215
+ named ones carry the reason.
2216
+
2217
+ ### GridDefinition
2218
+
2219
+ | Prop | Type | Default | Description |
2220
+ | ------------------------- | ------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------- |
2221
+ | `columns` | `ColumnType[]` | required | Column definitions |
2222
+ | `rowKey` | `keyof TRow \| (row) => Key` | auto | Unique row identifier |
2223
+ | `rowHeight` | `number` | `48` | Row height in px |
2224
+ | `visibleRowsCount` | `number \| 'all'` | `10` | Visible rows. `'all'` disables virtualization |
2225
+ | `showRowNumber` | `boolean \| { pinned?, width? }` | `false` | Row number column |
2226
+ | `rowSelection` | `boolean \| { pinned? }` | `false` | Checkbox selection column |
2227
+ | `rowDetail` | `RowDetailConfig` | — | Expandable detail panel (see below) |
2228
+ | `treeData` | `TreeDataConfig` | — | Rows that hold rows: a `treegrid` with a chevron column (see below) |
2229
+ | `groupBy` | `Key[]` | — | The columns the grid starts grouped by, outermost first. The column menu takes it from there |
2230
+ | `groupDefaultExpanded` | `boolean \| number` | `false` | Which group rows start open: `true` for all, a number for how many levels down |
2231
+ | `editable` | `boolean` | `false` | Whether every column can be edited. A column’s own `editable` takes priority |
2232
+ | `onCellEdit` | `(edit) => void \| boolean \| string` | — | Judges an edit and is told about it: nothing accepts, a string refuses. May be async |
2233
+ | `dataSource` | `DataSource` | — | The grid fetches its own rows a block at a time; `data` is not read at all |
2234
+ | `pagination` | `{ totalCount, pageSize? }` | — | Server-side pagination. Bypasses client-side filtering |
2235
+ | `topBar` / `bottomBar` | `boolean` | `false` | Show top/bottom bars |
2236
+ | `title` / `topBarContent` | `ReactNode` | — | Top bar content |
2237
+ | `globalFilter` | `boolean` | `false` | Enable global fuzzy search |
2238
+ | `globalFilterKeys` | `(keyof TRow)[]` | all | Limit global filter columns |
2239
+ | `sortable` / `resizable` | `boolean` | `true` | Enable sorting/resizing for all columns |
2240
+ | `contextMenu` | `boolean \| ContextMenuConfig` | `true` | Control column header context menu. `false` hides it. Object: `{ sort?, pin?, group? }` |
2241
+ | `resizerStyle` | `'visible' \| 'hover' \| 'hidden'` | `'visible'` | Resizer handle visibility. `'hover'`: shows on header cell hover. `'hidden'`: invisible but resize still works |
2242
+ | `noDataComponent` | `ReactNode` | `'empty'` | Custom empty state |
2243
+ | `footer` | `boolean \| { label? }` | `false` | A pinned row of grand totals over every column with an `aggregate`. `label` replaces the default `Total` |
2244
+ | `export` | `boolean \| ExportConfig` | `false` | Export buttons in the top bar: `{ csv?, xlsx?, fileName? }`. The writers load on the first press |
2245
+
2246
+ ### ColumnType
2247
+
2248
+ | Prop | Type | Default | Description |
2249
+ | ------------------------ | --------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
2250
+ | `key` | `Key` | required | Column identifier (maps to TRow property) |
2251
+ | `header` | `string` | — | Header text |
2252
+ | `width` | `number` | `200` | Base width in px |
2253
+ | `align` | `'start' \| 'end' \| 'center' \| 'left' \| 'right'` | `'left'` | Cell alignment. `start`/`end` follow the reading order; `left`/`right` stay on the screen side they name |
2254
+ | `pin` | `'START' \| 'END'` (also `'LEFT'`/`'RIGHT'`) | — | Pin to an edge of the **inline axis** (sticky on scroll), so it holds under either `dir`. `LEFT`/`RIGHT` are the older spelling of the same two |
2255
+ | `columns` | `ColumnType[]` | — | Nested columns (grouped header) |
2256
+ | `Cell` | `({ cell }) => ReactNode` | — | Custom renderer. `cell`: `{ value, row, column, grid }` |
2257
+ | `sortable` / `resizable` | `boolean` | inherits | Override grid-level setting |
2258
+ | `flexible` | `boolean` | `true` | Participate in flex width distribution |
2259
+ | `filterable` | `boolean \| FilterConfig` | — | `true` (text), `{ type: 'number', min?, max? }`, `{ type: 'multiselect', options? }` |
2260
+ | `contextMenu` | `boolean \| ContextMenuConfig` | inherits | Override grid-level context menu. `false` hides entirely. `{ sort?, pin?, group? }` controls sections |
2261
+ | `aggregate` | `AggregateName \| (values, rows) => value` | — | Total this column over each group row, and over the footer. See below |
2262
+ | `AggregateCell` | `({ cell }) => ReactNode` | — | Renders the aggregate. `cell`: `{ value, column, rows, scope }`, `scope` being `'group'` or `'footer'` |
2263
+ | `editable` | `boolean \| (row) => boolean` | inherits | Whether this column’s cells can be edited — every row, or the rows a predicate says |
2264
+ | `editor` | `CellEditorType \| CellEditorConfig` | inferred | `text`/`number`/`checkbox`/`select`. Without one it is read off the value in the cell |
2265
+ | `EditCell` | `({ cell }) => ReactNode` | — | An editor of your own, bound to `cell.draft` with `cell.commitEdit()`/`cell.cancelEdit()` |
2266
+ | `exportValue` | `(row: TRow) => value` | the field | What this column writes to an exported file — an export runs no React, so a `Cell` renderer needs it |
2267
+ | `exportFormat` | `string` | — | The number format its cells wear in a workbook, `'#,##0.00'` or `'yyyy-mm-dd'` |
2268
+
2269
+ ### Aggregation
2270
+
2271
+ `aggregate` on a column totals it over the rows under each group row, and over the whole grid when
2272
+ `def.footer` is on. `AggregateName` is `'sum' | 'avg' | 'min' | 'max' | 'count'`; anything else is a
2273
+ function `(values: unknown[], rows: TRow[]) => number | string | null`, handed this column's value from
2274
+ every row in scope beside the rows themselves.
2275
+
2276
+ - **The four numeric ones skip everything that is not a number**, so a blank cell is not a zero — and a
2277
+ column holding no number at all answers **`null` rather than `0`**, because a total of nothing is not
2278
+ zero. `count` counts **rows**, which is why it is the one that never skips anything.
2279
+ - **`avg` rounds to two decimals.** Any other precision is an `AggregateCell`; the value itself is what
2280
+ the aggregation returned, and formatting is the renderer's.
2281
+ - **Every total covers the rows the filters left.** Under server-side pagination that is the page rather
2282
+ than the table, since those are the only rows the grid has.
2283
+ - **A group row's label cell stops spanning at the first aggregated column**, so each figure sits under
2284
+ its own heading. With nothing aggregating, it spans every data column beside it as it always did.
2285
+ - The footer is a row of the grid: `aria-rowcount` counts it, it is numbered last, and Ctrl+End lands in
2286
+ it. Style nodes: `datagrid.footer`, `datagrid.footer.cell`, `datagrid.footer.label`, and
2287
+ `datagrid.body.groupRow.aggregate` for the same value on a group row.
2288
+
2289
+ ```tsx
2290
+ <DataGrid
2291
+ data={people}
2292
+ def={{
2293
+ footer: true, // or { label: 'All teams' }
2294
+ columns: [
2295
+ { key: 'country' },
2296
+ { key: 'username', header: 'People', aggregate: 'count' },
2297
+ { key: 'age', header: 'Avg age', aggregate: 'avg' },
2298
+ { key: 'salary', header: 'Payroll', aggregate: 'sum', AggregateCell: ({ cell }) => <Box px={3}>{format(cell.value)}</Box> },
2299
+ ],
2300
+ }}
2301
+ />
2302
+ ```
2303
+
2304
+ ### Export to Excel and CSV
2305
+
2306
+ **Never install a spreadsheet library for this.** The grid writes `.xlsx` itself — an `.xlsx` is a ZIP of
2307
+ XML parts, and both writers sit behind a dynamic import, so they cost the grid's bundle nothing until
2308
+ somebody presses the button and there is nothing to add to `package.json`.
2309
+
2310
+ Two ways in. `def.export` puts the buttons in the top bar (which needs `topBar: true`):
2311
+
2312
+ ```tsx
2313
+ <DataGrid data={rows} def={{ topBar: true, export: true }} />
2314
+ // or: export: { csv: false, fileName: 'payroll' }
2315
+ ```
2316
+
2317
+ A `ref` on the grid is the same two calls, for a toolbar of your own:
2318
+
2319
+ ```tsx
2320
+ const grid = useRef<DataGridHandle>(null);
2321
+
2322
+ <DataGrid ref={grid} data={rows} def={{ columns }} />
2323
+ <Button onClick={() => grid.current?.exportXlsx({ sheetName: 'Q1' })}>Download</Button>
2324
+ ```
2325
+
2326
+ Both take `fileName`, `columns` (keys, in the order given), `groups` and `footer`; `exportXlsx` also takes
2327
+ `sheetName`, `headerFill` and `headerColor` (as `RRGGBB`), and `exportCsv` takes `delimiter` and
2328
+ `escapeFormulas`. Both return a promise that resolves once the file has been handed to the browser.
2329
+
2330
+ - **The file is what the grid is showing**: the visible columns in their pinned order, the rows the filters
2331
+ and the sort left, the group rows and their totals. The one addition is a column hidden _because the grid
2332
+ is grouped by it_ — its values moved to the group rows, and a file with no Country column in it is not
2333
+ the grid.
2334
+ - **An export runs no React**, so a column drawn by a `Cell` renderer exports the raw field. The column's
2335
+ own `exportValue: (row) => …` is what it writes instead, and `exportFormat: '#,##0.00'` is the number
2336
+ format the workbook's cells wear.
2337
+ - **Values keep their type.** A number is a number, a boolean is a boolean, and a `Date` is a real date cell
2338
+ (given `yyyy-mm-dd` automatically when the column declares no format of its own).
2339
+ - **Grouping is Excel's own outline**, not indentation: a collapsed group in the grid opens collapsed in
2340
+ Excel, and every row is in the file either way. The workbook also gets a bold header on a frozen row,
2341
+ column widths from the grid's own, and an auto-filter when nothing is grouped.
2342
+ - **A CSV starts with a byte-order mark**, without which Excel reads UTF-8 as the local code page, and a
2343
+ value beginning `=`, `+`, `-` or `@` is quoted into text — a downloaded file must not run a formula
2344
+ somebody typed into a cell. `escapeFormulas: false` turns that off.
2345
+ - Style nodes: `datagrid.topBar.export` and `datagrid.topBar.export.button`.
2346
+
2347
+ ### RowDetailConfig
2348
+
2349
+ | Prop | Type | Default | Description |
2350
+ | -------------------- | ------------------------------------- | -------- | ---------------------------------------------- |
2351
+ | `content` | `(row: TRow) => ReactNode` | required | Render function for the detail panel |
2352
+ | `height` | `'auto' \| number \| (row) => number` | `'auto'` | Detail row height |
2353
+ | `expandOnRowClick` | `boolean` | `false` | Click anywhere on the row to toggle expansion |
2354
+ | `pinned` | `boolean` | `false` | Pin the expand column to the reading start |
2355
+ | `expandColumnWidth` | `number` | `50` | Width of the expand column in px |
2356
+ | `expandColumnHeader` | `string` | `''` | Header text for the expand column |
2357
+ | `scrollIntoView` | `boolean` | `true` | Scroll a panel opened below the fold into view |
2358
+
2359
+ ### TreeDataConfig
2360
+
2361
+ Rows that hold rows. The shape comes off the data — nested in it, or named by a path on every row — and
2362
+ the grid becomes a `role="treegrid"`: one column grows a chevron and an indent, and every row carries
2363
+ `aria-level`, `aria-posinset`, `aria-setsize` and, where there is something under it, `aria-expanded`.
2364
+ On that column **Right** opens a shut row, **Left** shuts an open one and steps out to the parent of a
2365
+ row that is already shut (they swap in a right-to-left grid).
2366
+
2367
+ | Prop | Type | Default | Description |
2368
+ | ----------------- | ------------------------------------ | --------- | ------------------------------------------------------------------------- |
2369
+ | `childrenKey` | `keyof TRow` | — | Nested data: the field a row's children live in |
2370
+ | `getChildren` | `(row: TRow) => TRow[] \| undefined` | — | Nested data, worked out rather than stored |
2371
+ | `pathKey` | `keyof TRow` | — | Flat data: the field holding the path to this row, its own segment last |
2372
+ | `getPath` | `(row: TRow) => Key[]` | — | Flat data, worked out |
2373
+ | `column` | `Key` | first own | Which column carries the chevrons and the indent |
2374
+ | `indent` | `number` | `4` | How far one level indents, on the ÷4 spacing scale |
2375
+ | `defaultExpanded` | `boolean \| number \| Key[]` | none open | `true` for every row, a number for a depth, or the keys to open |
2376
+ | `selection` | `'self' \| 'cascade'` | `'self'` | `cascade` ticks the whole subtree, and half of one reads as indeterminate |
2377
+ | `hasChildren` | `keyof TRow \| (row) => boolean` | — | **Lazy only**: whether the row holds rows the server can be asked for |
2378
+
2379
+ ```tsx
2380
+ <DataGrid
2381
+ data={files}
2382
+ def={{
2383
+ rowKey: 'id',
2384
+ treeData: { childrenKey: 'children', defaultExpanded: 1, selection: 'cascade' },
2385
+ columns: [
2386
+ { key: 'name', header: 'Name' },
2387
+ { key: 'size', header: 'Size', align: 'end' },
2388
+ ],
2389
+ }}
2390
+ // Controlled instead: `expandedTreeKeys` is the open rows, and this reports them.
2391
+ onExpandedTreeKeysChange={(keys, { reason }) => setOpen(keys)}
2392
+ />
2393
+ ```
2394
+
2395
+ A filter over a tree keeps **a match and every ancestor of one**, so the way to a match stays visible; a
2396
+ sort happens inside each parent. Nothing under a shut row is built at all. _Group By_ is not offered on a
2397
+ tree: the rows are already in a shape.
2398
+
2399
+ **Beside `def.dataSource` the tree is the server's** — one level per open row, and the browser holds none
2400
+ of it. Each request carries `treeKeys` (the row whose children are wanted, named by its `rowKey` and its
2401
+ ancestors', `[]` for the top), and `hasChildren` is how a row says there is a level to ask for — without
2402
+ one no row grows a chevron. `childrenKey`/`getChildren`/`pathKey`/`getPath` are not read, `selection: 'cascade'`
2403
+ is not offered (a tick cannot reach rows nobody has fetched), and `defaultExpanded` costs one request per
2404
+ row it opens, so name the keys rather than passing `true`.
2405
+
2406
+ ```tsx
2407
+ <DataGrid
2408
+ def={{
2409
+ rowKey: 'id',
2410
+ dataSource: { getRows: async ({ startRow, endRow, treeKeys, signal }) => fetchLevel(treeKeys, startRow, endRow, signal) },
2411
+ treeData: { hasChildren: 'folder', column: 'name' },
2412
+ columns: [
2413
+ { key: 'name', header: 'Name' },
2414
+ { key: 'size', header: 'Size', align: 'end' },
2415
+ ],
2416
+ }}
2417
+ />
2418
+ ```
2419
+
2420
+ ### Cell editing
2421
+
2422
+ `column.editable` is the opt-in and `def.onCellEdit` is the one function that both **judges** an edit and
2423
+ is **told** about it: answer nothing (or `true`) to accept the value, a string to refuse it with that
2424
+ message, `false` to refuse it with the grid's own. It may return a promise, so a check against a server is
2425
+ the same function — and an answer to an edit the user has since abandoned is dropped.
2426
+
2427
+ ```tsx
2428
+ <DataGrid
2429
+ data={people}
2430
+ def={{
2431
+ rowKey: 'id',
2432
+ onCellEdit: ({ columnKey, value, oldValue, row, rowKey }) => {
2433
+ if (columnKey === 'name' && !String(value).trim()) return 'A name is required';
2434
+ },
2435
+ columns: [
2436
+ { key: 'name', header: 'Name', editable: true },
2437
+ { key: 'salary', header: 'Salary', editable: true, align: 'end' }, // a number, so a number field
2438
+ { key: 'active', header: 'Active', editable: (row) => row.salary > 0 }, // a boolean, so a checkbox
2439
+ { key: 'country', editable: true, editor: { type: 'select', options: [{ value: 'Japan' }, { value: 'Peru' }] } },
2440
+ ],
2441
+ }}
2442
+ onCellEditsChange={(edits) => setEdits(edits)}
2443
+ />
2444
+ ```
2445
+
2446
+ - **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean
2447
+ `checkbox`, everything else `text` — unless `column.editor` names one (`text`/`number`/`checkbox`/
2448
+ `select`) or configures it (`{ type, options?, placeholder?, step?, min?, max? }`; `options` may be a
2449
+ function of the row). `column.EditCell` is a control of your own, bound to `cell.draft` with
2450
+ `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` — the same three calls the built-in four
2451
+ make.
2452
+ - **Pointer:** a press makes a cell the current one — it takes the ring, whether the pointer or the
2453
+ keyboard put it there — and a **double press opens the editor** on an editable cell. A cell that cannot
2454
+ be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a
2455
+ tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor.
2456
+ - **Keys (APG's editable cell):** Enter or F2 opens the editor, any printable character opens it on that
2457
+ character (replacing the value), Escape throws the draft away, Enter commits and hands the keyboard back
2458
+ to the cell, and **Tab commits and opens the next editable cell** — along the row and on into the rows
2459
+ after it. A press somewhere else commits; `checkbox` and `select` commit on the change instead.
2460
+ - **A refused value keeps the editor open**, with the message in a `role="alert"` in the top layer (a
2461
+ bubble inside the scroller would be clipped on the last row) and `aria-invalid` + `aria-describedby` on
2462
+ the field. Typing clears the message.
2463
+ - **The grid does not own `data`, so an accepted value is kept as an edit over the rows it was given**, and
2464
+ is what every cell, every `Cell` renderer and every export reads. That is what lets a `def.dataSource`
2465
+ grid be edited at all. `onCellEditsChange(edits, { reason })` reports the whole list, oldest first —
2466
+ the stream an undo is built from — and `ref.clearEdits()` hands them back once your own data holds them.
2467
+ A datasource `refresh()` drops them too.
2468
+ - **An edit does not re-sort or re-filter the grid.** Sorting and filtering read the row's own values; only
2469
+ what is _displayed_ reads the edit, so a row never jumps out from under the person typing into it.
2470
+ - Style nodes: `datagrid.body.cell` gains `isEditing` and `isInvalid` variants — one ring, red when the
2471
+ value was refused — and `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error` are
2472
+ the editor and its message.
2473
+
2474
+ ### ContextMenuConfig
2475
+
2476
+ | Prop | Type | Default | Description |
2477
+ | ------- | --------- | ------- | ------------------------------------------------------------------------- |
2478
+ | `sort` | `boolean` | `true` | Show Sort Ascending / Sort Descending / Clear Sort |
2479
+ | `pin` | `boolean` | `true` | Show the two pin actions and Unpin, each labelled by the side it lands on |
2480
+ | `group` | `boolean` | `true` | Show Group By / Un-Group All |
2481
+
2482
+ ### Server-Side Pagination
2483
+
2484
+ ```tsx
2485
+ <DataGrid
2486
+ data={pageData} page={page} loading={loading}
2487
+ onServerStateChange={(state, { reason }) => {
2488
+ // state = { page, pageSize, sortColumn, sortDirection, columnFilters, globalFilterValue }
2489
+ // reason = what moved: 'page' | 'page-size' | 'sort' | 'filter' | 'clear'
2490
+ setPage(state.page); refetch(state);
2491
+ }}
2492
+ def={{ columns: [...], bottomBar: true, pagination: { totalCount, pageSize: 25 }, globalFilter: true }}
2493
+ />
2494
+ ```
2495
+
2496
+ ### Style Customization
2497
+
2498
+ All sub-components are customizable via `Box.components()`:
2499
+
2500
+ ```tsx
2501
+ Box.components({
2502
+ datagrid: { children: { header: { children: { cell: { styles: { textTransform: 'uppercase' } } } } } },
2503
+ subgrid: {
2504
+ extends: 'datagrid', // MUST use extends — inherits pinning, sticky, hover groups, filters, etc.
2505
+ styles: { b: 0, shadow: 'none' },
2506
+ children: { body: { children: { cell: { styles: { fontSize: 13 } } } } },
2507
+ },
2508
+ });
2509
+ <DataGrid component="subgrid" data={data} def={def} /> {/* children resolve under subgrid.* */}
2510
+ ```
2511
+
2512
+ ### Accessibility (A7)
2513
+
2514
+ DataGrid is the APG **grid** pattern, over a virtualized body, and it supplies the whole thing — do
2515
+ not add roles or `tabIndex` by hand.
2516
+
2517
+ - Roles: the scrolling element is `role="grid"` (not the root, which also holds the top and bottom
2518
+ bars); the header and the body are `rowgroup`s; rows are `role="row"` and cells `role="gridcell"`
2519
+ / `columnheader`. The scroll spacers virtualization needs are `role="presentation"`.
2520
+ - Numbers that survive virtualization: `aria-rowcount` / `aria-colcount` describe the **whole**
2521
+ grid, and `aria-rowindex` / `aria-colindex` place each rendered row and cell inside it — header
2522
+ rows are row 1 onwards, so a body row's number counts them.
2523
+ - `aria-sort` on every sortable header (`none` until it is the sorted one), `aria-selected` on rows
2524
+ **only** when `rowSelection` is on, `aria-multiselectable` on the grid with it, `aria-busy` while
2525
+ `loading`, and `aria-expanded` on a group row and on a row with an open detail panel.
2526
+ - Keyboard: one cell is in the tab order at a time. Arrows move it, Home/End go to the row's ends,
2527
+ Ctrl+Home/End to the grid's corners (scrolling to a row that has not been rendered), PageUp/Down
2528
+ move a screenful. Enter/Space sorts on a sortable header and otherwise steps into the cell's own
2529
+ control — or, on an editable cell, opens its editor; F2 steps in even on a header; Escape hands the
2530
+ keyboard back to the cell, and from an editor throws the draft away as well. Down/Up keep
2531
+ the **column**, not the cell ordinal, so they land under where they started even through a
2532
+ grouped header or a group row whose cells cover several columns each.
2533
+ - The column resizer is APG's window splitter: `role="separator"` with `aria-valuenow`/`-valuemin`/
2534
+ `-valuemax` in pixels and `aria-controls` on its header cell. Tab or F2 reaches it, the arrows
2535
+ move it 16px, Home/End take it to the narrowest the grid allows and to the grid's own width.
2536
+ - Every control the grid draws for itself is named after what it acts on: "Select row 4", "Filter
2537
+ Country", "Column options for Age", "Columns". The column menu is the APG menu button —
2538
+ `aria-haspopup="menu"`, `role="menuitem"` rows, arrows and typeahead, Escape returns focus.
2539
+ - Selection is announced through a live region ("3 of 200 rows selected"), because ticking a
2540
+ checkbox halfway down a grid changes nothing a screen reader would otherwise read out.
2541
+
2542
+ **Give it a title.** A grid is not named by the rows in it: `def.title` is rendered in the top bar
2543
+ and pointed at by `aria-labelledby`.
2544
+
2545
+ **Known gap:** Tab does not yet stay inside the grid — controls inside cells are still their own tab
2546
+ stops, as they were before A7.
2547
+
2548
+ ### Component Style Tree
2549
+
2550
+ | Component Name | Description | Variants |
2551
+ | --------------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2552
+ | `datagrid` | Root container | — |
2553
+ | `datagrid.content` | Scroll container for header + body | — |
2554
+ | `datagrid.topBar` | Top bar (title, filters, column groups) | — |
2555
+ | `datagrid.topBar.globalFilter` | Global search wrapper | — |
2556
+ | `datagrid.topBar.globalFilter.stats` | Filtered rows count badge | — |
2557
+ | `datagrid.topBar.columnGroups` | Column group chips container | — |
2558
+ | `datagrid.topBar.columnGroups.icon` | Column group icon | — |
2559
+ | `datagrid.topBar.columnGroups.separator` | Separator between groups | — |
2560
+ | `datagrid.topBar.columnGroups.item` | Column group chip | — |
2561
+ | `datagrid.topBar.columnGroups.item.icon` | Remove icon on chip | — |
2562
+ | `datagrid.topBar.columnVisibility` | Column visibility dropdown | — |
2563
+ | `datagrid.topBar.columnVisibility.badge` | Hidden columns count badge | — |
2564
+ | `datagrid.filter.cell` | Filter row cell | `isPinned`, `isFirstLeftPinned`, `isLastLeftPinned`, `isFirstRightPinned`, `isLastRightPinned` |
2565
+ | `datagrid.filter.cell.input` | Filter input container (text/number/multiselect) | — |
2566
+ | `datagrid.header` | Header grid container (sticky) | — |
2567
+ | `datagrid.header.cell` | Header cell | `isPinned`, `isFirstLeftPinned`, `isLastLeftPinned`, `isFirstRightPinned`, `isLastRightPinned`, `isSortable`, `isRowSelection`, `isRowNumber`, `isFirstLeaf`, `isLastLeaf`, `isEmptyCell` |
2568
+ | `datagrid.header.cell.contextMenu` | Column context menu button | — |
2569
+ | `datagrid.header.cell.contextMenu.icon` | Context menu icon | — |
2570
+ | `datagrid.header.cell.contextMenu.tooltip` | Context menu popup | — |
2571
+ | `datagrid.header.cell.contextMenu.tooltip.item` | Context menu action | — |
2572
+ | `datagrid.header.cell.contextMenu.tooltip.item.icon` | Action icon | — |
2573
+ | `datagrid.header.cell.contextMenu.tooltip.item.separator` | Menu separator line | — |
2574
+ | `datagrid.header.cell.resizer` | Column resize handle | — |
2575
+ | `datagrid.body` | Body grid container (virtualized rows) | — |
2576
+ | `datagrid.body.cell` | Body cell | `isPinned`, `isFirstLeftPinned`, `isLastLeftPinned`, `isFirstRightPinned`, `isLastRightPinned`, `isRowNumber`, `isRowSelection`, `isRowSelected`, `isFirstLeaf`, `isLastLeaf`, `isEmptyCell`, `isEditing`, `isInvalid` |
2577
+ | `datagrid.body.cell.text` | Default cell text renderer | — |
2578
+ | `datagrid.body.cell.editor` | The open cell editor | `isPending` |
2579
+ | `datagrid.body.cell.error` | What a refused value says, in the top layer | — |
2580
+ | `datagrid.body.cell.rowDetail` | Row detail expand/collapse button | — |
2581
+ | `datagrid.body.cell.tree` | Tree cell: the indent, the chevron and the value | — |
2582
+ | `datagrid.body.cell.tree.toggle` | Tree expand/collapse chevron | `isExpanded` |
2583
+ | `datagrid.body.cell.tree.spacer` | What a leaf puts where its chevron would be | — |
2584
+ | `datagrid.body.row` | Data row (display: contents) | — |
2585
+ | `datagrid.body.groupRow` | Group row (display: contents) | — |
2586
+ | `datagrid.body.groupRow.expandButton` | Group expand/collapse button | — |
2587
+ | `datagrid.body.detailRow` | Expanded detail row | — |
2588
+ | `datagrid.body.empty` | Empty state container | — |
2589
+ | `datagrid.emptyColumns` | No columns selected placeholder | — |
2590
+ | `datagrid.bottomBar` | Bottom bar (row count, pagination) | — |
2591
+ | `datagrid.bottomBar.info` | Status text ("Rows: ...", "Selected: ...") | — |
2592
+ | `datagrid.bottomBar.clearFilters` | "Clear filters" link | — |
2593
+ | `datagrid.bottomBar.pagination` | Pagination controls wrapper | — |
2594
+ | `datagrid.bottomBar.pagination.button` | Pagination nav button | — |
2595
+ | `datagrid.bottomBar.pagination.info` | Page info text ("1 of 5") | — |
2596
+
2597
+ ---
2598
+
2599
+ ## Dropdown Component
2600
+
2601
+ ```tsx
2602
+ import Dropdown from '@box-kite/react/components/dropdown';
2603
+ ```
2604
+
2605
+ ### Usage
2606
+
2607
+ ```tsx
2608
+ // Single selection (uncontrolled). `label` is not decoration — see Accessibility below.
2609
+ <Dropdown<string> label="Fruit" defaultValue="apple" onValueChange={(fruit, { reason }) => console.log(fruit, reason)}>
2610
+ <Dropdown.Unselect>Pick a fruit...</Dropdown.Unselect>
2611
+ <Dropdown.Item value="apple">Apple</Dropdown.Item>
2612
+ <Dropdown.Item value="banana">Banana</Dropdown.Item>
2613
+ </Dropdown>
2614
+
2615
+ // Controlled
2616
+ <Dropdown<string> label="Fruit" value={fruit} onValueChange={(value) => setFruit(value as string)}>
2617
+ <Dropdown.Item value="apple">Apple</Dropdown.Item>
2618
+ <Dropdown.Item value="banana">Banana</Dropdown.Item>
2619
+ </Dropdown>
2620
+
2621
+ // Multiple + search + checkboxes
2622
+ <Dropdown<string> label="Fruit" multiple showCheckbox isSearchable searchPlaceholder="Search...">
2623
+ <Dropdown.SelectAll>Select all</Dropdown.SelectAll>
2624
+ <Dropdown.EmptyItem>No results</Dropdown.EmptyItem>
2625
+ <Dropdown.Display>{(values) => values.length === 0 ? 'Pick...' : `${values.length} selected`}</Dropdown.Display>
2626
+ <Dropdown.Item value="apple">Apple</Dropdown.Item>
2627
+ <Dropdown.Item value="banana">Banana</Dropdown.Item>
2628
+ </Dropdown>
2629
+
2630
+ // Form integration: name prop renders hidden inputs with JSON-stringified values
2631
+ <Dropdown<string> name="fruits" multiple defaultValue={['apple']}>...</Dropdown>
2632
+ ```
2633
+
2634
+ ### Props
2635
+
2636
+ | Prop | Type | Description |
2637
+ | ------------------------ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
2638
+ | `value` / `defaultValue` | `TVal \| TVal[]` | Controlled / uncontrolled selected value(s) |
2639
+ | `label` | `ReactNode` | The control's name, rendered above it and wired with `aria-labelledby` |
2640
+ | `labelProps` | `BoxProps<'div'>` | Styles for the wrapper the label and the trigger share |
2641
+ | `multiple` | `boolean` | Multi-select mode |
2642
+ | `isSearchable` | `boolean` | Editable combobox: a text field that filters the list (see Accessibility) |
2643
+ | `searchPlaceholder` | `string` | Search input placeholder |
2644
+ | `hideIcon` | `boolean` | Hide chevron icon |
2645
+ | `showCheckbox` | `boolean` | Show checkboxes in multiple mode |
2646
+ | `name` | `string` | Form field name (renders hidden `<input>` elements) |
2647
+ | `onValueChange` | `ChangeHandler<TVal \| TVal[] \| undefined, …>` | The selection — an array in `multiple` mode — and why: `select`, `deselect`, `select-all`, `clear` |
2648
+ | `onChange` | `(value: TVal \| undefined, values: TVal[]) => void` | **Deprecated**, still firing. The option acted on, and the selection beside it |
2649
+ | `itemsProps` | `BoxStyleProps` | Style overrides for the opened items container (`dropdown.items`) |
2650
+ | `iconProps` | `BoxStyleProps` | Style overrides for the chevron icon container (`dropdown.icon`) |
2651
+ | `variant` | `ClassNameType` | Propagates to root **and all child sub-components** |
2652
+
2653
+ Also accepts all `BoxProps` (styling props), which apply to the root element — a `<button>`, or the wrapper around the text field when `isSearchable`. Anything in `props` goes to the combobox itself: the button, or that field.
2654
+
2655
+ ### Sub-Components
2656
+
2657
+ All sub-components accept BoxProps for per-instance style overrides.
2658
+
2659
+ | Sub-Component | Purpose |
2660
+ | --------------------- | ---------------------------------------------------------------------------------- |
2661
+ | `Dropdown.Item<TVal>` | Selectable option. Requires `value` prop |
2662
+ | `Dropdown.Unselect` | Clear selection option (shown when items selected) |
2663
+ | `Dropdown.SelectAll` | Select all (shown in `multiple` when not all selected) |
2664
+ | `Dropdown.EmptyItem` | Shown when search yields no results |
2665
+ | `Dropdown.Display` | Custom display: static content or `(values: TVal[], isOpen: boolean) => ReactNode` |
2666
+
2667
+ ### Accessibility
2668
+
2669
+ Dropdown is the APG **select-only combobox**, and it supplies the whole pattern — do not add roles by
2670
+ hand (`props={{ role: 'combobox' }}` on the trigger and `role="option"` on items used to be the advice;
2671
+ delete it). What you get:
2672
+
2673
+ - Trigger: `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-activedescendant`. **DOM focus
2674
+ never leaves it** — the popup is navigated by naming the highlighted option, not by focusing it.
2675
+ - Popup: `role="listbox"` (`aria-multiselectable` in `multiple` mode), rows `role="option"` with
2676
+ `aria-selected`. `Dropdown.Unselect` and `Dropdown.SelectAll` are options too, so the arrows reach them.
2677
+ - Keyboard, closed: Down/Up/Enter/Space open (on the selection, else at an end), Home/End open at an end,
2678
+ a printable character opens at the first match. Open: arrows move, Home/End jump, typing searches,
2679
+ Enter/Space choose, Escape closes unchanged, Tab chooses then leaves, Alt+Up chooses and closes.
2680
+ - `disabled` on a `Dropdown.Item` gets `aria-disabled`, is skipped by the arrows and ignores clicks.
2681
+ - `showCheckbox` draws the boxes as decoration (`aria-hidden`): `aria-selected` on the option is the state,
2682
+ and a focusable input inside an option would be one widget nested in another.
2683
+
2684
+ **You still owe it a name.** A combobox is not named by its contents — the trigger's text is its _value_ —
2685
+ so pass `label`, or your own `aria-label` / `aria-labelledby` through `props`. With neither, the control
2686
+ has no accessible name at all.
2687
+
2688
+ ```tsx
2689
+ <Dropdown<string> label="Fruit">…</Dropdown>
2690
+ <Dropdown<string> props={{ 'aria-label': 'Fruit' }}>…</Dropdown>
2691
+ ```
2692
+
2693
+ `isSearchable` switches patterns rather than decorating this one: the **editable combobox**. The search
2694
+ `<input>` _is_ the combobox — `role="combobox"`, `aria-autocomplete="list"`, and whatever you pass in
2695
+ `props` all land on it, so nothing focusable sits inside anything else focusable. The field shows the
2696
+ selection as its value and the query while one is being typed; a custom `Dropdown.Display` keeps drawing
2697
+ the selection instead, since arbitrary JSX cannot be a field's value. Its keyboard map differs: the
2698
+ printable keys type (no typeahead — the visible field owns them), Space types a space, Home/End and the
2699
+ left/right arrows move the caret and hand the highlight back to the field, only Down/Up reach the listbox,
2700
+ Enter chooses the highlighted option, and Escape closes the listbox before a second Escape clears the
2701
+ field. Filtering never moves the highlight onto a suggestion — that is the user's to do with an arrow.
2702
+
2703
+ ### Style Customization
2704
+
2705
+ **Per-instance** — use `itemsProps`, `iconProps`, or BoxProps directly on sub-components:
2706
+
2707
+ ```tsx
2708
+ <Dropdown<string> itemsProps={{ width: 80, maxHeight: 50 }} iconProps={{ color: 'gray-400' }}>
2709
+ <Dropdown.Item value="a" bgColor="blue-50" fontWeight={600}>
2710
+ Highlighted
2711
+ </Dropdown.Item>
2712
+ </Dropdown>
2713
+ ```
2714
+
2715
+ **Global** — override defaults via `Box.components()` (deep-merged):
2716
+
2717
+ ```tsx
2718
+ Box.components({
2719
+ dropdown: {
2720
+ styles: { borderRadius: 4, bgColor: 'gray-50' },
2721
+ children: {
2722
+ items: { styles: { shadow: 'large', borderRadius: 4 } },
2723
+ item: { styles: { borderRadius: 2, hover: { bgColor: 'blue-50' } } },
2724
+ },
2725
+ },
2726
+ });
2727
+ ```
2728
+
2729
+ **Custom variants** — `variant` propagates to all children. Define matching variants on each child:
2730
+
2731
+ ```tsx
2732
+ Box.components({
2733
+ dropdown: {
2734
+ variants: { dense: { p: 1, fontSize: 12 } },
2735
+ children: {
2736
+ items: { variants: { dense: { maxHeight: 40, gap: 0 } } },
2737
+ item: { variants: { dense: { p: 1, lineHeight: 16 } } },
2738
+ unselect: { variants: { dense: { p: 1 } } },
2739
+ selectAll: { variants: { dense: { p: 1 } } },
2740
+ emptyItem: { variants: { dense: { p: 1 } } },
2741
+ },
2742
+ },
2743
+ });
2744
+ <Dropdown variant="dense">...</Dropdown>; // applies to root + all children
2745
+ ```
2746
+
2747
+ ### Component Style Tree
2748
+
2749
+ | Component Name | Description | Built-in Variants |
2750
+ | -------------------- | ---------------------------------- | --------------------- |
2751
+ | `dropdown` | Root button trigger | `compact` |
2752
+ | `dropdown.items` | Opened items container (top layer) | — |
2753
+ | `dropdown.item` | Selectable item | `compact`, `multiple` |
2754
+ | `dropdown.unselect` | Clear selection option | `compact` |
2755
+ | `dropdown.selectAll` | Select all option | `compact` |
2756
+ | `dropdown.emptyItem` | No results placeholder | `compact` |
2757
+ | `dropdown.icon` | Chevron arrow container | — |
2758
+
2759
+ ---
2760
+
2761
+ ## Select Component
2762
+
2763
+ Data-driven dropdown — pass `data` + `def` instead of composing children. Wraps Dropdown internally, shares the same `dropdown.*` style tree.
2764
+
2765
+ ```tsx
2766
+ import Select from '@box-kite/react/components/select';
2767
+
2768
+ // Basic — `label` names the combobox, same as on Dropdown
2769
+ <Select<User, number> label="User" data={users} def={{ valueKey: 'id', displayKey: 'name', placeholder: 'Pick...' }}
2770
+ value={selected} onValueChange={(value) => setSelected(value as number)} />
2771
+
2772
+ // Multiple + search + custom display
2773
+ <Select<User, number> label="Users" data={users} multiple showCheckbox isSearchable searchPlaceholder="Search..."
2774
+ def={{
2775
+ valueKey: 'id', displayKey: 'name', placeholder: 'Pick users...',
2776
+ selectAllText: 'Select all', emptyText: 'No results',
2777
+ display: (user) => `${user.name} — ${user.role}`,
2778
+ selectedDisplay: (rows) => `${rows.length} selected`,
2779
+ }} />
2780
+ ```
2781
+
2782
+ ### SelectDef
2783
+
2784
+ | Prop | Type | Description |
2785
+ | ----------------- | ---------------------------------------------- | ------------------------------------------------------ |
2786
+ | `valueKey` | `keyof TRow` | Required — field used as option value |
2787
+ | `displayKey` | `keyof TRow` | Field to display (defaults to valueKey) |
2788
+ | `display` | `(row: TRow) => ReactNode` | Custom render per item |
2789
+ | `selectedDisplay` | `(rows: TRow[], isOpen: boolean) => ReactNode` | Custom trigger display (receives resolved row objects) |
2790
+ | `placeholder` | `string` | Unselect/placeholder text |
2791
+ | `selectAllText` | `string` | Select all option text (multiple mode) |
2792
+ | `emptyText` | `string` | Empty search results text |
2793
+
2794
+ Also accepts: `data` (TRow[]), `value`/`defaultValue`, `label`/`labelProps`, `multiple`, `isSearchable`, `searchPlaceholder`, `showCheckbox`, `hideIcon`, `name`, `onValueChange` (and the deprecated `onChange`), `itemsProps`, `iconProps`, `variant`, and all BoxProps. Same styling, variants and combobox accessibility as Dropdown — including owing it a name, and including `isSearchable` switching it to the editable combobox.
2795
+
2796
+ ---
2797
+
2798
+ ## Debugging Tips
2799
+
2800
+ 1. **Inspect styles**: `<style id="box-kite-styles">` in document head
2801
+ 2. **Class names**: Elements get classes like `_b`, `_2a`, etc.
2802
+ 3. **CSS variables**: In `:root` rules
2803
+ 4. **Theme issues**: Ensure `<Box.Theme>` wraps your app
2804
+ 5. **Layer theming**: a tooltip or dropdown popup stays where it was declared and inherits the theme; only the no-Popover-API fallback uses the `#box-kite-portal` container