@primitiv-ui/react 0.1.27 → 0.1.29

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 (307) hide show
  1. package/README.md +4 -0
  2. package/package.json +6 -1
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
  4. package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
  5. package/src/AccessibleIcon/types.ts +15 -2
  6. package/src/Accordion/Accordion.tsx +25 -4
  7. package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
  8. package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
  9. package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
  10. package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
  11. package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
  12. package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
  13. package/src/Accordion/types.ts +72 -21
  14. package/src/Alert/Alert.tsx +36 -7
  15. package/src/Alert/__tests__/Alert.test.tsx +5 -0
  16. package/src/Alert/types.ts +18 -2
  17. package/src/Avatar/Avatar.tsx +99 -15
  18. package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
  19. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
  20. package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
  21. package/src/Avatar/hooks/useAvatarImage.ts +16 -0
  22. package/src/Avatar/types.ts +15 -3
  23. package/src/Breadcrumb/Breadcrumb.tsx +136 -18
  24. package/src/Breadcrumb/README.md +47 -0
  25. package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
  26. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
  27. package/src/Breadcrumb/types.ts +73 -7
  28. package/src/Button/Button.tsx +2 -0
  29. package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
  30. package/src/Button/types.ts +21 -0
  31. package/src/Carousel/Carousel.tsx +263 -15
  32. package/src/Carousel/README.md +749 -72
  33. package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
  34. package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
  35. package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
  36. package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
  37. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
  38. package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
  39. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +88 -0
  40. package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
  41. package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
  42. package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
  43. package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
  44. package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
  45. package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
  46. package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
  47. package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
  48. package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
  49. package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
  50. package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
  51. package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
  52. package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
  53. package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
  54. package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
  55. package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
  56. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
  57. package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
  58. package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
  59. package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
  60. package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
  61. package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
  62. package/src/Carousel/hooks/index.ts +1 -0
  63. package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
  64. package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
  65. package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
  66. package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
  67. package/src/Carousel/loopEngine.ts +54 -0
  68. package/src/Carousel/types.ts +457 -35
  69. package/src/Checkbox/Checkbox.tsx +9 -1
  70. package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
  71. package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
  72. package/src/Checkbox/types.ts +19 -0
  73. package/src/CheckboxCard/CheckboxCard.tsx +13 -4
  74. package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
  75. package/src/CheckboxCard/types.ts +65 -17
  76. package/src/Collapsible/Collapsible.tsx +49 -3
  77. package/src/Collapsible/README.md +40 -0
  78. package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
  79. package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
  80. package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
  81. package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
  82. package/src/Collapsible/types.ts +89 -22
  83. package/src/ContextMenu/ContextMenu.tsx +270 -4
  84. package/src/ContextMenu/types.ts +148 -6
  85. package/src/DirectionProvider/DirectionProvider.tsx +41 -9
  86. package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
  87. package/src/DirectionProvider/types.ts +28 -3
  88. package/src/Divider/Divider.tsx +24 -10
  89. package/src/Divider/__tests__/Divider.test.tsx +5 -0
  90. package/src/Divider/types.ts +10 -3
  91. package/src/Drawer/Drawer.tsx +315 -0
  92. package/src/Drawer/README.md +124 -0
  93. package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
  94. package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
  95. package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
  96. package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
  97. package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
  98. package/src/Drawer/index.ts +2 -0
  99. package/src/Drawer/types.ts +66 -0
  100. package/src/Dropdown/Dropdown.tsx +117 -0
  101. package/src/Dropdown/types.ts +241 -11
  102. package/src/EmptyState/EmptyState.tsx +74 -4
  103. package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
  104. package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
  105. package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
  106. package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
  107. package/src/EmptyState/types.ts +49 -6
  108. package/src/Field/Field.tsx +23 -0
  109. package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
  110. package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
  111. package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
  112. package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
  113. package/src/Field/types.ts +60 -12
  114. package/src/Fieldset/Fieldset.tsx +54 -18
  115. package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
  116. package/src/Fieldset/types.ts +20 -2
  117. package/src/Input/Input.tsx +14 -9
  118. package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
  119. package/src/Input/types.ts +19 -2
  120. package/src/InputGroup/InputGroup.tsx +11 -3
  121. package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
  122. package/src/InputGroup/types.ts +26 -5
  123. package/src/MillerColumns/MillerColumns.tsx +86 -19
  124. package/src/MillerColumns/types.ts +83 -27
  125. package/src/Modal/Modal.tsx +31 -4
  126. package/src/Modal/README.md +19 -10
  127. package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
  128. package/src/Modal/hooks/useModalContent.ts +32 -0
  129. package/src/Modal/types.ts +110 -29
  130. package/src/NavigationMenu/NavigationMenu.tsx +890 -0
  131. package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
  132. package/src/NavigationMenu/README.md +355 -0
  133. package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
  134. package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
  135. package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
  136. package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
  137. package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
  138. package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
  139. package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
  140. package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
  141. package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
  142. package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
  143. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
  144. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
  145. package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
  146. package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
  147. package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
  148. package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
  149. package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
  150. package/src/NavigationMenu/constants.ts +5 -0
  151. package/src/NavigationMenu/hooks/index.ts +7 -0
  152. package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
  153. package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
  154. package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
  155. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
  156. package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
  157. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
  158. package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
  159. package/src/NavigationMenu/index.ts +3 -0
  160. package/src/NavigationMenu/types.ts +259 -0
  161. package/src/NavigationMenu/utils.ts +89 -0
  162. package/src/Popover/Popover.tsx +427 -0
  163. package/src/Popover/PopoverContext.ts +13 -0
  164. package/src/Popover/README.md +106 -0
  165. package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
  166. package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
  167. package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
  168. package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
  169. package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
  170. package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
  171. package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
  172. package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
  173. package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
  174. package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
  175. package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
  176. package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
  177. package/src/Popover/hooks/index.ts +3 -0
  178. package/src/Popover/hooks/usePopoverContent.ts +98 -0
  179. package/src/Popover/hooks/usePopoverRoot.ts +84 -0
  180. package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
  181. package/src/Popover/index.ts +2 -0
  182. package/src/Popover/types.ts +197 -0
  183. package/src/Portal/Portal.tsx +50 -10
  184. package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
  185. package/src/Portal/types.ts +5 -0
  186. package/src/Progress/Progress.tsx +33 -1
  187. package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
  188. package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
  189. package/src/Progress/types.ts +20 -4
  190. package/src/Radio/Radio.tsx +9 -1
  191. package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
  192. package/src/Radio/hooks/useRadioRoot.ts +3 -0
  193. package/src/Radio/types.ts +20 -0
  194. package/src/RadioCard/RadioCard.tsx +11 -0
  195. package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
  196. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
  197. package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
  198. package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
  199. package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
  200. package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
  201. package/src/RadioCard/types.ts +95 -22
  202. package/src/RadioGroup/RadioGroup.tsx +11 -0
  203. package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
  204. package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
  205. package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
  206. package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
  207. package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
  208. package/src/RadioGroup/types.ts +73 -9
  209. package/src/SegmentedControl/README.md +154 -0
  210. package/src/SegmentedControl/SegmentedControl.tsx +309 -0
  211. package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
  212. package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
  213. package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
  214. package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
  215. package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
  216. package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
  217. package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
  218. package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
  219. package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
  220. package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
  221. package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
  222. package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
  223. package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
  224. package/src/SegmentedControl/hooks/index.ts +2 -0
  225. package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
  226. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +86 -0
  227. package/src/SegmentedControl/index.ts +2 -0
  228. package/src/SegmentedControl/types.ts +138 -0
  229. package/src/Select/README.md +122 -157
  230. package/src/Select/Select.tsx +595 -68
  231. package/src/Select/SelectContext.ts +38 -0
  232. package/src/Select/SelectItemIndicatorContext.ts +29 -0
  233. package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
  234. package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
  235. package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
  236. package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
  237. package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
  238. package/src/Select/__tests__/Select.form.test.tsx +38 -0
  239. package/src/Select/__tests__/Select.group.test.tsx +6 -6
  240. package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
  241. package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
  242. package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
  243. package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
  244. package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
  245. package/src/Select/__tests__/Select.placeholder.test.tsx +4 -4
  246. package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
  247. package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
  248. package/src/Select/__tests__/Select.selection.test.tsx +104 -0
  249. package/src/Select/__tests__/Select.separator.test.tsx +71 -0
  250. package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
  251. package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
  252. package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
  253. package/src/Select/constants.ts +8 -0
  254. package/src/Select/hooks/index.ts +2 -0
  255. package/src/Select/hooks/useSelectContent.ts +149 -0
  256. package/src/Select/hooks/useSelectRoot.ts +149 -0
  257. package/src/Select/types.ts +199 -31
  258. package/src/SkipNav/SkipNav.tsx +69 -29
  259. package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
  260. package/src/SkipNav/types.ts +17 -9
  261. package/src/Slider/Slider.tsx +33 -1
  262. package/src/Slider/SliderContext.ts +28 -1
  263. package/src/Slider/types.ts +157 -9
  264. package/src/Slot/Slot.tsx +94 -19
  265. package/src/Slot/__tests__/Slot.test.tsx +110 -0
  266. package/src/Slot/composeEventHandlers.ts +26 -2
  267. package/src/Slot/types.ts +16 -0
  268. package/src/Status/Status.tsx +36 -15
  269. package/src/Status/__tests__/Status.test.tsx +5 -0
  270. package/src/Status/types.ts +12 -1
  271. package/src/Switch/Switch.tsx +15 -2
  272. package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
  273. package/src/Switch/hooks/useSwitchRoot.ts +3 -0
  274. package/src/Switch/types.ts +49 -22
  275. package/src/Table/Table.tsx +44 -10
  276. package/src/Table/types.ts +66 -17
  277. package/src/Tabs/Tabs.tsx +8 -0
  278. package/src/Tabs/types.ts +29 -4
  279. package/src/Textarea/Textarea.tsx +9 -1
  280. package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
  281. package/src/Textarea/types.ts +20 -3
  282. package/src/Toggle/Toggle.tsx +51 -13
  283. package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
  284. package/src/Toggle/types.ts +54 -4
  285. package/src/ToggleGroup/ToggleGroup.tsx +14 -1
  286. package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
  287. package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
  288. package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
  289. package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
  290. package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
  291. package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
  292. package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
  293. package/src/ToggleGroup/types.ts +55 -2
  294. package/src/Tooltip/Tooltip.tsx +98 -20
  295. package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
  296. package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
  297. package/src/Tooltip/types.ts +89 -8
  298. package/src/Tree/Tree.tsx +187 -33
  299. package/src/Tree/types.ts +54 -12
  300. package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
  301. package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
  302. package/src/VisuallyHidden/types.ts +11 -1
  303. package/src/index.ts +4 -0
  304. package/src/test/intersectionObserverPolyfill.ts +7 -0
  305. package/src/test/resizeObserverPolyfill.ts +80 -0
  306. package/src/utils/focusable.ts +15 -0
  307. package/src/utils/index.ts +1 -0
