shadimage
Kibo UI
uireelsource: ok · refreshed

reel

A composable Reel component that looks like Instagram Stories - a full-height, 9:16 aspect ratio container with video playback and progress indicators.

npx shadcn add "https://www.kibo-ui.com/r/reel.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
ui-primitives@radix-ui/react-use-controllable-state
iconslucide-react
animationmotion

Registry dependencies

buttonprogress

Files2 · source cached 10/8/2026, 5:14:22 AM

index.tsx
"use client";

import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
  ChevronLeft,
  ChevronRight,
  Pause,
  Play,
  Volume2,
  VolumeX,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import type {
  ComponentProps,
  HTMLAttributes,
  MouseEventHandler,
  ReactNode,
  VideoHTMLAttributes,
} from "react";
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";

// Explicit type for reel items
export type ReelItem = {
  id: string | number;
  type: "video" | "image";
  src: string;
  duration: number; // Duration in seconds for both video and image
  alt?: string;
  title?: string;
  description?: string;
};

type ReelContextType = {
  currentIndex: number;
  setCurrentIndex: (index: number) => void;
  isPlaying: boolean;
  setIsPlaying: (playing: boolean) => void;
  isMuted: boolean;
  setIsMuted: (muted: boolean) => void;
  progress: number;
  setProgress: (progress: number) => void;
  duration: number;
  setDuration: (duration: number) => void;
  data: ReelItem[];
  currentItem: ReelItem;
  isNavigating: boolean;
  setIsNavigating: (navigating: boolean) => void;
  isTransitioning: boolean;
  setIsTransitioning: (transitioning: boolean) => void;
};

const ReelContext = createContext<ReelContextType | undefined>(undefined);

const useReelContext = () => {
  const context = useContext(ReelContext);
  if (!context) {
    throw new Error("useReelContext must be used within a Reel");
  }
  return context;
};

export type ReelProps = HTMLAttributes<HTMLDivElement> & {
  data: ReelItem[];
  defaultIndex?: number;
  index?: number;
  onIndexChange?: (index: number) => void;
  defaultPlaying?: boolean;
  playing?: boolean;
  onPlayingChange?: (playing: boolean) => void;
  defaultMuted?: boolean;
  muted?: boolean;
  onMutedChange?: (muted: boolean) => void;
  autoPlay?: boolean;
};

export const Reel = ({
  className,
  data,
  defaultIndex = 0,
  index: controlledIndex,
  onIndexChange: controlledOnIndexChange,
  defaultPlaying,
  playing: controlledPlaying,
  onPlayingChange: controlledOnPlayingChange,
  defaultMuted = true,
  muted: controlledMuted,
  onMutedChange: controlledOnMutedChange,
  autoPlay = true,
  ...props
}: ReelProps) => {
  const [currentIndex, setCurrentIndexState] = useControllableState({
    defaultProp: defaultIndex,
    prop: controlledIndex,
    onChange: controlledOnIndexChange,
  });

  const [isPlaying, setIsPlaying] = useControllableState({
    defaultProp: defaultPlaying ?? autoPlay,
    prop: controlledPlaying,
    onChange: controlledOnPlayingChange,
  });

  const [isMuted, setIsMuted] = useControllableState({
    defaultProp: defaultMuted,
    prop: controlledMuted,
    onChange: controlledOnMutedChange,
  });

  const [progress, setProgress] = useState(0);
  const [duration, setDuration] = useState(0);
  const [isNavigating, setIsNavigating] = useState(false);
  const [isTransitioning, setIsTransitioning] = useState(false);

  const setCurrentIndex = useCallback(
    (index: number) => {
      setIsTransitioning(true);
      setProgress(0); // Reset progress immediately to prevent showing 100% during transition
      setCurrentIndexState(index);
    },
    [setCurrentIndexState]
  );

  const currentItem = data[currentIndex];

  return (
    <ReelContext.Provider
      value={{
        currentIndex,
        setCurrentIndex,
        isPlaying,
        setIsPlaying,
        isMuted,
        setIsMuted,
        progress,
        setProgress,
        duration,
        setDuration,
        data,
        currentItem,
        isNavigating,
        setIsNavigating,
        isTransitioning,
        setIsTransitioning,
      }}
    >
      <div
        className={cn(
          "relative isolate h-full w-auto overflow-hidden bg-black",
          "aspect-[9/16]",
          className
        )}
        {...props}
      />
    </ReelContext.Provider>
  );
};

