@primitiv-ui/react 0.1.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 (585) hide show
  1. package/README.md +79 -0
  2. package/package.json +59 -0
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +40 -0
  4. package/src/AccessibleIcon/README.md +42 -0
  5. package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +47 -0
  6. package/src/AccessibleIcon/index.ts +2 -0
  7. package/src/AccessibleIcon/types.ts +8 -0
  8. package/src/Accordion/Accordion.tsx +412 -0
  9. package/src/Accordion/AccordionContext.ts +12 -0
  10. package/src/Accordion/README.md +202 -0
  11. package/src/Accordion/__tests__/Accordion.asChild.test.tsx +237 -0
  12. package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +333 -0
  13. package/src/Accordion/__tests__/Accordion.controlled-state.test.tsx +175 -0
  14. package/src/Accordion/__tests__/Accordion.data-attributes.test.tsx +272 -0
  15. package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +311 -0
  16. package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +119 -0
  17. package/src/Accordion/__tests__/Accordion.forceMount.test.tsx +119 -0
  18. package/src/Accordion/__tests__/Accordion.keyboard-interaction.test.tsx +736 -0
  19. package/src/Accordion/__tests__/Accordion.mouse-interaction.test.tsx +212 -0
  20. package/src/Accordion/__tests__/Accordion.multiple-mode.test.tsx +90 -0
  21. package/src/Accordion/__tests__/Accordion.reading-direction.test.tsx +139 -0
  22. package/src/Accordion/__tests__/Accordion.uncontrolled-state.test.tsx +154 -0
  23. package/src/Accordion/hooks/index.ts +6 -0
  24. package/src/Accordion/hooks/useAccordionContext.ts +1 -0
  25. package/src/Accordion/hooks/useAccordionHeaderContext.ts +10 -0
  26. package/src/Accordion/hooks/useAccordionItem.ts +22 -0
  27. package/src/Accordion/hooks/useAccordionItemContext.ts +1 -0
  28. package/src/Accordion/hooks/useAccordionRoot.ts +151 -0
  29. package/src/Accordion/hooks/useAccordionTrigger.ts +90 -0
  30. package/src/Accordion/index.ts +1 -0
  31. package/src/Accordion/types.ts +81 -0
  32. package/src/Alert/Alert.tsx +43 -0
  33. package/src/Alert/README.md +54 -0
  34. package/src/Alert/__tests__/Alert.test.tsx +28 -0
  35. package/src/Alert/index.ts +2 -0
  36. package/src/Alert/types.ts +5 -0
  37. package/src/Avatar/Avatar.tsx +149 -0
  38. package/src/Avatar/AvatarContext.ts +20 -0
  39. package/src/Avatar/README.md +116 -0
  40. package/src/Avatar/__tests__/Avatar.asChild.test.tsx +53 -0
  41. package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +14 -0
  42. package/src/Avatar/__tests__/Avatar.error-handling.test.tsx +30 -0
  43. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +75 -0
  44. package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +81 -0
  45. package/src/Avatar/hooks/index.ts +2 -0
  46. package/src/Avatar/hooks/useAvatarContext.ts +1 -0
  47. package/src/Avatar/hooks/useAvatarImage.ts +40 -0
  48. package/src/Avatar/index.ts +3 -0
  49. package/src/Avatar/types.ts +44 -0
  50. package/src/Breadcrumb/Breadcrumb.tsx +234 -0
  51. package/src/Breadcrumb/README.md +111 -0
  52. package/src/Breadcrumb/__tests__/Breadcrumb.asChild.test.tsx +33 -0
  53. package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +132 -0
  54. package/src/Breadcrumb/index.ts +2 -0
  55. package/src/Breadcrumb/types.ts +22 -0
  56. package/src/Button/Button.tsx +95 -0
  57. package/src/Button/README.md +112 -0
  58. package/src/Button/__tests__/Button.asChild.test.tsx +91 -0
  59. package/src/Button/__tests__/Button.basic-rendering.test.tsx +126 -0
  60. package/src/Button/__tests__/Button.contract.test.tsx +72 -0
  61. package/src/Button/__tests__/Button.disabled.test.tsx +52 -0
  62. package/src/Button/__tests__/Button.icon-usage.test.tsx +57 -0
  63. package/src/Button/__tests__/Button.keyboard-interaction.test.tsx +70 -0
  64. package/src/Button/index.ts +2 -0
  65. package/src/Button/types.ts +8 -0
  66. package/src/Carousel/Carousel.tsx +708 -0
  67. package/src/Carousel/CarouselContext.ts +11 -0
  68. package/src/Carousel/README.md +848 -0
  69. package/src/Carousel/__tests__/Carousel.asChild.test.tsx +178 -0
  70. package/src/Carousel/__tests__/Carousel.auto-play.test.tsx +617 -0
  71. package/src/Carousel/__tests__/Carousel.basic-rendering.test.tsx +569 -0
  72. package/src/Carousel/__tests__/Carousel.controlled-state.test.tsx +137 -0
  73. package/src/Carousel/__tests__/Carousel.error-handling.test.tsx +81 -0
  74. package/src/Carousel/__tests__/Carousel.ids.test.tsx +111 -0
  75. package/src/Carousel/__tests__/Carousel.imperative-api.test.tsx +213 -0
  76. package/src/Carousel/__tests__/Carousel.indicators.test.tsx +560 -0
  77. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +276 -0
  78. package/src/Carousel/__tests__/Carousel.keyboard-navigation.test.tsx +158 -0
  79. package/src/Carousel/__tests__/Carousel.play-pause.test.tsx +232 -0
  80. package/src/Carousel/__tests__/Carousel.prev-next.test.tsx +68 -0
  81. package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +49 -0
  82. package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +87 -0
  83. package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +179 -0
  84. package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +109 -0
  85. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +151 -0
  86. package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +183 -0
  87. package/src/Carousel/__tests__/Carousel.touch-interaction.test.tsx +96 -0
  88. package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +70 -0
  89. package/src/Carousel/__tests__/Carousel.translations.test.tsx +157 -0
  90. package/src/Carousel/__tests__/Carousel.uncontrolled-state.test.tsx +146 -0
  91. package/src/Carousel/hooks/index.ts +4 -0
  92. package/src/Carousel/hooks/useCarouselContext.ts +13 -0
  93. package/src/Carousel/hooks/useCarouselRoot.ts +450 -0
  94. package/src/Carousel/hooks/useCarouselSlide.ts +45 -0
  95. package/src/Carousel/hooks/useCarouselViewport.ts +290 -0
  96. package/src/Carousel/index.ts +3 -0
  97. package/src/Carousel/types.ts +400 -0
  98. package/src/Checkbox/Checkbox.tsx +228 -0
  99. package/src/Checkbox/CheckboxContext.ts +12 -0
  100. package/src/Checkbox/README.md +156 -0
  101. package/src/Checkbox/__tests__/Checkbox.asChild.test.tsx +69 -0
  102. package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +41 -0
  103. package/src/Checkbox/__tests__/Checkbox.controlled-state.test.tsx +82 -0
  104. package/src/Checkbox/__tests__/Checkbox.disabled.test.tsx +15 -0
  105. package/src/Checkbox/__tests__/Checkbox.indeterminate.test.tsx +82 -0
  106. package/src/Checkbox/__tests__/Checkbox.indicator.test.tsx +117 -0
  107. package/src/Checkbox/__tests__/Checkbox.uncontrolled-state.test.tsx +89 -0
  108. package/src/Checkbox/hooks/index.ts +2 -0
  109. package/src/Checkbox/hooks/useCheckboxContext.ts +1 -0
  110. package/src/Checkbox/hooks/useCheckboxRoot.ts +32 -0
  111. package/src/Checkbox/index.ts +1 -0
  112. package/src/Checkbox/types.ts +33 -0
  113. package/src/CheckboxCard/CheckboxCard.tsx +208 -0
  114. package/src/CheckboxCard/CheckboxCardContext.ts +12 -0
  115. package/src/CheckboxCard/README.md +114 -0
  116. package/src/CheckboxCard/__tests__/CheckboxCard.asChild.test.tsx +54 -0
  117. package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +58 -0
  118. package/src/CheckboxCard/__tests__/CheckboxCard.controlled-state.test.tsx +77 -0
  119. package/src/CheckboxCard/__tests__/CheckboxCard.disabled.test.tsx +55 -0
  120. package/src/CheckboxCard/__tests__/CheckboxCard.error-handling.test.tsx +20 -0
  121. package/src/CheckboxCard/__tests__/CheckboxCard.indeterminate.test.tsx +60 -0
  122. package/src/CheckboxCard/__tests__/CheckboxCard.indicator.test.tsx +136 -0
  123. package/src/CheckboxCard/__tests__/CheckboxCard.uncontrolled-state.test.tsx +73 -0
  124. package/src/CheckboxCard/hooks/index.ts +2 -0
  125. package/src/CheckboxCard/hooks/useCheckboxCardContext.ts +1 -0
  126. package/src/CheckboxCard/hooks/useCheckboxCardRoot.ts +30 -0
  127. package/src/CheckboxCard/index.ts +3 -0
  128. package/src/CheckboxCard/types.ts +33 -0
  129. package/src/Collapsible/Collapsible.tsx +316 -0
  130. package/src/Collapsible/CollapsibleContext.ts +7 -0
  131. package/src/Collapsible/README.md +174 -0
  132. package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +240 -0
  133. package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +118 -0
  134. package/src/Collapsible/__tests__/Collapsible.controlled-state.test.tsx +134 -0
  135. package/src/Collapsible/__tests__/Collapsible.disabled.test.tsx +132 -0
  136. package/src/Collapsible/__tests__/Collapsible.error-handling.test.tsx +40 -0
  137. package/src/Collapsible/__tests__/Collapsible.forceMount.test.tsx +111 -0
  138. package/src/Collapsible/__tests__/Collapsible.triggerIcon.test.tsx +93 -0
  139. package/src/Collapsible/__tests__/Collapsible.uncontrolled-state.test.tsx +125 -0
  140. package/src/Collapsible/hooks/index.ts +2 -0
  141. package/src/Collapsible/hooks/useCollapsibleRoot.ts +34 -0
  142. package/src/Collapsible/hooks/useCollapsibleTrigger.ts +49 -0
  143. package/src/Collapsible/index.ts +1 -0
  144. package/src/Collapsible/types.ts +48 -0
  145. package/src/ContextMenu/ContextMenu.tsx +1004 -0
  146. package/src/ContextMenu/ContextMenuContentContext.ts +15 -0
  147. package/src/ContextMenu/ContextMenuContext.ts +21 -0
  148. package/src/ContextMenu/ContextMenuGroupContext.ts +8 -0
  149. package/src/ContextMenu/ContextMenuItemIndicatorContext.ts +8 -0
  150. package/src/ContextMenu/ContextMenuRadioGroupContext.ts +9 -0
  151. package/src/ContextMenu/ContextMenuSubContext.ts +15 -0
  152. package/src/ContextMenu/README.md +275 -0
  153. package/src/ContextMenu/__tests__/ContextMenu.asChild.test.tsx +186 -0
  154. package/src/ContextMenu/__tests__/ContextMenu.basic-rendering.test.tsx +39 -0
  155. package/src/ContextMenu/__tests__/ContextMenu.checkbox-item.test.tsx +145 -0
  156. package/src/ContextMenu/__tests__/ContextMenu.error-handling.test.tsx +113 -0
  157. package/src/ContextMenu/__tests__/ContextMenu.group-label.test.tsx +48 -0
  158. package/src/ContextMenu/__tests__/ContextMenu.item-indicator.test.tsx +88 -0
  159. package/src/ContextMenu/__tests__/ContextMenu.item.test.tsx +106 -0
  160. package/src/ContextMenu/__tests__/ContextMenu.keyboard-interaction.test.tsx +172 -0
  161. package/src/ContextMenu/__tests__/ContextMenu.mouse-interaction.test.tsx +227 -0
  162. package/src/ContextMenu/__tests__/ContextMenu.radio-item.test.tsx +127 -0
  163. package/src/ContextMenu/__tests__/ContextMenu.reading-direction.test.tsx +152 -0
  164. package/src/ContextMenu/__tests__/ContextMenu.separator.test.tsx +47 -0
  165. package/src/ContextMenu/__tests__/ContextMenu.state-modes.test.tsx +119 -0
  166. package/src/ContextMenu/__tests__/ContextMenu.sub.test.tsx +262 -0
  167. package/src/ContextMenu/__tests__/ContextMenu.typeahead.test.tsx +89 -0
  168. package/src/ContextMenu/constants.ts +4 -0
  169. package/src/ContextMenu/index.ts +1 -0
  170. package/src/ContextMenu/types.ts +199 -0
  171. package/src/DirectionProvider/DirectionContext.ts +21 -0
  172. package/src/DirectionProvider/DirectionProvider.tsx +31 -0
  173. package/src/DirectionProvider/README.md +62 -0
  174. package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +29 -0
  175. package/src/DirectionProvider/index.ts +3 -0
  176. package/src/DirectionProvider/types.ts +10 -0
  177. package/src/Divider/Divider.tsx +57 -0
  178. package/src/Divider/README.md +57 -0
  179. package/src/Divider/__tests__/Divider.test.tsx +41 -0
  180. package/src/Divider/index.ts +1 -0
  181. package/src/Divider/types.ts +5 -0
  182. package/src/Dropdown/Dropdown.tsx +842 -0
  183. package/src/Dropdown/DropdownContentContext.ts +15 -0
  184. package/src/Dropdown/DropdownContext.ts +17 -0
  185. package/src/Dropdown/DropdownGroupContext.ts +8 -0
  186. package/src/Dropdown/DropdownItemIndicatorContext.ts +13 -0
  187. package/src/Dropdown/DropdownRadioGroupContext.ts +9 -0
  188. package/src/Dropdown/DropdownSubContext.ts +15 -0
  189. package/src/Dropdown/README.md +284 -0
  190. package/src/Dropdown/__tests__/Dropdown.asChild.test.tsx +286 -0
  191. package/src/Dropdown/__tests__/Dropdown.basic-rendering.test.tsx +43 -0
  192. package/src/Dropdown/__tests__/Dropdown.checkbox-item.test.tsx +121 -0
  193. package/src/Dropdown/__tests__/Dropdown.disabled.test.tsx +143 -0
  194. package/src/Dropdown/__tests__/Dropdown.error-handling.test.tsx +85 -0
  195. package/src/Dropdown/__tests__/Dropdown.group-label.test.tsx +68 -0
  196. package/src/Dropdown/__tests__/Dropdown.item-indicator.test.tsx +260 -0
  197. package/src/Dropdown/__tests__/Dropdown.item.test.tsx +72 -0
  198. package/src/Dropdown/__tests__/Dropdown.keyboard-edge-cases.test.tsx +77 -0
  199. package/src/Dropdown/__tests__/Dropdown.keyboard-interaction.test.tsx +310 -0
  200. package/src/Dropdown/__tests__/Dropdown.mouse-interaction.test.tsx +347 -0
  201. package/src/Dropdown/__tests__/Dropdown.radio-item.test.tsx +134 -0
  202. package/src/Dropdown/__tests__/Dropdown.reading-direction.test.tsx +153 -0
  203. package/src/Dropdown/__tests__/Dropdown.separator.test.tsx +46 -0
  204. package/src/Dropdown/__tests__/Dropdown.state-modes.test.tsx +100 -0
  205. package/src/Dropdown/__tests__/Dropdown.sub.test.tsx +185 -0
  206. package/src/Dropdown/__tests__/Dropdown.trigger.test.tsx +110 -0
  207. package/src/Dropdown/__tests__/Dropdown.typeahead.test.tsx +133 -0
  208. package/src/Dropdown/constants.ts +4 -0
  209. package/src/Dropdown/hooks/index.ts +9 -0
  210. package/src/Dropdown/hooks/useCloseSiblingSub.ts +13 -0
  211. package/src/Dropdown/hooks/useDropdownContent.ts +162 -0
  212. package/src/Dropdown/hooks/useDropdownContext.ts +1 -0
  213. package/src/Dropdown/hooks/useDropdownGroup.ts +18 -0
  214. package/src/Dropdown/hooks/useDropdownItem.ts +49 -0
  215. package/src/Dropdown/hooks/useDropdownLabel.ts +15 -0
  216. package/src/Dropdown/hooks/useDropdownRoot.ts +57 -0
  217. package/src/Dropdown/hooks/useDropdownSubContext.ts +1 -0
  218. package/src/Dropdown/hooks/useDropdownTrigger.ts +31 -0
  219. package/src/Dropdown/index.ts +1 -0
  220. package/src/Dropdown/types.ts +200 -0
  221. package/src/EmptyState/EmptyState.tsx +245 -0
  222. package/src/EmptyState/README.md +129 -0
  223. package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +32 -0
  224. package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +30 -0
  225. package/src/EmptyState/__tests__/EmptyState.Media.test.tsx +34 -0
  226. package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +28 -0
  227. package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +26 -0
  228. package/src/EmptyState/index.ts +2 -0
  229. package/src/EmptyState/types.ts +21 -0
  230. package/src/Field/Field.tsx +239 -0
  231. package/src/Field/FieldContext.ts +22 -0
  232. package/src/Field/README.md +167 -0
  233. package/src/Field/__tests__/Field.asChild.test.tsx +83 -0
  234. package/src/Field/__tests__/Field.basic-rendering.test.tsx +104 -0
  235. package/src/Field/__tests__/Field.state-cascade.test.tsx +75 -0
  236. package/src/Field/hooks/index.ts +2 -0
  237. package/src/Field/hooks/useFieldContext.ts +1 -0
  238. package/src/Field/hooks/useFieldProps.ts +57 -0
  239. package/src/Field/index.ts +2 -0
  240. package/src/Field/types.ts +33 -0
  241. package/src/Fieldset/Fieldset.tsx +104 -0
  242. package/src/Fieldset/README.md +74 -0
  243. package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +81 -0
  244. package/src/Fieldset/__tests__/Fieldset.disabled.test.tsx +41 -0
  245. package/src/Fieldset/index.ts +2 -0
  246. package/src/Fieldset/types.ts +5 -0
  247. package/src/Input/Input.tsx +120 -0
  248. package/src/Input/README.md +180 -0
  249. package/src/Input/__tests__/Input.asChild.test.tsx +85 -0
  250. package/src/Input/__tests__/Input.basic-rendering.test.tsx +118 -0
  251. package/src/Input/__tests__/Input.disabled.test.tsx +49 -0
  252. package/src/Input/__tests__/Input.field-integration.test.tsx +148 -0
  253. package/src/Input/index.ts +2 -0
  254. package/src/Input/types.ts +7 -0
  255. package/src/InputGroup/InputGroup.tsx +228 -0
  256. package/src/InputGroup/README.md +178 -0
  257. package/src/InputGroup/__tests__/InputGroup.asChild.test.tsx +109 -0
  258. package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +106 -0
  259. package/src/InputGroup/index.ts +2 -0
  260. package/src/InputGroup/types.ts +13 -0
  261. package/src/MillerColumns/MillerColumns.tsx +329 -0
  262. package/src/MillerColumns/MillerColumnsContext.ts +25 -0
  263. package/src/MillerColumns/README.md +278 -0
  264. package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +82 -0
  265. package/src/MillerColumns/__tests__/MillerColumns.asChild.test.tsx +106 -0
  266. package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +68 -0
  267. package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +52 -0
  268. package/src/MillerColumns/__tests__/MillerColumns.column-projection.test.tsx +161 -0
  269. package/src/MillerColumns/__tests__/MillerColumns.controlled-state.test.tsx +90 -0
  270. package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +77 -0
  271. package/src/MillerColumns/__tests__/MillerColumns.disabled-items.test.tsx +65 -0
  272. package/src/MillerColumns/__tests__/MillerColumns.error-handling.test.tsx +57 -0
  273. package/src/MillerColumns/__tests__/MillerColumns.fixtures.ts +15 -0
  274. package/src/MillerColumns/__tests__/MillerColumns.item-indicator.test.tsx +57 -0
  275. package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +181 -0
  276. package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +47 -0
  277. package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +137 -0
  278. package/src/MillerColumns/__tests__/MillerColumns.roving-tabindex.test.tsx +91 -0
  279. package/src/MillerColumns/__tests__/MillerColumns.selection.test.tsx +54 -0
  280. package/src/MillerColumns/__tests__/MillerColumns.uncontrolled-state.test.tsx +70 -0
  281. package/src/MillerColumns/hooks/index.ts +7 -0
  282. package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +23 -0
  283. package/src/MillerColumns/hooks/useMillerColumnsColumnContext.ts +1 -0
  284. package/src/MillerColumns/hooks/useMillerColumnsContext.ts +1 -0
  285. package/src/MillerColumns/hooks/useMillerColumnsItem.ts +157 -0
  286. package/src/MillerColumns/hooks/useMillerColumnsItemContext.ts +1 -0
  287. package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +76 -0
  288. package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
  289. package/src/MillerColumns/index.ts +3 -0
  290. package/src/MillerColumns/types.ts +93 -0
  291. package/src/MillerColumns/useMillerColumnsSelection.ts +31 -0
  292. package/src/MillerColumns/utils.ts +75 -0
  293. package/src/Modal/Modal.tsx +474 -0
  294. package/src/Modal/ModalContext.ts +13 -0
  295. package/src/Modal/README.md +207 -0
  296. package/src/Modal/__tests__/Modal.accessibility.test.tsx +167 -0
  297. package/src/Modal/__tests__/Modal.asChild.test.tsx +162 -0
  298. package/src/Modal/__tests__/Modal.click-outside.test.tsx +115 -0
  299. package/src/Modal/__tests__/Modal.content.test.tsx +193 -0
  300. package/src/Modal/__tests__/Modal.controlled-state.test.tsx +120 -0
  301. package/src/Modal/__tests__/Modal.error-handling.test.tsx +30 -0
  302. package/src/Modal/__tests__/Modal.escape-hatches.test.tsx +99 -0
  303. package/src/Modal/__tests__/Modal.imperative-api.test.tsx +119 -0
  304. package/src/Modal/__tests__/Modal.nested.test.tsx +106 -0
  305. package/src/Modal/__tests__/Modal.overlay.test.tsx +99 -0
  306. package/src/Modal/__tests__/Modal.portal.test.tsx +90 -0
  307. package/src/Modal/__tests__/Modal.presence.test.tsx +111 -0
  308. package/src/Modal/__tests__/Modal.trigger.test.tsx +70 -0
  309. package/src/Modal/__tests__/Modal.uncontrolled-state.test.tsx +72 -0
  310. package/src/Modal/__tests__/dialog-polyfill.ts +40 -0
  311. package/src/Modal/hooks/index.ts +4 -0
  312. package/src/Modal/hooks/useModalContent.ts +62 -0
  313. package/src/Modal/hooks/useModalContext.ts +1 -0
  314. package/src/Modal/hooks/useModalRoot.ts +81 -0
  315. package/src/Modal/hooks/useModalTrigger.ts +25 -0
  316. package/src/Modal/index.ts +3 -0
  317. package/src/Modal/types.ts +76 -0
  318. package/src/Portal/Portal.tsx +28 -0
  319. package/src/Portal/README.md +70 -0
  320. package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +17 -0
  321. package/src/Portal/index.ts +2 -0
  322. package/src/Portal/types.ts +6 -0
  323. package/src/Progress/Progress.tsx +178 -0
  324. package/src/Progress/ProgressContext.ts +15 -0
  325. package/src/Progress/README.md +112 -0
  326. package/src/Progress/__tests__/Progress.asChild.test.tsx +37 -0
  327. package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +65 -0
  328. package/src/Progress/__tests__/Progress.error-handling.test.tsx +40 -0
  329. package/src/Progress/__tests__/Progress.fixtures.ts +7 -0
  330. package/src/Progress/__tests__/Progress.value.test.tsx +83 -0
  331. package/src/Progress/hooks/index.ts +2 -0
  332. package/src/Progress/hooks/useProgressContext.ts +1 -0
  333. package/src/Progress/hooks/useProgressRoot.ts +45 -0
  334. package/src/Progress/index.ts +3 -0
  335. package/src/Progress/types.ts +43 -0
  336. package/src/RadioCard/README.md +133 -0
  337. package/src/RadioCard/RadioCard.tsx +334 -0
  338. package/src/RadioCard/RadioCardContext.ts +23 -0
  339. package/src/RadioCard/RadioCardItemContext.ts +10 -0
  340. package/src/RadioCard/__tests__/RadioCard.asChild.test.tsx +76 -0
  341. package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +87 -0
  342. package/src/RadioCard/__tests__/RadioCard.controlled-state.test.tsx +107 -0
  343. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +61 -0
  344. package/src/RadioCard/__tests__/RadioCard.error-handling.test.tsx +35 -0
  345. package/src/RadioCard/__tests__/RadioCard.indicator.test.tsx +119 -0
  346. package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +158 -0
  347. package/src/RadioCard/__tests__/RadioCard.orientation.test.tsx +90 -0
  348. package/src/RadioCard/__tests__/RadioCard.reading-direction.test.tsx +65 -0
  349. package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +108 -0
  350. package/src/RadioCard/hooks/index.ts +3 -0
  351. package/src/RadioCard/hooks/useRadioCardContext.ts +1 -0
  352. package/src/RadioCard/hooks/useRadioCardItemContext.ts +1 -0
  353. package/src/RadioCard/hooks/useRadioCardRoot.ts +77 -0
  354. package/src/RadioCard/index.ts +4 -0
  355. package/src/RadioCard/types.ts +51 -0
  356. package/src/RadioGroup/README.md +185 -0
  357. package/src/RadioGroup/RadioGroup.tsx +353 -0
  358. package/src/RadioGroup/RadioGroupContext.ts +23 -0
  359. package/src/RadioGroup/RadioGroupItemContext.ts +10 -0
  360. package/src/RadioGroup/__tests__/RadioGroup.asChild.test.tsx +105 -0
  361. package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +72 -0
  362. package/src/RadioGroup/__tests__/RadioGroup.controlled-state.test.tsx +109 -0
  363. package/src/RadioGroup/__tests__/RadioGroup.disabled-keydown-guards.test.tsx +68 -0
  364. package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +79 -0
  365. package/src/RadioGroup/__tests__/RadioGroup.error-handling.test.tsx +33 -0
  366. package/src/RadioGroup/__tests__/RadioGroup.indicator.test.tsx +85 -0
  367. package/src/RadioGroup/__tests__/RadioGroup.keyboard-interaction.test.tsx +135 -0
  368. package/src/RadioGroup/__tests__/RadioGroup.orientation.test.tsx +90 -0
  369. package/src/RadioGroup/__tests__/RadioGroup.reading-direction.test.tsx +65 -0
  370. package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +45 -0
  371. package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +105 -0
  372. package/src/RadioGroup/__tests__/RadioGroup.uncontrolled-state.test.tsx +96 -0
  373. package/src/RadioGroup/hooks/index.ts +3 -0
  374. package/src/RadioGroup/hooks/useRadioGroupContext.ts +1 -0
  375. package/src/RadioGroup/hooks/useRadioGroupItemContext.ts +1 -0
  376. package/src/RadioGroup/hooks/useRadioGroupRoot.ts +87 -0
  377. package/src/RadioGroup/index.ts +1 -0
  378. package/src/RadioGroup/types.ts +51 -0
  379. package/src/Select/README.md +203 -0
  380. package/src/Select/Select.tsx +204 -0
  381. package/src/Select/__tests__/Select.asChild.test.tsx +36 -0
  382. package/src/Select/__tests__/Select.basic-rendering.test.tsx +17 -0
  383. package/src/Select/__tests__/Select.controlled-state.test.tsx +69 -0
  384. package/src/Select/__tests__/Select.data-attributes.test.tsx +29 -0
  385. package/src/Select/__tests__/Select.field-integration.test.tsx +150 -0
  386. package/src/Select/__tests__/Select.group.test.tsx +42 -0
  387. package/src/Select/__tests__/Select.placeholder.test.tsx +32 -0
  388. package/src/Select/index.ts +2 -0
  389. package/src/Select/types.ts +89 -0
  390. package/src/SkipNav/README.md +87 -0
  391. package/src/SkipNav/SkipNav.tsx +116 -0
  392. package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +23 -0
  393. package/src/SkipNav/__tests__/SkipNav.ids.test.tsx +19 -0
  394. package/src/SkipNav/index.ts +1 -0
  395. package/src/SkipNav/types.ts +26 -0
  396. package/src/Slider/README.md +215 -0
  397. package/src/Slider/Slider.tsx +308 -0
  398. package/src/Slider/SliderContext.ts +24 -0
  399. package/src/Slider/__tests__/Slider.asChild.test.tsx +119 -0
  400. package/src/Slider/__tests__/Slider.basic-rendering.test.tsx +157 -0
  401. package/src/Slider/__tests__/Slider.controlled-state.test.tsx +78 -0
  402. package/src/Slider/__tests__/Slider.disabled.test.tsx +82 -0
  403. package/src/Slider/__tests__/Slider.error-handling.test.tsx +45 -0
  404. package/src/Slider/__tests__/Slider.fixtures.ts +53 -0
  405. package/src/Slider/__tests__/Slider.form.test.tsx +67 -0
  406. package/src/Slider/__tests__/Slider.inverted.test.tsx +112 -0
  407. package/src/Slider/__tests__/Slider.keyboard-interaction.test.tsx +118 -0
  408. package/src/Slider/__tests__/Slider.multiple-thumbs.test.tsx +84 -0
  409. package/src/Slider/__tests__/Slider.orientation.test.tsx +101 -0
  410. package/src/Slider/__tests__/Slider.pointer-interaction.test.tsx +205 -0
  411. package/src/Slider/__tests__/Slider.reading-direction.test.tsx +99 -0
  412. package/src/Slider/__tests__/Slider.uncontrolled-state.test.tsx +69 -0
  413. package/src/Slider/__tests__/Slider.value-commit.test.tsx +103 -0
  414. package/src/Slider/hooks/index.ts +3 -0
  415. package/src/Slider/hooks/useSliderContext.ts +1 -0
  416. package/src/Slider/hooks/useSliderRoot.ts +197 -0
  417. package/src/Slider/hooks/useSliderThumb.ts +77 -0
  418. package/src/Slider/index.ts +3 -0
  419. package/src/Slider/types.ts +48 -0
  420. package/src/Slider/utils.ts +155 -0
  421. package/src/Slot/Slot.tsx +158 -0
  422. package/src/Slot/__tests__/Slot.test.tsx +163 -0
  423. package/src/Slot/__tests__/composeEventHandlers.test.ts +74 -0
  424. package/src/Slot/composeEventHandlers.ts +38 -0
  425. package/src/Slot/index.ts +3 -0
  426. package/src/Slot/types.ts +5 -0
  427. package/src/Status/README.md +50 -0
  428. package/src/Status/Status.tsx +44 -0
  429. package/src/Status/__tests__/Status.test.tsx +28 -0
  430. package/src/Status/index.ts +2 -0
  431. package/src/Status/types.ts +5 -0
  432. package/src/Switch/README.md +121 -0
  433. package/src/Switch/Switch.tsx +167 -0
  434. package/src/Switch/SwitchContext.ts +10 -0
  435. package/src/Switch/__tests__/Switch.asChild.test.tsx +56 -0
  436. package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +76 -0
  437. package/src/Switch/__tests__/Switch.contract.test.tsx +109 -0
  438. package/src/Switch/__tests__/Switch.controlled-state.test.tsx +79 -0
  439. package/src/Switch/__tests__/Switch.disabled.test.tsx +60 -0
  440. package/src/Switch/__tests__/Switch.error-handling.test.tsx +20 -0
  441. package/src/Switch/__tests__/Switch.keyboard-interaction.test.tsx +56 -0
  442. package/src/Switch/__tests__/Switch.thumb.test.tsx +84 -0
  443. package/src/Switch/__tests__/Switch.uncontrolled-state.test.tsx +83 -0
  444. package/src/Switch/hooks/index.ts +2 -0
  445. package/src/Switch/hooks/useSwitchContext.ts +1 -0
  446. package/src/Switch/hooks/useSwitchRoot.ts +28 -0
  447. package/src/Switch/index.ts +3 -0
  448. package/src/Switch/types.ts +37 -0
  449. package/src/Table/README.md +205 -0
  450. package/src/Table/Table.tsx +380 -0
  451. package/src/Table/__tests__/Table.Body.test.tsx +61 -0
  452. package/src/Table/__tests__/Table.Caption.test.tsx +70 -0
  453. package/src/Table/__tests__/Table.Cell.test.tsx +73 -0
  454. package/src/Table/__tests__/Table.Footer.test.tsx +61 -0
  455. package/src/Table/__tests__/Table.Head.test.tsx +61 -0
  456. package/src/Table/__tests__/Table.Header.test.tsx +73 -0
  457. package/src/Table/__tests__/Table.Root.test.tsx +49 -0
  458. package/src/Table/__tests__/Table.Row.test.tsx +67 -0
  459. package/src/Table/__tests__/Table.ScrollArea.test.tsx +83 -0
  460. package/src/Table/index.ts +1 -0
  461. package/src/Table/types.ts +63 -0
  462. package/src/Tabs/README.md +110 -0
  463. package/src/Tabs/Tabs.tsx +434 -0
  464. package/src/Tabs/TabsContext.ts +13 -0
  465. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +114 -0
  466. package/src/Tabs/__tests__/Tabs.asChild.test.tsx +91 -0
  467. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +483 -0
  468. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +133 -0
  469. package/src/Tabs/__tests__/Tabs.controlled-state.test.tsx +152 -0
  470. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +203 -0
  471. package/src/Tabs/__tests__/Tabs.error-handling.test.tsx +82 -0
  472. package/src/Tabs/__tests__/Tabs.fixtures.ts +171 -0
  473. package/src/Tabs/__tests__/Tabs.imperative-api.test.tsx +118 -0
  474. package/src/Tabs/__tests__/Tabs.keyboard-interaction.test.tsx +192 -0
  475. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +61 -0
  476. package/src/Tabs/__tests__/Tabs.mouse-interaction.test.tsx +216 -0
  477. package/src/Tabs/__tests__/Tabs.reading-direction.test.tsx +58 -0
  478. package/src/Tabs/__tests__/Tabs.uncontrolled-state.test.tsx +197 -0
  479. package/src/Tabs/hooks/index.ts +4 -0
  480. package/src/Tabs/hooks/useTabsContent.ts +27 -0
  481. package/src/Tabs/hooks/useTabsContext.ts +1 -0
  482. package/src/Tabs/hooks/useTabsRoot.ts +148 -0
  483. package/src/Tabs/hooks/useTabsTrigger.ts +111 -0
  484. package/src/Tabs/index.ts +3 -0
  485. package/src/Tabs/types.ts +99 -0
  486. package/src/Tabs/utils.ts +8 -0
  487. package/src/Textarea/README.md +98 -0
  488. package/src/Textarea/Textarea.tsx +93 -0
  489. package/src/Textarea/__tests__/Textarea.asChild.test.tsx +85 -0
  490. package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +107 -0
  491. package/src/Textarea/__tests__/Textarea.disabled.test.tsx +49 -0
  492. package/src/Textarea/__tests__/Textarea.field-integration.test.tsx +134 -0
  493. package/src/Textarea/index.ts +2 -0
  494. package/src/Textarea/types.ts +7 -0
  495. package/src/Toggle/README.md +97 -0
  496. package/src/Toggle/Toggle.tsx +81 -0
  497. package/src/Toggle/__tests__/Toggle.asChild.test.tsx +42 -0
  498. package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +28 -0
  499. package/src/Toggle/__tests__/Toggle.controlled-state.test.tsx +60 -0
  500. package/src/Toggle/__tests__/Toggle.disabled.test.tsx +34 -0
  501. package/src/Toggle/__tests__/Toggle.keyboard-interaction.test.tsx +42 -0
  502. package/src/Toggle/__tests__/Toggle.uncontrolled-state.test.tsx +40 -0
  503. package/src/Toggle/index.ts +2 -0
  504. package/src/Toggle/types.ts +23 -0
  505. package/src/ToggleGroup/README.md +137 -0
  506. package/src/ToggleGroup/ToggleGroup.tsx +298 -0
  507. package/src/ToggleGroup/ToggleGroupContext.ts +9 -0
  508. package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +65 -0
  509. package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +50 -0
  510. package/src/ToggleGroup/__tests__/ToggleGroup.disabled.test.tsx +54 -0
  511. package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +151 -0
  512. package/src/ToggleGroup/__tests__/ToggleGroup.multiple-mode.test.tsx +144 -0
  513. package/src/ToggleGroup/__tests__/ToggleGroup.reading-direction.test.tsx +28 -0
  514. package/src/ToggleGroup/__tests__/ToggleGroup.single-mode.test.tsx +139 -0
  515. package/src/ToggleGroup/hooks/index.ts +2 -0
  516. package/src/ToggleGroup/hooks/useToggleGroupContext.ts +1 -0
  517. package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +110 -0
  518. package/src/ToggleGroup/index.ts +2 -0
  519. package/src/ToggleGroup/types.ts +72 -0
  520. package/src/Tooltip/README.md +214 -0
  521. package/src/Tooltip/Tooltip.tsx +260 -0
  522. package/src/Tooltip/TooltipContext.ts +20 -0
  523. package/src/Tooltip/__tests__/Tooltip.asChild.test.tsx +77 -0
  524. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +180 -0
  525. package/src/Tooltip/__tests__/Tooltip.controlled-state.test.tsx +128 -0
  526. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  527. package/src/Tooltip/__tests__/Tooltip.focus-interaction.test.tsx +88 -0
  528. package/src/Tooltip/__tests__/Tooltip.hover-interaction.test.tsx +179 -0
  529. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +85 -0
  530. package/src/Tooltip/__tests__/Tooltip.uncontrolled-state.test.tsx +67 -0
  531. package/src/Tooltip/hooks/index.ts +4 -0
  532. package/src/Tooltip/hooks/useTooltipContent.ts +53 -0
  533. package/src/Tooltip/hooks/useTooltipProvider.ts +41 -0
  534. package/src/Tooltip/hooks/useTooltipRoot.ts +106 -0
  535. package/src/Tooltip/hooks/useTooltipTrigger.ts +44 -0
  536. package/src/Tooltip/index.ts +1 -0
  537. package/src/Tooltip/types.ts +64 -0
  538. package/src/Tree/README.md +339 -0
  539. package/src/Tree/Tree.tsx +571 -0
  540. package/src/Tree/TreeContext.ts +24 -0
  541. package/src/Tree/__tests__/Tree.aria.test.tsx +53 -0
  542. package/src/Tree/__tests__/Tree.asChild.test.tsx +134 -0
  543. package/src/Tree/__tests__/Tree.basic-rendering.test.tsx +111 -0
  544. package/src/Tree/__tests__/Tree.branch-behaviour.test.tsx +87 -0
  545. package/src/Tree/__tests__/Tree.controlled-expansion.test.tsx +92 -0
  546. package/src/Tree/__tests__/Tree.data-attributes.test.tsx +88 -0
  547. package/src/Tree/__tests__/Tree.disabled-items.test.tsx +196 -0
  548. package/src/Tree/__tests__/Tree.error-handling.test.tsx +71 -0
  549. package/src/Tree/__tests__/Tree.forceMount.test.tsx +72 -0
  550. package/src/Tree/__tests__/Tree.keyboard-interaction.test.tsx +150 -0
  551. package/src/Tree/__tests__/Tree.multiple-selection.test.tsx +151 -0
  552. package/src/Tree/__tests__/Tree.range-selection.test.tsx +200 -0
  553. package/src/Tree/__tests__/Tree.recursion-depth.test.tsx +73 -0
  554. package/src/Tree/__tests__/Tree.roving-tabindex.test.tsx +117 -0
  555. package/src/Tree/__tests__/Tree.selection-path.test.tsx +404 -0
  556. package/src/Tree/__tests__/Tree.single-selection.test.tsx +108 -0
  557. package/src/Tree/__tests__/Tree.uncontrolled-expansion.test.tsx +69 -0
  558. package/src/Tree/hooks/index.ts +3 -0
  559. package/src/Tree/hooks/useTreeItemKeyboard.ts +86 -0
  560. package/src/Tree/hooks/useTreePath.ts +68 -0
  561. package/src/Tree/hooks/useTreeRoot.ts +279 -0
  562. package/src/Tree/index.ts +3 -0
  563. package/src/Tree/types.ts +224 -0
  564. package/src/Tree/utils.ts +59 -0
  565. package/src/VisuallyHidden/README.md +58 -0
  566. package/src/VisuallyHidden/VisuallyHidden.tsx +67 -0
  567. package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +59 -0
  568. package/src/VisuallyHidden/index.ts +2 -0
  569. package/src/VisuallyHidden/types.ts +5 -0
  570. package/src/hooks/index.ts +3 -0
  571. package/src/hooks/useCollection.ts +74 -0
  572. package/src/hooks/useControllableState.ts +81 -0
  573. package/src/hooks/useRovingTabindex.ts +178 -0
  574. package/src/index.ts +38 -0
  575. package/src/test/intersectionObserverPolyfill.ts +83 -0
  576. package/src/test/popoverPolyfill.ts +86 -0
  577. package/src/test/scrollPolyfill.ts +23 -0
  578. package/src/types.ts +13 -0
  579. package/src/utils/__tests__/createStrictContext.test.tsx +69 -0
  580. package/src/utils/__tests__/deriveId.test.ts +28 -0
  581. package/src/utils/__tests__/getKeyToActionMap.test.ts +106 -0
  582. package/src/utils/createStrictContext.ts +49 -0
  583. package/src/utils/deriveId.ts +31 -0
  584. package/src/utils/getKeyToActionMap.ts +95 -0
  585. package/src/utils/index.ts +3 -0