@@ -16,7 +16,15 @@ Currently exposes:
16
16
  - **`Carousel.Viewport`** — slide container, rendered as a `<div>` with
17
17
  a `data-carousel-viewport` attribute the recommended scroll-snap CSS
18
18
  targets. Must be rendered as a descendant of `Carousel.Root`; rendering
19
- it elsewhere throws a descriptive error.
19
+ it elsewhere throws a descriptive error. Also owns mouse click-and-drag
20
+ scrolling — pointerdown/pointermove track the pointer into
21
+ `scrollLeft`/`scrollTop` (amplified by a sensitivity multiplier) once
22
+ past a small movement threshold (so a link/button inside a slide still
23
+ receives a plain click), flipping a `data-dragging` styling hook for
24
+ the duration; release lets the
25
+ existing `scroll-snap-type` settle to the nearest slide, and a
26
+ horizontal (non-`Shift`) mouse-wheel notch translates to horizontal
27
+ scroll — see "JS vs CSS responsibilities" below for both.
20
28
  - **`Carousel.Slide`** — an individual slide. Renders a `<div role="group"
21
29
  aria-roledescription="slide">` and self-registers with the Root so each
22
30
  slide knows its zero-based `data-index` and the live `data-total`
@@ -24,7 +32,17 @@ aria-roledescription="slide">` and self-registers with the Root so each
24
32
  `"N of M"` (e.g. `"1 of 3"`); pass `ariaLabel` to override with a more
25
33
  meaningful description (e.g. `"Hand-picked for you"`). Emits
26
34
  `data-state="active" | "inactive"` tracking the active page, plus a
27
- `data-carousel-slide` CSS hook.
35
+ `data-carousel-slide` CSS hook. Also emits
36
+ `data-snap-align="start" | "center" | "end"` on a page's leading slide
37
+ only (every slide, when `slidesPerPage` is 1), valued from its own
38
+ `snapAlign` prop if set, else the root's resolved `snapAlign` — the hook
39
+ consumer CSS should scope `scroll-snap-align` to, so an interior slide of
40
+ a multi-slide page is never a valid scroll-snap resting position, and the
41
+ native resting position agrees with `snapAlign` (see "Multi-slide snap
42
+ targeting" below). Pass `snapAlign` directly on a `Carousel.Slide` to
43
+ override the root default for that one slide (e.g. a variable-width
44
+ layout where only some slides should centre — matches Ark UI's per-`Item`
45
+ `snapAlign`).
28
46
  - **`Carousel.NextTrigger`** — `<button>` that advances the active page
29
47
  by one. `disabled` at the last page, and whenever zero or one slides
30
48
  are registered. Consumer `onClick` runs before the navigation;
@@ -67,8 +85,9 @@ auto-rotation tick.
67
85
 
68
86
  The component ships zero styles, but a few features sit on the line
69
87
  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_:
88
+ is that JS owns _what is the active page_ and _scrolls the viewport_
89
+ (`viewport.scrollTo`, then the browser's CSS snap engine makes the final
90
+ correction), and CSS owns _what the user sees_:
72
91
 
73
92
  | Feature | JS owns | CSS owns |
74
93
  | ---------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------ |
@@ -76,20 +95,30 @@ the browser_ (`scrollIntoView`), and CSS owns _what the user sees_:
76
95
  | Boundary clamping | `canGoNext` / `canGoPrevious`, trigger `disabled` | — |
77
96
  | Crossfade / scale / dissolve | `data-state="active"` flip on slides | `position: absolute`, `opacity` + `transition` |
78
97
  | 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` |
98
+ | Peek of adjacent slides | `snapAlign` → viewport `scrollTo` alignment | Viewport `padding-inline`, slide `flex-basis`, `scroll-snap-align` |
80
99
  | 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 |
100
+ | Variable-size slides | viewport `scrollTo` the target slide's offset | Per-slide width / `aspect-ratio`, `scroll-snap-align` |
101
+ | Snap targeting | `snapAlign: "start" \| "center" \| "end"` (Root default, overridable per-`Slide`) | `scroll-snap-type` on viewport, `scroll-snap-align` scoped to `[data-snap-align="start" \| "center" \| "end"]` |
102
+ | Snap strictness | `snapType: "mandatory" \| "proximity"` (Root only) | `scroll-snap-type` strictness scoped to `[data-snap-type="mandatory" \| "proximity"]` on the viewport |
83
103
  | Reduced motion | `behavior: "instant"` | Optional `@media (prefers-reduced-motion: reduce)` on consumer animations |
84
104
  | Keyboard navigation | Arrow / Home / End on focused viewport | `:focus-visible` on viewport |
85
105
  | Touch / swipe | Native scroll + `scrollsnapchange` to sync state | `overscroll-behavior-x: contain`, `scrollbar-width: none` |
106
+ | Mouse click-and-drag (`allowMouseDrag`, off by default) | `scrollLeft`/`scrollTop` set from the pointer delta × sensitivity | `cursor: grab`, `cursor: grabbing` on `[data-dragging]` |
107
+ | Mouse-wheel scroll (horizontal) | `deltaY` → `scrollLeft` when `deltaX` is negligible | — |
108
+ | Overscroll (keyboard / wheel / drag) | `onOverscrollStatusChange`, `isOverscrolling()` (drag only) | Optional resistance visual on `[data-overscroll="start" \| "end"]` (drag only) |
86
109
  | Indicator state | `data-state` on `[data-carousel-indicator]` | Visual: dot, bar, thumbnail, etc. |
