reel

PreviousNext

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

Docs
kibo-uiui

Preview

Loading preview…
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}
  />
);

Installation

npx shadcn@latest add @kibo-ui/reel

Usage

import { Reel } from "@/components/ui/reel"
<Reel />