'use client' import * as React from 'react' import useEmblaCarousel from 'embla-carousel-react' import { mergeProps } from '@base-ui/react/merge-props' import { useRender } from '@base-ui/react/use-render' import Accessibility, { type AccessibilityOptionsType } from 'embla-carousel-accessibility' import Autoplay, { type AutoplayOptionsType } from 'embla-carousel-autoplay' import AutoScroll, { type AutoScrollOptionsType } from 'embla-carousel-auto-scroll' import AutoHeight, { type AutoHeightOptionsType } from 'embla-carousel-auto-height' import ClassNames, { type ClassNamesOptionsType } from 'embla-carousel-class-names' import Fade, { type FadeOptionsType } from 'embla-carousel-fade' import { WheelGesturesPlugin, type WheelGesturesPluginOptions } from 'embla-carousel-wheel-gestures' import type { EmblaCarouselType, EmblaOptionsType, EmblaPluginType } from 'embla-carousel' import { cn } from '../../internal/utils' import { useDirection } from '../../hooks/use-direction' import { useReducedMotion } from '../../hooks/use-reduced-motion' type CarouselOrientation = 'horizontal' | 'vertical' type CarouselApi = EmblaCarouselType type CarouselOptions = EmblaOptionsType type CarouselPlugin = EmblaPluginType type CarouselAutoplayOptions = AutoplayOptionsType & { resumeAfter?: number } type CarouselAutoScrollOptions = AutoScrollOptionsType & { resumeAfter?: number } type CarouselAutoHeightOptions = AutoHeightOptionsType type CarouselFadeOptions = FadeOptionsType type CarouselClassNamesOptions = ClassNamesOptionsType type CarouselAccessibilityOptions = AccessibilityOptionsType type CarouselWheelGesturesOptions = WheelGesturesPluginOptions interface CarouselAutoplayState { delay: number cycleId: number isPlaying: boolean } interface CarouselContextValue { api: CarouselApi | undefined viewportRef: (node: HTMLDivElement | null) => void orientation: CarouselOrientation direction: 'ltr' | 'rtl' light: boolean autoHeight: boolean loop: boolean reducedMotion: boolean selectedIndex: number scrollSnaps: number[] canScrollPrev: boolean canScrollNext: boolean subscribeScrollProgress: (onChange: () => void) => () => void getScrollProgress: () => number autoplay: CarouselAutoplayState | null scrollPrev: () => void scrollNext: () => void scrollTo: (index: number) => void } const CarouselContext = React.createContext(null) function useCarousel(): CarouselContextValue { const ctx = React.useContext(CarouselContext) if (!ctx) { throw new Error('Carousel sub-components must be rendered inside ') } return ctx } interface CarouselProps extends Omit, 'onSelect' | 'onScroll' | 'children'> { /** * Scroll axis. Maps to Embla's `axis`; exposed as `data-orientation`. * @default 'horizontal' */ orientation?: CarouselOrientation /** * Wrap around from the last slide to the first. * @default false */ loop?: boolean /** * Where slides settle within the viewport. * @default 'start' */ align?: 'start' | 'center' | 'end' /** * How many slides advance per step. `'auto'` groups by how many fit the viewport. * @default 1 */ slidesToScroll?: number | 'auto' /** * Clamp scrolling so there's no empty space at the edges. `false` disables containment. * @default 'trimSnaps' */ containScroll?: false | 'trimSnaps' | 'keepSnaps' /** Release snap points; glide to a momentum stop. */ dragFree?: boolean /** Index of the slide to start on. */ startSnap?: number /** * When `false`, the engine is inert (no drag/snap) - e.g. to disable at a breakpoint. * @default true */ active?: boolean /** * Whether pointer dragging is enabled (Embla's `watchDrag`). * @default true */ draggable?: boolean /** Scroll animation duration (Embla's ease). Forced to `0` under reduced motion. */ duration?: number /** Escape hatch: a raw Embla options object, merged last so it wins over the flat props. */ options?: CarouselOptions /** Enable the Autoplay plugin. `resumeAfter` (ms) restarts the timer after user interaction. */ autoplay?: boolean | CarouselAutoplayOptions /** Enable continuous Auto Scroll. Mutually exclusive with `autoplay`; paused under reduced motion. */ autoScroll?: boolean | CarouselAutoScrollOptions /** Enable the Auto Height plugin; animates the viewport to each slide's height. */ autoHeight?: boolean | CarouselAutoHeightOptions /** Enable the Fade plugin (cross-fade instead of slide). Best with one slide per view. */ fade?: boolean | CarouselFadeOptions /** Enable the Class Names plugin, which toggles `snapped` / `inView` classes on slides. */ classNames?: boolean | CarouselClassNamesOptions /** * Keyboard/ARIA plugin for the viewport. Set `false` to opt out. * @default true */ accessibility?: boolean | CarouselAccessibilityOptions /** * Enable trackpad / mouse-wheel scrolling. * @default false */ wheelGestures?: boolean | CarouselWheelGesturesOptions /** Additional Embla plugins to append. */ plugins?: CarouselPlugin[] /** Receives the Embla instance once initialized - for imperative control. */ setApi?: (api: CarouselApi) => void /** Fires on init and whenever the engine re-initializes. */ onReInit?: (api: CarouselApi) => void /** Fires when the selected snap changes. */ onSelect?: (api: CarouselApi) => void /** Fires continuously while scrolling. */ onScroll?: (api: CarouselApi) => void /** * Hint stored in context for light-on-dark surfaces; read via `useCarousel()`. * @default false */ light?: boolean /** The content. */ children: React.ReactNode } function Carousel({ orientation = 'horizontal', loop = false, align = 'start', slidesToScroll = 1, containScroll = 'trimSnaps', dragFree, startSnap, active = true, draggable, duration, options, autoplay, autoScroll, autoHeight, fade, classNames, accessibility = true, wheelGestures = false, plugins: userPlugins, setApi, onReInit, onSelect, onScroll, light = false, className, children, ...rest }: CarouselProps) { const direction = useDirection() const reducedMotion = useReducedMotion() const autoplayEnabled = !!autoplay const autoScrollEnabled = !!autoScroll const warnedMutualExclusive = React.useRef(false) React.useEffect(() => { if ( process.env.NODE_ENV !== 'production' && autoplayEnabled && autoScrollEnabled && !warnedMutualExclusive.current ) { warnedMutualExclusive.current = true // eslint-disable-next-line no-console console.warn('[Carousel] `autoplay` and `autoScroll` are mutually exclusive - preferring `autoplay`.') } }, [autoplayEnabled, autoScrollEnabled]) const resumeAfterMs = (typeof autoplay === 'object' && typeof autoplay.resumeAfter === 'number' && autoplay.resumeAfter) || (typeof autoScroll === 'object' && typeof autoScroll.resumeAfter === 'number' && autoScroll.resumeAfter) || 0 const plugins = React.useMemo(() => { const list: CarouselPlugin[] = [] if (accessibility !== false) { list.push(Accessibility(typeof accessibility === 'object' ? accessibility : undefined)) } if (wheelGestures !== false) { list.push(WheelGesturesPlugin(typeof wheelGestures === 'object' ? wheelGestures : undefined)) } if (autoplay) { const opts = typeof autoplay === 'object' ? autoplay : undefined const { resumeAfter, ...emblaOpts } = opts ?? {} const autoResume = typeof resumeAfter === 'number' && resumeAfter > 0 list.push(Autoplay(autoResume ? { ...emblaOpts, defaultInteraction: false } : emblaOpts)) } else if (autoScroll && !reducedMotion) { const opts = typeof autoScroll === 'object' ? autoScroll : undefined const { resumeAfter, ...emblaOpts } = opts ?? {} const autoResume = typeof resumeAfter === 'number' && resumeAfter > 0 list.push(AutoScroll(autoResume ? { ...emblaOpts, defaultInteraction: false } : emblaOpts)) } if (autoHeight) { list.push(AutoHeight(typeof autoHeight === 'object' ? autoHeight : undefined)) } if (fade) { list.push(Fade(typeof fade === 'object' ? fade : undefined)) } if (classNames) { list.push(ClassNames(typeof classNames === 'object' ? classNames : undefined)) } if (userPlugins) list.push(...userPlugins) return list }, [accessibility, wheelGestures, autoplay, autoScroll, autoHeight, fade, classNames, userPlugins, reducedMotion]) const emblaOptions = React.useMemo(() => { const merged: CarouselOptions = { axis: orientation === 'vertical' ? 'y' : 'x', loop, align, slidesToScroll, containScroll, active, } if (dragFree !== undefined) merged.dragFree = dragFree if (startSnap !== undefined) merged.startSnap = startSnap if (duration !== undefined) merged.duration = duration if (draggable !== undefined) merged.draggable = draggable if (orientation === 'horizontal') merged.direction = direction if (reducedMotion) merged.duration = 0 return { ...merged, ...options } }, [ orientation, direction, loop, align, slidesToScroll, containScroll, dragFree, startSnap, active, draggable, duration, options, reducedMotion, ]) const [viewportRef, emblaApi] = useEmblaCarousel(emblaOptions, plugins) const setApiRef = React.useRef(setApi) const onReInitRef = React.useRef(onReInit) const onSelectRef = React.useRef(onSelect) const onScrollRef = React.useRef(onScroll) React.useEffect(() => { setApiRef.current = setApi }) React.useEffect(() => { onReInitRef.current = onReInit }) React.useEffect(() => { onSelectRef.current = onSelect }) React.useEffect(() => { onScrollRef.current = onScroll }) React.useEffect(() => { if (!emblaApi || !reducedMotion) return const snapToTargetInstantly = () => { emblaApi.internalEngine().scrollBody.useDuration(0) } emblaApi.on('pointerup', snapToTargetInstantly) return () => { emblaApi.off('pointerup', snapToTargetInstantly) } }, [emblaApi, reducedMotion]) const [state, setState] = React.useState({ selectedIndex: 0, scrollSnaps: [] as number[], canScrollPrev: false, canScrollNext: false, }) const [autoplayState, setAutoplayState] = React.useState(null) const scrollProgressRef = React.useRef(0) const scrollListenersRef = React.useRef void>>(new Set()) const subscribeScrollProgress = React.useCallback((onChange: () => void) => { scrollListenersRef.current.add(onChange) return () => { scrollListenersRef.current.delete(onChange) } }, []) const getScrollProgress = React.useCallback(() => scrollProgressRef.current, []) React.useEffect(() => { if (!emblaApi) return const notifyScroll = () => { scrollProgressRef.current = emblaApi.scrollProgress() scrollListenersRef.current.forEach((cb) => cb()) } const syncFull = () => { notifyScroll() setState({ selectedIndex: emblaApi.selectedSnap(), scrollSnaps: emblaApi.snapList(), canScrollPrev: emblaApi.canGoToPrev(), canScrollNext: emblaApi.canGoToNext(), }) } const syncSelection = () => { setState((s) => ({ ...s, selectedIndex: emblaApi.selectedSnap(), canScrollPrev: emblaApi.canGoToPrev(), canScrollNext: emblaApi.canGoToNext(), })) } const syncScroll = () => { notifyScroll() } const syncInView = () => {} syncFull() setApiRef.current?.(emblaApi) onReInitRef.current?.(emblaApi) const handleReInit = () => { const autoplayWasPlaying = emblaApi.plugins().autoplay?.isPlaying() const autoScrollWasPlaying = emblaApi.plugins().autoScroll?.isPlaying() syncFull() onReInitRef.current?.(emblaApi) if (autoplayEnabled && autoplayWasPlaying) emblaApi.plugins().autoplay?.play() else if (autoScrollEnabled && autoScrollWasPlaying) emblaApi.plugins().autoScroll?.play() } const handleSelect = () => { syncSelection() onSelectRef.current?.(emblaApi) } const handleScroll = () => { syncScroll() onScrollRef.current?.(emblaApi) } emblaApi.on('reinit', handleReInit) emblaApi.on('select', handleSelect) emblaApi.on('scroll', handleScroll) emblaApi.on('slidesinview', syncInView) emblaApi.on('slideschanged', syncFull) const autoplayPlugin = emblaApi.plugins().autoplay const autoScrollPlugin = emblaApi.plugins().autoScroll const resumeAfter = resumeAfterMs let resumeTimer: ReturnType | undefined const clearResumeTimer = () => { if (resumeTimer !== undefined) { clearTimeout(resumeTimer) resumeTimer = undefined } } const scheduleResume = (play: () => void) => { if (resumeAfter <= 0) return clearResumeTimer() resumeTimer = setTimeout(() => { resumeTimer = undefined play() }, resumeAfter) } let cleanupAutoplay: (() => void) | undefined if (autoplayPlugin) { const rawDelay = autoplayPlugin.options.delay const initialDelay = typeof rawDelay === 'number' ? rawDelay : 4000 setAutoplayState({ delay: initialDelay, cycleId: 0, isPlaying: autoplayPlugin.isPlaying(), }) const onPlay = () => { clearResumeTimer() setAutoplayState((s) => (s ? { ...s, isPlaying: true } : s)) } const onStop = () => setAutoplayState((s) => (s ? { ...s, isPlaying: false } : s)) const onTimerSet = () => setAutoplayState((s) => (s ? { ...s, cycleId: s.cycleId + 1, isPlaying: true } : s)) const onTimerStopped = () => setAutoplayState((s) => (s ? { ...s, isPlaying: false } : s)) const resetOnSelect = () => autoplayPlugin.reset() const onInteraction = (_api: CarouselApi, event: { detail: { interaction: string } }) => { if (event.detail.interaction === 'pointerdown' || event.detail.interaction === 'slidefocus') { autoplayPlugin.stop() scheduleResume(() => emblaApi.plugins().autoplay?.play()) } } emblaApi.on('autoplay:play', onPlay) emblaApi.on('autoplay:stop', onStop) emblaApi.on('autoplay:timerset', onTimerSet) emblaApi.on('autoplay:timerstopped', onTimerStopped) emblaApi.on('select', resetOnSelect) if (resumeAfter > 0) emblaApi.on('autoplay:interaction', onInteraction) if (autoplayEnabled) autoplayPlugin.play() cleanupAutoplay = () => { emblaApi.off('autoplay:play', onPlay) emblaApi.off('autoplay:stop', onStop) emblaApi.off('autoplay:timerset', onTimerSet) emblaApi.off('autoplay:timerstopped', onTimerStopped) emblaApi.off('select', resetOnSelect) if (resumeAfter > 0) emblaApi.off('autoplay:interaction', onInteraction) } } else { setAutoplayState(null) } let cleanupAutoScroll: (() => void) | undefined if (autoScrollPlugin && autoScrollEnabled && !autoplayEnabled) { autoScrollPlugin.play() const onInteraction = (_api: CarouselApi, event: { detail: { interaction: string } }) => { if (event.detail.interaction === 'pointerdown' || event.detail.interaction === 'slidefocus') { autoScrollPlugin.stop() scheduleResume(() => emblaApi.plugins().autoScroll?.play()) } } if (resumeAfter > 0) { emblaApi.on('autoscroll:interaction', onInteraction) cleanupAutoScroll = () => emblaApi.off('autoscroll:interaction', onInteraction) } } return () => { emblaApi.off('reinit', handleReInit) emblaApi.off('select', handleSelect) emblaApi.off('scroll', handleScroll) emblaApi.off('slidesinview', syncInView) emblaApi.off('slideschanged', syncFull) cleanupAutoplay?.() cleanupAutoScroll?.() clearResumeTimer() } }, [emblaApi, autoplayEnabled, autoScrollEnabled, resumeAfterMs]) const scrollPrev = React.useCallback(() => emblaApi?.goToPrev(), [emblaApi]) const scrollNext = React.useCallback(() => emblaApi?.goToNext(), [emblaApi]) const scrollTo = React.useCallback((i: number) => emblaApi?.goTo(i), [emblaApi]) const isAutoHeight = !!autoHeight const contextValue = React.useMemo( () => ({ api: emblaApi, viewportRef, orientation, direction, light, autoHeight: isAutoHeight, loop, reducedMotion, selectedIndex: state.selectedIndex, scrollSnaps: state.scrollSnaps, canScrollPrev: state.canScrollPrev, canScrollNext: state.canScrollNext, subscribeScrollProgress, getScrollProgress, autoplay: autoplayState, scrollPrev, scrollNext, scrollTo, }), [ emblaApi, viewportRef, orientation, direction, light, isAutoHeight, loop, reducedMotion, state, subscribeScrollProgress, getScrollProgress, autoplayState, scrollPrev, scrollNext, scrollTo, ], ) return (
{children}
) } interface CarouselContentProps extends React.ComponentPropsWithoutRef<'div'> { /** Props for the scroll viewport, the element that clips the track. */ viewportProps?: React.ComponentPropsWithoutRef<'div'> } function CarouselContent({ className, children, viewportProps, ...rest }: CarouselContentProps) { const { viewportRef, orientation, autoHeight } = useCarousel() return (
{children}
) } type CarouselSlideProps = React.ComponentPropsWithoutRef<'div'> function CarouselSlide({ className, ...rest }: CarouselSlideProps) { const { orientation } = useCarousel() return (
) } interface CarouselThumbBox { x: number y: number width: number height: number } function sameThumbBox(a: CarouselThumbBox | null, b: CarouselThumbBox) { return a !== null && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height } interface CarouselThumbsContextValue { selectedIndex: number select: (index: number) => void light: boolean } const CarouselThumbsContext = React.createContext(null) const CarouselThumbIndexContext = React.createContext(0) function useCarouselThumbs(): CarouselThumbsContextValue { const ctx = React.useContext(CarouselThumbsContext) if (!ctx) { throw new Error(' must be rendered inside ') } return ctx } // Naming the slides keeps the indicator, which is a child of the track too, out // of the snap count. const CAROUSEL_THUMB_OPTIONS: CarouselOptions = { slides: ':scope > [data-slot=carousel-thumb]' } const CAROUSEL_THUMB_TRANSITION = cn( 'transition-[opacity,translate,scale] motion-reduce:transition-none', 'duration-[300ms,250ms,250ms]', 'ease-[ease-out,cubic-bezier(0.175,0.885,0.32,1.5),cubic-bezier(0.175,0.885,0.32,1.5)]', 'active:duration-[300ms,100ms,100ms] active:ease-[ease-out,ease-in-out,ease-in-out]', ) interface CarouselThumbsProps extends Omit< React.ComponentPropsWithoutRef<'div'>, 'draggable' | 'onSelect' | 'onScroll' > { /** * Lay the thumbnails out in a row or a column. Independent of the carousel's own axis. * @default 'horizontal' */ orientation?: CarouselOrientation /** * Light-on-dark styling for use over imagery. * @default false */ light?: boolean } function CarouselThumbs({ orientation = 'horizontal', light: lightProp, className, children, ...rest }: CarouselThumbsProps) { const { selectedIndex, scrollTo, light: ctxLight } = useCarousel() const light = lightProp ?? ctxLight const [api, setApi] = React.useState() const [box, setBox] = React.useState(null) const vertical = orientation === 'vertical' // No dependency list: any render can add, remove or resize a thumbnail. Offsets // are relative to the track, which is what Embla translates, so scrolling the // rail carries the indicator along without a re-measure. React.useEffect(() => { const thumb = api?.slideNodes()[selectedIndex] const track = api?.containerNode() const measure = () => { if (!thumb || !track) return setBox(null) // Layout offsets, never rects: the thumbnail scales and shifts under the // press, and a rect would catch it mid-animation. The track's transform // makes it the offset parent in most engines; where it doesn't, the // thumbnail is measured from further up and the track's own offset comes // back off. const nested = thumb.offsetParent === track const next = { x: thumb.offsetLeft - (nested ? 0 : track.offsetLeft), y: thumb.offsetTop - (nested ? 0 : track.offsetTop), width: thumb.offsetWidth, height: thumb.offsetHeight, } setBox((previous) => (sameThumbBox(previous, next) ? previous : next)) } measure() if (!thumb || !track || typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(measure) observer.observe(thumb) return () => observer.disconnect() }) React.useEffect(() => { const thumb = api?.slideNodes()[selectedIndex] const viewport = api?.rootNode() if (!thumb || !viewport) return const slide = thumb.getBoundingClientRect() const frame = viewport.getBoundingClientRect() const visible = vertical ? slide.top >= frame.top - 1 && slide.bottom <= frame.bottom + 1 : slide.left >= frame.left - 1 && slide.right <= frame.right + 1 if (!visible) api?.goTo(selectedIndex) }, [api, selectedIndex, vertical]) const context = React.useMemo( () => ({ selectedIndex, select: scrollTo, light }), [selectedIndex, scrollTo, light], ) return ( {React.Children.map(children, (child, index) => ( {child} ))} {box ? ( ) } interface CarouselThumbProps extends React.ComponentPropsWithoutRef<'button'> { /** Slide this thumbnail selects. Defaults to its position among the thumbnails. */ index?: number } function CarouselThumb({ index: indexProp, className, children, onClick, ...rest }: CarouselThumbProps) { const { selectedIndex, select, light } = useCarouselThumbs() const position = React.useContext(CarouselThumbIndexContext) const index = indexProp ?? position const active = index === selectedIndex return ( ) } type CarouselButtonState = { disabled: boolean; direction: 'prev' | 'next' } type CarouselNavPosition = 'inside' | 'outside' | 'outside-half' | 'none' const PREV_POSITION_CLASSES: Record = { inside: cn( 'absolute z-10', 'data-[orientation=horizontal]:inset-s-4 data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:-translate-y-1/2', 'data-[orientation=vertical]:inset-s-1/2 data-[orientation=vertical]:top-4 data-[orientation=vertical]:-translate-x-1/2', ), outside: cn( 'absolute z-10', 'data-[orientation=horizontal]:-inset-s-14 data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:-translate-y-1/2', 'data-[orientation=vertical]:inset-s-1/2 data-[orientation=vertical]:-top-14 data-[orientation=vertical]:-translate-x-1/2', ), 'outside-half': cn( 'absolute z-10', 'data-[orientation=horizontal]:inset-s-0 data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:-translate-y-1/2', 'data-[orientation=horizontal]:ltr:-translate-x-1/2 data-[orientation=horizontal]:rtl:translate-x-1/2', 'data-[orientation=vertical]:inset-s-1/2 data-[orientation=vertical]:top-0 data-[orientation=vertical]:-translate-x-1/2 data-[orientation=vertical]:-translate-y-1/2', ), none: '', } const NEXT_POSITION_CLASSES: Record = { inside: cn( 'absolute z-10', 'data-[orientation=horizontal]:inset-e-4 data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:-translate-y-1/2', 'data-[orientation=vertical]:inset-s-1/2 data-[orientation=vertical]:bottom-4 data-[orientation=vertical]:-translate-x-1/2', ), outside: cn( 'absolute z-10', 'data-[orientation=horizontal]:-inset-e-14 data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:-translate-y-1/2', 'data-[orientation=vertical]:inset-s-1/2 data-[orientation=vertical]:-bottom-14 data-[orientation=vertical]:-translate-x-1/2', ), 'outside-half': cn( 'absolute z-10', 'data-[orientation=horizontal]:inset-e-0 data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:-translate-y-1/2', 'data-[orientation=horizontal]:ltr:translate-x-1/2 data-[orientation=horizontal]:rtl:-translate-x-1/2', 'data-[orientation=vertical]:inset-s-1/2 data-[orientation=vertical]:bottom-0 data-[orientation=vertical]:-translate-x-1/2 data-[orientation=vertical]:translate-y-1/2', ), none: '', } interface CarouselPrevProps extends useRender.ComponentProps<'button', CarouselButtonState> { position?: CarouselNavPosition disabled?: boolean } function CarouselPrev({ className, position = 'inside', disabled: disabledProp, render, ...props }: CarouselPrevProps) { const { scrollPrev, canScrollPrev, orientation } = useCarousel() const disabled = (disabledProp ?? false) || !canScrollPrev const state: CarouselButtonState = { disabled, direction: 'prev' } const trigger = useRender({ defaultTagName: 'button', render, state, props: mergeProps<'button'>( { type: 'button', 'data-slot': 'carousel-prev', 'data-disabled': disabled || undefined, 'aria-label': 'Previous slide', disabled, onClick: scrollPrev, suppressHydrationWarning: true, } as unknown as React.ButtonHTMLAttributes, props, ), }) return (
{trigger}
) } interface CarouselNextProps extends useRender.ComponentProps<'button', CarouselButtonState> { position?: CarouselNavPosition disabled?: boolean } function CarouselNext({ className, position = 'inside', disabled: disabledProp, render, ...props }: CarouselNextProps) { const { scrollNext, canScrollNext, orientation } = useCarousel() const disabled = (disabledProp ?? false) || !canScrollNext const state: CarouselButtonState = { disabled, direction: 'next' } const trigger = useRender({ defaultTagName: 'button', render, state, props: mergeProps<'button'>( { type: 'button', 'data-slot': 'carousel-next', 'data-disabled': disabled || undefined, 'aria-label': 'Next slide', disabled, onClick: scrollNext, suppressHydrationWarning: true, } as unknown as React.ButtonHTMLAttributes, props, ), }) return (
{trigger}
) } interface CarouselPaginationProps extends React.ComponentPropsWithoutRef<'div'> { /** * Lay the bullets out in a row or a column. * @default 'horizontal' */ orientation?: CarouselOrientation /** * Light-on-dark styling for use over imagery. * @default false */ light?: boolean } function CarouselPagination({ className, orientation = 'horizontal', light: lightProp, ...rest }: CarouselPaginationProps) { const { scrollSnaps, selectedIndex, scrollTo, autoplay, reducedMotion, light: ctxLight } = useCarousel() const light = lightProp ?? ctxLight if (scrollSnaps.length <= 1) return null const isHorizontal = orientation === 'horizontal' return (
{scrollSnaps.map((_, index) => { const isActive = index === selectedIndex const renderAutoplay = isActive && autoplay !== null && autoplay.isPlaying && !reducedMotion return ( ) })}
) } interface CarouselAutoplayIndicatorProps { delay: number isPlaying: boolean light: boolean orientation: CarouselOrientation } function CarouselAutoplayIndicator({ delay, isPlaying, light, orientation }: CarouselAutoplayIndicatorProps) { const [armed, setArmed] = React.useState(false) React.useEffect(() => { let raf2 = 0 const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setArmed(true)) }) return () => { cancelAnimationFrame(raf1) cancelAnimationFrame(raf2) } }, []) const isHorizontal = orientation === 'horizontal' const fillValue = armed && isPlaying ? '100%' : '0%' return (