110
+ | Continuous scroll progress | `getScrollProgress()` / `getSlideProgress(index)`, rAF-batched | Reads `--carousel-progress` / `--slide-progress` for real-time effects, no re-render |
87
111
 
88
112
  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.
113
+ picks whether the viewport `scrollTo` aligns the target slide's leading
114
+ edge (`"start"`) or centres it (`"center"`) so the programmatic scroll
115
+ lands where the browser's CSS snap engine will settle. Everything else is
116
+ either a state knob (JS) or a visual rule (CSS), with no overlap.
117
+
118
+ The scroll is issued on the **viewport element itself** (`viewport.scrollTo`),
119
+ never `element.scrollIntoView()` — the latter walks every scrollable
120
+ ancestor (including the page/window) and would scroll the whole document
121
+ when a carousel is off-screen.
93
122
 
94
123
  The `apps/workbench` workbench at `/carousel` ships worked recipes for
95
124
  each cell of the matrix (single / multi / multi-step × slide / fade)
@@ -280,18 +309,67 @@ const carouselRef = useRef<CarouselImperativeApi>(null);
280
309
  carouselRef.current?.next();
281
310
  carouselRef.current?.previous();
282
311
  carouselRef.current?.goTo(2);
312
+ carouselRef.current?.scrollToIndex(5);
283
313
  carouselRef.current?.play();
284
314
  carouselRef.current?.pause();
285
315
  carouselRef.current?.refresh();
286
316
  const { page, totalPages, value } = carouselRef.current!.getProgress();
317
+ const pageSnapPoints = carouselRef.current!.getPageSnapPoints();
318
+ const isDragging = carouselRef.current!.isDragging();
319
+ const scrollProgress = carouselRef.current!.getScrollProgress(); // 0..1, continuous
320
+ const slideProgress = carouselRef.current!.getSlideProgress(0); // -1..1, continuous
321
+ ```
322
+
323
+ `next()`, `previous()`, and `goTo()` each take an optional trailing
324
+ `instant` boolean (default `false`) that bypasses the resolved
325
+ smooth/reduced-motion scroll for **just that one call** — matching Ark
326
+ UI's `scrollNext(instant?)` / `scrollPrev(instant?)` /
327
+ `scrollTo(page, instant?)`. Useful for e.g. an instant jump when
328
+ restoring a remembered position on mount, followed by ordinary smooth
329
+ navigation from then on:
330
+
331
+ ```tsx
332
+ carouselRef.current?.goTo(rememberedPage, true); // instant, no animation
333
+ carouselRef.current?.next(); // smooth, as usual
287
334
  ```
288
335
 
289
- `refresh()` re-issues the viewport's `scrollIntoView` for the current
336
+ The override never outlives the single call it's passed to — a plain
337
+ `next()` / `previous()` / `goTo()` right after always falls back to
338
+ the normal resolved behavior (`prefers-reduced-motion`-aware smooth
339
+ scrolling by default).
340
+
341
+ `scrollToIndex(slideIndex, instant?)` is the slide-granularity
342
+ counterpart to `goTo`'s page granularity — matching Ark UI's
343
+ `scrollToIndex(index, instant?)`. In a plain single-slide-per-page
344
+ carousel the two are equivalent; the distinction matters once
345
+ `slidesPerPage > 1`, where an interior slide of a page isn't itself a
346
+ scroll-snap position — `scrollToIndex` maps the given slide index to
347
+ its *containing* page (the same mapping `Carousel.Indicator` uses
348
+ internally) rather than requiring you to compute the page yourself.
349
+ It takes the same optional `instant` override as `next` / `previous` /
350
+ `goTo`.
351
+
352
+ `refresh()` re-issues the viewport's `scrollTo` for the current
290
353
  page — useful when external layout changes (window resize, container
291
354
  reflow, dynamic content) leave the scroll position misaligned with
292
355
  React state. `getProgress()` returns a normalised
293
356
  `value` in `[0, 1]` (`0` when there's at most one page) plus the
294
357
  live `page` and `totalPages`, intended for custom progress bars.
358
+ `getPageSnapPoints()` returns the leading slide index each page snaps
359
+ to (one entry per page, length `totalPages`) — the same offset
360
+ formula the carousel uses internally, including the end-aligned last
361
+ page for multi-slide layouts whose total isn't a whole number of
362
+ pages (see "Multi-slide pages and partial page advance" below).
363
+ Useful for a custom progress bar or indicator built from the raw
364
+ offsets instead of just the page count. `isDragging()` is a live
365
+ snapshot of whether a mouse drag is in progress (always `false`
366
+ unless `allowMouseDrag` is set) — see "Drag status" below for the
367
+ matching `onDragStatusChange` callback. For a ready-made rendered
368
+ progress announcement instead of building one from `getProgress()`
369
+ yourself, see "Progress text" below. `getScrollProgress()` and
370
+ `getSlideProgress(slideIndex)` are a *continuous* complement to
371
+ `getProgress()`'s page-granular step function — see "Continuous scroll
372
+ progress" below.
295
373
 
296
374
  Every method routes through the same internal state machine the
297
375
  trigger components use, so controlled-mode `onPageChange` /
@@ -323,90 +401,207 @@ asChild on a non-button still respects the boundary clamp.
323
401
 
324
402
  ### Snap alignment
325
403
 
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:
404
+ By default the Viewport scrolls so the target slide **centres** in the
405
+ scroll container — matching `scroll-snap-align: center` in consumer CSS.
406
+ For layouts where the leading (or trailing) edge should align instead —
407
+ e.g. a classic edge-to-edge gallery — set `snapAlign="start"` (or
408
+ `"end"`) so programmatic navigation lands on that position without the
409
+ browser snap-correcting after the scroll:
332
410
 
333
411
  ```tsx
334
- <Carousel.Root ariaLabel="Gallery" snapAlign="center">
412
+ <Carousel.Root ariaLabel="Gallery" snapAlign="start">
335
413
  …
336
414
  </Carousel.Root>
