@box-kite/react 0.0.1 → 1.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 (188) hide show
  1. package/.claude/rules/box-kite-rules.md +43 -0
  2. package/.claude/skills/box-kite/SKILL.md +431 -0
  3. package/BOX_AI_CONTEXT.md +1833 -0
  4. package/BOX_KITE_AI_CONTEXT.md +1833 -0
  5. package/LICENSE +21 -0
  6. package/README.md +710 -7
  7. package/a11y.cjs +1 -0
  8. package/a11y.d.ts +15 -0
  9. package/a11y.mjs +3 -0
  10. package/behavior.cjs +1 -0
  11. package/behavior.mjs +295 -0
  12. package/box.cjs +1 -0
  13. package/box.d.ts +46 -0
  14. package/box.mjs +23 -0
  15. package/client.cjs +1 -0
  16. package/client.mjs +163 -0
  17. package/components/baseSvg.cjs +1 -0
  18. package/components/baseSvg.d.ts +5 -0
  19. package/components/baseSvg.mjs +21 -0
  20. package/components/button.cjs +1 -0
  21. package/components/button.d.ts +16 -0
  22. package/components/button.mjs +19 -0
  23. package/components/chart.cjs +1 -0
  24. package/components/chart.d.ts +82 -0
  25. package/components/chart.mjs +261 -0
  26. package/components/checkbox.cjs +1 -0
  27. package/components/checkbox.d.ts +28 -0
  28. package/components/checkbox.mjs +38 -0
  29. package/components/dataGrid/components/dataGridBody.d.ts +7 -0
  30. package/components/dataGrid/components/dataGridBottomBar.d.ts +6 -0
  31. package/components/dataGrid/components/dataGridCell.d.ts +15 -0
  32. package/components/dataGrid/components/dataGridCellRowDetail.d.ts +6 -0
  33. package/components/dataGrid/components/dataGridCellRowSelection.d.ts +6 -0
  34. package/components/dataGrid/components/dataGridCellText.d.ts +6 -0
  35. package/components/dataGrid/components/dataGridColumnFilter.d.ts +9 -0
  36. package/components/dataGrid/components/dataGridColumnGroups.d.ts +7 -0
  37. package/components/dataGrid/components/dataGridContent.d.ts +6 -0
  38. package/components/dataGrid/components/dataGridDetailRow.d.ts +8 -0
  39. package/components/dataGrid/components/dataGridEmptyColumns.d.ts +6 -0
  40. package/components/dataGrid/components/dataGridFilterCell.d.ts +11 -0
  41. package/components/dataGrid/components/dataGridFilterRow.d.ts +6 -0
  42. package/components/dataGrid/components/dataGridGlobalFilter.d.ts +6 -0
  43. package/components/dataGrid/components/dataGridGroupRow.d.ts +8 -0
  44. package/components/dataGrid/components/dataGridHeader.d.ts +6 -0
  45. package/components/dataGrid/components/dataGridHeaderCell.d.ts +9 -0
  46. package/components/dataGrid/components/dataGridHeaderCellContextMenu.d.ts +11 -0
  47. package/components/dataGrid/components/dataGridHeaderCellResizer.d.ts +14 -0
  48. package/components/dataGrid/components/dataGridLoader.d.ts +11 -0
  49. package/components/dataGrid/components/dataGridPagination.d.ts +6 -0
  50. package/components/dataGrid/components/dataGridRow.d.ts +8 -0
  51. package/components/dataGrid/components/dataGridTopBar.d.ts +6 -0
  52. package/components/dataGrid/components/dataGridTopBarContextMenu.d.ts +6 -0
  53. package/components/dataGrid/contracts/dataGridContract.d.ts +213 -0
  54. package/components/dataGrid/gridNavigationContext.d.ts +24 -0
  55. package/components/dataGrid/models/cellModel.d.ts +18 -0
  56. package/components/dataGrid/models/columnModel.d.ts +150 -0
  57. package/components/dataGrid/models/columnVisibilityModel.d.ts +24 -0
  58. package/components/dataGrid/models/detailRowModel.d.ts +22 -0
  59. package/components/dataGrid/models/filterModel.d.ts +22 -0
  60. package/components/dataGrid/models/gridModel.d.ts +206 -0
  61. package/components/dataGrid/models/groupRowCellModel.d.ts +26 -0
  62. package/components/dataGrid/models/groupRowModel.d.ts +48 -0
  63. package/components/dataGrid/models/headerCellModel.d.ts +83 -0
  64. package/components/dataGrid/models/paginationModel.d.ts +32 -0
  65. package/components/dataGrid/models/rowModel.d.ts +34 -0
  66. package/components/dataGrid/models/viewportModel.d.ts +44 -0
  67. package/components/dataGrid/useGrid.d.ts +8 -0
  68. package/components/dataGrid/useGridNavigation.d.ts +21 -0
  69. package/components/dataGrid.cjs +1 -0
  70. package/components/dataGrid.d.ts +2 -0
  71. package/components/dataGrid.mjs +3178 -0
  72. package/components/dropdown/dropdownContext.d.ts +44 -0
  73. package/components/dropdown/dropdownItems.d.ts +22 -0
  74. package/components/dropdown/dropdownRowRenderer.d.ts +8 -0
  75. package/components/dropdown/dropdownSearch.d.ts +21 -0
  76. package/components/dropdown/utils.d.ts +1 -0
  77. package/components/dropdown.cjs +1 -0
  78. package/components/dropdown.d.ts +47 -0
  79. package/components/dropdown.mjs +479 -0
  80. package/components/flex.cjs +1 -0
  81. package/components/flex.d.ts +6 -0
  82. package/components/flex.mjs +16 -0
  83. package/components/form.cjs +1 -0
  84. package/components/form.d.ts +10 -0
  85. package/components/form.mjs +58 -0
  86. package/components/grid.cjs +1 -0
  87. package/components/grid.d.ts +6 -0
  88. package/components/grid.mjs +16 -0
  89. package/components/icon.cjs +1 -0
  90. package/components/icon.d.ts +36 -0
  91. package/components/icon.mjs +41 -0
  92. package/components/overlay.cjs +1 -0
  93. package/components/overlay.d.ts +43 -0
  94. package/components/overlay.mjs +82 -0
  95. package/components/presence.cjs +1 -0
  96. package/components/presence.d.ts +30 -0
  97. package/components/presence.mjs +11 -0
  98. package/components/radioButton.cjs +1 -0
  99. package/components/radioButton.d.ts +24 -0
  100. package/components/radioButton.mjs +32 -0
  101. package/components/radioGroup.cjs +1 -0
  102. package/components/radioGroup.d.ts +38 -0
  103. package/components/radioGroup.mjs +79 -0
  104. package/components/select.cjs +1 -0
  105. package/components/select.d.ts +42 -0
  106. package/components/select.mjs +36 -0
  107. package/components/semantics.cjs +1 -0
  108. package/components/semantics.d.ts +34 -0
  109. package/components/semantics.mjs +17 -0
  110. package/components/svg.cjs +1 -0
  111. package/components/svg.d.ts +63 -0
  112. package/components/svg.mjs +141 -0
  113. package/components/switch.cjs +1 -0
  114. package/components/switch.d.ts +20 -0
  115. package/components/switch.mjs +25 -0
  116. package/components/textarea.cjs +1 -0
  117. package/components/textarea.d.ts +27 -0
  118. package/components/textarea.mjs +33 -0
  119. package/components/textbox.cjs +1 -0
  120. package/components/textbox.d.ts +26 -0
  121. package/components/textbox.mjs +33 -0
  122. package/components/tooltip.cjs +1 -0
  123. package/components/tooltip.d.ts +63 -0
  124. package/components/tooltip.mjs +108 -0
  125. package/components/visuallyHidden.cjs +1 -0
  126. package/components/visuallyHidden.d.ts +6 -0
  127. package/components/visuallyHidden.mjs +23 -0
  128. package/effects.cjs +1 -0
  129. package/effects.mjs +6 -0
  130. package/forms.cjs +1 -0
  131. package/forms.mjs +21 -0
  132. package/icons/dotsIcon.d.ts +2 -0
  133. package/icons/expandIcon.d.ts +2 -0
  134. package/icons/groupingIcon.d.ts +2 -0
  135. package/icons/pinIcon.d.ts +2 -0
  136. package/icons/searchIcon.d.ts +2 -0
  137. package/icons/sortIcon.d.ts +2 -0
  138. package/motion.cjs +1 -0
  139. package/motion.mjs +66 -0
  140. package/package.json +203 -7
  141. package/platform.cjs +1 -0
  142. package/platform.mjs +32 -0
  143. package/react/a11y/callbacks.d.ts +12 -0
  144. package/react/a11y/useControllableState.d.ts +25 -0
  145. package/react/a11y/useDismiss.d.ts +25 -0
  146. package/react/a11y/useFocusReturn.d.ts +18 -0
  147. package/react/a11y/useIdentifier.d.ts +17 -0
  148. package/react/a11y/useRovingFocus.d.ts +84 -0
  149. package/react/animation/usePresence.d.ts +32 -0
  150. package/react/boxClassNames.d.ts +25 -0
  151. package/react/boxProps.d.ts +49 -0
  152. package/react/boxTagProps.d.ts +7 -0
  153. package/react/clientRuntime.d.ts +11 -0
  154. package/react/effects.d.ts +10 -0
  155. package/react/forms/labelledControl.d.ts +17 -0
  156. package/react/forms/placeholderProp.d.ts +13 -0
  157. package/react/hooks/usePortalContainer.d.ts +1 -0
  158. package/react/hooks/useVirtualization.d.ts +43 -0
  159. package/react/hooks/useVisibility.d.ts +9 -0
  160. package/react/reactTypes.d.ts +10 -0
  161. package/react/resolveStyles.d.ts +18 -0
  162. package/react/styleElements.d.ts +3 -0
  163. package/react/svg/attributesInProps.d.ts +4 -0
  164. package/react/svg/svgNaming.d.ts +15 -0
  165. package/react/theme/theme.d.ts +26 -0
  166. package/react/theme/themeContext.d.ts +7 -0
  167. package/react/useStyles.d.ts +22 -0
  168. package/react-shared.cjs +1 -0
  169. package/react-shared.mjs +105 -0
  170. package/rolldown-runtime.cjs +1 -0
  171. package/rsc.cjs +1 -0
  172. package/rsc.d.ts +27 -0
  173. package/rsc.mjs +18 -0
  174. package/ssg.cjs +1 -0
  175. package/ssg.d.ts +24 -0
  176. package/ssg.mjs +25 -0
  177. package/utils/animation/animationUtils.d.ts +29 -0
  178. package/utils/array/arrayUtils.d.ts +15 -0
  179. package/utils/box/boxUtils.d.ts +5 -0
  180. package/utils/chart/chartUtils.d.ts +98 -0
  181. package/utils/dom/domUtils.d.ts +26 -0
  182. package/utils/environment/environmentUtils.d.ts +18 -0
  183. package/utils/fn/fnUtils.d.ts +4 -0
  184. package/utils/form/formUtils.d.ts +8 -0
  185. package/utils/memo.d.ts +10 -0
  186. package/utils/object/objectUtils.d.ts +13 -0
  187. package/utils/string/fuzzySearch.d.ts +27 -0
  188. package/utils/string/stringUtils.d.ts +4 -0