export type ReelContentProps = Omit<
  HTMLAttributes<HTMLDivElement>,
  "children"
> & {
  children: (item: ReelItem, index: number) => ReactNode;
};

export const ReelContent = ({
  className,
  children,
  ...props
}: ReelContentProps) => {
  const { currentIndex, currentItem, setIsTransitioning } = useReelContext();

  const renderContent = () => {
    if (typeof children === "function") {
      return children(currentItem, currentIndex);
    }
    const childrenArray = Array.isArray(children) ? children : [children];
    return childrenArray[currentIndex];
  };

  return (
    <div
      className={cn("relative size-full", className)}
      data-reel-content
      {...props}
    >
      <AnimatePresence mode="wait">
        <motion.div
          animate={{ opacity: 1 }}
          className="absolute inset-0"
          exit={{ opacity: 0 }}
          initial={{ opacity: 0 }}
          key={currentIndex}
          onAnimationComplete={() => {
            // Mark transition as complete when fade-in completes
            setIsTransitioning(false);
          }}
          transition={{ duration: 0.3 }}
        >
          {renderContent()}
        </motion.div>
      </AnimatePresence>
    </div>
  );
};

export type ReelItemProps = HTMLAttributes<HTMLDivElement>;

export const ReelItem = ({ className, ...props }: ReelItemProps) => (
  <div
    className={cn("relative size-full overflow-hidden", className)}
    data-reel-item
    {...props}
  />
);

export type ReelVideoProps = VideoHTMLAttributes<HTMLVideoElement>;

const MS_TO_SECONDS = 1000;
const PERCENTAGE = 100;

export const ReelVideo = ({ className, ...props }: ReelVideoProps) => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const {
    isPlaying,
    isMuted,
    setDuration,
    setProgress,
    currentIndex,
    setCurrentIndex,
    data,
    progress,
    currentItem,
    isTransitioning,
  } = useReelContext();
  const animationFrameRef = useRef<number | undefined>(undefined);
  const startTimeRef = useRef<number | undefined>(undefined);
  const pausedProgressRef = useRef<number>(0);
  const duration = currentItem.duration;

  // Set duration when component mounts or currentIndex changes
  useEffect(() => {
    setDuration(duration);
    // Don't reset progress here anymore - it's handled in ReelContent after transition
    if (!isTransitioning) {
      pausedProgressRef.current = 0;
    }
  }, [duration, setDuration, isTransitioning]);

  // Handle muting
  useEffect(() => {
    const video = videoRef.current;
    if (!video) {
      return;
    }
    video.muted = isMuted;
  }, [isMuted]);

  // Store progress when pausing
  useEffect(() => {
    if (!isPlaying) {
      pausedProgressRef.current = progress;
    }
  }, [isPlaying, progress]);

  // Handle play/pause with duration-based progress
  useEffect(() => {
    const video = videoRef.current;
    if (!video) {
      return;
    }

    if (isPlaying && !isTransitioning) {
      video.play().catch(() => {
        // Ignore autoplay errors
      });

      // Start progress animation only when not transitioning
      const elapsedTime = (pausedProgressRef.current * duration) / PERCENTAGE;
      startTimeRef.current = performance.now() - elapsedTime * MS_TO_SECONDS;

      const updateProgress = (currentTime: number) => {
        const elapsed =
          (currentTime - (startTimeRef.current || 0)) / MS_TO_SECONDS;
        const newProgress = (elapsed / duration) * PERCENTAGE;

        if (newProgress >= PERCENTAGE) {
          const totalItems = data?.length || 0;
          if (currentIndex < totalItems - 1) {
            setCurrentIndex(currentIndex + 1);
          } else {
            setCurrentIndex(0);
          }
        } else {
          setProgress(newProgress);
          animationFrameRef.current = requestAnimationFrame(updateProgress);
        }
      };

      animationFrameRef.current = requestAnimationFrame(updateProgress);
    } else if (!isTransitioning) {
      video.pause();
    }

    return () => {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
    };
  }, [
    isPlaying,
    duration,
    currentIndex,
    setProgress,
    setCurrentIndex,
    data,
    isTransitioning,
  ]);

  // biome-ignore lint/correctness/useExhaustiveDependencies: Reset video when index changes
  useEffect(() => {
    const video = videoRef.current;
    if (video) {
      video.currentTime = 0;
    }
  }, [currentIndex]);

  return (
    <video
      className={cn("absolute inset-0 size-full object-cover", className)}
      loop
      muted={isMuted}
      playsInline
      ref={videoRef}
      {...props}
    />
  );
};