337
415
  ```
338
416
 
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.
417
+ `Carousel.Slide` publishes the resolved value as
418
+ `data-snap-align="start" | "center" | "end"` (on each valid resting slide —
419
+ see "Multi-slide snap targeting" below), so pair it with
420
+ `[data-snap-align="start"] { scroll-snap-align: start }` in your CSS
421
+ rather than hardcoding `scroll-snap-align: start` — that way the
422
+ native snap the user's own scroll settles into always agrees with
423
+ `snapAlign`, not just the programmatic `scrollTo`. The default is
424
+ `"center"`; `snapAlign` picks whether the viewport `scrollTo` aligns the
425
+ target slide's leading edge (`"start"`), centres it (`"center"`), or
426
+ aligns its trailing edge (`"end"`), and the browser's CSS snap engine
427
+ makes the final correction.
428
+
429
+ **Per-slide override.** `Carousel.Slide` also accepts its own `snapAlign`,
430
+ overriding the root default for just that slide — useful for a
431
+ variable-width layout where only some slides should start- or end-align
432
+ (matches Ark UI's per-`Item` `snapAlign`, which is where the `"end"` value
433
+ comes from too — the root-level prop above has no Ark equivalent):
434
+
435
+ ```tsx
436
+ <Carousel.Root ariaLabel="Gallery">
437
+ <Carousel.Viewport>
438
+ <Carousel.Slide>Regular, centre-aligned</Carousel.Slide>
439
+ <Carousel.Slide snapAlign="start">This one aligns to its leading edge</Carousel.Slide>
440
+ </Carousel.Viewport>
441
+ </Carousel.Root>
442
+ ```
443
+
444
+ The override only takes effect on a slide that's actually a valid
445
+ scroll-snap resting position (a page's leading slide) — an interior slide
446
+ of a multi-slide page never snaps, regardless of this prop (see "Multi-slide
447
+ snap targeting" below).
448
+
449
+ ### Snap strictness
450
+
451
+ `snapType` (default `"mandatory"`) is the raw `scroll-snap-type`
452
+ strictness — pass `"proximity"` for a looser, free-scrolling feel where
453
+ the browser only nudges toward a snap point near where the scroll
454
+ ends, rather than always forcing a rest on one:
455
+
456
+ ```tsx
457
+ <Carousel.Root ariaLabel="Gallery" snapType="proximity">
458
+ …
459
+ </Carousel.Root>
460
+ ```
461
+
462
+ `Carousel.Viewport` publishes the resolved value as
463
+ `data-snap-type="mandatory" | "proximity"`; pair it with
464
+ `[data-snap-type="proximity"] { scroll-snap-type: x proximity }` (`y`
465
+ when `orientation="vertical"`) in your CSS. Matches Ark UI's `snapType`.
466
+
467
+ ### Orientation
468
+
469
+ By default the carousel pages along the **inline (horizontal) axis**.
470
+ Pass `orientation="vertical"` to page along the **block (vertical)
471
+ axis** instead:
472
+
473
+ ```tsx
474
+ <Carousel.Root ariaLabel="Featured products" orientation="vertical">
475
+ …
476
+ </Carousel.Root>
477
+ ```
478
+
479
+ `orientation` is behavioural, not cosmetic — the primitive ships no
480
+ layout. It changes three things:
481
+
482
+ - **Scroll axis.** Programmatic paging scrolls the viewport on the
483
+ `top` (block) axis instead of `left` (inline), leaving the cross axis
484
+ untouched,
485
+ and the user-swipe sync reads `snapTargetBlock` off `scrollsnapchange`
486
+ instead of `snapTargetInline`.
487
+ - **Keyboard.** The viewport pages on `ArrowDown` / `ArrowUp` (the
488
+ horizontal arrows go inert). See [Keyboard navigation](#keyboard-navigation).
489
+ - **Styling hook.** The resolved value is published as
490
+ `data-orientation="horizontal" | "vertical"` on the rendered
491
+ `<section>`, so a single selector can switch your layout to a column
492
+ viewport with `scroll-snap-type: y mandatory`:
493
+
494
+ ```css
495
+ [data-orientation="vertical"] [data-carousel-viewport] {
496
+ flex-direction: column;
497
+ overflow-block: auto;
498
+ overflow-inline: hidden;
499
+ scroll-snap-type: y mandatory;
500
+ }
501
+ ```
502
+
503
+ The vertical viewport needs a bounded block-size (a height) for the
504
+ scroll axis to exist — give the root or viewport an explicit
505
+ `block-size` / `aspect-ratio`.
343
506
 
344
507
  ### Transition modes
345
508
 
346
509
  `Carousel.Root` accepts a `transition` prop that controls how the
347
- viewport handles slide changes visually.
510
+ viewport handles slide changes visually. The resolved value is
511
+ published on the Root as `data-transition` so consumer CSS can switch
512
+ the visual off a single hook (mirroring `data-orientation`).
348
513
 
349
514
  - `transition="slide"` (default) — relies on native CSS scroll-snap.
350
515
  The Viewport scrolls programmatically when the page changes and
351
516
  listens for `scrollsnapchange` so user swipes update React state.
517
+ - `transition="fade"` — installs no scroll wiring (like `"none"`), but
518
+ names the intent so a styled surface can ship a crossfade by default.
519
+ Native swipe/drag and peek don't apply (there's no scroll to swipe);
520
+ controls, indicators, and keyboard paging still work.
352
521
  - `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:
522
+ all and no default visual is implied. Consumer CSS owns the visual
523
+ via the `data-state="active" | "inactive"` hook on each slide, which
524
+ still flips with the active page.
525
+
526
+ Both `"fade"` and `"none"` are the entry point for crossfade, dissolve,
527
+ zoom, or any CSS-only transition pattern — stack the slides and drive
528
+ opacity/transform off `data-state`, keyed off the `data-transition`
529
+ hook so the scroll-snap layout isn't affected:
357
530
 
358
531
  ```css
359
- [data-carousel-slide] {
360
- position: absolute;
361
- inset: 0;
532
+ [data-transition="fade"] [data-carousel-slide] {
533
+ grid-area: 1 / 1; /* stack every slide in one cell */
362
534
  opacity: 0;
363
535
  transition: opacity 400ms;
364
536
  }
365
- [data-carousel-slide][data-state="active"] {
537
+ [data-transition="fade"] [data-carousel-slide][data-state="active"] {
366
538
  opacity: 1;
367
539
  }
368
540
  ```
369
541
 
370
542
  ### Reduced motion
371
543
 
372
- The Viewport's programmatic `scrollIntoView` reads
544
+ The Viewport's programmatic `scrollTo` reads
373
545
  `window.matchMedia("(prefers-reduced-motion: reduce)")` once on
374
546
  mount. When the user has reduced motion enabled at the OS level,
375
547
  page changes use `behavior: "instant"` instead of `"smooth"` so the
376
548
  carousel doesn't fight that preference. Touch-driven scrolling is
377
- unaffected — the browser owns that animation.
549
+ unaffected — the browser owns that animation. The imperative API's
550
+ per-call `instant` override (see "Imperative API" above) composes
551
+ with this — it forces `"instant"` for one call regardless of the
552
+ resolved preference, while every other call still follows it.
378
553
 
379
554
  ### Programmatic scroll sync
380
555
 
