@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.
- package/README.md +4 -0
- package/package.json +6 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
- package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
- package/src/AccessibleIcon/types.ts +15 -2
- package/src/Accordion/Accordion.tsx +25 -4
- package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
- package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
- package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
- package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
- package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
- package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
- package/src/Accordion/types.ts +72 -21
- package/src/Alert/Alert.tsx +36 -7
- package/src/Alert/__tests__/Alert.test.tsx +5 -0
- package/src/Alert/types.ts +18 -2
- package/src/Avatar/Avatar.tsx +99 -15
- package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
- package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
- package/src/Avatar/hooks/useAvatarImage.ts +16 -0
- package/src/Avatar/types.ts +15 -3
- package/src/Breadcrumb/Breadcrumb.tsx +136 -18
- package/src/Breadcrumb/README.md +47 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
- package/src/Breadcrumb/types.ts +73 -7
- package/src/Button/Button.tsx +2 -0
- package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
- package/src/Button/types.ts +21 -0
- package/src/Carousel/Carousel.tsx +263 -15
- package/src/Carousel/README.md +749 -72
- package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
- package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
- package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
- package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
- package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +88 -0
- package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
- package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
- package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
- package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
- package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
- package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
- package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
- package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
- package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
- package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
- package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
- package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
- package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
- package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
- package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
- package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
- package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
- package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
- package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
- package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
- package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
- package/src/Carousel/hooks/index.ts +1 -0
- package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
- package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
- package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
- package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
- package/src/Carousel/loopEngine.ts +54 -0
- package/src/Carousel/types.ts +457 -35
- package/src/Checkbox/Checkbox.tsx +9 -1
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
- package/src/Checkbox/types.ts +19 -0
- package/src/CheckboxCard/CheckboxCard.tsx +13 -4
- package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
- package/src/CheckboxCard/types.ts +65 -17
- package/src/Collapsible/Collapsible.tsx +49 -3
- package/src/Collapsible/README.md +40 -0
- package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
- package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
- package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
- package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
- package/src/Collapsible/types.ts +89 -22
- package/src/ContextMenu/ContextMenu.tsx +270 -4
- package/src/ContextMenu/types.ts +148 -6
- package/src/DirectionProvider/DirectionProvider.tsx +41 -9
- package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
- package/src/DirectionProvider/types.ts +28 -3
- package/src/Divider/Divider.tsx +24 -10
- package/src/Divider/__tests__/Divider.test.tsx +5 -0
- package/src/Divider/types.ts +10 -3
- package/src/Drawer/Drawer.tsx +315 -0
- package/src/Drawer/README.md +124 -0
- package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
- package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
- package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
- package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
- package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
- package/src/Drawer/index.ts +2 -0
- package/src/Drawer/types.ts +66 -0
- package/src/Dropdown/Dropdown.tsx +117 -0
- package/src/Dropdown/types.ts +241 -11
- package/src/EmptyState/EmptyState.tsx +74 -4
- package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
- package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
- package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
- package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
- package/src/EmptyState/types.ts +49 -6
- package/src/Field/Field.tsx +23 -0
- package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
- package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
- package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
- package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
- package/src/Field/types.ts +60 -12
- package/src/Fieldset/Fieldset.tsx +54 -18
- package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
- package/src/Fieldset/types.ts +20 -2
- package/src/Input/Input.tsx +14 -9
- package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
- package/src/Input/types.ts +19 -2
- package/src/InputGroup/InputGroup.tsx +11 -3
- package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
- package/src/InputGroup/types.ts +26 -5
- package/src/MillerColumns/MillerColumns.tsx +86 -19
- package/src/MillerColumns/types.ts +83 -27
- package/src/Modal/Modal.tsx +31 -4
- package/src/Modal/README.md +19 -10
- package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
- package/src/Modal/hooks/useModalContent.ts +32 -0
- package/src/Modal/types.ts +110 -29
- package/src/NavigationMenu/NavigationMenu.tsx +890 -0
- package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
- package/src/NavigationMenu/README.md +355 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
- package/src/NavigationMenu/constants.ts +5 -0
- package/src/NavigationMenu/hooks/index.ts +7 -0
- package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
- package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
- package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
- package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
- package/src/NavigationMenu/index.ts +3 -0
- package/src/NavigationMenu/types.ts +259 -0
- package/src/NavigationMenu/utils.ts +89 -0
- package/src/Popover/Popover.tsx +427 -0
- package/src/Popover/PopoverContext.ts +13 -0
- package/src/Popover/README.md +106 -0
- package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
- package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
- package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
- package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
- package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
- package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
- package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
- package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
- package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
- package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
- package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
- package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
- package/src/Popover/hooks/index.ts +3 -0
- package/src/Popover/hooks/usePopoverContent.ts +98 -0
- package/src/Popover/hooks/usePopoverRoot.ts +84 -0
- package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
- package/src/Popover/index.ts +2 -0
- package/src/Popover/types.ts +197 -0
- package/src/Portal/Portal.tsx +50 -10
- package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
- package/src/Portal/types.ts +5 -0
- package/src/Progress/Progress.tsx +33 -1
- package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
- package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
- package/src/Progress/types.ts +20 -4
- package/src/Radio/Radio.tsx +9 -1
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
- package/src/Radio/hooks/useRadioRoot.ts +3 -0
- package/src/Radio/types.ts +20 -0
- package/src/RadioCard/RadioCard.tsx +11 -0
- package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
- package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
- package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
- package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
- package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
- package/src/RadioCard/types.ts +95 -22
- package/src/RadioGroup/RadioGroup.tsx +11 -0
- package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
- package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
- package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
- package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
- package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
- package/src/RadioGroup/types.ts +73 -9
- package/src/SegmentedControl/README.md +154 -0
- package/src/SegmentedControl/SegmentedControl.tsx +309 -0
- package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
- package/src/SegmentedControl/hooks/index.ts +2 -0
- package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +86 -0
- package/src/SegmentedControl/index.ts +2 -0
- package/src/SegmentedControl/types.ts +138 -0
- package/src/Select/README.md +122 -157
- package/src/Select/Select.tsx +595 -68
- package/src/Select/SelectContext.ts +38 -0
- package/src/Select/SelectItemIndicatorContext.ts +29 -0
- package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
- package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
- package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
- package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
- package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
- package/src/Select/__tests__/Select.form.test.tsx +38 -0
- package/src/Select/__tests__/Select.group.test.tsx +6 -6
- package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
- package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
- package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
- package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
- package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
- package/src/Select/__tests__/Select.placeholder.test.tsx +4 -4
- package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
- package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
- package/src/Select/__tests__/Select.selection.test.tsx +104 -0
- package/src/Select/__tests__/Select.separator.test.tsx +71 -0
- package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
- package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
- package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
- package/src/Select/constants.ts +8 -0
- package/src/Select/hooks/index.ts +2 -0
- package/src/Select/hooks/useSelectContent.ts +149 -0
- package/src/Select/hooks/useSelectRoot.ts +149 -0
- package/src/Select/types.ts +199 -31
- package/src/SkipNav/SkipNav.tsx +69 -29
- package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
- package/src/SkipNav/types.ts +17 -9
- package/src/Slider/Slider.tsx +33 -1
- package/src/Slider/SliderContext.ts +28 -1
- package/src/Slider/types.ts +157 -9
- package/src/Slot/Slot.tsx +94 -19
- package/src/Slot/__tests__/Slot.test.tsx +110 -0
- package/src/Slot/composeEventHandlers.ts +26 -2
- package/src/Slot/types.ts +16 -0
- package/src/Status/Status.tsx +36 -15
- package/src/Status/__tests__/Status.test.tsx +5 -0
- package/src/Status/types.ts +12 -1
- package/src/Switch/Switch.tsx +15 -2
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
- package/src/Switch/hooks/useSwitchRoot.ts +3 -0
- package/src/Switch/types.ts +49 -22
- package/src/Table/Table.tsx +44 -10
- package/src/Table/types.ts +66 -17
- package/src/Tabs/Tabs.tsx +8 -0
- package/src/Tabs/types.ts +29 -4
- package/src/Textarea/Textarea.tsx +9 -1
- package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
- package/src/Textarea/types.ts +20 -3
- package/src/Toggle/Toggle.tsx +51 -13
- package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
- package/src/Toggle/types.ts +54 -4
- package/src/ToggleGroup/ToggleGroup.tsx +14 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
- package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
- package/src/ToggleGroup/types.ts +55 -2
- package/src/Tooltip/Tooltip.tsx +98 -20
- package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
- package/src/Tooltip/types.ts +89 -8
- package/src/Tree/Tree.tsx +187 -33
- package/src/Tree/types.ts +54 -12
- package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
- package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
- package/src/VisuallyHidden/types.ts +11 -1
- package/src/index.ts +4 -0
- package/src/test/intersectionObserverPolyfill.ts +7 -0
- package/src/test/resizeObserverPolyfill.ts +80 -0
- package/src/utils/focusable.ts +15 -0
- package/src/utils/index.ts +1 -0
package/src/Carousel/README.md
CHANGED
|
@@ -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
|
|
71
|
-
|
|
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` → `
|
|
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 | `
|
|
82
|
-
| Snap targeting | `snapAlign: "start" \| "center"` (Root
|
|
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 `
|
|
90
|
-
`"center"`) so the programmatic scroll
|
|
91
|
-
snap engine will settle. Everything else is
|
|
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
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
`
|
|
331
|
-
|
|
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="
|
|
412
|
+
<Carousel.Root ariaLabel="Gallery" snapAlign="start">
|
|
335
413
|
…
|
|
336
414
|
</Carousel.Root>
|
|
337
415
|
```
|
|
338
416
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
snap
|
|
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
|
|
354
|
-
"inactive"` hook on each slide, which
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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,
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
398
|
-
|
|
399
|
-
page via
|
|
400
|
-
page-drive is **only** a fallback:
|
|
401
|
-
supported it is authoritative (it reports
|
|
402
|
-
slide), so the observer stands down and does
|
|
403
|
-
That matters for `snapAlign="center"`
|
|
404
|
-
visible at once (e.g. a cover flow) —
|
|
405
|
-
heuristic would otherwise track the
|
|
406
|
-
than the centred one and fight
|
|
407
|
-
always feeds `Carousel.Root`'s
|
|
408
|
-
consumers can lazy-load slide
|
|
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
|
|
496
|
-
partial
|
|
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
|
-
`
|
|
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
|
|
542
|
-
|
|
|
543
|
-
| `ArrowRight` | Advance by one page (same as `NextTrigger`) |
|
|
544
|
-
| `ArrowLeft`
|
|
545
|
-
| `Home`
|
|
546
|
-
| `End`
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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.
|
|
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
|