{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"carousel","title":"Carousel","description":"Show slides one at a time and move between them with arrows, dots, the arrow keys or a swipe. Search terms: slideshow, image carousel, product tour, testimonials.","dependencies":["@phosphor-icons/react@2.1.10"],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/page-indicator"],"files":[{"path":"packages/ui/src/components/carousel/carousel.tsx","content":"\"use client\";\n\nimport { CaretLeftIcon, CaretRightIcon } from \"@phosphor-icons/react/dist/ssr\";\nimport * as React from \"react\";\n\nimport { useSiteplaneMessages } from \"../../system/locale/messages-context\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { Button, type ButtonDefaultProfileProps } from \"../button\";\nimport { PageIndicator, type PageIndicatorProps } from \"../page-indicator\";\n\nimport \"./carousel.css\";\n\n/** `md`, the default, has 36 px arrows 16 px from the edge; `lg` has 44 px arrows 20 px from it. */\nexport type CarouselSize = \"md\" | \"lg\";\n\n/** What the parts of a carousel share, and what a part of an application can read from it. */\nexport type CarouselContextValue = {\n  /** Whether the carousel can show the slide after the current one. */\n  canScrollNext: boolean;\n  /** Whether the carousel can show the slide before the current one. */\n  canScrollPrevious: boolean;\n  /** The number of slides. */\n  count: number;\n  /** The current slide, counted from 0. */\n  index: number;\n  /** Shows the slide after the current one. */\n  scrollNext: () => void;\n  /** Shows the slide before the current one. */\n  scrollPrevious: () => void;\n  /** Shows a slide, counted from 0. */\n  scrollTo: (index: number) => void;\n  size: CarouselSize;\n};\n\ntype CarouselInternals = CarouselContextValue & {\n  /** The slides in the order of the page, which they register themselves in. */\n  items: readonly HTMLElement[];\n  register: (element: HTMLElement) => () => void;\n};\n\nconst CarouselContext = React.createContext<CarouselInternals | null>(null);\n\nfunction useCarouselInternals(part: string): CarouselInternals {\n  const context = React.useContext(CarouselContext);\n  if (!context) throw new Error(`${part} must be used within a Carousel.`);\n  return context;\n}\n\n/**\n * The state of the carousel for a part of an application that sits inside it,\n * such as a caption that follows the slide or an indicator of its own.\n */\nexport function useCarousel(): CarouselContextValue {\n  const context = useCarouselInternals(\"useCarousel\");\n  return {\n    canScrollNext: context.canScrollNext,\n    canScrollPrevious: context.canScrollPrevious,\n    count: context.count,\n    index: context.index,\n    scrollNext: context.scrollNext,\n    scrollPrevious: context.scrollPrevious,\n    scrollTo: context.scrollTo,\n    size: context.size,\n  };\n}\n\n/** Whether the element reads right to left, where it stands. */\nfunction readsRightToLeft(element: Element): boolean {\n  return getComputedStyle(element).direction === \"rtl\";\n}\n\nfunction assignRef<T>(ref: React.Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) (ref as React.RefObject<T | null>).current = value;\n}\n\nexport interface CarouselProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> {\n  /** The current slide, counted from 0, for a carousel that the application controls. */\n  index?: number | undefined;\n  /** The slide shown first, counted from 0, in a carousel that keeps its own state. */\n  defaultIndex?: number | undefined;\n  /** Called with the slide a person moves to, counted from 0. */\n  onIndexChange?: ((index: number) => void) | undefined;\n  /**\n   * The size of its arrows and its indicator: `md` has\n   * 36 px arrows 16 px from the edge and 8 px dots, `lg` 44 px arrows 20 px\n   * from the edge and 10 px dots. `md` by default.\n   */\n  size?: CarouselSize | undefined;\n}\n\n/**\n * A row of slides that shows one at a time: the previous and next arrows sit\n * over the slide at its edges, the indicator at\n * its bottom, the arrow keys move between slides when focus is inside it, and a\n * swipe or a drag moves it on touch screens and with a mouse. Slides sit 8 px\n * apart and settle in 800 ms (rulebook M3), at once with reduced motion. The\n * region is a carousel and every slide a group of it, named by its place\n * (\"2 of 3\"); the slides that are out of view are inert, so their links and\n * buttons stay out of the focus order. The application can keep the slide in\n * state (`index`, `onIndexChange`) or leave it to the carousel\n * (`defaultIndex`). The parts are CarouselContent with CarouselItems, and\n * CarouselPrevious, CarouselNext and CarouselIndicator over them.\n */\nexport function Carousel({\n  children,\n  className,\n  defaultIndex = 0,\n  index: indexProp,\n  onIndexChange,\n  onKeyDownCapture,\n  size = \"md\",\n  ...props\n}: CarouselProps): React.ReactElement {\n  const messages = useSiteplaneMessages(\"carousel\");\n  const [items, setItems] = React.useState<readonly HTMLElement[]>([]);\n  const [uncontrolled, setUncontrolled] = React.useState(defaultIndex);\n  const controlled = indexProp !== undefined;\n  // Until the slides have registered, which happens after the first render,\n  // the carousel counts the slides its markup declares, so the server's\n  // markup already shows a dot for every slide and the arrows in their state,\n  // and nothing moves when the page hydrates (app finding, real-world check,\n  // 10 November 2026: the dots appeared only after hydration).\n  const declared = React.useMemo(() => declaredSlides(children), [children]);\n  const count = items.length > 0 ? items.length : declared;\n  const last = Math.max(count - 1, 0);\n  // Before the slides have registered (the server's markup and the first\n  // render) the slide asked for stands, so the track does not travel to it.\n  const asked = Math.max(controlled ? indexProp : uncontrolled, 0);\n  const index = count === 0 ? asked : Math.min(asked, last);\n\n  const scrollTo = React.useCallback(\n    (next: number) => {\n      const target = Math.min(Math.max(Math.round(next), 0), last);\n      if (target === index) return;\n      if (!controlled) setUncontrolled(target);\n      onIndexChange?.(target);\n    },\n    [controlled, index, last, onIndexChange],\n  );\n\n  const register = React.useCallback((element: HTMLElement) => {\n    setItems((previous) =>\n      previous.includes(element)\n        ? previous\n        : [...previous, element].sort((a, b) =>\n            a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING\n              ? -1\n              : 1,\n          ),\n    );\n    return () =>\n      setItems((previous) => previous.filter((item) => item !== element));\n  }, []);\n\n  const value = React.useMemo<CarouselInternals>(\n    () => ({\n      canScrollNext: index < last,\n      canScrollPrevious: index > 0,\n      count,\n      index,\n      items,\n      register,\n      scrollNext: () => scrollTo(index + 1),\n      scrollPrevious: () => scrollTo(index - 1),\n      scrollTo,\n      size,\n    }),\n    [count, index, items, last, register, scrollTo, size],\n  );\n\n  return (\n    <CarouselContext.Provider value={value}>\n      <div\n        aria-roledescription={messages.label}\n        className={cn(slotClass(\"carousel\", props), className)}\n        data-size={size}\n        data-slot=\"carousel\"\n        onKeyDownCapture={(event) => {\n          onKeyDownCapture?.(event);\n          if (\n            event.defaultPrevented ||\n            event.altKey ||\n            event.ctrlKey ||\n            event.metaKey ||\n            (event.key !== \"ArrowLeft\" && event.key !== \"ArrowRight\")\n          )\n            return;\n          // A field inside a slide keeps its own arrow keys.\n          if ((event.target as Element).closest(\"input, textarea, select\"))\n            return;\n          event.preventDefault();\n          // The arrows point the way the page reads: right goes on in a\n          // left-to-right page and back in a right-to-left one.\n          const rightToLeft = readsRightToLeft(event.currentTarget);\n          const forward = (event.key === \"ArrowRight\") !== rightToLeft;\n          scrollTo(index + (forward ? 1 : -1));\n        }}\n        role=\"region\"\n        {...props}\n      >\n        {children}\n      </div>\n    </CarouselContext.Provider>\n  );\n}\n\n/**\n * The slides a carousel's markup declares: every element among the children\n * of its CarouselContent, each a slide, as the parts are composed. A slide\n * that a component of the application renders elsewhere counts once it has\n * registered.\n */\nfunction declaredSlides(children: React.ReactNode): number {\n  let slides = 0;\n  React.Children.forEach(children, (child) => {\n    if (\n      !React.isValidElement<{ children?: React.ReactNode }>(child) ||\n      child.type !== CarouselContent\n    )\n      return;\n    React.Children.forEach(child.props.children, (slide) => {\n      if (React.isValidElement(slide)) slides += 1;\n    });\n  });\n  return slides;\n}\n\n/** How far a drag must go to turn the slide: a fifth of it, or a quick flick. */\nconst dragShare = 0.2;\nconst flickSpeed = 0.5;\nconst flickDistance = 24;\n/** The way a drag must go before it is one, so a tap is still a tap. */\nconst dragStart = 6;\n\n/**\n * The slides: a viewport that clips them and the track that carries them, 8\n * px apart. A drag or a swipe along it moves the track with the pointer and\n * turns the slide when it has gone a fifth of the way, or fast enough; past\n * the first and the last slide it gives a third of the way. The browser keeps\n * vertical scrolling (`touch-action: pan-y`).\n */\nexport function CarouselContent({\n  children,\n  className,\n  onClickCapture,\n  onDragStart,\n  onPointerCancel,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  ref,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  const { count, index, scrollTo } = useCarouselInternals(\"CarouselContent\");\n  const viewport = React.useRef<HTMLDivElement | null>(null);\n  const track = React.useRef<HTMLDivElement>(null);\n  const [dragging, setDragging] = React.useState(false);\n  const drag = React.useRef<{\n    at: number;\n    id: number;\n    moved: boolean;\n    x: number;\n  } | null>(null);\n  const suppressClick = React.useRef(false);\n\n  const setViewport = React.useCallback(\n    (element: HTMLDivElement | null) => {\n      viewport.current = element;\n      assignRef(ref, element);\n    },\n    [ref],\n  );\n\n  /** Where the drag has taken the track, in px along the page's reading direction. */\n  const travel = (event: React.PointerEvent): number => {\n    const start = drag.current;\n    if (!start) return 0;\n    const distance = event.clientX - start.x;\n    const sign = readsRightToLeft(event.currentTarget) ? -1 : 1;\n    // Past the first or the last slide the track gives only a third of the way.\n    const onwards = distance * sign < 0;\n    return (onwards ? index >= count - 1 : index <= 0)\n      ? distance / 3\n      : distance;\n  };\n\n  const finish = (event: React.PointerEvent, cancelled: boolean) => {\n    const start = drag.current;\n    if (!start || start.id !== event.pointerId) return;\n    drag.current = null;\n    track.current?.style.removeProperty(\"--sp-carousel-drag\");\n    if (!start.moved) return;\n    setDragging(false);\n    suppressClick.current = true;\n    window.setTimeout(() => {\n      suppressClick.current = false;\n    }, 0);\n    if (cancelled) return;\n    const distance = event.clientX - start.x;\n    const sign = readsRightToLeft(event.currentTarget) ? -1 : 1;\n    const width = viewport.current?.clientWidth ?? 1;\n    const speed = Math.abs(distance) / Math.max(event.timeStamp - start.at, 1);\n    const turns =\n      Math.abs(distance) > width * dragShare ||\n      (speed > flickSpeed && Math.abs(distance) > flickDistance);\n    if (turns) scrollTo(index + (distance * sign < 0 ? 1 : -1));\n  };\n\n  return (\n    <div\n      className={cn(slotClass(\"carousel-content\", props), className)}\n      data-dragging={dragging ? \"\" : undefined}\n      data-slot=\"carousel-content\"\n      onClickCapture={(event) => {\n        onClickCapture?.(event);\n        if (!suppressClick.current) return;\n        // The click that ends a drag belongs to the drag, not to what is under it.\n        suppressClick.current = false;\n        event.preventDefault();\n        event.stopPropagation();\n      }}\n      onDragStart={(event) => {\n        onDragStart?.(event);\n        // A picture in a slide would start the browser's own drag and cancel\n        // the pointer, so the carousel never saw the drag.\n        event.preventDefault();\n      }}\n      onPointerCancel={(event) => {\n        onPointerCancel?.(event);\n        finish(event, true);\n      }}\n      onPointerDown={(event) => {\n        onPointerDown?.(event);\n        if (event.defaultPrevented || count < 2) return;\n        if (event.pointerType === \"mouse\" && event.button !== 0) return;\n        drag.current = {\n          at: event.timeStamp,\n          id: event.pointerId,\n          moved: false,\n          x: event.clientX,\n        };\n      }}\n      onPointerMove={(event) => {\n        onPointerMove?.(event);\n        const start = drag.current;\n        if (!start || start.id !== event.pointerId) return;\n        if (!start.moved) {\n          if (Math.abs(event.clientX - start.x) < dragStart) return;\n          start.moved = true;\n          event.currentTarget.setPointerCapture(event.pointerId);\n          setDragging(true);\n        }\n        track.current?.style.setProperty(\n          \"--sp-carousel-drag\",\n          `${travel(event)}px`,\n        );\n      }}\n      onPointerUp={(event) => {\n        onPointerUp?.(event);\n        finish(event, false);\n      }}\n      ref={setViewport}\n      {...props}\n    >\n      <div\n        className=\"sp-carousel-track\"\n        data-slot=\"carousel-track\"\n        ref={track}\n        style={{ \"--sp-carousel-index\": index } as React.CSSProperties}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n\n/** One slide: a group of the carousel named by its place, inert while it is out of view. */\nexport function CarouselItem({\n  className,\n  ref,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  const messages = useSiteplaneMessages(\"carousel\");\n  const { count, index, items, register } =\n    useCarouselInternals(\"CarouselItem\");\n  const [node, setNode] = React.useState<HTMLDivElement | null>(null);\n  React.useLayoutEffect(\n    () => (node ? register(node) : undefined),\n    [node, register],\n  );\n  const setRef = React.useCallback(\n    (element: HTMLDivElement | null) => {\n      setNode(element);\n      assignRef(ref, element);\n    },\n    [ref],\n  );\n  const position = node ? items.indexOf(node) : -1;\n  return (\n    <div\n      aria-label={\n        position >= 0 ? messages.slidePosition(position + 1, count) : undefined\n      }\n      aria-roledescription={messages.slide}\n      className={cn(slotClass(\"carousel-item\", props), className)}\n      data-slot=\"carousel-item\"\n      // The slides out of view stay out of the focus order and the reading\n      // order, as a person sees one slide at a time.\n      inert={position >= 0 && position !== index ? true : undefined}\n      ref={setRef}\n      role=\"group\"\n      {...props}\n    />\n  );\n}\n\ntype CarouselArrowProps = Omit<\n  ButtonDefaultProfileProps,\n  \"profile\" | \"profileTone\" | \"shape\" | \"size\" | \"variant\"\n>;\n\nfunction CarouselArrow({\n  direction,\n  onClick,\n  ...props\n}: CarouselArrowProps & {\n  direction: \"next\" | \"previous\";\n}): React.ReactElement {\n  const messages = useSiteplaneMessages(\"carousel\");\n  const context = useCarouselInternals(\n    direction === \"next\" ? \"CarouselNext\" : \"CarouselPrevious\",\n  );\n  const next = direction === \"next\";\n  const Icon = next ? CaretRightIcon : CaretLeftIcon;\n  const slot = next ? \"carousel-next\" : \"carousel-previous\";\n  return (\n    <Button\n      aria-label={next ? messages.next : messages.previous}\n      className={cn(slotClass(slot, props), props.className)}\n      data-slot={slot}\n      disabled={next ? !context.canScrollNext : !context.canScrollPrevious}\n      shape=\"pill\"\n      size={context.size === \"lg\" ? \"icon-lg\" : \"icon-sm\"}\n      variant=\"default\"\n      {...props}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented)\n          (next ? context.scrollNext : context.scrollPrevious)();\n      }}\n    >\n      <Icon aria-hidden=\"true\" className=\"sp-carousel-arrow-glyph\" />\n    </Button>\n  );\n}\n\n/** The arrow that shows the previous slide: over the slide at its start edge, disabled on the first. */\nexport function CarouselPrevious(\n  props: CarouselArrowProps,\n): React.ReactElement {\n  return <CarouselArrow {...props} direction=\"previous\" />;\n}\n\n/** The arrow that shows the next slide: over the slide at its end edge, disabled on the last. */\nexport function CarouselNext(props: CarouselArrowProps): React.ReactElement {\n  return <CarouselArrow {...props} direction=\"next\" />;\n}\n\n/**\n * The kit's PageIndicator for the carousel: one dot for each slide, the\n * current one filled, and a click goes to that slide. Over the slide at its\n * bottom middle, in the carousel's size; `framed` sets the dots on a\n * translucent pill, for an indicator on an image.\n */\nexport function CarouselIndicator({\n  className,\n  framed,\n  labels,\n  variant,\n  ...props\n}: Omit<\n  PageIndicatorProps,\n  \"count\" | \"onPageChange\" | \"page\" | \"size\"\n>): React.ReactElement {\n  const messages = useSiteplaneMessages(\"carousel\");\n  const { count, index, scrollTo, size } =\n    useCarouselInternals(\"CarouselIndicator\");\n  return (\n    <div\n      className={cn(slotClass(\"carousel-indicator\", props), className)}\n      data-slot=\"carousel-indicator\"\n      // A line takes the width of its box, which the carousel gives it\n      // (carousel.css), where the dots set the width of theirs.\n      data-variant={variant}\n    >\n      <PageIndicator\n        count={count}\n        framed={framed}\n        labels={{\n          group: messages.slides,\n          page: messages.goToSlide,\n          ...labels,\n        }}\n        onPageChange={(page) => scrollTo(page - 1)}\n        page={index + 1}\n        size={size}\n        variant={variant}\n        {...props}\n      />\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/carousel/carousel.tsx"},{"path":"packages/ui/src/components/carousel/carousel.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* A Carousel is a row of slides that shows one at a time, the\n   * arrows over the slide at its edges and the indicator over its bottom. The\n   * root is the place the arrows and the indicator are measured from; its\n   * height is the host's, and the slides fill it. The sizes:\n   * md has 36 px arrows 16 px from the edge, lg has 44 px arrows 20 px from it,\n   * and both keep the indicator 16 px from the bottom. */\n  :where(.sp-carousel) {\n    --sp-carousel-edge: var(--sp-rw-space-4);\n    --sp-carousel-gap: var(--sp-rw-space-2);\n    --sp-carousel-direction: 1;\n\n    position: relative;\n    height: 100%;\n  }\n\n  :where(.sp-carousel[data-size=\"lg\"]) {\n    --sp-carousel-edge: var(--sp-rw-space-5);\n  }\n\n  /* The viewport clips the slides, and a horizontal swipe is the carousel's:\n   * the browser keeps the vertical scroll. */\n  :root .sp-carousel-content {\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    touch-action: pan-y;\n    user-select: none;\n  }\n\n  /* The track carries the slides, 8 px apart (rulebook M3). The slide is its own\n   * width plus the gap from the next, so the current slide sits at the\n   * index times that, and a drag adds the way the pointer has gone. A slide\n   * settles in the carousel duration on the carousel curve (M3); while a\n   * pointer drags the track it follows at once. The track lays out from the\n   * start of the reading direction, so a right-to-left page turns the sign. */\n  :root .sp-carousel-track {\n    display: flex;\n    height: 100%;\n    gap: var(--sp-carousel-gap);\n    transform: translateX(\n      calc(\n        var(--sp-carousel-index, 0) * -1 * (100% + var(--sp-carousel-gap)) *\n          var(--sp-carousel-direction) + var(--sp-carousel-drag, 0px)\n      )\n    );\n    transition: transform var(--motion-duration-carousel)\n      var(--motion-ease-carousel);\n  }\n\n  :root .sp-carousel-track:is(:dir(rtl), [dir=\"rtl\"], [dir=\"rtl\"] *) {\n    --sp-carousel-direction: -1;\n  }\n\n  :root .sp-carousel-content[data-dragging] > .sp-carousel-track {\n    transition: none;\n  }\n\n  /* A slide is as wide as the viewport, clips its picture to the surface\n   * corners and fills its height; a picture in it covers it. */\n  :root .sp-carousel-item {\n    height: 100%;\n    min-width: 0;\n    flex: 0 0 100%;\n    overflow: hidden;\n    border-radius: var(--sp-rw-radius-surface);\n  }\n\n  :root .sp-carousel-item > img {\n    display: block;\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n  }\n\n  /* The arrows: over the slide, in the middle of its height, one at each edge. */\n  :root .sp-carousel-previous,\n  :root .sp-carousel-next {\n    position: absolute;\n    top: 50%;\n    z-index: 10;\n    translate: 0 -50%;\n  }\n\n  :root .sp-carousel-previous {\n    inset-inline-start: var(--sp-carousel-edge);\n  }\n\n  :root .sp-carousel-next {\n    inset-inline-end: var(--sp-carousel-edge);\n  }\n\n  /* An arrow points the way the page reads, so a right-to-left page turns it. */\n  :root .sp-carousel-arrow-glyph:is(:dir(rtl), [dir=\"rtl\"], [dir=\"rtl\"] *) {\n    scale: -1 1;\n  }\n\n  /* The indicator: over the slide, in the middle of its bottom. */\n  :root .sp-carousel-indicator {\n    position: absolute;\n    bottom: var(--sp-rw-space-4);\n    left: 50%;\n    z-index: 10;\n    translate: -50% 0;\n  }\n\n  /* The indicator's box is as wide as what it holds, which a row of dots\n   * sets. A line of segments takes the width of its container instead, as on\n   * its own, so in that box it had none and shrank to its gaps, 16 by 6 px (3\n   * November 2026). Here the line spans a third of the carousel, centred as\n   * the dots are; a width class on the indicator gives it another. */\n  :root .sp-carousel-indicator[data-variant=\"line\"] {\n    width: calc(100% / 3);\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/carousel/carousel.css"},{"path":"packages/ui/src/components/carousel/index.ts","content":"export * from \"./carousel\";\n","type":"registry:ui","target":"~/src/siteplane/components/carousel/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/carousel.md","categories":["Data display"],"meta":{"avoidWhen":["Avoid for content that everyone must read; put it on the page, where no slide hides it.","Avoid for pictures that people compare with each other; show them side by side in a grid."],"description":"Show slides one at a time and move between them with arrows, dots, the arrow keys or a swipe.","knownLimitations":["No loop from the last slide to the first, no vertical orientation and no autoplay.","The carousel fills the height of its container; give the container a height or an aspect ratio."],"providerNotes":["The role descriptions, the names of the slides, such as 2 of 3, of the arrows and of the indicator are the carousel messages of SiteplaneLocaleProvider, English by default; an aria-label on a part names it otherwise."],"releaseStatus":"preview","searchTerms":["carousel","slides","slideshow","image carousel","product tour","testimonials"],"useWhen":["Use for a few related pictures or messages of which people need to see one at a time, such as a product tour, feature screenshots or customer stories.","Use index and onIndexChange when other parts of the page follow the current slide, such as a caption."]}}