export type ReelImageProps = Omit<ComponentProps<"img">, "alt"> & {
  alt: string;
  duration?: number;
  width?: number | string;
  height?: number | string;
};

const DEFAULT_IMAGE_DURATION = 5;

export const ReelImage = ({
  className,
  alt,
  duration = DEFAULT_IMAGE_DURATION,
  width,
  height,
  ...props
}: ReelImageProps) => {
  const {
    isPlaying,
    setDuration,
    setProgress,
    currentIndex,
    setCurrentIndex,
    data,
    progress,
    isTransitioning,
  } = useReelContext();
  const animationFrameRef = useRef<number | undefined>(undefined);
  const startTimeRef = useRef<number | undefined>(undefined);
  const pausedProgressRef = useRef<number>(0);

  // biome-ignore lint/correctness/useExhaustiveDependencies: Reset progress when index changes
  useEffect(() => {
    setDuration(duration);
    // Don't reset progress here anymore - it's handled in ReelContent after transition
    if (!isTransitioning) {
      pausedProgressRef.current = 0;
    }
  }, [currentIndex, duration, setDuration, isTransitioning]);

  // Handle play/pause
  useEffect(() => {
    if (isPlaying && !isTransitioning) {
      const elapsedTime = (pausedProgressRef.current * duration) / PERCENTAGE;
      startTimeRef.current = performance.now() - elapsedTime * MS_TO_SECONDS;

      const updateProgress = (currentTime: number) => {
        const elapsed =
          (currentTime - (startTimeRef.current || 0)) / MS_TO_SECONDS;
        const newProgress = (elapsed / duration) * PERCENTAGE;

        if (newProgress >= PERCENTAGE) {
          const totalItems = data?.length || 0;

          if (currentIndex < totalItems - 1) {
            setCurrentIndex(currentIndex + 1);
          } else {
            setCurrentIndex(0);
          }
        } else {
          setProgress(newProgress);
          pausedProgressRef.current = newProgress;
          animationFrameRef.current = requestAnimationFrame(updateProgress);
        }
      };

      animationFrameRef.current = requestAnimationFrame(updateProgress);
    } else if (!isTransitioning) {
      pausedProgressRef.current = progress;
    }

    return () => {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
    };
  }, [
    isPlaying,
    duration,
    currentIndex,
    setProgress,
    setCurrentIndex,
    data,
    progress,
    isTransitioning,
  ]);

  return (
    // biome-ignore lint/performance/noImgElement: "Reel is framework-agnostic"
    <img
      alt={alt}
      className={cn("absolute inset-0 size-full object-cover", className)}
      height={height}
      width={width}
      {...props}
    />
  );
};

export type ReelProgressProps = HTMLAttributes<HTMLDivElement> & {
  children?: (
    item: ReelItem,
    index: number,
    isActive: boolean,
    progress: number
  ) => ReactNode;
};