@@ -0,0 +1,848 @@
1
+ # Carousel
2
+
3
+ Headless, accessible **Carousel** built on native CSS scroll-snap. Implements
4
+ the [WAI-ARIA Carousel pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/),
5
+ ships zero styles, and is fully composable.
6
+
7
+ The component is built incrementally under strict TDD. This README
8
+ documents the surface that exists today.
9
+
10
+ ## Status
11
+
12
+ Currently exposes:
13
+
14
+ - **`Carousel.Root`** — labelled `<section>` wrapper with
15
+ `aria-roledescription="carousel"`.
16
+ - **`Carousel.Viewport`** — slide container, rendered as a `<div>` with
17
+ a `data-carousel-viewport` attribute the recommended scroll-snap CSS
18
+ targets. Must be rendered as a descendant of `Carousel.Root`; rendering
19
+ it elsewhere throws a descriptive error.
20
+ - **`Carousel.Slide`** — an individual slide. Renders a `<div role="group"
21
+ aria-roledescription="slide">` and self-registers with the Root so each
22
+ slide knows its zero-based `data-index` and the live `data-total`
23
+ count, even as slides mount and unmount. Each slide is auto-labelled
24
+ `"N of M"` (e.g. `"1 of 3"`); pass `ariaLabel` to override with a more
25
+ meaningful description (e.g. `"Hand-picked for you"`). Emits
26
+ `data-state="active" | "inactive"` tracking the active page, plus a
27
+ `data-carousel-slide` CSS hook.
28
+ - **`Carousel.NextTrigger`** — `<button>` that advances the active page
29
+ by one. `disabled` at the last page, and whenever zero or one slides
30
+ are registered. Consumer `onClick` runs before the navigation;
31
+ consumer-supplied `disabled={true}` is honoured alongside the boundary
32
+ check.
33
+ - **`Carousel.PreviousTrigger`** — `<button>` that retreats the active
34
+ page by one. `disabled` at the first page, with the same zero/one-slide
35
+ and consumer-`disabled` semantics as `NextTrigger`.
36
+ - **`Carousel.IndicatorGroup`** — labelled `<div role="group">`
37
+ wrapping consumer-mapped indicator dots. Pass `label` (becomes
38
+ `aria-label`) or `ariaLabelledBy`; the discriminated union rejects
39
+ both-or-neither at compile time.
40
+ - **`Carousel.Indicator`** — individual `<button>` dot. `index` prop
41
+ targets a zero-based page; clicking jumps to it. Auto-labelled
42
+ `"Slide N"`. The dot at the current page carries
43
+ `aria-disabled="true"` (a soft disable per the WAI-ARIA Carousel
44
+ APG); all dots emit `data-state="active" | "inactive"` and a
45
+ `data-carousel-indicator` CSS hook so consumer styles can paint the
46
+ active dot.
47
+ - **`Carousel.Indicators`** — convenience wrapper that auto-renders
48
+ one `Carousel.Indicator` per registered slide. Reuses the same
49
+ discriminated `label` / `ariaLabelledBy` shape as `IndicatorGroup`.
50
+ For custom indicator content, drop down to `IndicatorGroup` +
51
+ `Indicator`.
52
+ - **`Carousel.PlayPauseTrigger`** — `<button>` that toggles the
53
+ `playing` flag on Root. Auto-labels itself `"Start automatic slide
54
+ show"` / `"Stop automatic slide show"` per the WAI-ARIA Carousel
55
+ APG, exposes a `data-state="playing" | "paused"` styling hook, and
56
+ passes `{ playing }` to a function `children` render prop so
57
+ consumers can swap icons or labels per state.
58
+
59
+ Pass `autoplay` on `Carousel.Root` to advance the active page on a
60
+ timer while `playing` is `true`. Hover, focus, and active-touch
61
+ suspend the timer per the WAI-ARIA APG (with a user-initiated play
62
+ override), and the viewport's `aria-live` region flips between
63
+ `"polite"` and `"off"` so assistive tech doesn't announce every
64
+ auto-rotation tick.
65
+
66
+ ## JS vs CSS responsibilities
67
+
68
+ The component ships zero styles, but a few features sit on the line
69
+ between JS and CSS. This table is the contract — the rule of thumb
70
+ is that JS owns _what is the active page_ and _delegates the scroll to
71
+ the browser_ (`scrollIntoView`), and CSS owns _what the user sees_:
72
+
73
+ | Feature | JS owns | CSS owns |
74
+ | ---------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------ |
75
+ | Active page state | `page` / `defaultPage`, `onPageChange`, `goTo` | — |
76
+ | Boundary clamping | `canGoNext` / `canGoPrevious`, trigger `disabled` | — |
77
+ | Crossfade / scale / dissolve | `data-state="active"` flip on slides | `position: absolute`, `opacity` + `transition` |
78
+ | Slide layout & widths | — | `flex-basis` / `inline-size`, `gap`, `aspect-ratio` |
79
+ | Peek of adjacent slides | `snapAlign` → `scrollIntoView({ inline })` | Viewport `padding-inline`, slide `flex-basis`, `scroll-snap-align` |
80
+ | Gap between slides | — | `gap` on the viewport (no `spacing` prop — pure CSS) |
81
+ | Variable-size slides | `scrollIntoView` on the target slide | Per-slide width / `aspect-ratio`, `scroll-snap-align` |
82
+ | Snap targeting | `snapAlign: "start" \| "center"` (Root only) | `scroll-snap-type` on viewport, `scroll-snap-align` on each slide |
83
+ | Reduced motion | `behavior: "instant"` | Optional `@media (prefers-reduced-motion: reduce)` on consumer animations |
84
+ | Keyboard navigation | Arrow / Home / End on focused viewport | `:focus-visible` on viewport |
85
+ | Touch / swipe | Native scroll + `scrollsnapchange` to sync state | `overscroll-behavior-x: contain`, `scrollbar-width: none` |
86
+ | Indicator state | `data-state` on `[data-carousel-indicator]` | Visual: dot, bar, thumbnail, etc. |
87
+
88
+ The only JS prop on the visual side is `snapAlign`, and only because it
89
+ picks the `inline` option passed to `scrollIntoView` (`"start"` or
90
+ `"center"`) so the programmatic scroll lands where the browser's CSS
91
+ snap engine will settle. Everything else is either a state knob (JS) or
92
+ a visual rule (CSS), with no overlap.
93
+
94
+ The `apps/workbench` workbench at `/carousel` ships worked recipes for
95
+ each cell of the matrix (single / multi / multi-step × slide / fade)
96
+ plus peek, variable-size, and programmatic-control examples.
97
+
98
+ ## Usage
99
+
100
+ Every carousel must have an accessible name. Pass exactly one of `ariaLabel`
101
+ or `ariaLabelledBy`:
102
+
103
+ ```tsx
104
+ import { Carousel } from "@primitiv-ui/react";
105
+
106
+ <Carousel.Root ariaLabel="Featured products">
107
+ <Carousel.Viewport>
108
+ <Carousel.Slide>
109
+ <img src="/cube.png" alt="Cube" />
110
+ </Carousel.Slide>
111
+ <Carousel.Slide>
112
+ <img src="/sphere.png" alt="Sphere" />
113
+ </Carousel.Slide>
114
+ </Carousel.Viewport>
115
+ <Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
116
+ <Carousel.Indicators label="Choose slide" />
117
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
118
+ </Carousel.Root>;
119
+ ```
120
+
121
+ ```tsx
122
+ <h2 id="promos">Promotions</h2>
123
+ <Carousel.Root ariaLabelledBy="promos">…</Carousel.Root>
124
+ ```
125
+
126
+ The discriminated union on the props type rejects shapes that supply both
127
+ or neither at compile time.
128
+
129
+ ### Wrapping the slide container
130
+
131
+ Slides go inside `Carousel.Viewport`:
132
+
133
+ ```tsx
134
+ <Carousel.Root ariaLabel="Featured products">
135
+ <Carousel.Viewport>
136
+ <Carousel.Slide>First slide</Carousel.Slide>
137
+ <Carousel.Slide>Second slide</Carousel.Slide>
138
+ <Carousel.Slide>Third slide</Carousel.Slide>
139
+ </Carousel.Viewport>
140
+ </Carousel.Root>
141
+ ```
142
+
143
+ Each `Carousel.Slide` self-registers with the Root, so every slide
144
+ exposes its own `data-index="0"`, `data-index="1"`, … and a live
145
+ `data-total` reflecting the current slide count. Add or remove slides at
146
+ runtime and the indices and totals update automatically.
147
+
148
+ Slides are also auto-labelled `"N of M"` (e.g. `"1 of 3"`) — the format
149
+ the WAI-ARIA Carousel APG example uses. To override the auto-label with
150
+ a more meaningful description, pass `ariaLabel`:
151
+
152
+ ```tsx
153
+ <Carousel.Slide ariaLabel="Hand-picked for you">…</Carousel.Slide>
154
+ ```
155
+
156
+ The override remains stable as siblings mount and unmount around it.
157
+
158
+ ### Navigating between slides
159
+
160
+ `Carousel.NextTrigger` and `Carousel.PreviousTrigger` advance and retreat
161
+ the active page. Each slide's `data-state` flips between `"active"` and
162
+ `"inactive"` so consumer CSS can paint the current slide differently.
163
+
164
+ #### Uncontrolled
165
+
166
+ Pass `defaultPage` (or omit it for `0`); the Root owns the active page
167
+ internally:
168
+
169
+ ```tsx
170
+ <Carousel.Root ariaLabel="Featured products" defaultPage={0}>
171
+ <Carousel.Viewport>
172
+ <Carousel.Slide>First</Carousel.Slide>
173
+ <Carousel.Slide>Second</Carousel.Slide>
174
+ <Carousel.Slide>Third</Carousel.Slide>
175
+ </Carousel.Viewport>
176
+ <Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
177
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
178
+ </Carousel.Root>
179
+ ```
180
+
181
+ #### Controlled
182
+
183
+ Pass `page` and `onPageChange` together to lift state into the parent.
184
+ The Root defers every state change back through the callback — clicks
185
+ on `NextTrigger` / `PreviousTrigger` invoke `onPageChange` with the
186
+ proposed page; the visual reflects whatever `page` value the parent
187
+ re-renders with. Useful for syncing two carousels (e.g. a thumbnail
188
+ strip), persisting the active page to a URL, or reacting to deep links.
189
+
190
+ ```tsx
191
+ const [page, setPage] = useState(0);
192
+
193
+ <Carousel.Root ariaLabel="Featured products" page={page} onPageChange={setPage}>
194
+
195
+ </Carousel.Root>;
196
+ ```
197
+
198
+ The discriminated union on the props type rejects mixed shapes (e.g.
199
+ both `defaultPage` and `page`, or `page` without `onPageChange`) at
200
+ compile time.
201
+
202
+ ### Runtime validation
203
+
204
+ `Carousel.PlayPauseTrigger` rendered under a Root with `autoplay`
205
+ disabled (omitted, or `autoplay={false}`) throws on mount —
206
+ toggling `playing` is meaningless when no autoplay timer is wired,
207
+ and the throw surfaces the misuse during development rather than
208
+ shipping a no-op control to users.
209
+
210
+ `Carousel.Root` also throws once slides have registered if the
211
+ active `page` (controlled or `defaultPage`-seeded) is negative or
212
+ greater than or equal to the live `totalPages` —
213
+ otherwise the carousel would render with no active slide. The throw
214
+ is gated on `totalPages > 0` so transient zero-slide initial-render
215
+ states don't false-alarm.
216
+
217
+ ### Lightbox composition (with `Modal`)
218
+
219
+ Two `Carousel.Root`s sharing a controlled `page` stay in sync — pair
220
+ a thumbnail strip with a fullscreen viewer mounted inside the
221
+ in-tree `Modal`. Gate the inner carousel's `playing` flag on the
222
+ modal's `open` so autoplay only runs while the modal is visible:
223
+
224
+ ```tsx
225
+ const [page, setPage] = useState(0);
226
+ const [open, setOpen] = useState(false);
227
+
228
+ <>
229
+ <Carousel.Root
230
+ ariaLabel="Featured products — thumbnails"
231
+ page={page}
232
+ onPageChange={setPage}
233
+ slidesPerPage={3}
234
+ >
235
+ <Carousel.Viewport>
236
+ <Carousel.Slide>…</Carousel.Slide>…
237
+ </Carousel.Viewport>
238
+ </Carousel.Root>
239
+
240
+ <Modal.Root open={open} onOpenChange={setOpen}>
241
+ <Modal.Trigger>Open lightbox</Modal.Trigger>
242
+ <Modal.Portal>
243
+ <Modal.Content>
244
+ <Carousel.Root
245
+ ariaLabel="Featured products — fullscreen"
246
+ page={page}
247
+ onPageChange={setPage}
248
+ autoplay
249
+ playing={open}
250
+ onPlayingChange={() => {}}
251
+ >
252
+ <Carousel.Viewport>
253
+ <Carousel.Slide>…</Carousel.Slide>…
254
+ </Carousel.Viewport>
255
+ <Carousel.Indicators label="Choose slide" />
256
+ </Carousel.Root>
257
+ </Modal.Content>
258
+ </Modal.Portal>
259
+ </Modal.Root>
260
+ </>;
261
+ ```
262
+
263
+ `Carousel.Root` doesn't focus anything on mount, so the `Modal`'s
264
+ focus management isn't disturbed when the inner carousel mounts.
265
+
266
+ ### Imperative API
267
+
268
+ For programmatic control from outside the component (e.g. a global
269
+ keyboard shortcut, a "skip to last slide" button elsewhere on the
270
+ page, or restoring a remembered position on mount), `Carousel.Root`
271
+ exposes an imperative handle via `ref`:
272
+
273
+ ```tsx
274
+ const carouselRef = useRef<CarouselImperativeApi>(null);
275
+
276
+ <Carousel.Root ref={carouselRef} ariaLabel="Featured products">
277
+
278
+ </Carousel.Root>;
279
+
280
+ carouselRef.current?.next();
281
+ carouselRef.current?.previous();
282
+ carouselRef.current?.goTo(2);
283
+ carouselRef.current?.play();
284
+ carouselRef.current?.pause();
285
+ carouselRef.current?.refresh();
286
+ const { page, totalPages, value } = carouselRef.current!.getProgress();
287
+ ```
288
+
289
+ `refresh()` re-issues the viewport's `scrollIntoView` for the current
290
+ page — useful when external layout changes (window resize, container
291
+ reflow, dynamic content) leave the scroll position misaligned with
292
+ React state. `getProgress()` returns a normalised
293
+ `value` in `[0, 1]` (`0` when there's at most one page) plus the
294
+ live `page` and `totalPages`, intended for custom progress bars.
295
+
296
+ Every method routes through the same internal state machine the
297
+ trigger components use, so controlled-mode `onPageChange` /
298
+ `onPlayingChange` callbacks fire just as if the user had clicked.
299
+ `play()` also dismisses the hover/focus pause for the lifetime of
300
+ that playing session, matching the WAI-ARIA APG semantics for
301
+ user-initiated play.
302
+
303
+ ### `asChild` composition
304
+
305
+ `Carousel.NextTrigger`, `Carousel.PreviousTrigger`,
306
+ `Carousel.PlayPauseTrigger`, and `Carousel.Indicator` all accept an
307
+ `asChild` prop. When set, the trigger renders the consumer's child
308
+ element via the in-tree `Slot` (instead of its default `<button>`)
309
+ and merges every trigger prop — `onClick`, `aria-label`,
310
+ `aria-disabled`, `data-state`, custom `id`, etc. — onto it. Useful
311
+ for routing links and other elements that need trigger semantics:
312
+
313
+ ```tsx
314
+ <Carousel.NextTrigger asChild>
315
+ <a href="/products?page=2">Next</a>
316
+ </Carousel.NextTrigger>
317
+ ```
318
+
319
+ Because `<a>` and other non-button elements don't honour the HTML
320
+ `disabled` attribute, the prev/next triggers also short-circuit
321
+ their click handler when boundary clamping says "no further" — so
322
+ asChild on a non-button still respects the boundary clamp.
323
+
324
+ ### Snap alignment
325
+
326
+ By default the Viewport scrolls so the **start (left) edge** of the
327
+ target slide aligns with the start edge of the scroll container — matching
328
+ `scroll-snap-align: start` in consumer CSS. For layouts where slides are
329
+ narrower than the Viewport and centred (e.g. Cover Flow), set
330
+ `snapAlign="center"` so programmatic navigation lands on the centred
331
+ position without the browser snapping-correcting after the scroll:
332
+
333
+ ```tsx
334
+ <Carousel.Root ariaLabel="Gallery" snapAlign="center">
335
+
336
+ </Carousel.Root>
337
+ ```
338
+
339
+ Pair with `scroll-snap-align: center` on `Carousel.Slide` in your CSS.
340
+ The default is `"start"`; `snapAlign` picks the `inline` option passed
341
+ to `scrollIntoView` (`"start"` or `"center"`), and the browser's CSS
342
+ snap engine makes the final correction.
343
+
344
+ ### Transition modes
345
+
346
+ `Carousel.Root` accepts a `transition` prop that controls how the
347
+ viewport handles slide changes visually.
348
+
349
+ - `transition="slide"` (default) — relies on native CSS scroll-snap.
350
+ The Viewport scrolls programmatically when the page changes and
351
+ listens for `scrollsnapchange` so user swipes update React state.
352
+ - `transition="none"` — the Viewport installs no scroll wiring at
353
+ all. Consumer CSS owns the visual via the `data-state="active" |
354
+ "inactive"` hook on each slide, which still flips with the active
355
+ page. This is the entry point for crossfade, dissolve, zoom, or
356
+ any CSS-only transition pattern:
357
+
358
+ ```css
359
+ [data-carousel-slide] {
360
+ position: absolute;
361
+ inset: 0;
362
+ opacity: 0;
363
+ transition: opacity 400ms;
364
+ }
365
+ [data-carousel-slide][data-state="active"] {
366
+ opacity: 1;
367
+ }
368
+ ```
369
+
370
+ ### Reduced motion
371
+
372
+ The Viewport's programmatic `scrollIntoView` reads
373
+ `window.matchMedia("(prefers-reduced-motion: reduce)")` once on
374
+ mount. When the user has reduced motion enabled at the OS level,
375
+ page changes use `behavior: "instant"` instead of `"smooth"` so the
376
+ carousel doesn't fight that preference. Touch-driven scrolling is
377
+ unaffected — the browser owns that animation.
378
+
379
+ ### Programmatic scroll sync
380
+
381
+ When the active page changes for any reason (`Carousel.NextTrigger` /
382
+ `Carousel.PreviousTrigger` click, indicator click, autoplay tick),
383
+ the viewport calls `scrollIntoView` on the first slide of the new page
384
+ so the visual surface tracks React state. Because the browser owns the
385
+ scroll, consumer CSS owns slide width and gap, and `scroll-snap-align`
386
+ makes the final correction. Default `behavior` is `"smooth"`.
387
+
388
+ The reverse path is also wired: when the user swipes the viewport,
389
+ the browser fires `scrollsnapchange` with the snapped slide as the
390
+ target. The Viewport listens for that event, computes
391
+ `floor(slideIndex / slidesPerPage)`, and calls `goTo` so React state
392
+ follows the user's scroll. `onPageChange` is only invoked when the
393
+ page genuinely changes, so a snap that lands back on the active page
394
+ doesn't dispatch a spurious callback.
395
+
396
+ For browsers without `scrollsnapchange`, the same path runs against
397
+ an `IntersectionObserver` (threshold 0.6) on each slide — when the
398
+ observer fires, the lowest-index visible slide derives the active
399
+ page via `floor(firstVisibleSlideIndex / slidesPerPage)`. This
400
+ page-drive is **only** a fallback: when `scrollsnapchange` is
401
+ supported it is authoritative (it reports the precisely-snapped
402
+ slide), so the observer stands down and does not also drive the page.
403
+ That matters for `snapAlign="center"` carousels with several slides
404
+ visible at once (e.g. a cover flow) — the lowest-index-visible
405
+ heuristic would otherwise track the *leftmost* visible slide rather
406
+ than the centred one and fight `scrollsnapchange`. The observer still
407
+ always feeds `Carousel.Root`'s imperative `isInView(slideIndex)` so
408
+ consumers can lazy-load slide content based on actual visibility, not
409
+ just the active-page index.
410
+
411
+ ### Custom DOM ids
412
+
413
+ For SSR / hydration stability or external `aria-controls` linkage,
414
+ pin DOM `id`s on the rendered sub-components via the `ids` bag on
415
+ `Carousel.Root`:
416
+
417
+ ```tsx
418
+ <Carousel.Root
419
+ ariaLabel="Featured products"
420
+ ids={{
421
+ root: "promo-carousel",
422
+ viewport: "promo-viewport",
423
+ previousTrigger: "promo-prev",
424
+ nextTrigger: "promo-next",
425
+ playPauseTrigger: "promo-play-pause",
426
+ indicatorGroup: "promo-indicators",
427
+ }}
428
+ >
429
+
430
+ </Carousel.Root>
431
+ ```
432
+
433
+ Any keys you omit leave the corresponding element unidentified. A
434
+ direct `id` prop on a sub-component (e.g.
435
+ `<Carousel.NextTrigger id="…">`) wins over `ids.*` because it spreads
436
+ last.
437
+
438
+ ### Internationalisation
439
+
440
+ The component owns four user-visible strings: each slide's auto-
441
+ generated `aria-label` (`"N of M"`), each indicator's auto-generated
442
+ `aria-label` (`"Slide N"`), and the two `Carousel.PlayPauseTrigger`
443
+ accessible names (`"Start automatic slide show"` and
444
+ `"Stop automatic slide show"`). Override any subset of them via the
445
+ `translations` prop on `Carousel.Root`:
446
+
447
+ ```tsx
448
+ <Carousel.Root
449
+ ariaLabel="Produits en vedette"
450
+ translations={{
451
+ slideLabel: ({ index, total }) => `${index} sur ${total}`,
452
+ indicatorLabel: ({ index }) => `Diapositive ${index}`,
453
+ startSlideshow: "Démarrer le diaporama",
454
+ stopSlideshow: "Arrêter le diaporama",
455
+ }}
456
+ >
457
+
458
+ </Carousel.Root>
459
+ ```
460
+
461
+ `slideLabel` and `indicatorLabel` are functions (they receive
462
+ position info), the slideshow names are plain strings. Any keys you
463
+ omit fall back to the English defaults. Per-slide `ariaLabel`
464
+ overrides on `Carousel.Slide` still take precedence over
465
+ `translations.slideLabel`, so a single slide can carry a
466
+ domain-meaningful label (e.g. `"Hand-picked for you"`) without
467
+ losing the localised `"N of M"` format on the others.
468
+
469
+ ### Multi-slide pages and partial page advance
470
+
471
+ Pass `slidesPerPage` (default `1`) to make several slides visible per
472
+ page — the "image carousel" / "property cards" pattern. By default,
473
+ `Carousel.NextTrigger` / `Carousel.PreviousTrigger` advance one
474
+ full page at a time (`slidesPerMove="auto"`):
475
+
476
+ ```tsx
477
+ <Carousel.Root ariaLabel="Featured products" slidesPerPage={3}>
478
+ <Carousel.Viewport>
479
+ <Carousel.Slide>1</Carousel.Slide>
480
+ <Carousel.Slide>2</Carousel.Slide>
481
+ <Carousel.Slide>3</Carousel.Slide>
482
+ <Carousel.Slide>4</Carousel.Slide>
483
+ <Carousel.Slide>5</Carousel.Slide>
484
+ </Carousel.Viewport>
485
+ <Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
486
+ <Carousel.Indicators label="Choose page" />
487
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
488
+ </Carousel.Root>
489
+ ```
490
+
491
+ With `slidesPerPage={3}` and 5 slides:
492
+
493
+ - Total pages = `ceil(5 / 3) === 2`. `Carousel.Indicators` renders 2
494
+ dots.
495
+ - Page 0 contains slides 0–2; page 1 contains the remaining 3, 4 (a
496
+ partial last page).
497
+ - Each slide on the active page emits `data-state="active"`; slides
498
+ on other pages emit `"inactive"`.
499
+ - `Carousel.NextTrigger` advances one page per click; the boundary
500
+ clamp is at the last page (so Next is disabled while page 1 is
501
+ active).
502
+
503
+ The slide-level `aria-label="N of M"` continues to count individual
504
+ slides (so a 5-slide carousel announces "1 of 5", "2 of 5", … even
505
+ when grouped into 3-per-page).
506
+
507
+ Pass a numeric `slidesPerMove` to advance the visible window by an
508
+ arbitrary slide count per click instead of a full page:
509
+
510
+ ```tsx
511
+ <Carousel.Root
512
+ ariaLabel="Featured products"
513
+ slidesPerPage={3}
514
+ slidesPerMove={1}
515
+ >
516
+
517
+ </Carousel.Root>
518
+ ```
519
+
520
+ With `slidesPerPage=3`, `slidesPerMove=1`, and 5 slides, the active
521
+ window slides one slide at a time — pages show `[0,1,2]`, `[1,2,3]`,
522
+ `[2,3,4]`, so `Carousel.Indicators` renders 3 dots and the boundary
523
+ clamp respects the last full window. The indicator count formula is
524
+ `floor((total - slidesPerPage) / slidesPerMove) + 1` (vs.
525
+ `ceil(total / slidesPerPage)` for `"auto"`), so the visible window
526
+ always stays full in numeric mode.
527
+
528
+ ### Boundary behaviour
529
+
530
+ The prev/next triggers clamp at the ends: `Carousel.PreviousTrigger` is
531
+ `disabled` at the first slide, `Carousel.NextTrigger` at the last. Both
532
+ are also `disabled` when zero or one slides are registered, since
533
+ there's nowhere to navigate.
534
+
535
+ ### Keyboard navigation
536
+
537
+ `Carousel.Viewport` is in the tab order so keyboard users can reach the
538
+ rotation control without first tabbing through every slide's
539
+ interactive content. With the Viewport focused:
540
+
541
+ | Key | Action |
542
+ | ------------ | ----------------------------------------------- |
543
+ | `ArrowRight` | Advance by one page (same as `NextTrigger`) |
544
+ | `ArrowLeft` | Retreat by one page (same as `PreviousTrigger`) |
545
+ | `Home` | Jump to the first page |
546
+ | `End` | Jump to the last page |
547
+
548
+ Arrow keys clamp at the boundaries, mirroring the trigger buttons.
549
+ Keypresses are only intercepted when the Viewport itself is the focus
550
+ target — focus inside a slide (e.g. on a link or form control) keeps
551
+ its native arrow-key semantics.
552
+
553
+ ```tsx
554
+ <Carousel.Root ariaLabel="Featured products">
555
+ <Carousel.Viewport>
556
+ <Carousel.Slide>First</Carousel.Slide>
557
+ <Carousel.Slide>Second</Carousel.Slide>
558
+ <Carousel.Slide>Third</Carousel.Slide>
559
+ </Carousel.Viewport>
560
+ <Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
561
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
562
+ </Carousel.Root>
563
+ ```
564
+
565
+ Consumer-supplied `disabled={true}` on either trigger is honoured
566
+ regardless of boundary state — useful for momentarily freezing
567
+ navigation while another part of the UI takes over.
568
+
569
+ ### Indicator dots (manual)
570
+
571
+ For full control over indicator content, map them yourself with
572
+ `Carousel.IndicatorGroup` + `Carousel.Indicator`. Each dot's `index`
573
+ prop targets a zero-based page; clicking jumps to it.
574
+
575
+ ```tsx
576
+ <Carousel.IndicatorGroup label="Choose slide">
577
+ <Carousel.Indicator index={0} />
578
+ <Carousel.Indicator index={1} />
579
+ <Carousel.Indicator index={2} />
580
+ </Carousel.IndicatorGroup>
581
+ ```
582
+
583
+ Indicators are auto-labelled `"Slide N"` (1-indexed). The dot at the
584
+ current page carries `aria-disabled="true"` per the WAI-ARIA APG and
585
+ `data-state="active"`; non-active dots carry `aria-disabled="false"`
586
+ and `data-state="inactive"`. Style them via the
587
+ `data-carousel-indicator` attribute and the `data-state` hook:
588
+
589
+ ```css
590
+ [data-carousel-indicator][data-state="active"] {
591
+ background: black;
592
+ }
593
+ ```
594
+
595
+ ### Play / pause control
596
+
597
+ `Carousel.PlayPauseTrigger` toggles a `playing` flag on the Root. The
598
+ flag has the same controlled / uncontrolled split as `page`:
599
+
600
+ ```tsx
601
+ // Uncontrolled
602
+ <Carousel.Root ariaLabel="Featured products" defaultPlaying={false}>
603
+ <Carousel.PlayPauseTrigger />
604
+ </Carousel.Root>;
605
+
606
+ // Controlled
607
+ const [playing, setPlaying] = useState(false);
608
+ <Carousel.Root
609
+ ariaLabel="Featured products"
610
+ playing={playing}
611
+ onPlayingChange={setPlaying}
612
+ >
613
+ <Carousel.PlayPauseTrigger />
614
+ </Carousel.Root>;
615
+ ```
616
+
617
+ The discriminated union rejects mixed shapes (e.g. `defaultPlaying` +
618
+ `playing`, or `playing` without `onPlayingChange`) at compile time.
619
+
620
+ Pass a function as `children` to swap icons or labels per state:
621
+
622
+ ```tsx
623
+ <Carousel.PlayPauseTrigger>
624
+ {({ playing }) => (playing ? <PauseIcon /> : <PlayIcon />)}
625
+ </Carousel.PlayPauseTrigger>
626
+ ```
627
+
628
+ The trigger is auto-labelled `"Start automatic slide show"` (paused)
629
+ or `"Stop automatic slide show"` (playing) for assistive tech, and
630
+ emits `data-state="playing" | "paused"` for consumer CSS.
631
+
632
+ ### Autoplay timer
633
+
634
+ Pass `autoplay` on `Carousel.Root` to advance the active page on a
635
+ timer while `playing` is `true`:
636
+
637
+ ```tsx
638
+ // Default 4000ms cadence
639
+ <Carousel.Root ariaLabel="Featured products" autoplay defaultPlaying>
640
+
641
+ </Carousel.Root>
642
+
643
+ // Custom delay
644
+ <Carousel.Root
645
+ ariaLabel="Featured products"
646
+ autoplay={{ delay: 6000 }}
647
+ defaultPlaying
648
+ >
649
+
650
+ </Carousel.Root>
651
+ ```
652
+
653
+ The timer reads from the live `playing` flag and the active page —
654
+ toggling pause via `Carousel.PlayPauseTrigger` (or via the controlled
655
+ `onPlayingChange`) cancels the next tick. The timer stops once the
656
+ active page reaches the last slide.
657
+
658
+ The timer also pauses automatically while the user is hovering the
659
+ Root or has focus on a descendant element, per WCAG 2.2.2 (Pause,
660
+ Stop, Hide). Focus moving between descendants of the Root (e.g.
661
+ tabbing from `Previous` to `Next`) keeps the pause active; the timer
662
+ only resumes once the pointer leaves and focus has moved out of the
663
+ carousel entirely. The `playing` flag is unaffected — it stays
664
+ `true` while suspended, so toggling pause-resume via
665
+ `PlayPauseTrigger` continues to behave as the consumer expects.
666
+
667
+ Touch gestures pause the timer too: `pointerdown` with
668
+ `pointerType === "touch"` sets the suspension flag, and any
669
+ `pointerup` or `pointercancel` releases it. Mouse / pen
670
+ `pointerdown` is filtered out so the existing hover/focus path
671
+ keeps owning non-touch interaction without double-suspension.
672
+
673
+ **User-initiated play overrides the hover/focus pause.** Per the
674
+ WAI-ARIA Carousel APG example, when the user explicitly clicks
675
+ `PlayPauseTrigger` to start the slideshow, the hover/focus pause is
676
+ suspended for the lifetime of that playing session — otherwise the
677
+ user would fight a pause every time their pointer was already over
678
+ the carousel when they pressed play. The override resets when
679
+ `playing` flips back to `false` (via another click, or via an
680
+ external state change), so a subsequent play that's _not_ user-
681
+ initiated falls back to the standard WCAG pause.
682
+
683
+ **Viewport live region.** `Carousel.Viewport` is also the live region
684
+ for slide changes. Its `aria-live` defaults to `"polite"` so paged
685
+ manual navigation is announced to assistive tech, and flips to
686
+ `"off"` while autoplay is actively rotating (`autoplay` enabled and
687
+ `playing=true`) so screen readers aren't spammed with every tick.
688
+ The flip is reactive — pausing via `PlayPauseTrigger` returns the
689
+ viewport to `"polite"` for the duration of the pause.
690
+
691
+ ### Indicator dots (auto-rendered)
692
+
693
+ For the common case of one dot per slide with auto-generated labels,
694
+ use `Carousel.Indicators` — it reads the live slide count from
695
+ context and renders the right number of dots without any mapping
696
+ boilerplate:
697
+
698
+ ```tsx
699
+ <Carousel.Root ariaLabel="Featured products">
700
+ <Carousel.Viewport>
701
+ <Carousel.Slide>1</Carousel.Slide>
702
+ <Carousel.Slide>2</Carousel.Slide>
703
+ <Carousel.Slide>3</Carousel.Slide>
704
+ </Carousel.Viewport>
705
+ <Carousel.Indicators label="Choose slide" />
706
+ </Carousel.Root>
707
+ ```
708
+
709
+ The dot count tracks slide count automatically — add or remove a
710
+ slide and the indicator row updates on the next render. For custom
711
+ indicator content (thumbnails, numbers, mixed icons), drop down to
712
+ the manual `IndicatorGroup` + `Indicator` API above.
713
+
714
+ ## Recommended CSS
715
+
716
+ The component ships zero styles. The recipe below is the minimum
717
+ needed to get a working horizontal carousel with snap-aligned slides,
718
+ dot indicators, and sane mobile behaviour. Drop it into your stylesheet
719
+ and target the `data-carousel-*` attributes:
720
+
721
+ ```css
722
+ [data-carousel-viewport] {
723
+ display: flex;
724
+ overflow-x: auto;
725
+ scroll-snap-type: x mandatory;
726
+ scroll-behavior: smooth;
727
+ /* Prevent vertical page scroll from "rubber-banding" into the
728
+ carousel and vice-versa on iOS. */
729
+ overscroll-behavior-x: contain;
730
+ /* Hide native scrollbars on mobile while keeping scroll behaviour. */
731
+ scrollbar-width: none;
732
+ }
733
+ [data-carousel-viewport]::-webkit-scrollbar {
734
+ display: none;
735
+ }
736
+
737
+ [data-carousel-slide] {
738
+ flex: 0 0 100%;
739
+ scroll-snap-align: start;
740
+ /* Stop the OS picking up images for drag/save during a swipe. */
741
+ -webkit-user-drag: none;
742
+ }
743
+
744
+ /* WCAG 2.5.8: 24×24 minimum hit area; 44×44 recommended for comfort
745
+ on phones. The visible dot stays small via ::before so the button's
746
+ hit area can be larger than its visual footprint. */
747
+ [data-carousel-indicator] {
748
+ min-width: 44px;
749
+ min-height: 44px;
750
+ display: inline-grid;
751
+ place-items: center;
752
+ }
753
+ [data-carousel-indicator]::before {
754
+ content: "";
755
+ width: 0.5rem;
756
+ height: 0.5rem;
757
+ border-radius: 50%;
758
+ background: lightgray;
759
+ }
760
+ [data-carousel-indicator][data-state="active"]::before {
761
+ background: black;
762
+ }
763
+ ```
764
+
765
+ For multi-slide pages (`slidesPerPage={3}`, etc.), tune the slide's
766
+ `flex-basis` to share the viewport — e.g. `calc(100% / 3)` for three
767
+ slides per page, plus a `gap` on the viewport for the inter-slide
768
+ spacing. For a crossfade transition, use `transition="none"` on
769
+ `Carousel.Root` and style the slides absolute-positioned with an
770
+ opacity transition keyed off `[data-carousel-slide][data-state="active"]`.
771
+
772
+ ### Cover Flow (scroll-driven 3D)
773
+
774
+ A "cover flow" — narrow snap units with cards that tilt away in 3D
775
+ and stack with depth — can be built entirely in CSS, with no extra JS,
776
+ using scroll-driven animations (`view-timeline` + `animation-timeline`).
777
+ The full working source is the workbench example at
778
+ `apps/workbench/src/pages/CarouselExample/examples/_coverFlow.scss`;
779
+ this section documents the design so it can be reused or ported.
780
+
781
+ **Structure.** Each slide is a narrow snap unit that owns the named
782
+ timelines. Inside it, a card-sized `visual` box is centred over the
783
+ snap unit (and overflows it so neighbours interleave) and handles the
784
+ `translateX`; inside that, a `card` element handles the `rotateY`.
785
+ Keep `perspective()` *inside* the keyframe transform — a `overflow-x`
786
+ scroll container flattens a `perspective` CSS property, but the
787
+ function form is immune.
788
+
789
+ **Two timelines, one slide.** The rotate/translate run on a tight
790
+ centred band (`view-timeline-inset` shrinks the scrollport) over
791
+ `animation-range: contain`. The depth order runs on a *second*,
792
+ full-scrollport timeline (`inset: 0`) over `animation-range: cover`.
793
+ The wide range matters: `z-index` is an animatable integer, so a
794
+ `1 → peak → 1` keyframe driven by each slide's own timeline makes the
795
+ most-centred card win the stack generically — no per-slide numbers.
796
+ A narrow range would clamp every off-centre slide to the boundary
797
+ `z-index`, leaving DOM order to (incorrectly) break the ties on one
798
+ side.
799
+
800
+ **Customising.** Drive every dimension from a small set of custom
801
+ properties on the root, and derive the rest with `calc()`:
802
+
803
+ ```css
804
+ .cover-flow {
805
+ /* Geometry knobs */
806
+ --cf-viewport-w: 38rem; /* visible carousel width */
807
+ --cf-card-w: 180px; /* rendered card width */
808
+ --cf-aspect: 1.168; /* width ÷ height — 1 = square, >1 = wide */
809
+ --cf-snap: 90px; /* centre-to-centre spacing (< card-w → overlap) */
810
+ --cf-track-pad: 0px; /* grey track shown above/below the cards */
811
+ --cf-radius: 10px;
812
+ /* Motion knobs */
813
+ --cf-spread: 1.4; /* tilt band width, in multiples of --cf-snap */
814
+ --cf-tilt: 55deg; /* max rotateY of the side cards */
815
+ --cf-perspective: 500px;
816
+ --cf-shift: 30%; /* translateX of the card toward centre */
817
+ --cf-lift: 10; /* peak z-index of the centred card */
818
+
819
+ /* Derived — leave alone */
820
+ --cf-card-h: calc(var(--cf-card-w) / var(--cf-aspect));
821
+ --cf-viewport-h: calc(var(--cf-card-h) + var(--cf-track-pad) * 2);
822
+ --cf-center-pad: calc(50% - var(--cf-snap) / 2); /* centres first/last card */
823
+ --cf-band: calc(var(--cf-snap) * var(--cf-spread));
824
+ }
825
+ ```
826
+
827
+ With that in place the three common tweaks are one line each:
828
+
829
+ - **Viewport size** — set `--cf-viewport-w`. The height follows the
830
+ cards automatically (`card-h + 2·track-pad`), so you rarely set it.
831
+ - **Padding / spacing** — `--cf-track-pad` adds grey track above and
832
+ below the cards; `--cf-snap` is the centre-to-centre spacing — the
833
+ smaller it is relative to `--cf-card-w`, the more the cards overlap
834
+ (it also sets the centring gutter).
835
+ - **Slide shape** — `--cf-aspect` is width ÷ height (`1` = exact
836
+ square, `>1` = landscape, `<1` = portrait); `--cf-card-w` is the
837
+ rendered size. A 240px square is `--cf-card-w: 240px; --cf-aspect: 1`.
838
+
839
+ Two gotchas:
840
+
841
+ - `--cf-aspect` must resolve to a *number*, not a fraction token.
842
+ `--cf-aspect: 16 / 9` lands inside `card-w / aspect` and divides
843
+ twice; use `--cf-aspect: calc(16 / 9)` (or a literal like `1.778`).
844
+ - The whole recipe relies on `animation-timeline` and `calc()` length
845
+ division. Wrap it in `@supports (animation-timeline: --x)` and keep
846
+ a static `[data-state="active"] { z-index: var(--cf-lift) }` fallback
847
+ plus `@media (prefers-reduced-motion: reduce) { animation: none }` so
848
+ the carousel degrades to a plain scroll-snap row.