381
556
  When the active page changes for any reason (`Carousel.NextTrigger` /
382
557
  `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"`.
558
+ the viewport `scrollTo`s to the first slide of the new page (by its
559
+ measured offset — not `element.scrollIntoView()`, which would scroll the
560
+ whole document when the carousel is off-screen) so the visual surface
561
+ tracks React state. Consumer CSS owns slide width and gap, and
562
+ `scroll-snap-align` makes the final correction. Default `behavior` is
563
+ `"smooth"`.
387
564
 
388
565
  The reverse path is also wired: when the user swipes the viewport,
389
566
  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.
567
+ target. The Viewport listens for that event, maps the snapped slide
568
+ back to its page (grouping by `slidesPerPage` in `"auto"` mode, or
569
+ rounding to the nearest window start in numeric `slidesPerMove` mode,
570
+ clamped so an end-aligned tail slide lands on the last page), and calls
571
+ `goTo` so React state follows the user's scroll. `onPageChange` is only
572
+ invoked when the page genuinely changes, so a snap that lands back on
573
+ the active page doesn't dispatch a spurious callback.
395
574
 
396
575
  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.
576
+ an `IntersectionObserver` (threshold `0.6` by default — see
577
+ `inViewThreshold` below) on each slide — when the observer fires, the
578
+ lowest-index visible slide derives the active page via the same
579
+ slide-index-to-page mapping. This page-drive is **only** a fallback:
580
+ when `scrollsnapchange` is supported it is authoritative (it reports
581
+ the precisely-snapped slide), so the observer stands down and does
582
+ not also drive the page. That matters for `snapAlign="center"`
583
+ carousels with several slides visible at once (e.g. a cover flow) —
584
+ the lowest-index-visible heuristic would otherwise track the
585
+ *leftmost* visible slide rather than the centred one and fight
586
+ `scrollsnapchange`. The observer still always feeds `Carousel.Root`'s
587
+ imperative `isInView(slideIndex)` so consumers can lazy-load slide
588
+ content based on actual visibility, not just the active-page index.
589
+
590
+ **`inViewThreshold`.** Pass a number or an array of numbers on
591
+ `Carousel.Root` to override the default `0.6`:
592
+
593
+ ```tsx
594
+ <Carousel.Root ariaLabel="Featured products" inViewThreshold={0.3}>
595
+ ```
596
+
597
+ The value is passed straight through as the `IntersectionObserver`'s
598
+ own `threshold` option. For the single "is this slide in view"
599
+ boolean that `isInView` reports (and that the page-drive fallback
600
+ above reads), a plain number is used directly as the cutoff; an array
601
+ uses its **highest** value — the strictest crossing counts as
602
+ genuinely in view, since multiple thresholds otherwise only exist to
603
+ fire the observer's callback more often, not to change what "in view"
604
+ means.
410
605
 
411
606
  ### Custom DOM ids
412
607
 
@@ -492,8 +687,12 @@ With `slidesPerPage={3}` and 5 slides:
492
687
 
493
688
  - Total pages = `ceil(5 / 3) === 2`. `Carousel.Indicators` renders 2
494
689
  dots.
495
- - Page 0 contains slides 0–2; page 1 contains the remaining 3, 4 (a
496
- partial last page).
690
+ - Page 0 contains slides 0–2; page 1 **end-aligns** to slides 2–4 (a
691
+ full window flush with the track end, not a partial `[3,4]`). A
692
+ partial last page's leading slide can't align to the viewport start,
693
+ so it would snap to the previous slide and desync the active page —
694
+ end-aligning keeps every page a full, cleanly-snapping window (7
695
+ slides at `slidesPerPage={3}` → `[0,1,2] [3,4,5] [4,5,6]`).
497
696
  - Each slide on the active page emits `data-state="active"`; slides
498
697
  on other pages emit `"inactive"`.
499
698
  - `Carousel.NextTrigger` advances one page per click; the boundary
@@ -521,10 +720,24 @@ With `slidesPerPage=3`, `slidesPerMove=1`, and 5 slides, the active
521
720
  window slides one slide at a time — pages show `[0,1,2]`, `[1,2,3]`,
522
721
  `[2,3,4]`, so `Carousel.Indicators` renders 3 dots and the boundary
523
722
  clamp respects the last full window. The indicator count formula is
524
- `floor((total - slidesPerPage) / slidesPerMove) + 1` (vs.
723
+ `ceil((total - slidesPerPage) / slidesPerMove) + 1` (vs.
525
724
  `ceil(total / slidesPerPage)` for `"auto"`), so the visible window
526
725
  always stays full in numeric mode.
527
726
 
727
+ **The last window is end-aligned.** When the move doesn't divide the
728
+ track evenly, the final page snaps to the track end rather than dropping
729
+ the remainder — so every slide is always reachable. With
730
+ `slidesPerPage={3}`, `slidesPerMove={2}`, and 6 slides the pages are
731
+ `[0,1,2]`, `[2,3,4]`, `[3,4,5]` (the last shifts back by one to include
732
+ slide 5) — never `[0,1,2]`, `[2,3,4]` with slide 5 orphaned.
733
+
734
+ **Bounds are guarded.** `slidesPerPage` is coerced to an integer ≥ 1 and
735
+ a numeric `slidesPerMove` to an integer in `[1, slidesPerPage]`, so a
736
+ consumer passing `0`, a negative, a fraction, `NaN`, or a move larger
737
+ than a page can never divide by zero, skip past a page (orphaning the
738
+ slides in the gap), or make the carousel inert — it degrades to the
739
+ nearest sane layout.
740
+
528
741
  ### Boundary behaviour
529
742
 
530
743
  The prev/next triggers clamp at the ends: `Carousel.PreviousTrigger` is
@@ -532,23 +745,145 @@ The prev/next triggers clamp at the ends: `Carousel.PreviousTrigger` is
532
745
  are also `disabled` when zero or one slides are registered, since
533
746
  there's nowhere to navigate.
534
747
 
748
+ ### Loop (wrap-around navigation)
749
+
750
+ Pass `loop` to make navigation wrap around instead of clamping. `loop`
751
+ is a **mode selector** — a boolean for ergonomics, or a named mode:
752
+
753
+ ```tsx
754
+ <Carousel.Root ariaLabel="Featured products" loop> {/* = "wrap" */}
755
+ <Carousel.Root ariaLabel="Featured products" loop="wrap"> {/* semantic wrap */}
756
+ <Carousel.Root ariaLabel="Featured products" loop="infinite"> {/* continuous infinite */}
757
+ ```
758
+
759
+ | `loop` | Resolved `data-loop` | Behaviour |
760
+ | ------------------------- | -------------------- | ------------------------------------------ |
761
+ | omitted / `false` | `"none"` | Clamp at the ends (triggers disable). |
762
+ | `true` / `"wrap"` | `"wrap"` | Semantic wrap (visible rewind). |
763
+ | `"infinite"` | `"infinite"` | Continuous infinite (no rewind). |
764
+
765
+ In every wrapping mode:
766
+
767
+ - `Carousel.NextTrigger` on the last page advances to the first;
768
+ `Carousel.PreviousTrigger` on the first page retreats to the last.
769
+ - Neither trigger `disabled`s at a boundary (the arrow keys wrap too,
770
+ since they route through the same `next` / `previous`).
771
+ - **Autoplay keeps rotating past the last page** rather than stopping —
772
+ the natural pairing for an auto-rotating hero.
773
+
774
+ A single page has no wrap target, so the triggers stay `disabled`
775
+ regardless of `loop`. The resolved mode is published as
776
+ `data-loop="none" | "wrap" | "infinite"` on the Root (mirroring
777
+ `data-orientation` / `data-transition`) for consumer CSS.
778
+
779
+ **`"wrap"` vs `"infinite"`.** Both share the same page model (the wrap
780
+ arithmetic and never-disable boundaries). They differ only in the visual:
781
+ `"wrap"` smooth-scrolls the whole track back on a last→first wrap (a visible
782
+ rewind, the same path `Home` takes), while `"infinite"` glides one step on
783
+ with no rewind.
784
+
785
+ Under `loop="infinite"` the Viewport is **not** a scroll container. It clips
786
+ a **JS transform track** (`data-carousel-track`) laid out as one contiguous,
787
+ periodic strip: the real slides are flanked by a full period of `aria-hidden`,
788
+ `inert` **clones** each side (`[clones] [real slides] [clones]`), so every seam
789
+ is already-painted DOM. Navigation only ever translates the **whole track** — no
790
+ slide moves relative to it — and when a glide carries past the last real slide
791
+ onto a clone, the settle **re-bases** the track by exactly one period so the
792
+ identical real slide sits at the same pixels. Because the strip is periodic, the
793
+ re-base reveals nothing unrasterised and moves nothing (RFC 0018, clone-strip
794
+ revision), which is what structurally removes the iOS seam flash a per-slide fill
795
+ could never avoid. Every page change glides the track via a GPU-composited CSS
796
+ `transform` transition, but the **path differs by kind**: a *relative* step
797
+ (Prev / Next, the arrow keys, autoplay) always takes the **short way** —
798
+ continuing forward off the last slide onto the clone rather than rewinding
799
+ across the whole strip — while a *direct jump to a specific page* (an
800
+ indicator/thumbnail click, or `goTo()` / `scrollToIndex()`) takes the
801
+ **literal** distance between the two slides instead, even when the ring's
802
+ wrap would be shorter: picking the last indicator from the first visibly
803
+ travels forward through every slide in between, matching the indicators' own
804
+ reading order, rather than silently rewinding one step the other way around
805
+ the ring. The first positioning is instant (no glide on load).
806
+ `prefers-reduced-motion` sets the offset instantly with no transition.
807
+
808
+ The glide's **duration and easing** are read off the track from the
809
+ `--primitiv-carousel-glide-duration` and `--primitiv-carousel-glide-easing` custom
810
+ properties, so a consumer retunes the feel purely in CSS (the registry stylesheet
811
+ defaults them to motion tokens and ships `--glide-fast/medium/slow` presets). With
812
+ no stylesheet the engine falls back to a built-in `500ms` ease-out. Every slide is
813
+ also promoted to its own compositor layer (`transform: translateZ(0)` in the
814
+ registry sheet) so its rounded, clipped bitmap is painted before it scrolls in — no
815
+ iOS entering-edge white — while the engine windows the painted set (far slides are
816
+ `visibility: hidden`) to keep that layer count bounded.
817
+
818
+ The engine **re-measures on a `ResizeObserver`** (watching the track and the
819
+ slides), so it self-heals when the container resizes or a size-affecting knob
820
+ changes at runtime — `peek`, the slide `ratio`, ambient density, a multi-slide
821
+ `gap` — re-homing the track to the current page and re-running the paint window
822
+ against the fresh geometry. (A change that resizes *no* box — e.g. a single-slide
823
+ `gap`, which only alters the between-slide spacing — isn't observable; call the
824
+ imperative `refresh()` after one of those, or it self-corrects on the next
825
+ navigation.)
826
+
827
+ **Touch / mouse drag.** The track follows the pointer 1:1 (transition off), and
828
+ on release a velocity-projected **fling** snaps to the nearest **page** boundary
829
+ with the same glide, updating the active page from where it lands. With
830
+ `slidesPerPage > 1` the snap is a whole page, so a multi-slide fling advances (or
831
+ holds) a full page rather than settling mid-page and being jerked to the page
832
+ lead. Touch drag is always
833
+ on (there's no native scroll to fall back to); mouse drag stays opt-in via
834
+ `allowMouseDrag`. A tap under a small threshold still reaches a link / button
835
+ inside a slide.
836
+
837
+ **Direction, peek and multi-slide.** The engine is axis- and direction-generic:
838
+ it reads the sign of the measured stride as the axis direction, so an **RTL**
839
+ loop mirrors every move (no rewind) — including `snapAlign`, whose `start` / `end`
840
+ swap so the reading start stays the right edge — and it cancels the track's own
841
+ inset so **peek** / viewport padding stays centred. **Multi-slide**
842
+ (`slidesPerPage > 1`)
843
+ glides to each page's leading slide, so Prev / Next advance a whole page and wrap
844
+ cleanly; the inter-slide gap returns between the on-screen pair (single-slide
845
+ stays gapless so the gap never flashes through the viewport mid-glide). The gap
846
+ is scoped by the `data-slides-per-page` hook the engine sets on the track.
847
+
848
+ No slide is ever individually transformed — the strip only ever slides as a unit,
849
+ so there are no per-slide layers to flash. The track is **not** force-promoted to
850
+ one permanent GPU layer either (2D `translate`, no `backface-visibility`): the
851
+ clone strip is several viewports wide, and iOS Safari rasterises a large permanent
852
+ layer in tiles on demand, blanking slides at rest until they fill in. Letting the
853
+ browser composite the transition on the GPU but rasterise the visible region
854
+ normally at rest — like native scroll — sidesteps that.
855
+
856
+ This is the same transform + clone approach Embla and Swiper use. It replaced two
857
+ earlier attempts that each flashed on iOS: a native-scroll-snap **clone buffer**
858
+ (the native snap fought the teleport) and a clone-free per-slide `wrapShift` fill
859
+ (individual slides teleported, which iOS can't pre-rasterise). Keeping the clones
860
+ but driving them purely in JS — no native snap, no per-slide moves — removes both
861
+ failure modes at once.
862
+
863
+ > The track geometry is browser-only (jsdom reports no layout), so it ships
864
+ > unit-tested with mocked geometry, and real-browser tested in Playwright.
865
+
535
866
  ### Keyboard navigation
536
867
 
537
868
  `Carousel.Viewport` is in the tab order so keyboard users can reach the
538
869
  rotation control without first tabbing through every slide's
539
870
  interactive content. With the Viewport focused:
540
871
 
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.
872
+ | Key | Action |
873
+ | --------------------- | ----------------------------------------------- |
874
+ | `ArrowRight` / `ArrowDown` | Advance by one page (same as `NextTrigger`) |
875
+ | `ArrowLeft` / `ArrowUp` | Retreat by one page (same as `PreviousTrigger`) |
876
+ | `Home` | Jump to the first page |
877
+ | `End` | Jump to the last page |
878
+
879
+ The paging arrows follow the [orientation](#orientation): `ArrowRight` /
880
+ `ArrowLeft` for the default horizontal axis, `ArrowDown` / `ArrowUp` when
881
+ `orientation="vertical"` (the off-axis arrows go inert). `Home` / `End`
882
+ work in both. Arrow keys clamp at the boundaries, mirroring the trigger
883
+ buttons — or wrap around when [`loop`](#loop-wrap-around-navigation) is
884
+ set. Keypresses are only intercepted when the Viewport itself is the
885
+ focus target — focus inside a slide (e.g. on a link or form control)
886
+ keeps its native arrow-key semantics.
552
887
 
553
888
  ```tsx
554
889
  <Carousel.Root ariaLabel="Featured products">
@@ -566,6 +901,207 @@ Consumer-supplied `disabled={true}` on either trigger is honoured
566
901
  regardless of boundary state — useful for momentarily freezing
567
902
  navigation while another part of the UI takes over.
568
903
 
904
+ ### Mouse input
905
+
906
+ Two mouse interactions ship on `Carousel.Viewport` alongside the native
907
+ touch/trackpad scroll that already worked with zero custom code (the
908
+ scroll-driven `scrollsnapchange` sync doesn't care how the viewport got
909
+ scrolled):
910
+
911
+ - **Click-and-drag** — **opt-in** via `allowMouseDrag` on `Carousel.Root`
912
+ (default `false`; an unconditionally-on drag could conflict with a
913
+ consumer's own drag-sensitive slide content — a nested carousel, a
914
+ draggable card, a canvas). When enabled: click and hold on the viewport,
915
+ drag, and it scrolls like a swipe — `scrollLeft`/`scrollTop` track the
916
+ pointer, amplified by a 2× sensitivity multiplier so a full-slide
917
+ transition doesn't require dragging the slide's full on-screen width
918
+ (still no momentum/flick — the multiplier only scales the tracked
919
+ delta, motion stops dead on release) once the drag clears a small
920
+ (3px) movement threshold — below the threshold nothing happens, so a
921
+ plain click on a
922
+ link or button inside a slide still reaches it. Releasing the pointer
923
+ lets the existing `scroll-snap-type` settle to the nearest slide, exactly
924
+ like a touch swipe — no extra "scroll → state" wiring needed, the
925
+ `scrollsnapchange` sync already covers it. A `data-dragging` attribute is
926
+ set on the viewport for the duration of an active drag (see "Recommended
927
+ CSS" for the `cursor: grab` / `grabbing` pairing). Ignores non-mouse
928
+ pointer types (`touch`, `pen`) even when enabled — native scroll already
929
+ handles those. Also suppresses the browser's own native image/link drag
930
+ (the semi-transparent ghost that follows the cursor) on the viewport via
931
+ `preventDefault()` on `dragstart` — real `<img>`/`<a>` slide content is
932
+ natively draggable, and without this its native HTML5 drag would visually
933
+ compete with the custom pointer-drag above. Only active while
934
+ `allowMouseDrag` is on; native drag is left untouched otherwise.
935
+ - **Mouse-wheel scroll.** A physical wheel's vertical notches
936
+ (`deltaY`) already natively scroll a `orientation="vertical"`
937
+ carousel — nothing needed there. But browsers only auto-translate a
938
+ plain vertical wheel to horizontal scroll when `Shift` is held, so the
939
+ default horizontal orientation gets no scroll at all from a plain
940
+ wheel without help: the Viewport translates `deltaY` into `scrollLeft`
941
+ whenever `deltaX` is negligible. A trackpad or Magic Mouse horizontal
942
+ swipe already produces real `deltaX` and already scrolls a horizontal
943
+ viewport natively (the same mechanism as touch) — the translation
944
+ stands down whenever `deltaX` is non-negligible so it never fights
945
+ that. `deltaY` is normalized from `DOM_DELTA_LINE` / `DOM_DELTA_PAGE`
946
+ units to pixels first, so a physical wheel's larger, fewer ticks feel
947
+ proportional to a trackpad's many small pixel-mode ones.
948
+
949
+ Both stand down when `transition` isn't `"slide"` (the viewport isn't a
950
+ scrolling track in `"fade"` / `"none"` mode).
951
+
952
+ ### Drag status
953
+
954
+ The `data-dragging` DOM hook above is enough for CSS, but a consumer
955
+ reacting in JS — e.g. pausing a video in the active slide while the
956
+ user drags — needs the imperative `isDragging()` and/or the
957
+ `onDragStatusChange` callback (both only ever reachable when
958
+ `allowMouseDrag` is `true`; matches Ark UI's `api.isDragging` /
959
+ `onDragStatusChange`):
960
+
961
+ ```tsx
962
+ const carouselRef = useRef<CarouselImperativeApi>(null);
963
+
964
+ <Carousel.Root
965
+ ref={carouselRef}
966
+ ariaLabel="Featured products"
967
+ allowMouseDrag
968
+ onDragStatusChange={({ type, page, isDragging }) => {
969
+ // type: "dragging.start" | "dragging" | "dragging.end"
970
+ }}
971
+ >
972
+ …
973
+ </Carousel.Root>;
974
+
975
+ carouselRef.current?.isDragging(); // live snapshot, read on demand
976
+ ```
977
+
978
+ `onDragStatusChange` fires `"dragging.start"` the moment a drag crosses
979
+ the click-vs-drag movement threshold, `"dragging"` on every subsequent
980
+ pointer move while it's still active, and `"dragging.end"` on release
981
+ (`pointerup` or `pointercancel`) — never for a plain click that never
982
+ crossed the threshold. `page` is the live active page at that moment.
983
+
984
+ ### Overscroll
985
+
986
+ Pushing against a boundary with nowhere further to go — via the
987
+ keyboard, the wheel, or a mouse drag — fires `Carousel.Root`'s
988
+ `onOverscrollStatusChange`:
989
+
990
+ ```tsx
991
+ <Carousel.Root
992
+ ariaLabel="Featured products"
993
+ allowMouseDrag
994
+ onOverscrollStatusChange={({ type, edge, source, amount, page }) => {
995
+ // type: "overscroll.start" | "overscroll" | "overscroll.end"
996
+ // edge: "start" | "end"
997
+ // source: "keyboard" | "wheel" | "drag"
998
+ }}
999
+ >
1000
+ …
1001
+ </Carousel.Root>;
1002
+ ```
1003
+
1004
+ Detection is driven by the same page-boundary truth
1005
+ (`canGoNext`/`canGoPrevious`) as everything else in the primitive, from
1006
+ three input sources the carousel already owns the physics for:
1007
+
1008
+ - **Keyboard** — the forward/backward arrow key pressed while already
1009
+ at that boundary. `Home`/`End` are absolute jumps, not directional
1010
+ pushes, so they never fire this.
1011
+ - **Wheel** — the horizontal-desktop wheel-to-scroll translation (see
1012
+ "Mouse input" above), blocked at a boundary.
1013
+ - **Mouse drag** — a drag (`allowMouseDrag`) pushing past a boundary.
1014
+
1015
+ Keyboard and wheel are instantaneous taps with no meaningful distance —
1016
+ they always fire a single bare `"overscroll"` with `amount: 0`. Drag is
1017
+ the one continuous, physically real case: it fires `"overscroll.start"`
1018
+ the moment the drag starts pushing past the boundary, `"overscroll"` on
1019
+ every subsequent move while still pushing (with the live `amount`, the
1020
+ drag delta past the boundary), and `"overscroll.end"` when the drag
1021
+ stops pushing past it — either a reversal back within bounds mid-drag,
1022
+ or the drag ending — mirroring `onDragStatusChange`'s shape. A drag
1023
+ overscroll also sets a persistent `data-overscroll="start" | "end"`
1024
+ attribute on the Viewport for the same duration — a CSS hook for a
1025
+ rubber-band resistance visual, with no JS required. For the live
1026
+ imperative equivalent of `isDragging()`, `isOverscrolling()` reports
1027
+ whether a drag is currently pushing past a boundary (always `false` for
1028
+ a keyboard/wheel tap, which is instantaneous rather than a sustained
1029
+ state).
1030
+
1031
+ **Native touch/swipe overscroll is out of scope.** OS-level rubber-
1032
+ banding when a touch swipe is dragged past a boundary is browser-owned
1033
+ scroll physics with no JS hook to observe it from — consistent with
1034
+ this primitive's "native scroll is the source of truth" approach
1035
+ elsewhere (see "Mouse input" above, and "Reduced motion").
1036
+
1037
+ ### Continuous scroll progress
1038
+
1039
+ `getProgress()` and `isInView()` are both coarse — a page-granular step
1040
+ function and a threshold boolean, respectively. For a continuous signal
1041
+ (a parallax effect, a cover-flow-style scale/rotate, a custom progress
1042
+ bar that fills smoothly instead of stepping), `Carousel.Root` exposes
1043
+ two additional getters, each mirrored live onto a CSS custom property so
1044
+ a consumer's stylesheet can read them directly without touching
1045
+ JavaScript at all:
1046
+
1047
+ ```tsx
1048
+ carouselRef.current!.getScrollProgress(); // 0 (start) .. 1 (end)
1049
+ carouselRef.current!.getSlideProgress(0); // -1 .. 0 (centered) .. 1
1050
+ ```
1051
+
1052
+ ```css
1053
+ /* on the Viewport */
1054
+ .my-progress-bar {
1055
+ transform: scaleX(var(--carousel-progress));
1056
+ }
1057
+
1058
+ /* on each Slide */
1059
+ .my-slide {
1060
+ opacity: calc(1 - abs(var(--slide-progress)));
1061
+ }
1062
+ ```
1063
+
1064
+ `--carousel-progress` (on the Viewport) mirrors `getScrollProgress()` —
1065
+ how far the viewport has scrolled along its main axis, `0` at the start
1066
+ and `1` at the end (`0` when there's no overflow to scroll at all).
1067
+ `--slide-progress` (on each Slide) mirrors `getSlideProgress(index)` —
1068
+ `0` when that slide's center coincides with the viewport's center, `±1`
1069
+ when the slide's center has reached the viewport edge (clamped beyond
1070
+ that). Both recompute on every scroll tick and on viewport/slide resize,
1071
+ coalesced to at most once per animation frame so a fast native scroll
1072
+ never forces more than one layout read per frame.
1073
+
1074
+ `getSlideProgress` is normalised by the **viewport's** half-extent, not
1075
+ the slide's own — at `slidesPerPage > 1` a page's outer members can
1076
+ already read a large magnitude (e.g. ~±0.67 at three-per-page) while
1077
+ still fully on-screen and settled. This is *not* the same signal as
1078
+ `isInView` flipping to `false`: `isInView` is an IntersectionObserver
1079
+ threshold crossing, while `getSlideProgress` is raw geometry — a slide
1080
+ can read a large `getSlideProgress` magnitude and still be fully
1081
+ `isInView`, or vice versa near a partial-visibility threshold.
1082
+
1083
+ `getScrollProgress()` is robust under RTL with no `dir` check: modern
1084
+ browsers standardize RTL `scrollLeft` on the "negative" convention (`0`
1085
+ at the start, down to the negative of the max scroll at the end), so the
1086
+ implementation takes the absolute value before normalising — the same
1087
+ reasoning that lets the mouse-drag handler skip RTL special-casing (see
1088
+ "Mouse input" above).
1089
+
1090
+ **Under `loop="infinite"`**, this effect stands down (nothing ever
1091
+ scrolls — the engine translates the track via a JS transform instead),
1092
+ so `--slide-progress` is driven by the infinite engine itself: analytically
1093
+ on every discrete paint (an instant move, or any of a drag's many calls),
1094
+ plus a live-reading rAF ticker for the duration of an animated glide so
1095
+ the drift keeps pace with the track's own CSS transition rather than
1096
+ snapping to the settled value once it ends. This covers every CSS
1097
+ consumer of `--slide-progress` (parallax included) — the imperative
1098
+ **`getSlideProgress()` / `getScrollProgress()` getters are not** (they
1099
+ still only read what the native-scroll effect last wrote, so they read
1100
+ frozen under infinite); reach for the CSS custom property there instead.
1101
+ There is also no infinite equivalent of `--carousel-progress` — a
1102
+ 0 (start) .. 1 (end) reading has no natural definition for a loop with
1103
+ no start or end.
1104
+
569
1105
  ### Indicator dots (manual)
570
1106
 
571
1107
  For full control over indicator content, map them yourself with
@@ -688,6 +1224,62 @@ manual navigation is announced to assistive tech, and flips to
688
1224
  The flip is reactive — pausing via `PlayPauseTrigger` returns the
689
1225
  viewport to `"polite"` for the duration of the pause.
690
1226
 
1227
+ ### Autoplay status
1228
+
1229
+ The `playing` flag and `data-state` on `PlayPauseTrigger` cover
1230
+ play/pause toggles, but a consumer reacting to the timer itself in JS
1231
+ — e.g. logging analytics on every autoplay rotation — needs the
1232
+ `onAutoplayStatusChange` callback (only ever reachable when
1233
+ `autoplay` is enabled; matches Ark UI's `onAutoplayStatusChange`):
1234
+
1235
+ ```tsx
1236
+ <Carousel.Root
1237
+ ariaLabel="Featured products"
1238
+ autoplay
1239
+ defaultPlaying
1240
+ onAutoplayStatusChange={({ type, page, isPlaying }) => {
1241
+ // type: "autoplay.start" | "autoplay" | "autoplay.stop"
1242
+ }}
1243
+ >
1244
+ …
1245
+ </Carousel.Root>
1246
+ ```
1247
+
1248
+ `onAutoplayStatusChange` fires `"autoplay.start"` the moment the timer
1249
+ becomes active (autoplay enabled, `playing` true, another page
1250
+ remains, not suspended by hover/focus/touch), `"autoplay"` on every
1251
+ tick while it stays active, and `"autoplay.stop"` when the timer stops
1252
+ running — whether because `playing` flipped `false`, the last page was
1253
+ reached, or hover/focus/touch suspended it. `page` is the active page
1254
+ at that moment.
1255
+
1256
+ ### Progress text
1257
+
1258
+ `getProgress()` on the imperative API gives you the raw
1259
+ `{ page, totalPages, value }` data, but wiring an actual rendered
1260
+ announcement (`"1 of 3"`) yourself every time is boilerplate.
1261
+ `Carousel.ProgressText` renders it for you, formatted by
1262
+ `translations.progressText` (matches Ark UI's `Carousel.ProgressText`
1263
+ anatomy part):
1264
+
1265
+ ```tsx
1266
+ <Carousel.Root ariaLabel="Featured products">
1267
+ <Carousel.Viewport>…</Carousel.Viewport>
1268
+ <Carousel.ProgressText />
1269
+ </Carousel.Root>
1270
+ ```
1271
+
1272
+ Renders `"1 of 3"` by default (1-indexed, matching `slideLabel`); pass a
1273
+ `progressText` formatter through `translations` (a
1274
+ `({ page, totalPages }) => …` function) on the Root to customise the
1275
+ format, or `children` on `Carousel.ProgressText`
1276
+ itself to render something else entirely (an icon alongside the
1277
+ count, say) instead of the computed text. `Carousel.ProgressText`
1278
+ carries no ARIA wiring of its own — compose it inside your own live
1279
+ region if you want page changes announced; this mirrors Ark's
1280
+ unopinionated `progressText` part rather than duplicating the
1281
+ Viewport's own `aria-live` toggling (see "Autoplay timer" above).
1282
+
691
1283
  ### Indicator dots (auto-rendered)
692
1284
 
693
1285
  For the common case of one dot per slide with auto-generated labels,
@@ -711,6 +1303,28 @@ slide and the indicator row updates on the next render. For custom
711
1303
  indicator content (thumbnails, numbers, mixed icons), drop down to
712
1304
  the manual `IndicatorGroup` + `Indicator` API above.
713
1305
 
1306
+ ### Read-only (presentational) indicators
1307
+
1308
+ Pass `readOnly` when the dots are a progress display rather than a
1309
+ navigation control — e.g. a carousel driven solely by
1310
+ [`allowMouseDrag`](#mouse-input), where clickable dots would be a
1311
+ redundant, confusing second way to navigate:
1312
+
1313
+ ```tsx
1314
+ <Carousel.Indicators label="Progress" readOnly />
1315
+ // or, on a manually-mapped dot:
1316
+ <Carousel.Indicator index={0} readOnly />
1317
+ ```
1318
+
1319
+ A `readOnly` indicator renders a `<span>` instead of a `<button>`
1320
+ (no button semantics), carries `aria-hidden="true"` (it's decorative
1321
+ and not announced to assistive tech), and no longer calls `goTo`
1322
+ when clicked. It still tracks `data-state="active" | "inactive"`, so
1323
+ the same CSS that paints a normal indicator keeps working. A
1324
+ consumer's own `onClick` on an individual `Indicator`, if passed,
1325
+ still fires — only the internal navigation is suppressed. On
1326
+ `Carousel.Indicators`, `readOnly` forwards to every generated dot.
1327
+
714
1328
  ## Recommended CSS
715
1329
 
716
1330
  The component ships zero styles. The recipe below is the minimum
@@ -737,7 +1351,19 @@ and target the `data-carousel-*` attributes:
737
1351
  [data-carousel-slide] {
738
1352
  flex: 0 0 100%;
739
1353
  scroll-snap-align: start;
740
- /* Stop the OS picking up images for drag/save during a swipe. */
1354
+ }
1355
+
1356
+ /* Stop the OS picking up an image for drag/save during a swipe. Repeated on
1357
+ the media element itself, not just the slide wrapper — -webkit-user-drag
1358
+ isn't inherited, so a real <img>'s own native drag-source eligibility is
1359
+ unaffected by an ancestor's value. WebKit-only (no Firefox equivalent);
1360
+ pair with `allowMouseDrag`, which also `preventDefault()`s the drag start
1361
+ in JS for full cross-browser coverage. */
1362
+ [data-carousel-slide],
1363
+ [data-carousel-slide] > img,
1364
+ [data-carousel-slide] > video,
1365
+ [data-carousel-slide] > picture,
1366
+ [data-carousel-slide] > picture > img {
741
1367
  -webkit-user-drag: none;
742
1368
  }
743
1369
 
@@ -765,10 +1391,61 @@ and target the `data-carousel-*` attributes:
765
1391
  For multi-slide pages (`slidesPerPage={3}`, etc.), tune the slide's
766
1392
  `flex-basis` to share the viewport — e.g. `calc(100% / 3)` for three
767
1393
  slides per page, plus a `gap` on the viewport for the inter-slide
768
- spacing. For a crossfade transition, use `transition="none"` on
1394
+ spacing. **Scope `scroll-snap-align` to `[data-snap-align]` instead of
1395
+ every `[data-carousel-slide]`** once `slidesPerPage > 1` — see
1396
+ "Multi-slide snap targeting" below; the recipe above is correct as-is
1397
+ for the single-slide-per-page default, where every slide carries the
1398
+ hook. For a crossfade transition, use `transition="none"` on
769
1399
  `Carousel.Root` and style the slides absolute-positioned with an
770
1400
  opacity transition keyed off `[data-carousel-slide][data-state="active"]`.
771
1401
 
1402
+ ### Multi-slide snap targeting
1403
+
1404
+ When `slidesPerPage > 1`, only a page's *leading* slide is a valid
1405
+ scroll-snap resting position — an interior slide has no page of its
1406
+ own, so letting the browser's mandatory snap stop there leaves the
1407
+ viewport showing a straddled mix of two pages while `currentPage` (and
1408
+ any indicators driven from it) still claim a single clean page is
1409
+ active. This is easy to hit with incremental input — a mouse wheel
1410
+ notch or a drag release — that lands mid-page rather than jumping a
1411
+ full page at once. Scope `scroll-snap-align` to the `data-snap-align`
1412
+ hook (present only on each page's leading slide, and on every slide
1413
+ when `slidesPerPage` is 1) rather than applying it to every slide. Its
1414
+ *value* mirrors the root's resolved `snapAlign` — or the individual
1415
+ slide's own override (`Carousel.Slide`'s `snapAlign` prop), if it set
1416
+ one — so a user's own scroll (wheel/touch/drag) settles wherever the
1417
+ programmatic scroll already targets. Style all three values, not just
1418
+ `"start"`, if the consumer might set `snapAlign="center"` / `"end"` on
1419
+ the root or on individual slides:
1420
+
1421
+ ```css
1422
+ [data-carousel-slide] {
1423
+ flex: 0 0 calc(100% / 3); /* three slides per page, for example */
1424
+ }
1425
+ [data-carousel-slide][data-snap-align="start"] {
1426
+ scroll-snap-align: start;
1427
+ }
1428
+ [data-carousel-slide][data-snap-align="center"] {
1429
+ scroll-snap-align: center;
1430
+ }
1431
+ [data-carousel-slide][data-snap-align="end"] {
1432
+ scroll-snap-align: end;
1433
+ }
1434
+ ```
1435
+
1436
+ **Centering/ending a multi-slide page, not just its leading slide.** A
1437
+ `scroll-snap-align` on a single element only ever aligns *that* element's
1438
+ own box — `scroll-snap-align: center` on the leading slide would centre
1439
+ just the leading slide, leaving the rest of a `slidesPerPage > 1` page
1440
+ clipped off the trailing edge, if that were the whole story. It isn't:
1441
+ the Viewport also sets `scroll-margin-inline-end` (`-block-end` when
1442
+ vertical) on each page's leading slide, extending its native snap area —
1443
+ invisibly, with no layout effect — out to the page's actual last member.
1444
+ So `scroll-snap-align: center`/`"end"` end up centering/ending the whole
1445
+ page, matching what the programmatic `scrollTo` already does. This is
1446
+ automatic and needs no consumer CSS; it's a no-op when `slidesPerPage` is
1447
+ 1 (the leading slide already *is* the whole page).
1448
+
772
1449
  ### Cover Flow (scroll-driven 3D)
773
1450
 
774
1451
  A "cover flow" — narrow snap units with cards that tilt away in 3D