export const ReelProgress = ({
  className,
  children,
  ...props
}: ReelProgressProps) => {
  const { progress, currentIndex, data } = useReelContext();
  const FULL_PROGRESS = 100;

  const calculateProgress = (index: number) => {
    if (index < currentIndex) {
      return FULL_PROGRESS;
    }
    if (index === currentIndex) {
      return progress;
    }

    return 0;
  };

  if (typeof children === "function") {
    return (
      <div
        className={cn(
          "absolute top-0 right-0 left-0 z-40 flex gap-1 p-2",
          className
        )}
        {...props}
      >
        {data.map((item, index) => (
          <div className="relative flex-1" key={`${item.id}-progress`}>
            {children(
              item,
              index,
              index === currentIndex,
              calculateProgress(index)
            )}
          </div>
        ))}
      </div>
    );
  }

  return (
    <div
      className={cn(
        "absolute top-0 right-0 left-0 z-40 flex gap-1 p-2",
        className
      )}
      {...props}
    >
      {data.map((item, index) => (
        <Progress
          className="h-0.5 flex-1 bg-white/30 [&>div]:bg-white [&>div]:transition-none"
          key={`${item.id}-progress`}
          value={calculateProgress(index)}
        />
      ))}
    </div>
  );
};

export type ReelControlsProps = HTMLAttributes<HTMLDivElement>;

export const ReelControls = ({ className, ...props }: ReelControlsProps) => (
  <div
    className={cn(
      "absolute right-0 bottom-0 left-0 z-20 flex items-center justify-between p-4",
      "bg-gradient-to-t from-black/60 to-transparent",
      className
    )}
    {...props}
  />
);

export type ReelPreviousButtonProps = ComponentProps<typeof Button>;

export const ReelPreviousButton = ({
  className,
  children,
  ...props
}: ReelPreviousButtonProps) => {
  const { currentIndex, setCurrentIndex, setIsNavigating } = useReelContext();
  const NAVIGATION_RESET_DELAY = 50;

  const handlePrevious = () => {
    if (currentIndex > 0) {
      setIsNavigating(true);
      setCurrentIndex(currentIndex - 1);
      setTimeout(() => setIsNavigating(false), NAVIGATION_RESET_DELAY);
    }
  };

  return (
    <Button
      aria-label="Previous"
      className={cn(
        "rounded-full text-white hover:bg-white/10 hover:text-white",
        className
      )}
      disabled={currentIndex === 0}
      onClick={handlePrevious}
      size="icon"
      type="button"
      variant="ghost"
      {...props}
    >
      {children || <ChevronLeft className="size-4" />}
    </Button>
  );
};

export type ReelNextButtonProps = ComponentProps<typeof Button>;

export const ReelNextButton = ({
  className,
  children,
  ...props
}: ReelNextButtonProps) => {
  const { currentIndex, setCurrentIndex, data, setIsNavigating } =
    useReelContext();
  const totalItems = data?.length || 0;
  const NAVIGATION_RESET_DELAY = 50;

  const handleNext = () => {
    if (currentIndex < totalItems - 1) {
      setIsNavigating(true);
      setCurrentIndex(currentIndex + 1);
      setTimeout(() => setIsNavigating(false), NAVIGATION_RESET_DELAY);
    }
  };

  return (
    <Button
      aria-label="Next"
      className={cn(
        "rounded-full text-white hover:bg-white/10 hover:text-white",
        className
      )}
      disabled={currentIndex === totalItems - 1}
      onClick={handleNext}
      size="icon"
      type="button"
      variant="ghost"
      {...props}
    >
      {children || <ChevronRight className="size-4" />}
    </Button>
  );
};

export type ReelPlayButtonProps = ComponentProps<typeof Button>;

