diff --git a/.changeset/real-buses-scream.md b/.changeset/real-buses-scream.md
new file mode 100644
index 000000000..c1b27f4a7
--- /dev/null
+++ b/.changeset/real-buses-scream.md
@@ -0,0 +1,5 @@
+---
+"@obosbbl/grunnmuren-react": patch
+---
+
+The `Carousel` component now supports controlled state through `selectedIndex` and `onSelectedIndexChange` props
diff --git a/packages/react/src/carousel/carousel.stories.tsx b/packages/react/src/carousel/carousel.stories.tsx
index 06cabbb01..4104cfadf 100644
--- a/packages/react/src/carousel/carousel.stories.tsx
+++ b/packages/react/src/carousel/carousel.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
+import { useEffect, useState } from 'react';
import {
UNSAFE_Carousel as Carousel,
UNSAFE_CarouselItem as CarouselItem,
@@ -111,3 +112,57 @@ export const WithNavigationCallbacks = () => (
);
+
+export const Controlled = () => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setSelectedIndex((prevIndex) => (prevIndex + 1) % 4);
+ }, 3000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/react/src/carousel/carousel.tsx b/packages/react/src/carousel/carousel.tsx
index dc82179d6..ee054b963 100644
--- a/packages/react/src/carousel/carousel.tsx
+++ b/packages/react/src/carousel/carousel.tsx
@@ -5,9 +5,11 @@ import {
Children,
cloneElement,
createContext,
+ type Dispatch,
type HTMLProps,
isValidElement,
type JSX,
+ type SetStateAction,
useContext,
useEffect,
useRef,
@@ -19,6 +21,7 @@ import { Button, ButtonContext } from '../button';
import { MediaContext } from '../content';
import { translations } from '../translations';
import { useLocale } from '../use-locale';
+import { useMountEffect } from '../utils';
type CarouselItem = Pick & {
/** The index of the item that is currently in view */
@@ -40,12 +43,22 @@ type CarouselProps = Omit, 'onChange'> & {
* @param item { index: number; id?: string; prevIndex: number; prevId?: string }
*/
onChange?: (item: CarouselItem) => void;
+ /**
+ * For controlled selection, the index of the item that should be currently in view.
+ */
+ selectedIndex?: number;
+ /**
+ * For controlled selection, callback that is called when the selected index changes.
+ */
+ onSelectedIndexChange?: Dispatch>;
};
const Carousel = ({
className,
children,
onChange,
+ selectedIndex: controlledIndex,
+ onSelectedIndexChange: controlledOnIndexChange,
...rest
}: CarouselProps) => {
const carouselRef = useRef(null);
@@ -54,7 +67,14 @@ const Carousel = ({
const locale = useLocale();
const { previous, next } = translations;
- const [scrollTargetIndex, setScrollTargetIndex] = useState(0);
+ // Internal state for uncontrolled usage
+ const [_scrollTargetIndex, _setScrollTargetIndex] = useState(0);
+ // Resolve controlled vs uncontrolled state
+ const [scrollTargetIndex, setScrollTargetIndex] = [
+ controlledIndex ?? _scrollTargetIndex,
+ controlledOnIndexChange ?? _setScrollTargetIndex,
+ ];
+
const isScrollingProgrammatically = useRef(false);
const scrollTimeoutRef = useRef(null);
const scrollQueue = useRef([]);
@@ -68,6 +88,18 @@ const Carousel = ({
carouselItemsRef.current.children.length - 1 === scrollTargetIndex,
);
+ // Scroll to the correct item on mount if controlled
+ useMountEffect(() => {
+ if (controlledIndex !== undefined) {
+ // The carousel's selected index is controlled, ensure we scroll to the correct item on mount
+ carouselItemsRef.current?.children[controlledIndex]?.scrollIntoView({
+ behavior: 'instant',
+ inline: 'start',
+ block: 'nearest',
+ });
+ }
+ }, [controlledIndex]);
+
useEffect(() => {
setHasReachedScrollStart(scrollTargetIndex === 0);
setHasReachedScrollEnd(
diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts
index 958abe5fe..eef87bb6f 100644
--- a/packages/react/src/utils/index.ts
+++ b/packages/react/src/utils/index.ts
@@ -3,3 +3,5 @@ export {
type ScrollDirection,
useHorizontalScroll,
} from './horizontal-scroll';
+
+export { useMountEffect } from './use-mount-effect';
diff --git a/packages/react/src/utils/use-mount-effect.ts b/packages/react/src/utils/use-mount-effect.ts
new file mode 100644
index 000000000..f9d87e31d
--- /dev/null
+++ b/packages/react/src/utils/use-mount-effect.ts
@@ -0,0 +1,20 @@
+import { type EffectCallback, useEffect, useRef } from 'react';
+
+/**
+ * This hook is called only once when the component is mounted.
+ * @param effectCallback The effect callback to call on mount
+ * @param deps Deps of the effect
+ */
+export function useMountEffect(
+ effectCallback: EffectCallback,
+ deps?: readonly unknown[],
+) {
+ const hasMountedRef = useRef(false);
+ useEffect(() => {
+ if (!hasMountedRef.current) {
+ hasMountedRef.current = true;
+ effectCallback();
+ }
+ // biome-ignore lint/correctness/useExhaustiveDependencies: The dependency array is unknown here
+ }, deps);
+}