@@ -0,0 +1,1833 @@
1
+ # @box-kite/react - AI Assistant Context
2
+
3
+ Runtime CSS-in-JS library. `Box` component accepts 212 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
+ | a lucide/Tabler icon, styled | `<Icon>` | `components/icon` |
55
+
56
+ All imports from `@box-kite/react/components/...`. Semantics also export: `Mark`, `Figure`, `Figcaption`, `Details`, `Summary`, `Menu`, `Time`.
57
+
58
+ ---
59
+
60
+ ## Numeric Value Formatters
61
+
62
+ **#1 source of confusion.** Different props have different dividers:
63
+
64
+ | Prop Category | Divider | Example | CSS Output |
65
+ | -------------------------------------------------------------------------------------- | ------- | -------------------------- | ------------------------------ |
66
+ | Spacing (`p`, `m`, `gap`, `px`, `py`, `mx`, `my`, etc.) | 4 | `p={4}` | `padding: 1rem` (16px) |
67
+ | Font size (`fontSize`) | **16** | `fontSize={14}` | `font-size: 0.875rem` (14px) |
68
+ | Width/Height (numeric) | 4 | `width={20}` | `width: 5rem` (80px) |
69
+ | Border width (`b`, `bx`, `by`, `bt`, `br`, `bb`, `bl`, `bs`, `be`) | none | `b={1}` | `border-width: 1px` |
70
+ | Border radius (`borderRadius`, `borderRadiusTop`, …) | 4 | `borderRadius={2}` | `border-radius: 0.5rem` (8px) |
71
+ | Line height (`lineHeight`) | none | `lineHeight={24}` | `line-height: 24px` |
72
+ | SVG lengths (`strokeWidth`, `strokeDasharray`, `r`, `cx`, …) | none | `strokeWidth={2}` | `stroke-width: 2` (user units) |
73
+ | Times (`animationDuration`, `animationDelay`, `transitionDuration`, `transitionDelay`) | none | `animationDuration={1100}` | `animation-duration: 1100ms` |
74
+
75
+ ```tsx
76
+ // fontSize: divider 16 → value maps directly to px
77
+ fontSize={12} // 12px fontSize={14} // 14px fontSize={16} // 16px
78
+ fontSize={18} // 18px fontSize={24} // 24px fontSize={32} // 32px
79
+
80
+ // Spacing: divider 4 → value/4 = rem
81
+ p={1} // 4px p={2} // 8px p={3} // 12px p={4} // 16px p={6} // 24px p={8} // 32px
82
+
83
+ // Width/Height/min/max: divider 4 → value/4 = rem (NOT direct pixels)
84
+ width={20} // 5rem = 80px height={10} // 2.5rem = 40px height={20} // 5rem = 80px
85
+ maxWidth={300} // 75rem = 1200px minHeight={50} // 12.5rem = 200px
86
+ height="fit" // 100% height="fit-screen" // 100vh width="1/2" // 50%
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Prop Reference
92
+
93
+ ### Spacing
94
+
95
+ | Prop | CSS Property |
96
+ | ----------------------------------------------------------- | ------------------------------------------------------------------------------- |
97
+ | `p` / `px` / `py` / `pt` / `pr` / `pb` / `pl` / `ps` / `pe` | padding (all / inline axis / block axis / one physical side / one logical side) |
98
+ | `m` / `mx` / `my` / `mt` / `mr` / `mb` / `ml` / `ms` / `me` | margin (all / inline axis / block axis / one physical side / one logical side) |
99
+ | `gap` | gap (flexbox/grid) |
100
+
101
+ ### Logical properties and RTL
102
+
103
+ 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.
104
+
105
+ | Physical | Logical | CSS |
106
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
107
+ | `pl` / `pr` | `ps` / `pe` | `padding-inline-start` / `padding-inline-end` |
108
+ | `ml` / `mr` | `ms` / `me` | `margin-inline-start` / `margin-inline-end` |
109
+ | `bl` / `br` | `bs` / `be` | `border-inline-start-width` / `border-inline-end-width` |
110
+ | `left` / `right` | `insetStart` / `insetEnd` | `inset-inline-start` / `inset-inline-end` |
111
+ | `borderRadiusLeft` / `borderRadiusRight` | `borderRadiusStart` / `borderRadiusEnd` | the two corners on that inline side |
112
+ | `borderRadiusTopLeft` … `borderRadiusBottomRight` | `borderRadiusStartStart` / `borderRadiusStartEnd` / `borderRadiusEndEnd` / `borderRadiusEndStart` | one corner; **block axis first, inline axis second** |
113
+
114
+ `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.
115
+
116
+ ```tsx
117
+ // A notification card whose accent bar stays on the side the text starts from
118
+ <Flex props={{ dir }} ps={4} pe={3} py={3} bs={4} borderStyle="solid" borderColor="indigo-500" borderRadiusEnd={2}>
119
+ <Box flex1>Your order is on its way</Box>
120
+ <Box rtl={{ flip: 'xAxis' }}>→</Box>
121
+ </Flex>
122
+ ```
123
+
124
+ `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.
125
+
126
+ ```tsx
127
+ <Box props={{ dir: 'rtl' }} rtl={{ flip: 'xAxis' }} not={{ rtl: { ms: 2 } }} md={{ rtl: { textAlign: 'end' } }} />
128
+ ```
129
+
130
+ **The pre-built components come with it**, so one `dir` is the whole change:
131
+
132
+ - **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.
133
+ - **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.
134
+ - **A popup carries the direction out of the tree with it.** `Overlay` portals into a container that is a child of the body, where nothing of the declaration site is inherited, so it measures the direction it was declared in and writes it back on as `dir` — `Tooltip`, the `Dropdown` popup and the grid's column menu all get that.
135
+ - **What stays physical, on purpose**: `Overlay` positions its layer with a transform in page coordinates, so its box is anchored at the page's own origin either way, and the grid's loading bar sweeps the same way in both. A measured pixel has no reading order.
136
+
137
+ ### Layout
138
+
139
+ | Prop | CSS Property | Values |
140
+ | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
+ | `display` | display | `'flex'`, `'block'`, `'inline'`, `'grid'`, `'none'`, `'inline-flex'`, etc. |
142
+ | `d` | flex-direction | `'row'`, `'column'`, `'row-reverse'`, `'column-reverse'` |
143
+ | `wrap` | flex-wrap | `'wrap'`, `'nowrap'`, `'wrap-reverse'` |
144
+ | `ai` | align-items | `'center'`, `'start'`, `'end'`, `'stretch'`, `'baseline'` |
145
+ | `jc` | justify-content | `'center'`, `'start'`, `'end'`, `'between'`, `'around'`, `'evenly'` |
146
+ | `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 |
147
+ | `aspectRatio` | aspect-ratio | `'square'` (1/1), `'video'` (16/9), a ratio written compactly (`'4/3'`), a number (`aspectRatio={2}`), or `'auto'` |
148
+ | `flex` / `grow` / `shrink` | flex / flex-grow / flex-shrink | number or string |
149
+ | `container` | container(-type) | `true`, or a name — makes the element a query container (see `cq` below) |
150
+
151
+ ### Sizing
152
+
153
+ | Prop | CSS Property | Accepts |
154
+ | --------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
155
+ | `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%'`) |
156
+ | `minWidth` / `maxWidth` / `minHeight` / `maxHeight` | min/max sizing | number or string |
157
+
158
+ All sizing, spacing, and positioning props also accept percentage strings: `p="5%"`, `top="10%"`, `gap="2%"`.
159
+
160
+ ### Visual
161
+
162
+ | Prop | CSS Property | Notes |
163
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
164
+ | `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 |
165
+ | `b` / `bx` / `by` / `bt` / `br` / `bb` / `bl` / `bs` / `be` | border-width | direct px |
166
+ | `borderRadius` | border-radius | divider 4 (spacing scale) |
167
+ | `borderStyle` | border-style | `'solid'`, `'dashed'`, `'dotted'`, `'none'` |
168
+ | `fontSize` | font-size | divider 16 |
169
+ | `fontWeight` | font-weight | `400`, `500`, `600`, `700`, etc. |
170
+ | `lineHeight` | line-height | direct px |
171
+ | `textAlign` / `textDecoration` / `textTransform` / `whiteSpace` / `textOverflow` | text properties | string values |
172
+ | `overflow` | overflow | `'hidden'`, `'auto'`, `'scroll'`, `'visible'` |
173
+ | `position` | position | `'relative'`, `'absolute'`, `'fixed'`, `'sticky'` |
174
+ | `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` |
175
+ | `zIndex` | z-index | number |
176
+ | `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_ |
177
+ | `textShadow` / `textShadowColor` | text-shadow | `'xxs'`..`'lg'` or `'none'`; the colour is a separate prop, as with the box shadows |
178
+ | `bgGradient` | background-image | A gradient as a record: `{ linear: 'r', colors: ['blue-500', 'pink-500'] }`. Same property as `bgImage` — use one | |
179
+ | `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_ |
180
+ | `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 |
181
+ | `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 |
182
+ | `maskImage` | mask-image | The gradient record `bgGradient` takes, `'url(#id)'`, `'var(--name)'` or `'none'`. One mask, not a stack |
183
+ | `bgClip` | background-clip | `'border'`, `'padding'`, `'content'`, `'text'` — `text` needs `color="transparent"` beside it |
184
+ | `opacity` | opacity | number |
185
+ | `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 |
186
+ | `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 |
187
+ | `fieldSizing` | field-sizing | `'content'` — a control that grows with what is typed into it, no JavaScript measuring anything — or `'fixed'` |
188
+ | `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]` |
189
+ | `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 |
190
+ | `cursor` / `pointerEvents` / `userSelect` | misc | string values. For `transition`, `animation` and the transform props see _Animation and transitions_ below |
191
+
192
+ ### Colours, and the opacity modifier
193
+
194
+ The palette is **Tailwind 4.3's, in OKLCH**: twenty-six families of eleven steps (`50`–`950`) — the five
195
+ neutrals (`slate`, `gray`, `zinc`, `neutral`, `stone`), the four Tailwind 4.3 added (`mauve`, `mist`,
196
+ `olive`, `taupe`) and seventeen hues (`red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`,
197
+ `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`). Each token is a
198
+ CSS variable declared in `:root` the first time something uses it, so a colour you never write costs
199
+ nothing.
200
+
201
+ Any colour value takes a slash and a percentage:
202
+
203
+ ```tsx
204
+ <Box bgColor="blue-500/40" borderColor="black/10" color="currentColor/60" />
205
+ <Box theme={{ dark: { bgColor: 'sky-400/25' } }} hover={{ bgColor: 'blue-500/60' }} />
206
+ <Flex vars={{ 'color-grid': 'slate-500/20' }} />
207
+ ```
208
+
209
+ - It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`. The mix wraps the
210
+ **variable**, so a themed token is still themed and every element asking for the same value shares
211
+ one class. `oklab` because mixing towards transparency in a polar space drags the hue along.
212
+ - Not `opacity`, which fades the element, its text and its children. The modifier fades one declaration.
213
+ - Works on `color`, `bgColor`, `borderColor`, `outlineColor`, `fill`, `stroke` and on a `vars` entry —
214
+ and on a variable declared through `Box.extend()` (`bgColor="brand/30"`).
215
+ - A token the palette does not have, or a percentage outside 0–100, produces **no rule and no class
216
+ name** — the same silence every unmatched value gets.
217
+
218
+ ### Gradients, shadows and rings
219
+
220
+ A gradient is a **value**, written as a record — so its stops are palette tokens, it takes the opacity
221
+ modifier, it is themed, and two elements asking for the same one share a class.
222
+
223
+ ```tsx
224
+ // The key names the kind and carries its geometry; `colors` are the stops in order.
225
+ <Box bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }} />
226
+ <Box bgGradient={{ linear: 135, colors: ['blue-500/40', 'transparent'] }} />
227
+ <Box bgGradient={{ radial: 'circle', at: 'top left', colors: [['sky-500', '20%'], 'indigo-900'] }} />
228
+ <Box bgGradient={{ conic: 45, colors: ['red-500', 'yellow-500', 'red-500'] }} />
229
+
230
+ // Interpolation is what keeps two stops out of the grey middle sRGB runs them through.
231
+ <Box bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'], interpolate: 'oklch' }} />
232
+ ```
233
+
234
+ - `linear` takes `t`/`tr`/`r`/`br`/`b`/`bl`/`l`/`tl` or a number of degrees; `radial` takes
235
+ `'circle'`/`'ellipse'`/`true`; `conic` takes a start angle or `true`. Exactly one of the three.
236
+ - `at` centres a `radial` or `conic` gradient (not a `linear` one, which runs in a direction).
237
+ - `interpolate`: `'srgb'`, `'hsl'`, `'oklab'`, `'oklch'`, `'hsl-longer'`, `'oklch-longer'`.
238
+ - A stop is any colour value — a token, `blue-500/40`, a system colour, `var(--chart-1)` — or a
239
+ `[colour, position]` pair. **Two stops minimum**, and the record is judged whole: one bad stop, one
240
+ unknown key, two kinds at once, and the whole value emits no rule and no class name.
241
+ - It writes `background-image`, so `bgGradient` and `bgImage` are the same property.
242
+
243
+ **Four shadows stack**, because each sets its own custom property and all four write one composed
244
+ `box-shadow`. A ring and an elevation coexist instead of the last one winning:
245
+
246
+ ```tsx
247
+ <Box shadow="md" ring={2} ringColor="indigo-500" />
248
+ <Box insetShadow="sm" insetRing={1} shadow="lg" shadowColor="blue-500/40" />
249
+ <P textShadow="sm" textShadowColor="black/20" />
250
+ ```
251
+
252
+ - `shadow`: `xxs` `xs` `sm` `md` `lg` `xl` `xxl`, Tailwind's elevation scale — plus the three original
253
+ presets `small`/`medium`/`large`, which carry their own colour. `insetShadow`: `xxs`/`xs`/`sm`.
254
+ - `ring`/`insetRing` are a **width in px**, not a scale: `ring={2}`. Unlike `outline` a ring joins the
255
+ shadow stack, follows `borderRadius` and costs no layout. `currentColor` unless recoloured.
256
+ - `none` on `shadow` or `insetShadow` — and `0` on a ring — clears **just that layer**.
257
+ - A colour prop shows nothing on its own, the way `borderColor` does with no border width.
258
+ - `transition="shadow"` covers `box-shadow` and `text-shadow` both.
259
+
260
+ ### Filters, backdrop filters and masks
261
+
262
+ **Nine filter functions stack** the way the shadows do: `filter` is one property whose value is a list,
263
+ so each prop sets a layer of its own and all nine write the same composed declaration.
264
+
265
+ ```tsx
266
+ <Box blur="sm" grayscale={100} />
267
+ <Box brightness={110} saturate={150} hueRotate={90} />
268
+ <Box dropShadow="lg" dropShadowColor="indigo-500/40" />
269
+ <Box backdropBlur="sm" backdropSaturate={180} bgColor="white/20" />
270
+ ```
271
+
272
+ - `blur`, `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate`, `sepia`,
273
+ `dropShadow`. **A number is the function's own unit** — a percentage for the six that take one,
274
+ degrees for `hueRotate`, px for `blur`.
275
+ - `blur` takes Tailwind's scale too: `xs` (4px) `sm` (8px) `md` (12px) `lg` (16px) `xl` (24px)
276
+ `xxl` (40px) `xxxl` (64px).
277
+ - `dropShadow` is `xs`..`xxl` with `dropShadowColor`. It is cast by the **shape** — an SVG path, the
278
+ opaque part of a transparent PNG — where `shadow` draws a rectangle round the box.
279
+ - `none` clears **just that function**, the way it clears just one shadow layer.
280
+ - The nine `backdrop*` props are the same functions on `backdrop-filter`, which is the glassmorphism
281
+ half. `backdropOpacity` exists and `backdropDropShadow` does not — neither means anything on the
282
+ other side. The older `backdropFilter` prop writes that property directly: use one or the other.
283
+ - `transition="filter"` covers `filter` and `backdrop-filter` both.
284
+
285
+ **A mask is the gradient grammar again.** `maskImage` reads a gradient's alpha channel to decide which
286
+ of the element's pixels are painted, so a fade to `transparent` is the whole edge-fade recipe:
287
+
288
+ ```tsx
289
+ <Box maskImage={{ linear: 'b', colors: ['black', ['black', '55%'], 'transparent'] }} />
290
+ <Box maskImage="url(#frame)" />
291
+ ```
292
+
293
+ - The same record `bgGradient` takes and judged the same way, plus `url(#id)`/`var(--name)` and
294
+ `none`. One mask, not a stack.
295
+
296
+ **`bgClip="text"` is what turns a gradient into type**, and it needs `color="transparent"` beside it
297
+ or the text paints over its own background:
298
+
299
+ ```tsx
300
+ <H1 color="transparent" bgClip="text" bgGradient={{ linear: 'r', colors: ['violet-500', 'cyan-400'] }}>
301
+ Painted by the background
302
+ </H1>
303
+ ```
304
+
305
+ - `border` / `padding` / `content` / `text`.
306
+
307
+ ### Custom properties (`vars`)
308
+
309
+ The one prop whose declaration _names_ come from its value, for styling markup this library never
310
+ rendered — a chart library, a third-party widget, a subtree with its own tokens:
311
+
312
+ ```tsx
313
+ <Flex vars={{ 'color-revenue': 'sky-500', 'chart-gap': '4px', rows: 3 }} theme={{ dark: { vars: { 'color-revenue': 'sky-400' } } }}>
314
+ {/* --color-revenue, --chart-gap and --rows are inherited by everything in here */}
315
+ </Flex>
316
+ ```
317
+
318
+ - A **colour token** resolves to the variable behind it (`--color-revenue: var(--sky-500)`), and one
319
+ carrying an opacity modifier to the mix that applies it (`slate-500/20`), so it
320
+ follows the palette. Every other value is written out as it stands: a length, a number, a
321
+ `var(--x)`/`url(#x)` reference, a colour of your own. Names may carry a leading `--` or not.
322
+ - It is an ordinary prop: it nests in `theme`, `hover`, `md` and a group selector like all the others,
323
+ and it lands in a **class** — so two subtrees declaring the same variables share one rule, and
324
+ nothing needs a `<style>` tag or an `id` to scope it. It renders in a Server Component.
325
+ - A name that is not a CSS identifier, or a value containing `;` or a brace, is skipped — that entry
326
+ only, not the whole record, so one bad name cannot take a palette down with it.
327
+ - Not the same thing as `Box.extend({ variables })`, which declares tokens globally on `:root` before
328
+ the first render. `vars` is per element, per theme, per breakpoint.
329
+
330
+ ### The escape hatch (`css`)
331
+
332
+ The 5% the typed set does not cover, without an inline style: a style object compiled through the
333
+ same pipeline as every other prop — one class per object, shared between elements writing the same
334
+ one, nested wherever a prop nests, rendered on a server.
335
+
336
+ ```tsx
337
+ <Box css={{ mixBlendMode: 'multiply', scrollSnapType: 'x mandatory' }} hover={{ css: { isolation: 'isolate' } }} />
338
+ <Img css={{ objectPosition: 'top' }} md={{ css: { objectPosition: 'center' } }} />
339
+ ```
340
+
341
+ - **Property names are camelCase**, as React spells them (`WebkitLineClamp` → `-webkit-line-clamp`,
342
+ `msOverflowStyle` → `-ms-overflow-style`); a hyphenated name or a `--custom` one is written as it
343
+ stands. Names and keyword values are typed by `csstype`, so a misspelt property is a compile error.
344
+ - **Values are CSS, written out as they stand** — a number too, so a length wants its unit
345
+ (`width: '100px'`; the type refuses `width: 100`) while `zIndex`, `opacity`, `flexGrow` and
346
+ `lineHeight` take theirs. A colour token resolves the way a `vars` value does
347
+ (`outlineColor: 'sky-500'` → `var(--sky-500)`, `'rose-400/60'` → the mix), so the hatch stays themed.
348
+ - **It is sorted last**: on one element a `css` declaration wins over the typed prop naming the same
349
+ property. It is the override, and the cascade says so.
350
+ - **It nests like any prop** — `hover`, `md`, `theme`, `dataAttr`, `before`, `startingStyle`, a
351
+ `Box.keyframes()` step. What it does _not_ take is a selector or a nested rule: those are the
352
+ nesting keys' job, around it.
353
+ - A value carrying `;` or a brace, or a name that is not a property name, is dropped — that entry
354
+ only. A data URI's `;` is written `%3B`.
355
+ - **Governance**: reach for a typed prop first; for a property used more than once, `Box.extend()`
356
+ (five lines, indistinguishable from a built-in afterwards); `css` is for the one-off. An ESLint rule
357
+ that flags or forbids it per team policy is planned; until it ships, `grep -rn "css={{"` is the audit.
358
+
359
+ ### SVG
360
+
361
+ 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.)
362
+
363
+ | Prop | CSS Property | Accepts |
364
+ | ------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
365
+ | `fill` / `stroke` | fill / stroke | any colour variable, or `'none'` |
366
+ | `fillOpacity` / `strokeOpacity` | fill-opacity / stroke-opacity | `0`–`1` in tenths, same scale as `opacity` |
367
+ | `fillRule` | fill-rule | `'nonzero'`, `'evenodd'` |
368
+ | `strokeWidth` | stroke-width | number, **user units — no divider** (`strokeWidth={2}` → `2`) |
369
+ | `strokeLinecap` | stroke-linecap | `'butt'`, `'round'`, `'square'` |
370
+ | `strokeLinejoin` | stroke-linejoin | `'miter'`, `'round'`, `'bevel'` |
371
+ | `strokeMiterlimit` | stroke-miterlimit | number, 1 or greater |
372
+ | `strokeDasharray` | stroke-dasharray | number (`{12}` = dash 12, gap 12) or the pattern as a string (`"12 4"`) |
373
+ | `strokeDashoffset` | stroke-dashoffset | number, or a percentage of the path length (`'40%'`) |
374
+ | `paintOrder` | paint-order | `'normal'`, `'fill'`, `'stroke'`, `'markers'` |
375
+ | `vectorEffect` | vector-effect | `'none'`, `'non-scaling-stroke'` |
376
+ | `shapeRendering` | shape-rendering | `'auto'`, `'optimizeSpeed'`, `'crispEdges'`, `'geometricPrecision'` |
377
+ | `textAnchor` | text-anchor | `'start'`, `'middle'`, `'end'` — which part of a label sits on its `x` |
378
+ | `dominantBaseline` | dominant-baseline | `'auto'`, `'alphabetic'`, `'central'`, `'middle'`, `'hanging'`, `'text-top'`, `'text-bottom'`, `'ideographic'`, `'mathematical'` |
379
+ | `cx` / `cy` | cx / cy | number in user units, or a percentage — `<circle>`, `<ellipse>` |
380
+ | `r` | r | number in user units, or a percentage — `<circle>` |
381
+ | `rx` / `ry` | rx / ry | number, a percentage, or `'auto'` — `<ellipse>` radii, `<rect>` corners |
382
+ | `x` / `y` | x / y | number in user units, or a percentage — `<rect>`, `<image>`, `<use>`, `<foreignObject>`, nested `<svg>`. NOT `<text>` |
383
+
384
+ ```tsx
385
+ import { Circle, Path, Rect, Svg, SvgText } from '@box-kite/react/components/svg';
386
+
387
+ // Every property except vectorEffect is inherited, so set it once on the <svg>.
388
+ <Svg viewBox="0 0 200 48" width="200px" fill="none" stroke="violet-500" strokeWidth={3} strokeLinecap="round">
389
+ <Path d="M8 40 L56 12 L104 34 L152 8 L192 24" />
390
+ </Svg>
391
+
392
+ // Draw-on animation: the transition is already there (--svgTransitionTime), and
393
+ // prefers-reduced-motion zeroes it, so this is the whole thing.
394
+ <Svg fill="none" stroke="violet-500" strokeWidth={3} strokeDasharray={320} strokeDashoffset={320} hover={{ strokeDashoffset: 0 }}>
395
+ <Path d="M8 40 L56 12 L104 34 L152 8 L192 24" />
396
+ </Svg>
397
+
398
+ // Geometry is CSS, so a shape moves with no JavaScript. Text is placed by the two text props.
399
+ <Svg viewBox="0 0 96 96" width="96px" label="Three quarters" fill="none" textAnchor="middle" dominantBaseline="central">
400
+ <Circle cx={48} cy={48} r={38} stroke="indigo-600" strokeWidth={10} hover={{ r: 40 }} />
401
+ <SvgText x={48} y={48} fontSize={20} fill="slate-700">
402
+ 75%
403
+ </SvgText>
404
+ </Svg>
405
+
406
+ // A bar: y is the CSS geometry prop, width/height are the rect's own attributes.
407
+ <Rect x={16} y={32} width={24} height={64} rx={3} fill="sky-600" />
408
+ ```
409
+
410
+ **Nine things to get right:**
411
+
412
+ 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.
413
+ 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.
414
+ 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.
415
+ 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.
416
+ 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).
417
+ 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.
418
+ 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.)
419
+ 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" />`.
420
+ 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.
421
+
422
+ ---
423
+
424
+ ### Animation and transitions
425
+
426
+ Every Box already transitions `all` its properties over `--transitionTime` (0.25s), which is why a
427
+ `hover` colour fades without being asked. On top of that:
428
+
429
+ | Prop | CSS Property | Notes |
430
+ | ----------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
431
+ | `animation` | animation | One of four presets: `'spin'`, `'pulse'`, `'bounce'`, `'ping'`, or `'none'`. Keyframes come with the engine |
432
+ | `animationName` | animation-name | A sequence registered with `Box.keyframes()`, a preset name, or a name from any stylesheet |
433
+ | `animationDuration` / `animationDelay` | ms | **Milliseconds, no divider**: `animationDuration={1100}` → `1100ms` |
434
+ | `animationIterationCount` | number | A number or `'infinite'` |
435
+ | `animationDirection` / `animationFillMode` / `animationPlayState` | keywords | `'normal'`/`'reverse'`/`'alternate'`/`'alternate-reverse'`; `'none'`/`'forwards'`/`'backwards'`/`'both'`; `'running'`/`'paused'` |
436
+ | `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)'` |
437
+ | `transition` | transition-property | `'all'`, `'none'`, or a group: `'colors'`, `'opacity'`, `'shadow'`, `'transform'`, `'size'`, `'filter'` |
438
+ | `transitionDuration` / `transitionDelay` | ms | Milliseconds, same as the animation pair. Both duration props also take a spring name — that spring's settling time |
439
+ | `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 |
440
+ | `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 |
441
+ | `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 |
442
+ | `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 |
443
+
444
+ ```tsx
445
+ // A preset: no registration, and it stops under prefers-reduced-motion on its own
446
+ <Icon size={6} animation="spin"><Loader2 /></Icon>
447
+
448
+ // A sequence of your own — the steps are Box props, so the ÷4 scale and colour tokens apply
449
+ Box.keyframes({
450
+ 'slide-in': {
451
+ from: { opacity: 0, translateY: 3 }, // 3 → 0.75rem
452
+ to: { opacity: 1, translateY: 0 },
453
+ },
454
+ });
455
+
456
+ <Box animationName="slide-in" animationDuration={450} animationTimingFunction="ease-out" animationFillMode="backwards" />
457
+
458
+ // Narrow what transitions, and compose transforms on hover
459
+ <Box transition="transform" transitionDuration={300} hover={{ translateX: 2, translateY: -2, scale: 1.05 }} />
460
+
461
+ // A spring: the same name on both props, because a spring is a curve *and* a settling time
462
+ <Box transition="transform" transitionTimingFunction="spring-bouncy" transitionDuration="spring-bouncy" hover={{ scale: 1.1 }} />
463
+
464
+ // One of your own — sampled once, at module scope
465
+ const wobble = Box.spring({ stiffness: 120, damping: 8 });
466
+
467
+ <Box transition="transform" transitionTimingFunction={wobble.easing} transitionDuration={wobble.duration} hover={{ translateY: -2 }} />
468
+
469
+ // An entrance: what a just-mounted element starts from. No state, no effect, no library.
470
+ <Box p={4} startingStyle={{ opacity: 0, translateY: 2 }} transitionDuration={280} />
471
+
472
+ // Both directions, by hiding rather than unmounting: display flips at the *end* of the transition
473
+ <Box
474
+ display={open ? 'block' : 'none'}
475
+ opacity={open ? 1 : 0}
476
+ transitionBehavior="allow-discrete"
477
+ startingStyle={{ opacity: 0 }}
478
+ transitionDuration={260}
479
+ />
480
+
481
+ // Or really unmounting, once the exit has run: <Presence> holds the node until its CSS is finished
482
+ <Presence present={open}>
483
+ {({ present, ref, props }) => (
484
+ <Box
485
+ ref={ref}
486
+ props={props}
487
+ opacity={present ? 1 : 0}
488
+ translateY={present ? 0 : -2}
489
+ startingStyle={{ opacity: 0, translateY: -2 }}
490
+ transitionDuration={320}
491
+ />
492
+ )}
493
+ </Presence>
494
+
495
+ // height: auto, animated — the container opts its subtree in
496
+ <Box interpolateSize="allow-keywords">
497
+ <Box height={expanded ? 'auto' : 0} overflow="hidden" transition="size" transitionDuration={300}>
498
+ …
499
+ </Box>
500
+ </Box>
501
+ ```
502
+
503
+ - **`Box.keyframes({ name: { from, '50%', to } })`** registers sequences; stop keys are `'from'`,
504
+ `'to'` or a percentage string. Registration is free — a sequence is written into the stylesheet the
505
+ first time a rule names it, and exactly once, so an unused preset costs nothing. It reaches static
506
+ output through `getStyles()` and rides the base `<style>` element in element mode, so a Server
507
+ Component can animate with no client JavaScript.
508
+ - **A preset already respects reduced motion.** Preset durations are multiples of `--transitionTime`,
509
+ which the base stylesheet zeroes under `prefers-reduced-motion`. The moment you name a duration in
510
+ milliseconds you own that decision: say `motionReduce={{ animationName: 'none' }}` yourself.
511
+ - **`animation` is declared before the longhands**, so `animationDuration` and friends override
512
+ whatever a preset chose.
513
+ - **The transform props are CSS longhands, not one `transform` declaration.** `translateX` and
514
+ `translateY` each set a custom property and both compose into one `translate`, which still
515
+ transitions (a `var()` is substituted before the browser compares the two states) and still
516
+ animates (the base stylesheet registers both axes with `@property`, without which a keyframe
517
+ moving them would jump rather than interpolate). `rotate` and `scale` are their own properties
518
+ too. The one collision left: `flip` and `scale` both write `scale`.
519
+ - **A spring is a sampled curve, so it is two props.** The four presets are a damped oscillator
520
+ sampled into `linear()`: `spring` (540ms, barely a bounce), `spring-gentle` (660ms),
521
+ `spring-bouncy` (880ms, 20% past the target), `spring-snappy` (420ms). Name one on the timing
522
+ function and the same one on the duration — the duration is the spring's settling time, counted in
523
+ `--transitionTime` units, so reduced motion stops a spring too. For a spring of your own,
524
+ `Box.spring()` takes `stiffness`, `damping`, `mass` and `velocity` and returns `{ easing, duration }`.
525
+ - **What a sampled spring is not**: the curve is fixed once it is a string, so an interrupted
526
+ transition restarts rather than carrying its velocity across — fine for hover, a panel or a toggle,
527
+ not for a drag (that is framer-motion's job). `linear()` is missing in roughly one browser in
528
+ eight, so every curve is written with an `ease-out` declaration underneath it.
529
+ - **`startingStyle` is nesting, not a value.** It takes plain props — no breakpoint, pseudo-class or
530
+ theme _inside_ it; those nest around it instead — `md: { startingStyle: { … } }`, and the same inside a
531
+ `theme`. Every rule it writes sorts after the ordinary rules **and is emitted `!important`**, because
532
+ the browser computes the before-change style from the whole cascade: source order settles a tie in
533
+ specificity and nothing else, so a starting rule at `.x` would lose to the value it starts from at
534
+ `.dark .x` or `.x[data-state="open"]` and nothing would transition at all. The importance reaches
535
+ nothing but the before-change style. A browser without `@starting-style` drops the one rule and shows
536
+ the element finished. `Tooltip` and the `Dropdown` popup already carry one.
537
+ - **An exit needs someone to hold the node.** `@starting-style` runs when an element is first
538
+ rendered — mounted, or shown from `display: none`. Going the other way, React unmounts the node
539
+ immediately and there is nothing left to animate. Two answers: hide it instead — `display` plus
540
+ `transitionBehavior="allow-discrete"` flips `display` at the _end_ of the transition — or, when the
541
+ node really has to leave, `<Presence>`.
542
+ - **`<Presence present>` (`components/presence`) is the exit.** It keeps rendering its child with
543
+ `present: false` until the child's own CSS says the transition is over, then lets React remove it. A
544
+ render prop, handed `{ present, state, ref, props }`: `ref` goes on the element that carries the
545
+ transition (it is the one whose computed style the wait is measured from), `props` is
546
+ `{ 'data-state': 'open' | 'closed' }`. The wait is the element's computed `transition-duration` /
547
+ `animation-duration`, not a `transitionend` listener — which fires once per property with no way to
548
+ know how many are coming. So a reader on `prefers-reduced-motion` measures `0s` and the node leaves in
549
+ the same commit, with nothing to configure. `Tooltip`, the `Dropdown` popup and the DataGrid column
550
+ 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.
551
+ - **`Box.configure({ transition })`** changes what the base class transitions for the whole engine:
552
+ a group name, or `false` to declare nothing at all and leave transitions entirely to the props.
553
+ Call it before the first render.
554
+
555
+ ---
556
+
557
+ ## Pseudo-Classes, Breakpoints & Themes
558
+
559
+ ```tsx
560
+ // Pseudo-classes: hover, focus (:focus-within), focusVisible, hasFocus, active, valid, invalid,
561
+ // optional, disabled, checked, indeterminate, required, selected, hasChecked, hasRequired,
562
+ // hasDisabled, hasValid, hasInvalid — plus the eight states the browser tracks and nothing
563
+ // else can see: visited, target, open, placeholderShown, autofill, inRange, outOfRange, inert
564
+ // — plus rtl and ltr, the element's own resolved text direction
565
+ <Box bgColor="blue-500" hover={{ bgColor: 'blue-600' }} disabled={{ opacity: 0.5 }} />
566
+
567
+ // `open` is a <details>/<dialog> with the attribute, a popover, or a <select>'s picker; `inert`
568
+ // matches the whole inert subtree; `visited` takes colour properties only (the privacy rule)
569
+ <Box open={{ rotate: 180 }} placeholderShown={{ color: 'gray-400' }} inRange={{ borderColor: 'emerald-500' }} />
570
+
571
+ // Responsive breakpoints (mobile-first): sm(640) md(768) lg(1024) xl(1280) xxl(1536)
572
+ <Box p={2} md={{ p: 4 }} lg={{ p: 6 }} />
573
+
574
+ // Combine: breakpoints can nest pseudo-classes
575
+ <Box bgColor="white" hover={{ bgColor: 'gray-100' }} md={{ bgColor: 'gray-50', hover: { bgColor: 'gray-200' } }} />
576
+ ```
577
+
578
+ ### State variants — `dataAttr`, `ariaAttr`, `has`, `not`, `nth`
579
+
580
+ 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.
581
+
582
+ | Prop | Key | Selector it builds |
583
+ | ---------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
584
+ | `dataAttr` | `'state=open'` / `'loading'` | `[data-state="open"]` / `[data-loading]` |
585
+ | `ariaAttr` | `'selected'` / `'sort=ascending'` | `[aria-selected="true"]` — a bare key means `="true"` — / `[aria-sort="ascending"]` |
586
+ | `has` | `':checked'` / `'img[alt]'` | `:has(:checked)` / `:has(img[alt])` |
587
+ | `not` | a **state**: `hover`, `checked`, `'data-loading'`… | `:not(:hover)` / `:not([data-loading])` |
588
+ | `nth` | `'first'`, `'last'`, `'only'`, `'odd'`, `'even'`, `'3'`, `'2n+1'`, `'-n+3'`, `'last 2'` | `:first-child` / `:nth-child(2n+1)` / `:nth-last-child(2)` |
589
+
590
+ ```tsx
591
+ // The attribute goes in `props`, where every attribute goes; the styling goes in `dataAttr`.
592
+ <Box
593
+ props={{ 'data-state': state }}
594
+ dataAttr={{ 'state=busy': { bgColor: 'amber-500' }, 'state=done': { bgColor: 'emerald-500' } }}
595
+ />
596
+
597
+ // A bare aria key means ="true", so the attribute that makes the tab list correct is the one that colours it
598
+ <Button props={{ role: 'tab', 'aria-selected': isCurrent }} ariaAttr={{ selected: { bgColor: 'indigo-500' } }} />
599
+
600
+ // A container reacting to its own contents, and the inverse of a state
601
+ <Box has={{ 'input:checked': { borderColor: 'indigo-500' } }} />
602
+ <Box group={{ 'deck/hover': { not: { hover: { opacity: 0.5 } } } }} />
603
+
604
+ // A position among siblings: striping a list and dropping the last divider, with no index in the markup
605
+ <Box bb={1} nth={{ odd: { bgColor: 'slate-500/10' }, 'last 1': { bb: 0 } }} />
606
+
607
+ // Everything else nests around them, in either direction — the class name is built from the set
608
+ // rather than the order, so these two resolve to one class and one rule
609
+ <Box md={{ dataAttr: { 'state=open': { hover: { color: 'red-500' } } } }} />
610
+ <Box md={{ hover: { dataAttr: { 'state=open': { color: 'red-500' } } } }} />
611
+ ```
612
+
613
+ - **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.
614
+ - **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()`.
615
+ - **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.
616
+
617
+ ### Somebody else's state — `group` and `peer`
618
+
619
+ 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.
620
+
621
+ ```tsx
622
+ // `.group:hover .x` — the ancestor carries the class itself
623
+ <Flex className="card">
624
+ <Box opacity={0} group={{ 'card/hover': { opacity: 1 } }}>Edit</Box>
625
+ </Flex>
626
+
627
+ // `.peer:checked ~ .x` — what styles a label from the input before it, with no JavaScript
628
+ <Checkbox className="agree" label={<Box peer={{ 'agree/checked': { color: 'emerald-500' } }}>I agree</Box>} />
629
+
630
+ // A state this library never sets, on the ancestor
631
+ <Box group={{ 'row/data-state=open': { rotate: 90 }, 'row/aria-selected': { bgColor: 'sky-100' } }} />
632
+
633
+ // The element's own states still belong to the element: `.card:hover .x:hover`
634
+ <Box group={{ 'card/hover': { opacity: 0.6, hover: { opacity: 1 } } }} />
635
+ ```
636
+
637
+ - `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`.
638
+ - **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.
639
+ - 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`).
640
+
641
+ ### Pseudo-elements — `before`, `after`, `placeholder`, `selection`, `marker`…
642
+
643
+ 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.
644
+
645
+ | Prop | Element | Notes |
646
+ | --------------------------- | --------------------------------------- | --------------------------------------------------------------------------- |
647
+ | `before` / `after` | `::before` / `::after` | Generate a box, so they come with `content` (below) |
648
+ | `placeholder` | `::placeholder` | On `Textbox`/`Textarea` a **string** is the attribute, an object the styles |
649
+ | `selection` | `::selection` | Names descendants too — the text may be in a child |
650
+ | `marker` | `::marker` | Same: write it on the `<Ul>`, it reaches the `<Li>` |
651
+ | `firstLine` / `firstLetter` | `::first-line` / `-letter` | Typography only, per CSS |
652
+ | `backdrop` / `fileButton` | `::backdrop` / `::file-selector-button` | A `<dialog>`/popover, and an `<input type="file">` |
653
+
654
+ ```tsx
655
+ // A ::before with no `content` generates no box at all, so declaring one supplies content: '' —
656
+ // and the element exists in exactly the states you styled it in.
657
+ <Box position="relative" before={{ position: 'absolute', inset: 0, bgColor: 'indigo-500' }} />
658
+ <Box hover={{ before: { width: 'fit' } }} /> // .x:hover::before, content included
659
+
660
+ // content: a keyword, text (quoted for you), or CSS you wrote
661
+ <Box before={{ content: 'empty' }} /> // content: ''
662
+ <Box before={{ content: 'New' }} /> // content: "New"
663
+ <Box after={{ content: 'attr(data-count)' }} /> // attr(), counter(), url(), var(), image-set()
664
+ <Box after={{ content: '"Step " counter(step)' }} /> // a sequence has to be written as CSS
665
+ <Box before={{ content: 'none' }} /> // off again
666
+
667
+ // Everything nests around them, and the element still lands last and on the target
668
+ <Box md={{ dataAttr: { 'state=open': { after: { opacity: 1 } } } }} /> // @media … .x[data-state="open"]::after
669
+ <Box group={{ 'card/hover': { before: { opacity: 1 } } }} /> // .card:hover .x::before
670
+
671
+ // On an input the name means both things; both at once puts the text where attributes go
672
+ <Textbox props={{ placeholder: 'Search…' }} placeholder={{ color: 'slate-400', fontStyle: 'italic' }} />
673
+ ```
674
+
675
+ - **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.
676
+ - **`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.
677
+ - `placeholderStyles` is the old name for `placeholder` and still works.
678
+
679
+ ### Device and accessibility preferences
680
+
681
+ 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.
682
+
683
+ | Prop | Media query | When it applies |
684
+ | --------------- | -------------------------------- | -------------------------------------------------- |
685
+ | `pointerCoarse` | `pointer: coarse` | A finger or a pen — a bigger target, and no hover |
686
+ | `pointerFine` | `pointer: fine` | A mouse or a trackpad |
687
+ | `motionReduce` | `prefers-reduced-motion: reduce` | The user asked their OS for less motion |
688
+ | `forcedColors` | `forced-colors: active` | A forced-colors mode is on (Windows High Contrast) |
689
+ | `contrastMore` | `prefers-contrast: more` | The user asked their OS for more contrast |
690
+
691
+ 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.
692
+
693
+ ```tsx
694
+ // Reduced motion is already the default: every Box transitions on --transitionTime, and the
695
+ // preference sets that variable to 0s. Nothing to opt into — declare motionReduce only to
696
+ // replace a movement with something still, or to keep a transition you decided is safe.
697
+ <Box motionReduce={{ transform: 'none' }} />
698
+
699
+ // A component that named its own duration is the one case the default cannot reach.
700
+ <Box transitionDuration={150} motionReduce={{ transition: 'none' }} />
701
+
702
+ // Forced colors throw away background-color and color, so anything whose only edge was a fill
703
+ // (or a shadow, also dropped) needs a border — borders survive.
704
+ <Box bgColor="gray-900" color="gray-50" forcedColors={{ b: 1 }} />
705
+
706
+ // They nest like a breakpoint does
707
+ <Box contrastMore={{ borderColor: 'black', hover: { borderColor: 'blue-700' }, theme: { dark: { borderColor: 'white' } } }} />
708
+ ```
709
+
710
+ **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.
711
+
712
+ ### Container queries — `cq`
713
+
714
+ 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.
715
+
716
+ | Prop | CSS | Notes |
717
+ | --------------- | ------------------------------ | --------------------------------------------------------------------------------- |
718
+ | `container` | `container-type` / `container` | `true` → `inline-size`; a name → `container: <name> / inline-size` |
719
+ | `containerName` | `container-name` | The longhand, for a name beside `containerType="size"` |
720
+ | `containerType` | `container-type` | `'inline-size'`, `'size'` (both axes, needs its own block size), `'normal'` (off) |
721
+ | `cq` | `@container` | Keyed by size: `md`, its complement `maxMd`, or `'name/md'` for a named container |
722
+
723
+ | Size | Query | Complement |
724
+ | ----- | -------------------------- | ---------------------------------- |
725
+ | `xs` | `(min-width: 20rem)` 320px | `maxXs` → `not (min-width: 20rem)` |
726
+ | `sm` | `(min-width: 24rem)` 384px | `maxSm` |
727
+ | `md` | `(min-width: 28rem)` 448px | `maxMd` |
728
+ | `lg` | `(min-width: 32rem)` 512px | `maxLg` |
729
+ | `xl` | `(min-width: 36rem)` 576px | `maxXl` |
730
+ | `xxl` | `(min-width: 42rem)` 672px | `maxXxl` |
731
+
732
+ ```tsx
733
+ // A card that lays itself out from the space it was given — the window never comes into it
734
+ <Flex container>
735
+ <Flex d="column" cq={{ sm: { d: 'row', ai: 'center' } }} gap={4} p={4}>…</Flex>
736
+ </Flex>
737
+
738
+ // A name, for when a card sits inside a card. `cq` queries the *nearest* container by default.
739
+ <Flex container="page">
740
+ <Flex container="card">
741
+ <Box cq={{ md: { fontSize: 16 }, 'page/xl': { fontSize: 18 }, maxSm: { display: 'none' } }} />
742
+ </Flex>
743
+ </Flex>
744
+
745
+ // Everything a breakpoint nests, nests here too
746
+ <Box cq={{ md: { hover: { color: 'indigo-500' }, theme: { dark: { bgColor: 'slate-800' } }, before: { content: 'Wide' } } }} />
747
+ ```
748
+
749
+ - **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.
750
+ - **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.
751
+ - **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.
752
+ - `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.
753
+
754
+ ### Theme System
755
+
756
+ ```tsx
757
+ import Box from '@box-kite/react';
758
+
759
+ // Setup: wrap app in Box.Theme (auto-detects via prefers-color-scheme)
760
+ <Box.Theme> {/* auto light/dark detection */}
761
+ <Box.Theme theme="dark"> {/* explicit theme, ignores system pref */}
762
+ <Box.Theme theme="dark" use="global"> {/* applies class + data-theme to <html> */}
763
+ <Box.Theme storageKey="app-theme"> {/* persists user choice to localStorage */}
764
+
765
+ // Hook: read + set theme programmatically (must be within Box.Theme)
766
+ const [theme, setTheme] = Box.useTheme();
767
+ setTheme('dark'); // set explicit theme (persists to localStorage if storageKey provided)
768
+ setTheme(null); // reset to system auto-detection (clears localStorage)
769
+
770
+ // Supports any custom theme name — not limited to 'light'/'dark'
771
+ <Box.Theme theme="high-contrast">
772
+
773
+ // Theme-aware styles — nests with pseudo-classes and breakpoints
774
+ <Box
775
+ bgColor="white" color="gray-900"
776
+ hover={{ bgColor: 'gray-100' }}
777
+ theme={{ dark: { bgColor: 'gray-900', color: 'gray-100', hover: { bgColor: 'gray-700' } } }}
778
+ />
779
+ ```
780
+
781
+ **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"`).
782
+ **DOM**: Sets `data-theme` attribute and theme class on wrapper (local) or `document.documentElement` (global). Cleaned up on unmount.
783
+
784
+ #### globalStyles — App-wide styles on `<html>`
785
+
786
+ ```tsx
787
+ // Style the root document scrollbar (and any other html-level CSS). Only takes effect when use="global".
788
+ <Box.Theme
789
+ use="global"
790
+ globalStyles={{
791
+ scrollbarColor: ['violet-500', 'transparent'],
792
+ scrollbarWidth: 'thin',
793
+ theme: {
794
+ dark: { scrollbarColor: ['violet-700', 'gray-900'] },
795
+ light: { scrollbarColor: ['violet-300', 'gray-100'] },
796
+ },
797
+ }}
798
+ >
799
+ <App />
800
+ </Box.Theme>
801
+ ```
802
+
803
+ 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.
804
+
805
+ ---
806
+
807
+ ## Component System
808
+
809
+ ```tsx
810
+ // Component + variant props apply registered styles from Box.components()
811
+ <Box component="card" variant="bordered">
812
+ <Box component="card.header">Title</Box>
813
+ <Box component="card.body">Content</Box>
814
+ </Box>
815
+ ```
816
+
817
+ ### Box.components() — Define Custom Component Styles
818
+
819
+ ```tsx
820
+ Box.components({
821
+ card: {
822
+ styles: { display: 'flex', d: 'column', p: 4, bgColor: 'white', borderRadius: 8, shadow: 'medium' },
823
+ variants: {
824
+ bordered: { b: 1, borderColor: 'gray-200', shadow: 'none' },
825
+ elevated: { shadow: 'large' },
826
+ },
827
+ children: {
828
+ header: { styles: { fontSize: 18, fontWeight: 600, mb: 3 } },
829
+ body: { styles: { flex: 1 } },
830
+ },
831
+ },
832
+ });
833
+ ```
834
+
835
+ ### Component Inheritance with `extends`
836
+
837
+ ```tsx
838
+ Box.components({
839
+ subgrid: {
840
+ extends: 'datagrid', // inherits all styles, variants, and children; deep-merges overrides
841
+ styles: { b: 0, borderRadius: 0, shadow: 'none' },
842
+ children: { header: { children: { cell: { styles: { fontSize: 12 } } } } },
843
+ },
844
+ });
845
+ ```
846
+
847
+ ---
848
+
849
+ ## Extension System
850
+
851
+ ### Box.extend() — Add New Props, Colors, Variables
852
+
853
+ ```tsx
854
+ import Box from '@box-kite/react';
855
+
856
+ export const { extendedProps, extendedPropTypes } = Box.extend(
857
+ { 'brand-primary': '#ff6600', 'brand-secondary': '#0066ff' }, // CSS variables
858
+ {
859
+ // New props — a CSS property the registry does not have (`aspectRatio` is built in now)
860
+ imageRendering: [
861
+ {
862
+ values: ['auto', 'pixelated', 'crisp-edges'] as const,
863
+ styleName: 'image-rendering',
864
+ },
865
+ ],
866
+ },
867
+ {
868
+ // Extend existing props with new values
869
+ bgColor: [
870
+ {
871
+ values: ['brand-primary', 'brand-secondary'] as const,
872
+ styleName: 'background-color',
873
+ valueFormat: (value, getVariable) => getVariable(value),
874
+ },
875
+ ],
876
+ color: [
877
+ {
878
+ values: ['brand-primary', 'brand-secondary'] as const,
879
+ styleName: 'color',
880
+ valueFormat: (value, getVariable) => getVariable(value),
881
+ },
882
+ ],
883
+ },
884
+ );
885
+ ```
886
+
887
+ ### Per-Property Values (Multi-styleName)
888
+
889
+ 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:
890
+
891
+ ```tsx
892
+ Box.extend(
893
+ {
894
+ 'text-display-lg-size': '36px',
895
+ 'text-display-lg-weight': '700',
896
+ 'text-display-lg-line-height': '1.2',
897
+ 'text-display-lg-letter-spacing': '-0.02em',
898
+ },
899
+ {
900
+ textStyle: [
901
+ {
902
+ values: ['display-lg', 'display-sm'] as const,
903
+ styleName: ['font-size', 'font-weight', 'line-height', 'letter-spacing'],
904
+ valueFormat: (value, getVariable, styleName) => {
905
+ const suffix = { 'font-size': 'size', 'font-weight': 'weight', 'line-height': 'line-height', 'letter-spacing': 'letter-spacing' };
906
+ return getVariable(`text-${value}-${suffix[styleName!]}`);
907
+ },
908
+ },
909
+ ],
910
+ },
911
+ {},
912
+ );
913
+ // <Box textStyle="display-lg" /> → sets all 4 CSS properties
914
+ ```
915
+
916
+ ### TypeScript Type Augmentation
917
+
918
+ ```typescript
919
+ // types.d.ts — Generic approach (recommended)
920
+ import { ExtractComponentsAndVariants, ExtractBoxStyles } from '@box-kite/core/types';
921
+ import { components } from './boxComponents';
922
+ import { extendedPropTypes, extendedProps } from './boxExtends';
923
+
924
+ declare module '@box-kite/core/types' {
925
+ namespace Augmented {
926
+ interface BoxProps extends ExtractBoxStyles<typeof extendedProps> {}
927
+ interface BoxPropTypes extends ExtractBoxStyles<typeof extendedPropTypes> {}
928
+ interface ComponentsTypes extends ExtractComponentsAndVariants<typeof components> {}
929
+ }
930
+ }
931
+
932
+ // Manual approach (simple cases):
933
+ declare module '@box-kite/core/types' {
934
+ namespace Augmented {
935
+ interface BoxPropTypes {
936
+ bgColor: 'brand-primary' | 'brand-secondary';
937
+ }
938
+ interface ComponentsTypes {
939
+ card: 'bordered' | 'elevated';
940
+ }
941
+ }
942
+ }
943
+ ```
944
+
945
+ ---
946
+
947
+ ## Common Patterns
948
+
949
+ ```tsx
950
+ import Flex from '@box-kite/react/components/flex';
951
+ import Grid from '@box-kite/react/components/grid';
952
+ import Button from '@box-kite/react/components/button';
953
+ import Textbox from '@box-kite/react/components/textbox';
954
+
955
+ // Flex layout
956
+ <Flex d="column" gap={4} ai="center" jc="between">{children}</Flex>
957
+ <Flex inline gap={2}>Inline flex</Flex>
958
+
959
+ // Responsive stack
960
+ <Flex d="column" gap={2} md={{ d: 'row', gap: 4 }}>{children}</Flex>
961
+
962
+ // Grid
963
+ <Grid gridCols={3} gap={4}>{items.map(i => <Box key={i.id}>{i.content}</Box>)}</Grid>
964
+
965
+ // Card
966
+ <Box p={4} bgColor="white" borderRadius={8} shadow="medium">{content}</Box>
967
+
968
+ // Button with states
969
+ <Button px={4} py={2} bgColor="blue-500" color="white" borderRadius={6}
970
+ hover={{ bgColor: 'blue-600' }} disabled={{ opacity: 0.5, cursor: 'not-allowed' }}>Click</Button>
971
+
972
+ // Input
973
+ <Textbox placeholder="Enter..." width="fit" px={3} py={2} b={1} borderColor="gray-300"
974
+ borderRadius={6} focus={{ borderColor: 'blue-500', outline: 'none' }} />
975
+
976
+ // Truncated text
977
+ <Box overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">Long text...</Box>
978
+
979
+ // Overlay
980
+ <Box position="fixed" top={0} left={0} right={0} bottom={0} bgColor="black" opacity={0.5} zIndex={50} />
981
+ ```
982
+
983
+ **Portals**: `Overlay` (`components/overlay`) renders its children into `#box-kite-portal` at the place it
984
+ is declared, so they escape `overflow: hidden` and clipped ancestors. No ARIA, no open state — it is
985
+ positioning only. For a _description of a control_, use `Tooltip` instead (below): it is the same
986
+ layer with the APG pattern on it.
987
+
988
+ ### Styling an element Box cannot render (`useClassNames`)
989
+
990
+ An icon from a set, a `motion.div`, a router's `NavLink`, a third-party chart: there is no `tag` that
991
+ renders one, and the only styling channel they offer is `className`. `useClassNames` resolves the
992
+ same props Box would and gives you the class list to put on them.
993
+
994
+ ```tsx
995
+ import { useClassNames } from '@box-kite/react';
996
+
997
+ const { className, styles } = useClassNames({ color: 'sky-500', hover: { color: 'sky-300' } });
998
+
999
+ <>
1000
+ {styles}
1001
+ <NavLink to="/" className={className} />
1002
+ </>;
1003
+ ```
1004
+
1005
+ `styles` is defined in element mode only (the CSS as hoistable `<style>` elements); elsewhere it is
1006
+ undefined and rendering it costs nothing, so write the line either way. Pass `{ svg: true }` as a
1007
+ second argument for an element inside an `<svg>`. `<Icon>` is this hook plus that flag — reach for
1008
+ `Icon` for an icon and this only for everything else.
1009
+
1010
+ ### Group and Peer
1011
+
1012
+ ```tsx
1013
+ <Flex className="card-row" gap={2}>
1014
+ <Box opacity={0} group={{ 'card-row/hover': { opacity: 1 } }}>
1015
+ Actions
1016
+ </Box>
1017
+ </Flex>
1018
+ ```
1019
+
1020
+ ### Server-Side Rendering
1021
+
1022
+ ```tsx
1023
+ import { getStyles, resetStyles } from '@box-kite/react/ssg';
1024
+
1025
+ const html = renderToString(<App />); // any React server renderer
1026
+ const cssString = getStyles(); // the CSS for what was just rendered
1027
+ // <style id="box-kite-styles">{cssString}</style> in the document head
1028
+ resetStyles(); // reset before the next request
1029
+ ```
1030
+
1031
+ Or in one call, which injects the styles into the rendered `<head>` and resets afterwards:
1032
+
1033
+ ```tsx
1034
+ import { renderToStaticMarkup } from '@box-kite/react/ssg';
1035
+
1036
+ const { html, styles } = renderToStaticMarkup(<App />);
1037
+ ```
1038
+
1039
+ No DOM is needed: with no `document` in the process the engine collects CSS in memory. Sequential
1040
+ requests are independent and identical markup gets identical class names.
1041
+
1042
+ ---
1043
+
1044
+ ## React Server Components (React 19)
1045
+
1046
+ Box renders in a Server Component with no `'use client'` and no configuration — the `react-server`
1047
+ export condition resolves to a build that calls no hook and touches no DOM. Its CSS travels with its
1048
+ markup as `<style href precedence>` elements, which React 19 hoists into `<head>` and dedupes.
1049
+
1050
+ ```tsx
1051
+ // app/page.tsx — a Server Component
1052
+ import Flex from '@box-kite/react/components/flex';
1053
+ import { H1 } from '@box-kite/react/components/semantics';
1054
+
1055
+ export default function Page() {
1056
+ return (
1057
+ <Flex d="column" gap={2} p={6} bgColor="slate-50" sm={{ p: 8 }}>
1058
+ <H1 fontSize={24}>Rendered on the server</H1>
1059
+ </Flex>
1060
+ );
1061
+ }
1062
+ ```
1063
+
1064
+ Client components in the same app should use the same path, so their CSS is in the HTML too — one
1065
+ call, in a module the root layout imports:
1066
+
1067
+ ```tsx
1068
+ 'use client';
1069
+ import Box from '@box-kite/react';
1070
+
1071
+ Box.configure({ sink: 'element' }); // React 19 only
1072
+ ```
1073
+
1074
+ - `Box.configure({ sink, classNames })` — `sink`: `'cssom'` (browser default), `'textContent'`
1075
+ (tests, readable rules), `'string'` (server), `'element'` (React 19 style elements);
1076
+ `classNames`: `'hashed'` (default), `'readable'`, `'stable'` (content-hashed — the default in
1077
+ element mode so server and client agree). Call it before the first render.
1078
+ - In element mode rules live in CSS cascade layers, so **your own unlayered CSS always wins** over
1079
+ Box props, and declaring `Box.extend()` props before the first render matters.
1080
+ - The hook-free pre-built components render on the server too: `Flex`, `Grid`, `Button`,
1081
+ `Textbox`, `Textarea`, `RadioButton`, `Icon`, the SVG elements (`Svg`, `Path`, `Circle`, …) and the semantic tags (`H1`, `P`, `Link`, …).
1082
+ Import them straight into a Server Component.
1083
+ - `Dropdown`, `Tooltip`, `Overlay`, `DataGrid`, `Checkbox`, `Switch`, `RadioGroup`, `Select` and `Form` hold state, so their chunks
1084
+ ship a `'use client'` banner: a Server Component may import one, but it becomes a client
1085
+ boundary and its CSS is in the HTML only if a client module already ran
1086
+ `Box.configure({ sink: 'element' })`.
1087
+ - Client-only, so use them inside `'use client'`: hover-callback children
1088
+ (`{({ isHover }) => …}`) and `Box.Theme`. Theme _styles_ (`theme={{ dark: { … } }}`) work in a
1089
+ server component — put the theme class on `<html>`.
1090
+ - React 18 cannot hoist these elements; keep the default sink there.
1091
+
1092
+ ---
1093
+
1094
+ ## Behaviour primitives (`@box-kite/react/a11y`)
1095
+
1096
+ Five client hooks for building accessible components — the mechanics, not the ARIA. 2.2 KB gzipped
1097
+ and no styling engine behind them.
1098
+
1099
+ ```tsx
1100
+ import { useControllableState, useDismiss, useFocusReturn, useIdentifier, useRovingFocus } from '@box-kite/react/a11y';
1101
+ import VisuallyHidden from '@box-kite/react/components/visuallyHidden';
1102
+
1103
+ const id = useIdentifier('menu'); // stable id: `${id}-trigger`, `${id}-listbox`
1104
+ const [open, setOpen] = useControllableState({ value: props.open, defaultValue: false, onChange: props.onOpenChange });
1105
+
1106
+ const roving = useRovingFocus({ count: items.length, textOf: (i) => items[i].label, onSelect: (i) => choose(i) });
1107
+ // roving.activeIndex · roving.onKeyDown (on the focused element) · roving.itemProps(i) · roving.activeItem()
1108
+
1109
+ useDismiss({ enabled: open, inside: [triggerRef, popupRef], onDismiss: (reason, event) => setOpen(false, { reason, event }) });
1110
+ useFocusReturn({ enabled: open, returnTo: triggerRef });
1111
+ ```
1112
+
1113
+ - Every change carries a reason: `onChange(value, { reason, event })` — `'escape'`,
1114
+ `'outside-pointer'`, or one you name. The setter takes an updater and its identity is stable.
1115
+ - `useDismiss`: pass the **trigger** in `inside` as well as the popup, or pressing the trigger of an
1116
+ open popup dismisses and reopens in one gesture. Escape reaches the innermost layer only.
1117
+ - `useRovingFocus`: arrows per `orientation` (`'vertical'` default), Home/End, typeahead when
1118
+ `textOf` is given, Enter/Space to select, disabled items skipped. `focusItems: false` keeps DOM
1119
+ focus where it is for an `aria-activedescendant` pattern.
1120
+ - `useIdentifier`: React's `useId` without the punctuation, so the id also works as a CSS selector.
1121
+ - `VisuallyHidden`: screen-reader-only content — clipped, so it stays in the accessibility tree
1122
+ (`display: none` would not). It is a Box: `tag`, `component` and every style prop work.
1123
+ - The entry is client-only (`'use client'`); `visuallyHidden` renders on a server.
1124
+
1125
+ Full reference: `docs/a11y-primitives.md`.
1126
+
1127
+ ---
1128
+
1129
+ ## Form controls: Checkbox, Switch, RadioGroup
1130
+
1131
+ All three render **real native inputs**, so focus, the checked state, the disabled state and form
1132
+ submission come from the platform. What the components add is the part the platform cannot guess.
1133
+
1134
+ ```tsx
1135
+ import Checkbox from '@box-kite/react/components/checkbox';
1136
+ import Switch from '@box-kite/react/components/switch';
1137
+ import RadioGroup from '@box-kite/react/components/radioGroup';
1138
+ import RadioButton from '@box-kite/react/components/radioButton';
1139
+
1140
+ <Checkbox label="Accept the terms" name="terms" />
1141
+ <Checkbox label="Select all rows" name="rows" indeterminate />
1142
+ <Switch label="Email notifications" name="notify" defaultChecked />
1143
+
1144
+ <RadioGroup label="Plan" name="plan" defaultValue="free" onChange={(plan, { reason }) => setPlan(plan)}>
1145
+ <RadioGroup.Item value="free" label="Free" />
1146
+ <RadioGroup.Item value="pro" label="Pro" />
1147
+ <RadioGroup.Item value="team" label="Team" disabled />
1148
+ </RadioGroup>
1149
+
1150
+ <RadioButton name="plan" value="free" label="Free" /> {/* on its own, outside a group */}
1151
+ ```
1152
+
1153
+ **`label` is the important prop.** It renders the text inside a `<label>` that wraps the input, so
1154
+ the association needs no `htmlFor`/`id` pair and the whole row is a click target. Leave it out and
1155
+ nothing wraps the input — the markup is what it always was. `labelProps` styles that `<label>`;
1156
+ every other Box prop styles the control itself.
1157
+
1158
+ | Component | Prop | What it does |
1159
+ | ------------ | ------------------------ | --------------------------------------------------------------------------------------- |
1160
+ | all three | `label` / `labelProps` | The wrapping `<label>` and its styles. |
1161
+ | `Checkbox` | `indeterminate` | Sets the DOM property **and** `aria-checked="mixed"`, which is what is announced. |
1162
+ | `Switch` | — | `role="switch"` over the same input; Space **and** Enter toggle, Enter does not submit. |
1163
+ | `RadioGroup` | `label` | `role="radiogroup"` named by it — a set of radios with nothing over it is a flat list. |
1164
+ | `RadioGroup` | `name` | The field every item submits under. Generated when left out. |
1165
+ | `RadioGroup` | `value` / `defaultValue` | The selected item's value (a string — the platform's own model for a radio). |
1166
+ | `RadioGroup` | `onChange` | `(value, { reason, event })` — `click` or `keyboard`. |
1167
+ | `RadioGroup` | `orientation` | `vertical` (default) or `horizontal`. Both arrow pairs navigate either way. |
1168
+
1169
+ Arrow keys move **and select** inside a group, wrapping at both ends and skipping disabled items;
1170
+ Tab enters the group once and leaves it once, because a native radio set is already a single tab
1171
+ stop and the component does not fight that.
1172
+
1173
+ `checked`, `disabled` and `indeterminate` are each both a state and a pseudo-class style prop. Pass
1174
+ a boolean for the state, or the tuple `[state, styles]` for both:
1175
+ `checked={[on, { bgColor: 'emerald-500' }]}`.
1176
+
1177
+ ---
1178
+
1179
+ ## Form Component
1180
+
1181
+ A `<form>` that reads its own fields when it submits, so a simple form needs no state per input.
1182
+
1183
+ ```tsx
1184
+ import Form from '@box-kite/react/components/form';
1185
+ import Textbox from '@box-kite/react/components/textbox';
1186
+
1187
+ interface Credentials {
1188
+ email: string;
1189
+ password: string;
1190
+ }
1191
+
1192
+ <Form<Credentials> p={4} onSubmit={(values, e) => signIn(values)}>
1193
+ <Textbox name="email" type="email" />
1194
+ <Textbox name="password" type="password" />
1195
+ <Button type="submit">Sign in</Button>
1196
+ </Form>;
1197
+ ```
1198
+
1199
+ `onSubmit(values, event)` runs after the event's own `preventDefault()`, so nothing navigates. The
1200
+ object is built from the form's elements, and a field is in it only if it has a `name`:
1201
+
1202
+ | In the markup | In `values` |
1203
+ | --------------------------------- | -------------------------------------------------------- |
1204
+ | one named input | its `value` as a string |
1205
+ | one named checkbox or radio | its `checked` **boolean** |
1206
+ | several elements sharing a `name` | an array — the checked values, or every value for inputs |
1207
+ | `name="address.city"` | nested: `{ address: { city } }` |
1208
+ | `name="lines[0].qty"` | nested through an array: `{ lines: [{ qty }] }` |
1209
+
1210
+ The type argument is the shape you expect back; it is not checked against the fields, so keep it
1211
+ next to the markup. Everything else is a Box prop, and `props` takes the `<form>` attributes
1212
+ (`onSubmit` and `ref` are the component's own).
1213
+
1214
+ ---
1215
+
1216
+ ## Tooltip Component
1217
+
1218
+ ```tsx
1219
+ import Tooltip from '@box-kite/react/components/tooltip';
1220
+
1221
+ <Tooltip content="Deletes the row for good">{(trigger) => <Button {...trigger}>Delete</Button>}</Tooltip>;
1222
+ ```
1223
+
1224
+ The child is a **render prop**, not an element: what it receives (a `ref`, plus `aria-describedby`
1225
+ and the pointer/focus handlers in `props`) has to land on the control itself. One spread on a Box
1226
+ component — `<Button {...trigger}>` — or, on a plain element,
1227
+ `<button ref={trigger.ref} {...trigger.props}>`. The `ref` is what the bubble is positioned against,
1228
+ so the tooltip sits under the trigger and adds nothing to the layout.
1229
+
1230
+ | Prop | Default | What it does |
1231
+ | ------------------------- | ------- | ---------------------------------------------------------------------------------- |
1232
+ | `content` | — | The description. Nothing renders while it is empty. |
1233
+ | `open` / `defaultOpen` | — | Controlled / uncontrolled open state. |
1234
+ | `onOpenChange` | — | `(open, { reason, event })` — `hover`, `focus`, `pointer-leave`, `blur`, `escape`. |
1235
+ | `openDelay` | `300` | Hover dwell before it appears. Focus ignores it and shows immediately. |
1236
+ | `closeDelay` | `150` | Grace period after the pointer leaves, so it can travel onto the tooltip. |
1237
+ | `adjustTranslateX` / `…Y` | `0px` | Nudge where the bubble lands. |
1238
+
1239
+ Every other Box prop styles the bubble, over the built-in `tooltip` component style. What the
1240
+ component guarantees, so you do not wire it: `role="tooltip"`, `aria-describedby` on the trigger only
1241
+ while it is open, opening on hover **and** focus, Escape to dismiss with focus left where it is (and
1242
+ no re-show until the pointer leaves and returns), the pointer able to move onto the tooltip, and no
1243
+ auto-hide timer — the three WCAG 1.4.13 rules.
1244
+
1245
+ ---
1246
+
1247
+ ## Chart primitives (`components/chart`)
1248
+
1249
+ Four small drawings built from the `components/svg` elements, server-safe, in one entry. **Not a
1250
+ chart library**: no axes, no legends, no data transformations. What they give you is that a chart is
1251
+ a Box — its colour, size, dark mode, hover state and breakpoints are the props you already know.
1252
+ For a real chart with axes and a tooltip, theme Recharts instead.
1253
+
1254
+ ```tsx
1255
+ import { Gauge, MiniDonut, ProgressRing, Sparkline } from '@box-kite/react/components/chart';
1256
+
1257
+ // A sparkline stretches to whatever box it is in and keeps its line one width thick.
1258
+ <Sparkline data={[4, 9, 6, 12, 10, 15]} width="7rem" color="sky-500" />
1259
+ <Sparkline data={[4, 9, 6, 12, 10, 15]} variant="area" fillOpacity={0.2} color="violet-500" />
1260
+ <Sparkline data={[4, 9, 6, 12]} variant="bar" min={0} max={20} color="emerald-500" />
1261
+
1262
+ // A ring or a dial: the arc eases between values with no animation code.
1263
+ <ProgressRing value={0.62} color="emerald-500" label="62% complete" />
1264
+ <Gauge value={0.4} sweep={180} start={270} color="rose-500">
1265
+ <SvgText x={50} y={48} textAnchor="middle" fontSize={22} fill="rose-500" stroke="none">40%</SvgText>
1266
+ </Gauge>
1267
+
1268
+ // One segment per value, each its share of the whole. No total needed.
1269
+ <MiniDonut data={[5, 3, 2]} colors={['sky-500', 'emerald-500', 'var(--chart-3)']} />
1270
+
1271
+ // A gradient fill is a value now, so it takes a theme and a hover like any other paint.
1272
+ <Sparkline data={[4, 9, 6, 12]} variant="area" stroke="url(#trend)" fill="url(#trend)">
1273
+ <Defs>
1274
+ <LinearGradient id="trend" x1="0" y1="0" x2="1" y2="0">
1275
+ <Stop offset="0%" stopColor="currentColor" color="sky-500" />
1276
+ <Stop offset="100%" stopColor="currentColor" color="violet-500" />
1277
+ </LinearGradient>
1278
+ </Defs>
1279
+ </Sparkline>
1280
+ ```
1281
+
1282
+ | Prop | Component | Default | What it is |
1283
+ | ----------------- | ------------------------ | ----------------------- | -------------------------------------------------------------------------------------------------- |
1284
+ | `data` | `Sparkline`, `MiniDonut` | — | The numbers. A sparkline reads them oldest-first; a donut draws each as its share of the whole |
1285
+ | `variant` | `Sparkline` | `'line'` | `'line'`, `'area'` (the line with the space beneath it filled) or `'bar'` |
1286
+ | `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 |
1287
+ | `value` | `ProgressRing`, `Gauge` | — | How full the arc is, 0–1. Rounded to half a percent (see below) and clamped |
1288
+ | `thickness` | all but `Sparkline` | `10` (`20` for a donut) | The ring's width, out of the 100-unit box the drawing is computed in |
1289
+ | `trackOpacity` | `ProgressRing`, `Gauge` | `0.2` | The unfilled part, as the same colour faded — so the track follows the theme too |
1290
+ | `sweep` / `start` | `Gauge` | `270` / `225` | How far round the dial goes and where it begins, in degrees clockwise from twelve o'clock |
1291
+ | `colors` | `MiniDonut` | six tokens | One paint per segment, cycled. Anything `fill` takes, including `var(--chart-1)` |
1292
+ | `label` | all | — | Names the drawing: `role="img"` with this text. Without one it is `aria-hidden` |
1293
+ | `children` | all | — | SVG content drawn after the chart — an `SvgText` in the middle of a ring, a `Defs` with a gradient |
1294
+
1295
+ Everything else is an `Svg`'s: `width`/`height` are the SVG attributes (`width="7rem"`,
1296
+ `width={200}`, and a sparkline defaults to `width="100%"`), the paint is inherited by the shapes
1297
+ inside (the default stroke is `currentColor`, so `color="sky-500"` recolours any of them), and every
1298
+ Box prop, pseudo-class, breakpoint and theme works.
1299
+
1300
+ **Four things to get right:**
1301
+
1302
+ 1. **Naming is `Svg`'s rule, and a chart is where it matters.** A sparkline beside the number it
1303
+ summarises is decoration — leave it unnamed, or a screen reader reads the row twice. A chart that
1304
+ _is_ the data needs a `label` saying what the shape shows: `label="Revenue, rising 12% over six
1305
+ months"`, not `label="Chart"`.
1306
+ 2. **Shape is an attribute, paint is a class.** A sparkline's geometry is the `d` attribute, which
1307
+ the styling engine never sees, so ten thousand different shapes generate no CSS at all; the
1308
+ colour and the width are style props, so the rows share one rule each. That is what makes one
1309
+ sparkline per row of a virtualized grid affordable.
1310
+ 3. **A ring's value is rounded to half a percent.** A dash length has to be a style prop to be able
1311
+ to transition, so it lands in a class name — rounding caps a column of percentages at a couple of
1312
+ hundred rules instead of one per row, and half a percent of a 48px ring is a third of a pixel.
1313
+ 4. **A sparkline is the one primitive not drawn to scale.** It is `preserveAspectRatio="none"` and
1314
+ `vectorEffect="non-scaling-stroke"`, so it fills a cell of unknown width and the line stays one
1315
+ width thick. Both are ordinary props — `vectorEffect="none"` opts out. The rings are drawn to
1316
+ scale and stay circular.
1317
+
1318
+ In a DataGrid, a cell renderer is a component: define it outside the render, or every scroll
1319
+ remounts the column.
1320
+
1321
+ ### Theming a chart library — `ChartContainer`
1322
+
1323
+ The bridge for the chart you did not write. It declares the variables a chart reads, so the chart
1324
+ names no colour at all and its dark mode belongs to the page:
1325
+
1326
+ ```tsx
1327
+ import { ChartContainer } from '@box-kite/react/components/chart';
1328
+ import { Area, AreaChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts';
1329
+
1330
+ <ChartContainer
1331
+ series={['revenue', 'cost']}
1332
+ vars={{ 'chart-grid': 'slate-200', 'chart-label': 'slate-500' }}
1333
+ theme={{ dark: { vars: { 'chart-grid': 'slate-800', 'chart-label': 'slate-400' } } }}
1334
+ height={60}
1335
+ >
1336
+ <ResponsiveContainer width="100%" height="100%">
1337
+ <AreaChart data={months}>
1338
+ <CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" vertical={false} />
1339
+ <XAxis dataKey="month" stroke="var(--chart-label)" fontSize={12} />
1340
+ <YAxis stroke="var(--chart-label)" fontSize={12} width={36} />
1341
+ <Area dataKey="revenue" stroke="var(--color-revenue)" fill="var(--color-revenue)" fillOpacity={0.15} />
1342
+ <Area dataKey="cost" stroke="var(--color-cost)" fill="var(--color-cost)" fillOpacity={0.15} />
1343
+ </AreaChart>
1344
+ </ResponsiveContainer>
1345
+ </ChartContainer>;
1346
+ ```
1347
+
1348
+ | Prop | What it is |
1349
+ | -------- | ---------------------------------------------------------------------------------------------------------- |
1350
+ | `series` | `['revenue', 'cost']` takes the palette in order, or `{ revenue: 'emerald-600' }` names a paint per series |
1351
+
1352
+ Everything else is a Box prop, which is the point:
1353
+
1354
+ - It always declares `--chart-1` … `--chart-6` (the six `MiniDonut` cycles through) and redeclares
1355
+ them one step lighter under the dark theme. A series with no paint of its own reads a slot, wrapping
1356
+ round after six.
1357
+ - Replacing a slot needs no prop for it: `vars={{ 'chart-1': 'teal-600' }}` and
1358
+ `theme={{ dark: { vars: { 'chart-1': 'teal-400' } } }}` merge with the defaults rather than replacing
1359
+ them, and any other name a chart reads (`--chart-grid`, `--chart-label`) is declared the same way.
1360
+ - `--color-<series>` and `--chart-n` are deliberately the names the ecosystem uses, so a chart copied
1361
+ from shadcn's charts works unchanged. Unlike that recipe there is no `<style>` tag per chart and no
1362
+ `id` to scope it with: the variables are in a class, so two tiles with the same series share one rule.
1363
+ - A series name becomes part of a custom-property name, so it has to be a CSS identifier. A Recharts
1364
+ dot-path `dataKey` (`user.name`) is skipped — that series only, not the rest of the palette.
1365
+ - It adds no role, no ARIA and no styling of its own: it is a `<div>` with variables on it, and what is
1366
+ inside owns its own semantics. Size it like any Box (`height={60}` is 15rem) — Recharts
1367
+ `ResponsiveContainer` measures the parent.
1368
+
1369
+ ```tsx
1370
+ function TrendCell({ cell }: { cell: CellModel<Row> }) {
1371
+ return (
1372
+ <Flex px={3} ai="center" height="fit">
1373
+ <Sparkline data={cell.row.data.trend} color="emerald-500" />
1374
+ </Flex>
1375
+ );
1376
+ }
1377
+
1378
+ <DataGrid data={rows} def={{ rowKey: 'id', columns: [{ key: 'trend', header: 'Last 12 months', width: 180, Cell: TrendCell }] }} />;
1379
+ ```
1380
+
1381
+ ---
1382
+
1383
+ ## Key Reminders for AI Assistants
1384
+
1385
+ 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
1386
+ 2. **NEVER `<Box tag="...">` for common elements** — use `<Button>`, `<Link>`, `<H1>`, `<P>`, `<Nav>`, `<Svg>`, `<Path>`, `<Circle>`, etc.
1387
+ 3. **NEVER `<Box display="flex/grid">`** — use `<Flex>` / `<Grid>`
1388
+ 4. **fontSize divider is 16** (not 4). `fontSize={14}` → 14px
1389
+ 5. **Spacing divider is 4**. `p={4}` → 16px (1rem)
1390
+ 6. **Border width and lineHeight are direct px**. `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
1391
+ 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`)
1392
+ 8. **Breakpoints are mobile-first**: base → sm → md → lg → xl → xxl
1393
+ 9. **Theme styles nest**: `theme={{ dark: { hover: { ... } } }}`
1394
+ 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
1395
+ 11. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
1396
+ 12. **Box is memoized** with `React.memo`
1397
+ 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
1398
+ 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
1399
+ 15. **All sizing props use divider 4** — `width`, `height`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight` are NOT direct pixels. `height={10}` = 2.5rem = 40px
1400
+ 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`
1401
+ 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
1402
+
1403
+ ---
1404
+
1405
+ ## DataGrid Component
1406
+
1407
+ ```tsx
1408
+ import DataGrid from '@box-kite/react/components/dataGrid';
1409
+
1410
+ <DataGrid
1411
+ data={users}
1412
+ def={{
1413
+ rowKey: 'id',
1414
+ title: 'Users',
1415
+ topBar: true,
1416
+ bottomBar: true,
1417
+ globalFilter: true,
1418
+ rowSelection: { pinned: true },
1419
+ showRowNumber: { pinned: true },
1420
+ rowHeight: 40,
1421
+ visibleRowsCount: 15, // or 'all' to disable virtualization
1422
+ columns: [
1423
+ { key: 'name', header: 'Name', filterable: true },
1424
+ { key: 'age', header: 'Age', width: 80, align: 'right', filterable: { type: 'number' } },
1425
+ { key: 'email', header: 'Email', width: 250, filterable: true },
1426
+ { key: 'status', header: 'Status', filterable: { type: 'multiselect' } },
1427
+ { key: 'country', header: 'Country', pin: 'END' },
1428
+ {
1429
+ key: 'actions',
1430
+ header: '',
1431
+ pin: 'END',
1432
+ width: 80,
1433
+ sortable: false,
1434
+ resizable: false,
1435
+ Cell: ({ cell }) => <Button onClick={() => edit(cell.row.data)}>Edit</Button>,
1436
+ },
1437
+ ],
1438
+ rowDetail: { content: (user) => <UserDetails user={user} />, height: 'auto', expandOnRowClick: true, expandColumnHeader: 'Details' },
1439
+ contextMenu: { sort: true, pin: true, group: false },
1440
+ resizerStyle: 'hover',
1441
+ }}
1442
+ onSelectionChange={(e) => console.log(e.selectedRowKeys)}
1443
+ />;
1444
+ ```
1445
+
1446
+ ### DataGridProps
1447
+
1448
+ | Prop | Type | Description |
1449
+ | --------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
1450
+ | `data` | `TRow[]` | Row data (required) |
1451
+ | `def` | `GridDefinition` | Grid config (required) |
1452
+ | `component` | `string` | Style tree name (default: `'datagrid'`) |
1453
+ | `loading` | `boolean` | Loading state |
1454
+ | `filters` | `((row) => boolean)[]` | External predicate filters (applied before column/global filters) |
1455
+ | `page` / `onPageChange` | `number` / `(page, size) => void` | Controlled pagination (1-indexed) |
1456
+ | `onSortChange` | `(columnKey, direction) => void` | Sort callback (`direction`: `'ASC'`/`'DESC'`/`undefined`) |
1457
+ | `onServerStateChange` | `(state) => void` | Unified: `{ page, pageSize, sortColumn, sortDirection, columnFilters, globalFilterValue }` |
1458
+ | `onSelectionChange` | `(event) => void` | `event`: `{ action, selectedRowKeys, affectedRowKeys, isAllSelected }` |
1459
+ | `expandedRowKeys` / `onExpandedRowKeysChange` | `Key[]` / `(keys) => void` | Controlled expanded rows |
1460
+ | `globalFilterValue` / `onGlobalFilterChange` | `string` / `(value) => void` | Controlled global filter |
1461
+ | `columnFilters` / `onColumnFiltersChange` | `ColumnFilters` / `(filters) => void` | Controlled column filters |
1462
+
1463
+ ### GridDefinition
1464
+
1465
+ | Prop | Type | Default | Description |
1466
+ | ------------------------- | ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------- |
1467
+ | `columns` | `ColumnType[]` | required | Column definitions |
1468
+ | `rowKey` | `keyof TRow \| (row) => Key` | auto | Unique row identifier |
1469
+ | `rowHeight` | `number` | `48` | Row height in px |
1470
+ | `visibleRowsCount` | `number \| 'all'` | `10` | Visible rows. `'all'` disables virtualization |
1471
+ | `showRowNumber` | `boolean \| { pinned?, width? }` | `false` | Row number column |
1472
+ | `rowSelection` | `boolean \| { pinned? }` | `false` | Checkbox selection column |
1473
+ | `rowDetail` | `RowDetailConfig` | — | Expandable detail panel (see below) |
1474
+ | `pagination` | `{ totalCount, pageSize? }` | — | Server-side pagination. Bypasses client-side filtering |
1475
+ | `topBar` / `bottomBar` | `boolean` | `false` | Show top/bottom bars |
1476
+ | `title` / `topBarContent` | `ReactNode` | — | Top bar content |
1477
+ | `globalFilter` | `boolean` | `false` | Enable global fuzzy search |
1478
+ | `globalFilterKeys` | `(keyof TRow)[]` | all | Limit global filter columns |
1479
+ | `sortable` / `resizable` | `boolean` | `true` | Enable sorting/resizing for all columns |
1480
+ | `contextMenu` | `boolean \| ContextMenuConfig` | `true` | Control column header context menu. `false` hides it. Object: `{ sort?, pin?, group? }` |
1481
+ | `resizerStyle` | `'visible' \| 'hover' \| 'hidden'` | `'visible'` | Resizer handle visibility. `'hover'`: shows on header cell hover. `'hidden'`: invisible but resize still works |
1482
+ | `noDataComponent` | `ReactNode` | `'empty'` | Custom empty state |
1483
+
1484
+ ### ColumnType
1485
+
1486
+ | Prop | Type | Default | Description |
1487
+ | ------------------------ | --------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
1488
+ | `key` | `Key` | required | Column identifier (maps to TRow property) |
1489
+ | `header` | `string` | — | Header text |
1490
+ | `width` | `number` | `200` | Base width in px |
1491
+ | `align` | `'start' \| 'end' \| 'center' \| 'left' \| 'right'` | `'left'` | Cell alignment. `start`/`end` follow the reading order; `left`/`right` stay on the screen side they name |
1492
+ | `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 |
1493
+ | `columns` | `ColumnType[]` | — | Nested columns (grouped header) |
1494
+ | `Cell` | `({ cell }) => ReactNode` | — | Custom renderer. `cell`: `{ value, row, column, grid }` |
1495
+ | `sortable` / `resizable` | `boolean` | inherits | Override grid-level setting |
1496
+ | `flexible` | `boolean` | `true` | Participate in flex width distribution |
1497
+ | `filterable` | `boolean \| FilterConfig` | — | `true` (text), `{ type: 'number', min?, max? }`, `{ type: 'multiselect', options? }` |
1498
+ | `contextMenu` | `boolean \| ContextMenuConfig` | inherits | Override grid-level context menu. `false` hides entirely. `{ sort?, pin?, group? }` controls sections |
1499
+
1500
+ ### RowDetailConfig
1501
+
1502
+ | Prop | Type | Default | Description |
1503
+ | -------------------- | ------------------------------------- | -------- | --------------------------------------------- |
1504
+ | `content` | `(row: TRow) => ReactNode` | required | Render function for the detail panel |
1505
+ | `height` | `'auto' \| number \| (row) => number` | `'auto'` | Detail row height |
1506
+ | `expandOnRowClick` | `boolean` | `false` | Click anywhere on the row to toggle expansion |
1507
+ | `pinned` | `boolean` | `false` | Pin the expand column to the reading start |
1508
+ | `expandColumnWidth` | `number` | `50` | Width of the expand column in px |
1509
+ | `expandColumnHeader` | `string` | `''` | Header text for the expand column |
1510
+
1511
+ ### ContextMenuConfig
1512
+
1513
+ | Prop | Type | Default | Description |
1514
+ | ------- | --------- | ------- | ------------------------------------------------------------------------- |
1515
+ | `sort` | `boolean` | `true` | Show Sort Ascending / Sort Descending / Clear Sort |
1516
+ | `pin` | `boolean` | `true` | Show the two pin actions and Unpin, each labelled by the side it lands on |
1517
+ | `group` | `boolean` | `true` | Show Group By / Un-Group All |
1518
+
1519
+ ### Server-Side Pagination
1520
+
1521
+ ```tsx
1522
+ <DataGrid
1523
+ data={pageData} page={page} loading={loading}
1524
+ onServerStateChange={(state) => {
1525
+ // state = { page, pageSize, sortColumn, sortDirection, columnFilters, globalFilterValue }
1526
+ setPage(state.page); refetch(state);
1527
+ }}
1528
+ def={{ columns: [...], bottomBar: true, pagination: { totalCount, pageSize: 25 }, globalFilter: true }}
1529
+ />
1530
+ ```
1531
+
1532
+ ### Style Customization
1533
+
1534
+ All sub-components are customizable via `Box.components()`:
1535
+
1536
+ ```tsx
1537
+ Box.components({
1538
+ datagrid: { children: { header: { children: { cell: { styles: { textTransform: 'uppercase' } } } } } },
1539
+ subgrid: {
1540
+ extends: 'datagrid', // MUST use extends — inherits pinning, sticky, hover groups, filters, etc.
1541
+ styles: { b: 0, shadow: 'none' },
1542
+ children: { body: { children: { cell: { styles: { fontSize: 13 } } } } },
1543
+ },
1544
+ });
1545
+ <DataGrid component="subgrid" data={data} def={def} /> {/* children resolve under subgrid.* */}
1546
+ ```
1547
+
1548
+ ### Accessibility (A7)
1549
+
1550
+ DataGrid is the APG **grid** pattern, over a virtualized body, and it supplies the whole thing — do
1551
+ not add roles or `tabIndex` by hand.
1552
+
1553
+ - Roles: the scrolling element is `role="grid"` (not the root, which also holds the top and bottom
1554
+ bars); the header and the body are `rowgroup`s; rows are `role="row"` and cells `role="gridcell"`
1555
+ / `columnheader`. The scroll spacers virtualization needs are `role="presentation"`.
1556
+ - Numbers that survive virtualization: `aria-rowcount` / `aria-colcount` describe the **whole**
1557
+ grid, and `aria-rowindex` / `aria-colindex` place each rendered row and cell inside it — header
1558
+ rows are row 1 onwards, so a body row's number counts them.
1559
+ - `aria-sort` on every sortable header (`none` until it is the sorted one), `aria-selected` on rows
1560
+ **only** when `rowSelection` is on, `aria-multiselectable` on the grid with it, `aria-busy` while
1561
+ `loading`, and `aria-expanded` on a group row and on a row with an open detail panel.
1562
+ - Keyboard: one cell is in the tab order at a time. Arrows move it, Home/End go to the row's ends,
1563
+ Ctrl+Home/End to the grid's corners (scrolling to a row that has not been rendered), PageUp/Down
1564
+ move a screenful. Enter/Space sorts on a sortable header and otherwise steps into the cell's own
1565
+ control; F2 steps in even on a header; Escape hands the keyboard back to the cell. Down/Up keep
1566
+ the **column**, not the cell ordinal, so they land under where they started even through a
1567
+ grouped header or a group row whose cells cover several columns each.
1568
+ - The column resizer is APG's window splitter: `role="separator"` with `aria-valuenow`/`-valuemin`/
1569
+ `-valuemax` in pixels and `aria-controls` on its header cell. Tab or F2 reaches it, the arrows
1570
+ move it 16px, Home/End take it to the narrowest the grid allows and to the grid's own width.
1571
+ - Every control the grid draws for itself is named after what it acts on: "Select row 4", "Filter
1572
+ Country", "Column options for Age", "Columns". The column menu is the APG menu button —
1573
+ `aria-haspopup="menu"`, `role="menuitem"` rows, arrows and typeahead, Escape returns focus.
1574
+ - Selection is announced through a live region ("3 of 200 rows selected"), because ticking a
1575
+ checkbox halfway down a grid changes nothing a screen reader would otherwise read out.
1576
+
1577
+ **Give it a title.** A grid is not named by the rows in it: `def.title` is rendered in the top bar
1578
+ and pointed at by `aria-labelledby`.
1579
+
1580
+ **Known gap:** Tab does not yet stay inside the grid — controls inside cells are still their own tab
1581
+ stops, as they were before A7.
1582
+
1583
+ ### Component Style Tree
1584
+
1585
+ | Component Name | Description | Variants |
1586
+ | --------------------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1587
+ | `datagrid` | Root container | — |
1588
+ | `datagrid.content` | Scroll container for header + body | — |
1589
+ | `datagrid.topBar` | Top bar (title, filters, column groups) | — |
1590
+ | `datagrid.topBar.globalFilter` | Global search wrapper | — |
1591
+ | `datagrid.topBar.globalFilter.stats` | Filtered rows count badge | — |
1592
+ | `datagrid.topBar.columnGroups` | Column group chips container | — |
1593
+ | `datagrid.topBar.columnGroups.icon` | Column group icon | — |
1594
+ | `datagrid.topBar.columnGroups.separator` | Separator between groups | — |
1595
+ | `datagrid.topBar.columnGroups.item` | Column group chip | — |
1596
+ | `datagrid.topBar.columnGroups.item.icon` | Remove icon on chip | — |
1597
+ | `datagrid.topBar.columnVisibility` | Column visibility dropdown | — |
1598
+ | `datagrid.topBar.columnVisibility.badge` | Hidden columns count badge | — |
1599
+ | `datagrid.filter.cell` | Filter row cell | `isPinned`, `isFirstLeftPinned`, `isLastLeftPinned`, `isFirstRightPinned`, `isLastRightPinned` |
1600
+ | `datagrid.filter.cell.input` | Filter input container (text/number/multiselect) | — |
1601
+ | `datagrid.header` | Header grid container (sticky) | — |
1602
+ | `datagrid.header.cell` | Header cell | `isPinned`, `isFirstLeftPinned`, `isLastLeftPinned`, `isFirstRightPinned`, `isLastRightPinned`, `isSortable`, `isRowSelection`, `isRowNumber`, `isFirstLeaf`, `isLastLeaf`, `isEmptyCell` |
1603
+ | `datagrid.header.cell.contextMenu` | Column context menu button | — |
1604
+ | `datagrid.header.cell.contextMenu.icon` | Context menu icon | — |
1605
+ | `datagrid.header.cell.contextMenu.tooltip` | Context menu popup | — |
1606
+ | `datagrid.header.cell.contextMenu.tooltip.item` | Context menu action | — |
1607
+ | `datagrid.header.cell.contextMenu.tooltip.item.icon` | Action icon | — |
1608
+ | `datagrid.header.cell.contextMenu.tooltip.item.separator` | Menu separator line | — |
1609
+ | `datagrid.header.cell.resizer` | Column resize handle | — |
1610
+ | `datagrid.body` | Body grid container (virtualized rows) | — |
1611
+ | `datagrid.body.cell` | Body cell | `isPinned`, `isFirstLeftPinned`, `isLastLeftPinned`, `isFirstRightPinned`, `isLastRightPinned`, `isRowNumber`, `isRowSelection`, `isRowSelected`, `isFirstLeaf`, `isLastLeaf`, `isEmptyCell` |
1612
+ | `datagrid.body.cell.text` | Default cell text renderer | — |
1613
+ | `datagrid.body.cell.rowDetail` | Row detail expand/collapse button | — |
1614
+ | `datagrid.body.row` | Data row (display: contents) | — |
1615
+ | `datagrid.body.groupRow` | Group row (display: contents) | — |
1616
+ | `datagrid.body.groupRow.expandButton` | Group expand/collapse button | — |
1617
+ | `datagrid.body.detailRow` | Expanded detail row | — |
1618
+ | `datagrid.body.empty` | Empty state container | — |
1619
+ | `datagrid.emptyColumns` | No columns selected placeholder | — |
1620
+ | `datagrid.bottomBar` | Bottom bar (row count, pagination) | — |
1621
+ | `datagrid.bottomBar.info` | Status text ("Rows: ...", "Selected: ...") | — |
1622
+ | `datagrid.bottomBar.clearFilters` | "Clear filters" link | — |
1623
+ | `datagrid.bottomBar.pagination` | Pagination controls wrapper | — |
1624
+ | `datagrid.bottomBar.pagination.button` | Pagination nav button | — |
1625
+ | `datagrid.bottomBar.pagination.info` | Page info text ("1 of 5") | — |
1626
+
1627
+ ---
1628
+
1629
+ ## Dropdown Component
1630
+
1631
+ ```tsx
1632
+ import Dropdown from '@box-kite/react/components/dropdown';
1633
+ ```
1634
+
1635
+ ### Usage
1636
+
1637
+ ```tsx
1638
+ // Single selection (uncontrolled). `label` is not decoration — see Accessibility below.
1639
+ <Dropdown<string> label="Fruit" defaultValue="apple" onChange={(value, values) => console.log(value)}>
1640
+ <Dropdown.Unselect>Pick a fruit...</Dropdown.Unselect>
1641
+ <Dropdown.Item value="apple">Apple</Dropdown.Item>
1642
+ <Dropdown.Item value="banana">Banana</Dropdown.Item>
1643
+ </Dropdown>
1644
+
1645
+ // Controlled
1646
+ <Dropdown<string> label="Fruit" value={fruit} onChange={(value) => setFruit(value!)}>
1647
+ <Dropdown.Item value="apple">Apple</Dropdown.Item>
1648
+ <Dropdown.Item value="banana">Banana</Dropdown.Item>
1649
+ </Dropdown>
1650
+
1651
+ // Multiple + search + checkboxes
1652
+ <Dropdown<string> label="Fruit" multiple showCheckbox isSearchable searchPlaceholder="Search...">
1653
+ <Dropdown.SelectAll>Select all</Dropdown.SelectAll>
1654
+ <Dropdown.EmptyItem>No results</Dropdown.EmptyItem>
1655
+ <Dropdown.Display>{(values) => values.length === 0 ? 'Pick...' : `${values.length} selected`}</Dropdown.Display>
1656
+ <Dropdown.Item value="apple">Apple</Dropdown.Item>
1657
+ <Dropdown.Item value="banana">Banana</Dropdown.Item>
1658
+ </Dropdown>
1659
+
1660
+ // Form integration: name prop renders hidden inputs with JSON-stringified values
1661
+ <Dropdown<string> name="fruits" multiple defaultValue={['apple']}>...</Dropdown>
1662
+ ```
1663
+
1664
+ ### Props
1665
+
1666
+ | Prop | Type | Description |
1667
+ | ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------- |
1668
+ | `value` / `defaultValue` | `TVal \| TVal[]` | Controlled / uncontrolled selected value(s) |
1669
+ | `label` | `ReactNode` | The control's name, rendered above it and wired with `aria-labelledby` |
1670
+ | `labelProps` | `BoxProps<'div'>` | Styles for the wrapper the label and the trigger share |
1671
+ | `multiple` | `boolean` | Multi-select mode |
1672
+ | `isSearchable` | `boolean` | Editable combobox: a text field that filters the list (see Accessibility) |
1673
+ | `searchPlaceholder` | `string` | Search input placeholder |
1674
+ | `hideIcon` | `boolean` | Hide chevron icon |
1675
+ | `showCheckbox` | `boolean` | Show checkboxes in multiple mode |
1676
+ | `name` | `string` | Form field name (renders hidden `<input>` elements) |
1677
+ | `onChange` | `(value: TVal \| undefined, values: TVal[]) => void` | Selection callback |
1678
+ | `itemsProps` | `BoxStyleProps` | Style overrides for the opened items container (`dropdown.items`) |
1679
+ | `iconProps` | `BoxStyleProps` | Style overrides for the chevron icon container (`dropdown.icon`) |
1680
+ | `variant` | `ClassNameType` | Propagates to root **and all child sub-components** |
1681
+
1682
+ 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.
1683
+
1684
+ ### Sub-Components
1685
+
1686
+ All sub-components accept BoxProps for per-instance style overrides.
1687
+
1688
+ | Sub-Component | Purpose |
1689
+ | --------------------- | ---------------------------------------------------------------------------------- |
1690
+ | `Dropdown.Item<TVal>` | Selectable option. Requires `value` prop |
1691
+ | `Dropdown.Unselect` | Clear selection option (shown when items selected) |
1692
+ | `Dropdown.SelectAll` | Select all (shown in `multiple` when not all selected) |
1693
+ | `Dropdown.EmptyItem` | Shown when search yields no results |
1694
+ | `Dropdown.Display` | Custom display: static content or `(values: TVal[], isOpen: boolean) => ReactNode` |
1695
+
1696
+ ### Accessibility
1697
+
1698
+ Dropdown is the APG **select-only combobox**, and it supplies the whole pattern — do not add roles by
1699
+ hand (`props={{ role: 'combobox' }}` on the trigger and `role="option"` on items used to be the advice;
1700
+ delete it). What you get:
1701
+
1702
+ - Trigger: `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-activedescendant`. **DOM focus
1703
+ never leaves it** — the popup is navigated by naming the highlighted option, not by focusing it.
1704
+ - Popup: `role="listbox"` (`aria-multiselectable` in `multiple` mode), rows `role="option"` with
1705
+ `aria-selected`. `Dropdown.Unselect` and `Dropdown.SelectAll` are options too, so the arrows reach them.
1706
+ - Keyboard, closed: Down/Up/Enter/Space open (on the selection, else at an end), Home/End open at an end,
1707
+ a printable character opens at the first match. Open: arrows move, Home/End jump, typing searches,
1708
+ Enter/Space choose, Escape closes unchanged, Tab chooses then leaves, Alt+Up chooses and closes.
1709
+ - `disabled` on a `Dropdown.Item` gets `aria-disabled`, is skipped by the arrows and ignores clicks.
1710
+ - `showCheckbox` draws the boxes as decoration (`aria-hidden`): `aria-selected` on the option is the state,
1711
+ and a focusable input inside an option would be one widget nested in another.
1712
+
1713
+ **You still owe it a name.** A combobox is not named by its contents — the trigger's text is its _value_ —
1714
+ so pass `label`, or your own `aria-label` / `aria-labelledby` through `props`. With neither, the control
1715
+ has no accessible name at all.
1716
+
1717
+ ```tsx
1718
+ <Dropdown<string> label="Fruit">…</Dropdown>
1719
+ <Dropdown<string> props={{ 'aria-label': 'Fruit' }}>…</Dropdown>
1720
+ ```
1721
+
1722
+ `isSearchable` switches patterns rather than decorating this one: the **editable combobox**. The search
1723
+ `<input>` _is_ the combobox — `role="combobox"`, `aria-autocomplete="list"`, and whatever you pass in
1724
+ `props` all land on it, so nothing focusable sits inside anything else focusable. The field shows the
1725
+ selection as its value and the query while one is being typed; a custom `Dropdown.Display` keeps drawing
1726
+ the selection instead, since arbitrary JSX cannot be a field's value. Its keyboard map differs: the
1727
+ printable keys type (no typeahead — the visible field owns them), Space types a space, Home/End and the
1728
+ left/right arrows move the caret and hand the highlight back to the field, only Down/Up reach the listbox,
1729
+ Enter chooses the highlighted option, and Escape closes the listbox before a second Escape clears the
1730
+ field. Filtering never moves the highlight onto a suggestion — that is the user's to do with an arrow.
1731
+
1732
+ ### Style Customization
1733
+
1734
+ **Per-instance** — use `itemsProps`, `iconProps`, or BoxProps directly on sub-components:
1735
+
1736
+ ```tsx
1737
+ <Dropdown<string> itemsProps={{ width: 80, maxHeight: 50 }} iconProps={{ color: 'gray-400' }}>
1738
+ <Dropdown.Item value="a" bgColor="blue-50" fontWeight={600}>
1739
+ Highlighted
1740
+ </Dropdown.Item>
1741
+ </Dropdown>
1742
+ ```
1743
+
1744
+ **Global** — override defaults via `Box.components()` (deep-merged):
1745
+
1746
+ ```tsx
1747
+ Box.components({
1748
+ dropdown: {
1749
+ styles: { borderRadius: 4, bgColor: 'gray-50' },
1750
+ children: {
1751
+ items: { styles: { shadow: 'large', borderRadius: 4 } },
1752
+ item: { styles: { borderRadius: 2, hover: { bgColor: 'blue-50' } } },
1753
+ },
1754
+ },
1755
+ });
1756
+ ```
1757
+
1758
+ **Custom variants** — `variant` propagates to all children. Define matching variants on each child:
1759
+
1760
+ ```tsx
1761
+ Box.components({
1762
+ dropdown: {
1763
+ variants: { dense: { p: 1, fontSize: 12 } },
1764
+ children: {
1765
+ items: { variants: { dense: { maxHeight: 40, gap: 0 } } },
1766
+ item: { variants: { dense: { p: 1, lineHeight: 16 } } },
1767
+ unselect: { variants: { dense: { p: 1 } } },
1768
+ selectAll: { variants: { dense: { p: 1 } } },
1769
+ emptyItem: { variants: { dense: { p: 1 } } },
1770
+ },
1771
+ },
1772
+ });
1773
+ <Dropdown variant="dense">...</Dropdown>; // applies to root + all children
1774
+ ```
1775
+
1776
+ ### Component Style Tree
1777
+
1778
+ | Component Name | Description | Built-in Variants |
1779
+ | -------------------- | ------------------------------- | --------------------- |
1780
+ | `dropdown` | Root button trigger | `compact` |
1781
+ | `dropdown.items` | Opened items container (portal) | — |
1782
+ | `dropdown.item` | Selectable item | `compact`, `multiple` |
1783
+ | `dropdown.unselect` | Clear selection option | `compact` |
1784
+ | `dropdown.selectAll` | Select all option | `compact` |
1785
+ | `dropdown.emptyItem` | No results placeholder | `compact` |
1786
+ | `dropdown.icon` | Chevron arrow container | — |
1787
+
1788
+ ---
1789
+
1790
+ ## Select Component
1791
+
1792
+ Data-driven dropdown — pass `data` + `def` instead of composing children. Wraps Dropdown internally, shares the same `dropdown.*` style tree.
1793
+
1794
+ ```tsx
1795
+ import Select from '@box-kite/react/components/select';
1796
+
1797
+ // Basic — `label` names the combobox, same as on Dropdown
1798
+ <Select<User, number> label="User" data={users} def={{ valueKey: 'id', displayKey: 'name', placeholder: 'Pick...' }}
1799
+ value={selected} onChange={(value) => setSelected(value!)} />
1800
+
1801
+ // Multiple + search + custom display
1802
+ <Select<User, number> label="Users" data={users} multiple showCheckbox isSearchable searchPlaceholder="Search..."
1803
+ def={{
1804
+ valueKey: 'id', displayKey: 'name', placeholder: 'Pick users...',
1805
+ selectAllText: 'Select all', emptyText: 'No results',
1806
+ display: (user) => `${user.name} — ${user.role}`,
1807
+ selectedDisplay: (rows) => `${rows.length} selected`,
1808
+ }} />
1809
+ ```
1810
+
1811
+ ### SelectDef
1812
+
1813
+ | Prop | Type | Description |
1814
+ | ----------------- | ---------------------------------------------- | ------------------------------------------------------ |
1815
+ | `valueKey` | `keyof TRow` | Required — field used as option value |
1816
+ | `displayKey` | `keyof TRow` | Field to display (defaults to valueKey) |
1817
+ | `display` | `(row: TRow) => ReactNode` | Custom render per item |
1818
+ | `selectedDisplay` | `(rows: TRow[], isOpen: boolean) => ReactNode` | Custom trigger display (receives resolved row objects) |
1819
+ | `placeholder` | `string` | Unselect/placeholder text |
1820
+ | `selectAllText` | `string` | Select all option text (multiple mode) |
1821
+ | `emptyText` | `string` | Empty search results text |
1822
+
1823
+ Also accepts: `data` (TRow[]), `value`/`defaultValue`, `label`/`labelProps`, `multiple`, `isSearchable`, `searchPlaceholder`, `showCheckbox`, `hideIcon`, `name`, `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.
1824
+
1825
+ ---
1826
+
1827
+ ## Debugging Tips
1828
+
1829
+ 1. **Inspect styles**: `<style id="box-kite-styles">` in document head
1830
+ 2. **Class names**: Elements get classes like `_b`, `_2a`, etc.
1831
+ 3. **CSS variables**: In `:root` rules
1832
+ 4. **Theme issues**: Ensure `<Box.Theme>` wraps your app
1833
+ 5. **Portal theming**: Tooltips/dropdowns use `#box-kite-portal` container