export const ReelPlayButton = ({
  className,
  children,
  ...props
}: ReelPlayButtonProps) => {
  const { isPlaying, setIsPlaying } = useReelContext();

  return (
    <Button
      aria-label={isPlaying ? "Pause" : "Play"}
      className={cn(
        "rounded-full text-white hover:bg-white/10 hover:text-white",
        className
      )}
      onClick={() => setIsPlaying(!isPlaying)}
      size="icon"
      variant="ghost"
      {...props}
    >
      {children ||
        (isPlaying ? (
          <Pause className="size-4" />
        ) : (
          <Play className="size-4" />
        ))}
    </Button>
  );
};

export type ReelMuteButtonProps = ComponentProps<typeof Button>;

export const ReelMuteButton = ({
  className,
  children,
  ...props
}: ReelMuteButtonProps) => {
  const { isMuted, setIsMuted } = useReelContext();

  return (
    <Button
      aria-label={isMuted ? "Unmute" : "Mute"}
      className={cn(
        "rounded-full text-white hover:bg-white/10 hover:text-white",
        className
      )}
      onClick={() => setIsMuted(!isMuted)}
      size="icon"
      variant="ghost"
      {...props}
    >
      {children ||
        (isMuted ? (
          <VolumeX className="size-4" />
        ) : (
          <Volume2 className="size-4" />
        ))}
    </Button>
  );
};

export type ReelNavigationProps = HTMLAttributes<HTMLButtonElement>;

export const ReelNavigation = ({
  className,
  ...props
}: ReelNavigationProps) => {
  const { setCurrentIndex, currentIndex, data, setIsNavigating } =
    useReelContext();
  const totalItems = data?.length || 0;
  const NAVIGATION_RESET_DELAY = 50;
  const HALF_WIDTH_DIVISOR = 2;

  const handleClick: MouseEventHandler<HTMLButtonElement> = (e) => {
    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const width = rect.width;

    if (x < width / HALF_WIDTH_DIVISOR) {
      if (currentIndex > 0) {
        setIsNavigating(true);
        setCurrentIndex(currentIndex - 1);
        setTimeout(() => setIsNavigating(false), NAVIGATION_RESET_DELAY);
      }
    } else if (currentIndex < totalItems - 1) {
      setIsNavigating(true);
      setCurrentIndex(currentIndex + 1);
      setTimeout(() => setIsNavigating(false), NAVIGATION_RESET_DELAY);
    }
  };

  return (
    <button
      className={cn("absolute inset-0 z-10 flex", className)}
      onClick={handleClick}
      type="button"
      {...props}
    >
      <div className="flex-1 cursor-pointer" />
      <div className="flex-1 cursor-pointer" />
    </button>
  );
};

export type ReelOverlayProps = HTMLAttributes<HTMLDivElement>;

export const ReelOverlay = ({ className, ...props }: ReelOverlayProps) => (
  <div
    className={cn("pointer-events-none absolute inset-0 z-30", className)}
    {...props}
  />
);

export type ReelHeaderProps = HTMLAttributes<HTMLDivElement>;

export const ReelHeader = ({ className, ...props }: ReelHeaderProps) => (
  <div
    className={cn(
      "absolute top-0 right-0 left-0 z-20 p-4 pt-6",
      "bg-gradient-to-b from-black/60 to-transparent",
      className
    )}
    {...props}
  />
);

export type ReelFooterProps = HTMLAttributes<HTMLDivElement>;

export const ReelFooter = ({ className, ...props }: ReelFooterProps) => (
  <div
    className={cn(
      "absolute right-0 bottom-0 left-0 z-20 p-4",
      "bg-gradient-to-t from-black/60 to-transparent",
      className
    )}
    {...props}
  />
);
reel-controlled.tsx
"use client";

import { useState } from "react";
import {
  Reel,
  ReelContent,
  ReelControls,
  ReelFooter,
  ReelHeader,
  ReelImage,
  type ReelItem,
  ReelNavigation,
  ReelProgress,
  ReelVideo,
} from "./index";

