diff --git a/apps/docs/package.json b/apps/docs/package.json
index 5dab22e0..e6253057 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -40,6 +40,7 @@
"@solidjs/start": "0.6.1",
"@tanstack/solid-virtual": "3.0.0-beta.6",
"clsx": "2.0.0",
+ "embla-carousel-autoplay": "^8.3.1",
"minisearch": "7.1.0",
"solid-js": "1.8.15",
"undici": "5.23.0",
diff --git a/apps/docs/src/examples/carousel.module.css b/apps/docs/src/examples/carousel.module.css
new file mode 100644
index 00000000..c09d6e12
--- /dev/null
+++ b/apps/docs/src/examples/carousel.module.css
@@ -0,0 +1,142 @@
+.item {
+ width: 400px;
+ height: 200px;
+ background-color: hsl(0 0% 98%);
+ margin: 8px;
+ flex: 0 0 100%;
+ min-width: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 8px;
+ border: 2px solid hsl(240 5% 84%);
+ transition: border-color 250ms;
+}
+
+.button {
+ appearance: none;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ height: 40px;
+ padding: 0 16px;
+ margin-inline: 20px;
+ border-radius: 6px;
+ background-color: hsl(200 98% 39%);
+ color: white;
+ transition: background-color 250ms;
+}
+
+.button:hover {
+ background-color: hsl(201 96% 32%);
+}
+
+.button:focus-visible {
+ outline: 2px solid hsl(200 98% 39%);
+ outline-offset: 2px;
+}
+
+.button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.horizontal {
+ .root {
+ max-width: 500px;
+ overflow: hidden;
+ margin: auto;
+ }
+ .viewport {
+ display: flex;
+ max-height: 400px;
+ gap: 16px;
+ margin: 16px;
+ }
+
+ .indicators {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ margin-top: 16px;
+ }
+
+ .indicator {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: hsl(240 5% 84%);
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ transition: background-color 250ms;
+ }
+
+ .indicator[data-selected] {
+ background: hsl(200 98% 39%);
+ }
+}
+
+/* Dark theme styles */
+[data-kb-theme="dark"] .horizontal, .autoplay, .vertical {
+ .item {
+ background-color: hsl(240 4% 16%);
+ border-color: hsl(240 5% 26%);
+ }
+
+ .button {
+ background-color: hsl(201 96% 32%);
+ color: hsla(0 100% 100% / 0.9);
+ }
+
+ .button:hover {
+ background-color: hsl(200 98% 39%);
+ }
+
+ .button:active {
+ background-color: hsl(199 89% 48%);
+ }
+
+ .indicator {
+ background: hsl(240 5% 26%);
+ }
+
+ .indicator[data-selected] {
+ background: hsl(201 96% 32%);
+ }
+}
+
+.vertical {
+ .root {
+ margin: 32px auto;
+ overflow: hidden;
+ margin: auto;
+ }
+ .viewport {
+ max-height: 250px;
+ margin: 16px;
+ }
+
+ .indicators {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 16px;
+ }
+
+ .indicator {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.3);
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ }
+
+ .indicator[data-selected] {
+ background: rgba(0, 0, 0, 0.8);
+ }
+}
+
diff --git a/apps/docs/src/examples/carousel.tsx b/apps/docs/src/examples/carousel.tsx
new file mode 100644
index 00000000..72ba2eb9
--- /dev/null
+++ b/apps/docs/src/examples/carousel.tsx
@@ -0,0 +1,115 @@
+import styles from "./carousel.module.css";
+import { Carousel } from "@kobalte/core/carousel";
+import { Index } from "solid-js";
+import Autoplay from "embla-carousel-autoplay"
+
+const slides = Array.from({ length: 5 }).map((_, i) => i + 1);
+
+export function HorrizontalExample() {
+ return (
+
+
+
+
+ {(_, index) => (
+
+ Slide {index}
+
+ )}
+
+
+
+
+ Prev
+
+ {(_, index) => (
+
+ )}
+
+ Next
+
+
+
+ );
+}
+
+export function VerticalExample() {
+ return (
+
+
+
+
+ {(_, index) => (
+
+ Slide {index}
+
+ )}
+
+
+
+ {
+
+
+ {(_, index) => (
+
+ )}
+
+
+ }
+
+
+ );
+}
+
+export function AutoPlayExample() {
+ const autoPlayPlugin = Autoplay({ delay: 1800, stopOnInteraction: true });
+ return (
+
+
autoPlayPlugin.play(false)}
+ class={styles.root}
+ >
+
+
+ {(_, index) => (
+
+ Slide {index}
+
+ )}
+
+
+
+
+ Prev
+
+ {(_, index) => (
+
+ )}
+
+ Next
+
+
+
+ );
+}
diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx
index cd2a5c19..e017abd5 100644
--- a/apps/docs/src/routes/docs/core.tsx
+++ b/apps/docs/src/routes/docs/core.tsx
@@ -61,6 +61,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [
title: "Button",
href: "/docs/core/components/button",
},
+ {
+ title: "Carousel",
+ href: "/docs/core/components/carousel",
+ status: "new"
+ },
{
title: "Checkbox",
href: "/docs/core/components/checkbox",
diff --git a/apps/docs/src/routes/docs/core/components/carousel.mdx b/apps/docs/src/routes/docs/core/components/carousel.mdx
new file mode 100644
index 00000000..ccd33489
--- /dev/null
+++ b/apps/docs/src/routes/docs/core/components/carousel.mdx
@@ -0,0 +1,348 @@
+import { Preview, TabsSnippets, Kbd } from "../../../../components";
+
+import {
+ HorrizontalExample,
+ VerticalExample,
+ AutoPlayExample
+} from "../../../../examples/carousel";
+
+# Carousel
+
+A carousel component that allows cycling through items (slides) with navigation controls, indicators, Built on top of [Embla Carousel](https://www.embla-carousel.com/).
+
+## Import
+
+```ts
+import { Carousel } from "@kobalte/core/carousel";
+// or
+import { Root, Viewport, ... } from "@kobalte/core/carousel";
+```
+
+
+## Features
+
+- Follow WAI ARIA best practices for carousel components
+- Support for horizontal and vertical orientations
+- Built-in navigation controls (Previous/Next buttons)
+- Support for slide indicators
+- Full Embla Carousel configuration support
+
+## Anatomy
+
+The carousel consists of:
+
+- **Carousel:** The root container for the carousel.
+- **Carousel.Viewport:** Contains the slides that are visible in the carousel.
+- **Carousel.Item:** An individual slide within the carousel.
+- **Carousel.Previous:** Button to navigate to the previous slide.
+- **Carousel.Next:** Button to navigate to the next slide.
+- **Carousel.Indicator:** Visual indicators showing the current slide position.
+
+```tsx
+
+
+
+
+
+
+
+
+
+```
+
+
+## Example
+
+
+
+
+
+
+
+ index.tsx
+ style.css
+
+
+ ```tsx
+ import { Carousel } from "@kobalte/core/carousel";
+ import "./style.css";
+
+ const images = [
+ "https://picsum.photos/800/400?random=1",
+ "https://picsum.photos/800/400?random=2",
+ "https://picsum.photos/800/400?random=3",
+ "https://picsum.photos/800/400?random=4",
+ ];
+
+ function App() {
+ return (
+
+
+
+ {(src, index) => (
+
+
+
+ )}
+
+
+
+
+ Prev
+
+ {(_, index) => (
+
+ )}
+
+ Next
+
+
+ );
+ }
+ ```
+
+
+ ```css
+ .carousel {
+ max-width: 500px;
+ overflow: hidden;
+ margin: auto;
+ }
+
+ .carousel__viewport {
+ display: flex;
+ max-height: 400px;
+ gap: 16px;
+ margin: 16px;
+ }
+
+ .carousel__item {
+ flex: 0 0 100%;
+ min-width: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 8px;
+ border: 2px solid black;
+ }
+
+ .carousel__item img {
+ max-width: 100%;
+ max-height: 100%;
+ }
+
+ .carousel__controls {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 16px;
+ }
+
+ .carousel__previous,
+ .carousel__next {
+ padding: 8px;
+ border-radius: 8px;
+ background: white;
+ color: black;
+ border: none;
+ cursor: pointer;
+ }
+
+ .carousel__indicator {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.3);
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ }
+
+ .carousel__indicator[data-selected] {
+ background: rgba(0, 0, 0, 0.8);
+ }
+ ```
+
+
+
+## Usage
+
+### Vertical Orientation
+
+The carousel can be oriented vertically by setting the `orientation` prop to `vertical`.
+
+
+
+
+
+
+### Autoplay
+
+The carousel supports autoplay functionality using the Embla Carousel autoplay plugin.
+
+
+
+
+
+```tsx
+import Autoplay from "embla-carousel-autoplay";
+
+function AutoplayExample() {
+ const autoPlayPlugin = Autoplay({ delay: 2000, stopOnInteraction: true });
+ return (
+ autoPlayPlugin.play(false)}
+ >
+ {/ ... rest of the carousel code ... /}
+
+ );
+}
+```
+
+## API Reference
+
+### Carousel.Root
+
+#### Props
+
+| Prop | Type | Default | Description |
+| :--- | :--- | :------ | :---------- |
+| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | The orientation of the carousel. |
+| `disabled` | `boolean` | `false` | Whether the carousel is disabled. |
+| `defaultSelectedIndex` | `number` | `0` | The index of the slide that should be active when initially rendered. |
+| `startIndex` | `number` | - | The controlled value of the selected slide index. |
+| `onSelectedIndexChange` | `(index: number) => void` | - | Event handler called when the selected index changes. |
+| `align` | `'start' \| 'center' \| 'end'` | `'start'` | The alignment of the slides in the carousel. |
+| `loop` | `boolean` | `false` | Whether the carousel should loop. |
+| `dragFree` | `boolean` | `false` | Whether the carousel should drag free or snap to slides. |
+| `slidesToScroll` | `number` | `1` | The number of slides to scroll at a time. |
+| `dragThreshold` | `number` | `10` | The drag threshold in pixels before a drag movement starts. |
+| `duration` | `number` | `25` | The duration of the scroll animation in milliseconds. |
+| `plugins` | `EmblaPluginType[]` | `[]` | Custom embla carousel plugins. |
+
+#### Data Attributes
+
+| Attribute | Description |
+| :--- | :---------- |
+| `data-orientation` | The orientation of the carousel: `"horizontal"` or `"vertical"` |
+| `aria-roledescription` | Set to `"carousel"` |
+| `role` | Set to `"region"` |
+
+### Carousel.Viewport
+
+#### Props
+
+| Prop | Type | Description |
+| :--- | :--- | :---------- |
+| `ref` | `T \| ((el: T) => void)` | A reference to the viewport element. |
+
+#### Data Attributes
+
+| Attribute | Description |
+| :--- | :---------- |
+| `data-orientation` | Matches the parent carousel's orientation |
+
+### Carousel.Item
+
+#### Props
+
+| Prop | Type | Description |
+| :--- | :--- | :---------- |
+| `index` | `number` | Required. The index of this item in the carousel. |
+| `ref` | `T \| ((el: T) => void)` | A reference to the item element. |
+
+#### Data Attributes
+
+| Attribute | Description |
+| :--- | :---------- |
+| `data-orientation` | Matches the parent carousel's orientation |
+| `data-selected` | Present when the item is currently selected |
+| `role` | Set to `"group"` |
+| `aria-roledescription` | Set to `"slide"` |
+
+### Carousel.Previous
+
+`Carousel.Previous` use [Button](/docs/core/components/button) component under the hood.
+
+#### Props
+
+Inherits all button props plus:
+
+| Prop | Type | Description |
+| :--- | :--- | :---------- |
+| `ref` | `T \| ((el: T) => void)` | A reference to the button element. |
+
+#### Data Attributes
+
+| Attribute | Description |
+| :--- | :---------- |
+| `data-orientation` | Matches the parent carousel's orientation |
+| `aria-label` | Set to `"Previous slide"` |
+| `disabled` | Present when cannot scroll to previous slide |
+
+### Carousel.Next
+
+`Carousel.Next` use [Button](/docs/core/components/button) component under the hood.
+
+#### Props
+
+Inherits all button props plus:
+
+| Prop | Type | Description |
+| :--- | :--- | :---------- |
+| `ref` | `T \| ((el: T) => void)` | A reference to the button element. |
+
+#### Data Attributes
+
+| Attribute | Description |
+| :--- | :---------- |
+| `data-orientation` | Matches the parent carousel's orientation |
+| `aria-label` | Set to `"Next slide"` |
+| `disabled` | Present when cannot scroll to next slide |
+
+### Carousel.Indicator
+
+`Carousel.Indicator` use [Button](/docs/core/components/button) component under the hood.
+
+#### Props
+
+Inherits all button props plus:
+
+| Prop | Type | Description |
+| :--- | :--- | :---------- |
+| `index` | `number` | Required. The index this indicator represents. |
+| `ref` | `T \| ((el: T) => void)` | A reference to the button element. |
+
+#### Data Attributes
+
+| Attribute | Description |
+| :--- | :---------- |
+| `data-orientation` | Matches the parent carousel's orientation |
+| `data-selected` | Present when this indicator's slide is selected |
+| `role` | Set to `"tab"` |
+| `aria-selected` | Set to `true` when this indicator's slide is selected |
+
+## Accessibility
+
+### Keyboard Interactions
+
+| Key | Description |
+| :-- | :---------- |
+| `ArrowRight` | Moves to the next slide when orientation is horizontal |
+| `ArrowLeft` | Moves to the previous slide when orientation is horizontal |
+| `ArrowDown` | Moves to the next slide when orientation is vertical |
+| `ArrowUp` | Moves to the previous slide when orientation is vertical |
+| `Home` | Moves to the first slide |
+| `End` | Moves to the last slide |
+| `Tab` | Moves focus to the next focusable element |
+| `Shift + Tab` | Moves focus to the previous focusable element |
+
+### ARIA
+
+The carousel follows the [WAI-ARIA Carousel Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/). The root element has `role="region"` and `aria-roledescription="carousel"`, while each slide has `role="group"` and `aria-roledescription="slide"`.
diff --git a/packages/core/package.json b/packages/core/package.json
index edc1870b..01206326 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -76,6 +76,8 @@
"@kobalte/utils": "^0.9.1",
"@solid-primitives/props": "^3.1.8",
"@solid-primitives/resize-observer": "^2.0.26",
+ "embla-carousel": "^8.4.0",
+ "embla-carousel-solid": "^8.4.0",
"solid-presence": "^0.1.8",
"solid-prevent-scroll": "^0.1.4"
},
diff --git a/packages/core/src/carousel/carousel-context.tsx b/packages/core/src/carousel/carousel-context.tsx
new file mode 100644
index 00000000..c21f99b8
--- /dev/null
+++ b/packages/core/src/carousel/carousel-context.tsx
@@ -0,0 +1,31 @@
+import { type Accessor, createContext, useContext } from "solid-js";
+import type { CreateEmblaCarouselType } from "embla-carousel-solid";
+import type { Orientation } from "@kobalte/utils";
+
+export type CarouselApi = CreateEmblaCarouselType[1];
+
+export interface CarouselContextValue {
+ isDisabled: Accessor;
+ orientation: Accessor;
+ canScrollPrev: Accessor;
+ canScrollNext: Accessor;
+ selectedIndex: Accessor;
+ api: Accessor;
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ scrollTo: (index: number) => void;
+}
+
+export const CarouselContext = createContext();
+
+export function useCarouselContext() {
+ const context = useContext(CarouselContext);
+
+ if (context === undefined) {
+ throw new Error(
+ "[kobalte]: `useCarouselContext` must be used within a `Carousel` component"
+ );
+ }
+
+ return context;
+}
diff --git a/packages/core/src/carousel/carousel-indicator.tsx b/packages/core/src/carousel/carousel-indicator.tsx
new file mode 100644
index 00000000..08e56297
--- /dev/null
+++ b/packages/core/src/carousel/carousel-indicator.tsx
@@ -0,0 +1,51 @@
+import { type ValidComponent, splitProps } from "solid-js";
+import { type ElementOf, type PolymorphicProps } from "../polymorphic";
+import { useCarouselContext } from "./carousel-context";
+import { Button } from "../button";
+
+export interface CarouselIndicatorOptions {
+ /** The index this indicator represents and will navigate to when clicked. */
+ index: number;
+}
+
+export interface CarouselIndicatorCommonProps {
+ ref?: T | ((el: T) => void);
+}
+
+export interface CarouselIndicatorRenderProps extends CarouselIndicatorCommonProps {
+ role: "tab";
+ type: "button";
+ "aria-label": string;
+ "aria-selected": boolean;
+ "data-orientation": string;
+ "data-selected": string | undefined;
+ onClick: () => void;
+}
+
+export type CarouselIndicatorProps =
+ CarouselIndicatorOptions & Partial>>;
+
+/**
+ * A button that allows users to navigate directly to a specific slide.
+ */
+export function CarouselIndicator(
+ props: PolymorphicProps>
+) {
+ const context = useCarouselContext();
+ const [local, others] = splitProps(props as CarouselIndicatorProps, ["index", "ref"]);
+
+ const isSelected = () => context.selectedIndex() === local.index;
+
+ return (
+