// Example data for the reel
const reelItems: ReelItem[] = [
  {
    id: "video-1",
    type: "video",
    src: "/videos/sample-video-1.mp4",
    duration: 10, // 10 seconds
    title: "First Video",
    description: "This is the first video in the reel",
  },
  {
    id: "image-1",
    type: "image",
    src: "/images/sample-landscape.jpg",
    duration: 5, // 5 seconds
    alt: "Beautiful landscape",
    title: "Nature Photo",
    description: "A stunning landscape photograph",
  },
  {
    id: "video-2",
    type: "video",
    src: "/videos/sample-video-2.mp4",
    duration: 15, // 15 seconds
    title: "Second Video",
    description: "Another exciting video",
  },
  {
    id: "image-2",
    type: "image",
    src: "/images/sample-cityscape.jpg",
    duration: 7, // 7 seconds
    alt: "City skyline",
    title: "Urban Photography",
    description: "Modern city architecture",
  },
];

export function ReelControlledExample() {
  // Controlled state for all reel properties
  const [currentIndex, setCurrentIndex] = useState(0);
  const [isPlaying, setIsPlaying] = useState(true);
  const [isMuted, setIsMuted] = useState(true);

  // External controls
  const handleJumpToItem = (index: number) => {
    setCurrentIndex(index);
  };

  const handlePlayPause = () => {
    setIsPlaying(!isPlaying);
  };

  const handleToggleMute = () => {
    setIsMuted(!isMuted);
  };

  const handleNext = () => {
    if (currentIndex < reelItems.length - 1) {
      setCurrentIndex(currentIndex + 1);
    } else {
      setCurrentIndex(0); // Loop back to start
    }
  };

  const handlePrevious = () => {
    if (currentIndex > 0) {
      setCurrentIndex(currentIndex - 1);
    } else {
      setCurrentIndex(reelItems.length - 1); // Loop to end
    }
  };

  const currentItem = reelItems[currentIndex];

  return (
    <div className="flex flex-col gap-6">
      {/* External Controls */}
      <div className="flex flex-col gap-4 rounded-lg border p-4">
        <h3 className="font-semibold text-lg">External Controls</h3>

        <div className="flex gap-2">
          <button
            className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
            onClick={handlePrevious}
            type="button"
          >
            Previous
          </button>
          <button
            className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
            onClick={handlePlayPause}
            type="button"
          >
            {isPlaying ? "Pause" : "Play"}
          </button>
          <button
            className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
            onClick={handleNext}
            type="button"
          >
            Next
          </button>
          <button
            className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
            onClick={handleToggleMute}
            type="button"
          >
            {isMuted ? "Unmute" : "Mute"}
          </button>
        </div>

        <div className="flex gap-2">
          {reelItems.map((item, index) => (
            <button
              className={`rounded px-3 py-1 ${
                index === currentIndex
                  ? "bg-blue-500 text-white"
                  : "bg-gray-200 hover:bg-gray-300"
              }`}
              key={item.id}
              onClick={() => handleJumpToItem(index)}
              type="button"
            >
              {index + 1}
            </button>
          ))}
        </div>

        <div className="text-gray-600 text-sm">
          <p>Current Index: {currentIndex}</p>
          <p>Playing: {isPlaying ? "Yes" : "No"}</p>
          <p>Muted: {isMuted ? "Yes" : "No"}</p>
          <p>Current Item: {currentItem.title}</p>
        </div>
      </div>

      {/* Controlled Reel Component */}
      <div className="mx-auto w-full max-w-sm">
        <Reel
          data={reelItems}
          index={currentIndex}
          muted={isMuted}
          onIndexChange={setCurrentIndex}
          onMutedChange={setIsMuted}
          onPlayingChange={setIsPlaying}
          playing={isPlaying}
        >
          <ReelProgress />

          <ReelHeader>
            <div className="text-white">
              <h2 className="font-bold text-xl">Controlled Reel</h2>
              <p className="text-sm opacity-80">
                Item {currentIndex + 1} of {reelItems.length}
              </p>
            </div>
          </ReelHeader>

          <ReelContent>
            {(item) => (
              <>
                {item.type === "video" ? (
                  <ReelVideo src={item.src} />
                ) : (
                  <ReelImage
                    alt={item.alt || ""}
                    duration={item.duration}
                    src={item.src}
                  />
                )}
              </>
            )}
          </ReelContent>

          <ReelNavigation />

          <ReelFooter>
            <div className="text-white">
              <h3 className="font-semibold text-lg">{currentItem.title}</h3>
              {currentItem.description && (
                <p className="mt-1 text-sm opacity-90">
                  {currentItem.description}
                </p>
              )}
            </div>
          </ReelFooter>

          <ReelControls />
        </Reel>
      </div>

      {/* Advanced Controlled Example with Custom UI */}
      <div className="flex flex-col gap-4 rounded-lg border p-4">
        <h3 className="font-semibold text-lg">Advanced Controlled Example</h3>
        <p className="text-gray-600 text-sm">
          This example shows how you can build a completely custom UI while
          controlling the Reel state from outside.
        </p>

        <div className="flex gap-4">
          <div className="flex-1">
            <Reel
              className="aspect-[9/16]"
              data={reelItems}
              index={currentIndex}
              muted={isMuted}
              onIndexChange={setCurrentIndex}
              onMutedChange={setIsMuted}
              onPlayingChange={setIsPlaying}
              playing={isPlaying}
            >
              <ReelContent>
                {(item) => (
                  <>
                    {item.type === "video" ? (
                      <ReelVideo src={item.src} />
                    ) : (
                      <ReelImage
                        alt={item.alt || ""}
                        duration={item.duration}
                        src={item.src}
                      />
                    )}
                  </>
                )}
              </ReelContent>
            </Reel>
          </div>

          <div className="flex flex-1 flex-col justify-center gap-4">
            <div>
              <h4 className="font-semibold">{currentItem.title}</h4>
              <p className="text-gray-600 text-sm">{currentItem.description}</p>
            </div>

            <div className="space-y-2">
              <div className="flex items-center gap-2">
                <label className="font-medium text-sm" htmlFor="timeline">
                  Timeline:
                </label>
                <input
                  className="flex-1"
                  id="timeline"
                  max={reelItems.length - 1}
                  min={0}
                  onChange={(e) => setCurrentIndex(Number(e.target.value))}
                  type="range"
                  value={currentIndex}
                />
              </div>

              <div className="flex items-center gap-2">
                <input
                  checked={isPlaying}
                  id="autoplay"
                  onChange={(e) => setIsPlaying(e.target.checked)}
                  type="checkbox"
                />
                <label className="text-sm" htmlFor="autoplay">
                  Auto-play
                </label>
              </div>

              <div className="flex items-center gap-2">
                <input
                  checked={isMuted}
                  id="muted"
                  onChange={(e) => setIsMuted(e.target.checked)}
                  type="checkbox"
                />
                <label className="text-sm" htmlFor="muted">
                  Muted
                </label>
              </div>
            </div>

            <div className="grid grid-cols-2 gap-2">
              {reelItems.map((item, index) => (
                <button
                  className={`rounded p-2 text-xs ${
                    index === currentIndex
                      ? "bg-blue-500 text-white"
                      : "bg-gray-100 hover:bg-gray-200"
                  }`}
                  key={item.id}
                  onClick={() => setCurrentIndex(index)}
                  type="button"
                >
                  {item.title}
                </button>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// Example of using the controlled Reel in a parent component
export default function ControlledReelPage() {
  return (
    <div className="container mx-auto py-8">
      <h1 className="mb-8 text-center font-bold text-3xl">
        Controlled Reel Examples
      </h1>
      <ReelControlledExample />
    </div>
  );
}

More from Kibo UI

Browse all components in this library →