Carousel (Tailwind)

PreviousNext

A slideshow component for cycling through content.

Docs
roiuiitem

Preview

Loading preview…
registry/brook/tailwind/ui/carousel.tsx
"use client";

import { useControlled } from "@base-ui/utils/useControlled";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";

type CarouselContextValue = {
  currentIndex: number;
  setCurrentIndex: (index: number) => void;
  totalItems: number;
  gap: number;
  variant: "default" | "inset";
  goToIndex: (index: number) => void;
  nextSlide: () => void;
  prevSlide: () => void;
  canGoNext: boolean;
  canGoPrev: boolean;
  viewportRef: React.RefObject<HTMLDivElement | null>;
};

const CarouselContext = createContext<CarouselContextValue | null>(null);

function useCarousel() {
  const context = useContext(CarouselContext);
  if (!context) {
    throw new Error("Carousel components must be used within Carousel.Root");
  }
  return context;
}

export type CarouselRootProps = React.ComponentProps<"div"> & {
  /** Total number of items in the carousel. */
  totalItems: number;
  /** Gap between items in pixels. @default 16 */
  gap?: number;
  /** Controlled index value. */
  index?: number;
  /** Default index for uncontrolled mode. @default 0 */
  defaultIndex?: number;
  /** Callback when index changes. */
  onIndexChange?: (index: number) => void;
  /** Align carousel content. @default "start" */
  align?: "start" | "center";
  /** Carousel variant. @default "default" */
  variant?: "default" | "inset";
};

/** Root component. Manages state and provides context. */
export function Root({
  children,
  totalItems,
  gap = 16,
  index: indexProp,
  defaultIndex = 0,
  onIndexChange,
  align = "start",
  variant = "default",
  className,
  ...props
}: CarouselRootProps) {
  const [currentIndex, setCurrentIndexInternal] = useControlled({
    controlled: indexProp,
    default: defaultIndex,
    name: "Carousel",
    state: "index",
  });

  const viewportRef = useRef<HTMLDivElement>(null);
  const bleedRefFromContext = useBleedRef();
  const [insetPaddingLeft, setInsetPaddingLeft] = useState(0);
  const [insetPaddingRight, setInsetPaddingRight] = useState(0);

  const maxIndex = totalItems - 1;

  const setCurrentIndex = useCallback(
    (index: number) => {
      setCurrentIndexInternal(index);
      onIndexChange?.(index);
    },
    [setCurrentIndexInternal, onIndexChange]
  );

  const goToIndex = useCallback(
    (index: number) => {
      const viewport = viewportRef.current;
      if (!viewport) {
        return;
      }

      const slides = viewport.querySelectorAll('[role="group"]');
      const targetSlide = slides[index] as HTMLElement;

      if (targetSlide) {
        let targetScroll = targetSlide.offsetLeft;

        // For inset variant, adjust scroll position to account for left padding
        if (variant === "inset" && bleedRefFromContext?.current) {
          const parent = bleedRefFromContext.current.parentElement;
          if (parent) {
            const parentRect = parent.getBoundingClientRect();
            const leftPadding = parentRect.left;
            targetScroll = targetSlide.offsetLeft - leftPadding;
          }
        }

        viewport.scrollTo({ left: targetScroll, behavior: "smooth" });
      }

      setCurrentIndex(index);
    },
    [setCurrentIndex, variant, bleedRefFromContext]
  );

  const getVisibleItemsCount = useCallback(() => {
    const viewport = viewportRef.current;
    if (!viewport) {
      return 1;
    }

    const slides = viewport.querySelectorAll('[role="group"]');
    if (slides.length === 0) {
      return 1;
    }

    const viewportRect = viewport.getBoundingClientRect();
    let visibleCount = 0;
    const VISIBILITY_THRESHOLD = 0.5;

    for (const slide of slides) {
      const slideRect = slide.getBoundingClientRect();
      // Check if slide is at least 50% visible in viewport
      const visibleWidth = Math.min(slideRect.right, viewportRect.right) - Math.max(slideRect.left, viewportRect.left);
      const slideWidth = slideRect.width;

      if (visibleWidth / slideWidth >= VISIBILITY_THRESHOLD) {
        visibleCount++;
      }
    }

    return Math.max(1, visibleCount);
  }, []);

  // Calculate if we can navigate based on whether the next jump would go beyond bounds
  const visibleItemsForNav = getVisibleItemsCount();
  const canGoNext = currentIndex + visibleItemsForNav <= maxIndex;
  const canGoPrev = currentIndex > 0;

  const nextSlide = useCallback(() => {
    const visibleItems = getVisibleItemsCount();
    const newIndex = Math.min(currentIndex + visibleItems, maxIndex);
    goToIndex(newIndex);
  }, [currentIndex, maxIndex, goToIndex, getVisibleItemsCount]);

  const prevSlide = useCallback(() => {
    const visibleItems = getVisibleItemsCount();
    const newIndex = Math.max(currentIndex - visibleItems, 0);
    goToIndex(newIndex);
  }, [currentIndex, goToIndex, getVisibleItemsCount]);

  const value: CarouselContextValue = {
    currentIndex,
    setCurrentIndex,
    totalItems,
    gap,
    variant,
    goToIndex,
    nextSlide,
    prevSlide,
    canGoNext,
    canGoPrev,
    viewportRef,
  };

  // Sync currentIndex with scroll position
  useEffect(() => {
    const viewport = viewportRef.current;
    if (!viewport) {
      return;
    }

    const handleScroll = () => {
      const slides = viewport.querySelectorAll('[role="group"]');
      if (slides.length === 0) {
        return;
      }

      const viewportRect = viewport.getBoundingClientRect();
      let closestIndex = 0;
      let closestDistance = Number.POSITIVE_INFINITY;

      slides.forEach((slide, index) => {
        const slideRect = slide.getBoundingClientRect();
        // Calculate distance from slide's left edge to viewport's left edge
        const distance = Math.abs(slideRect.left - viewportRect.left);

        if (distance < closestDistance) {
          closestDistance = distance;
          closestIndex = index;
        }
      });

      if (closestIndex !== currentIndex) {
        setCurrentIndexInternal(closestIndex);
      }
    };

    viewport.addEventListener("scroll", handleScroll, { passive: true });
    return () => viewport.removeEventListener("scroll", handleScroll);
  }, [currentIndex, setCurrentIndexInternal]);

  // Calculate inset padding based on parent container
  useEffect(() => {
    if (variant !== "inset" || !bleedRefFromContext?.current || !viewportRef.current) {
      return;
    }

    const calculatePadding = () => {
      const bleed = bleedRefFromContext.current;
      const viewport = viewportRef.current;
      if (!(bleed && viewport)) {
        return;
      }

      const parent = bleed.parentElement;
      if (!parent) {
        return;
      }

      const parentRect = parent.getBoundingClientRect();
      const viewportRect = viewport.getBoundingClientRect();

      // Get parent's computed padding to account for container padding
      const parentStyles = window.getComputedStyle(parent);
      const parentPaddingLeft = Number.parseFloat(parentStyles.paddingLeft);
      const parentPaddingRight = Number.parseFloat(parentStyles.paddingRight);

      // Calculate the padding needed to align cards with parent's content area (inside padding)
      // Left padding: distance from viewport's left edge to parent's content left edge, minus gap
      const leftPadding = Math.max(0, parentRect.left + parentPaddingLeft - viewportRect.left - gap);

      // Right padding: distance from parent's content right edge to viewport's right edge
      const rightPadding = Math.max(0, viewportRect.right - (parentRect.right - parentPaddingRight));

      setInsetPaddingLeft(leftPadding);
      setInsetPaddingRight(rightPadding);
    };

    calculatePadding();

    window.addEventListener("resize", calculatePadding);
    return () => window.removeEventListener("resize", calculatePadding);
  }, [variant, bleedRefFromContext, gap]);

  return (
    <CarouselContext.Provider value={value}>
      <div
        className={cn(
          "relative mx-auto w-full overflow-visible rounded-lg",
          "data-[align=center]:flex data-[align=center]:flex-col data-[align=center]:items-center",
          className
        )}
        data-align={align}
        data-slot="carousel"
        style={
          {
            "--calculated-inset-padding-left": `${insetPaddingLeft}px`,
            "--calculated-inset-padding-right": `${insetPaddingRight}px`,
          } as React.CSSProperties
        }
        {...props}
      >
        {children}
        <div
          aria-atomic="true"
          aria-live="polite"
          className="-m-px absolute h-px w-px overflow-hidden whitespace-nowrap border-0 p-0"
          style={{ clip: "rect(0, 0, 0, 0)" }}
        >
          Item {currentIndex + 1} of {totalItems}
        </div>
      </div>
    </CarouselContext.Provider>
  );
}

export type CarouselBleedProps = React.ComponentProps<"div">;

const BleedRefContext = createContext<React.RefObject<HTMLDivElement | null> | null>(null);

export function useBleedRef() {
  return useContext(BleedRefContext);
}

/** Bleed wrapper. Extends carousel to full viewport width. */
export function Bleed({ className, children, ...props }: CarouselBleedProps) {
  const bleedRef = useRef<HTMLDivElement | null>(null);

  return (
    <BleedRefContext.Provider value={bleedRef}>
      <div
        className={cn("-ml-[50vw] -mr-[50vw] relative right-1/2 left-1/2 w-screen", className)}
        ref={bleedRef}
        {...props}
      >
        {children}
      </div>
    </BleedRefContext.Provider>
  );
}

export type CarouselViewportProps = React.ComponentProps<"div">;

/** Scrollable viewport. */
export function Viewport({ className, children, ...props }: CarouselViewportProps) {
  const { viewportRef } = useCarousel();

  return (
    <div
      aria-atomic="false"
      aria-live="polite"
      className={cn(
        "scroll-snap-stop-always relative w-full overflow-y-hidden overflow-x-scroll overscroll-x-contain",
        "py-[calc(2px+2px)] [-ms-overflow-style:none] [scrollbar-width:none]",
        "[&::-webkit-scrollbar]:hidden",
        className
      )}
      ref={viewportRef}
      {...props}
    >
      {children}
    </div>
  );
}

export type CarouselContentProps = React.ComponentProps<"div">;

/** Content wrapper. Flex container for horizontal layout. */
export function Content({ className, children, ...props }: CarouselContentProps) {
  const { gap, variant } = useCarousel();

  return (
    <div
      className={cn(
        "flex items-stretch",
        "before:w-[var(--inset-padding-left,0)] before:flex-shrink-0 before:content-['']",
        "after:w-[var(--inset-padding-right,0)] after:flex-shrink-0 after:content-['']",
        className
      )}
      style={
        {
          gap: `${gap}px`,
          "--inset-padding-left":
            variant === "inset"
              ? "var(--calculated-inset-padding-left, max(var(--min-edge), var(--min-padding)))"
              : undefined,
          "--inset-padding-right":
            variant === "inset"
              ? "var(--calculated-inset-padding-right, max(var(--min-edge), var(--min-padding)))"
              : undefined,
        } as React.CSSProperties
      }
      {...props}
    >
      {children}
    </div>
  );
}

export type CarouselItemProps = React.ComponentProps<"div"> & {
  /** Item index (required). */
  index: number;
};

/** Individual carousel slide. */
export function Item({ index, className, children, ...props }: CarouselItemProps) {
  const { totalItems, goToIndex, nextSlide, prevSlide, canGoNext, canGoPrev } = useCarousel();

  const isVisible = true;

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      switch (e.key) {
        case "ArrowLeft":
          if (canGoPrev) {
            e.preventDefault();
            prevSlide();
          }
          break;
        case "ArrowRight":
          if (canGoNext) {
            e.preventDefault();
            nextSlide();
          }
          break;
        case "Home":
          e.preventDefault();
          goToIndex(0);
          break;
        case "End":
          e.preventDefault();
          goToIndex(totalItems - 1);
          break;
        default:
          // No action for other keys
          break;
      }
    },
    [canGoPrev, canGoNext, prevSlide, nextSlide, goToIndex, totalItems]
  );

  return (
    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Keyboard navigation is required for carousel accessibility
    <div
      aria-label={`${index + 1} of ${totalItems}`}
      aria-roledescription="slide"
      className={cn(
        "relative flex-shrink-0 rounded-[var(--radius)]",
        "focus-visible:outline-2 focus-visible:outline-[color:var(--color-ring)] focus-visible:outline-offset-[1px]",
        className
      )}
      onKeyDown={handleKeyDown}
      role="group"
      tabIndex={isVisible ? 0 : -1}
      {...props}
    >
      {children}
    </div>
  );
}

export type CarouselPreviousProps = React.ComponentProps<"button">;

/** Previous button. Auto-disabled at start. */
export function Previous({ className, children, ...props }: CarouselPreviousProps) {
  const { prevSlide, canGoPrev } = useCarousel();

  return (
    <button
      aria-controls="carousel-slides"
      aria-label="Scroll to previous items"
      className={cn(
        "relative h-10 w-10 rounded-full border-[0.5px] border-[color:oklch(from_var(--border)_l_c_h_/_0.8)]",
        "flex cursor-pointer items-center justify-center bg-[color:var(--card)] text-[color:var(--foreground)]",
        "opacity-90 shadow-[var(--shadow-md)] transition-all duration-200 ease-[var(--ease-out-quad)]",
        "hover:scale-105 hover:bg-[color:var(--muted)] hover:opacity-100",
        "focus-visible:outline-2 focus-visible:outline-[color:var(--ring)] focus-visible:outline-offset-2",
        "active:scale-95",
        "disabled:pointer-events-none disabled:cursor-default disabled:bg-[color:var(--muted)] disabled:text-[color:var(--muted-foreground)] disabled:opacity-30",
        "disabled:hover:scale-100 disabled:hover:bg-[color:var(--muted)] disabled:hover:opacity-30",
        "motion-reduce:transition-none [&_svg]:h-4 [&_svg]:w-4",
        className
      )}
      disabled={!canGoPrev}
      onClick={prevSlide}
      type="button"
      {...props}
    >
      {children || (
        <svg aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
          <path d="m15 18-6-6 6-6" />
        </svg>
      )}
    </button>
  );
}

export type CarouselNextProps = React.ComponentProps<"button">;

/** Next button. Auto-disabled at end. */
export function Next({ className, children, ...props }: CarouselNextProps) {
  const { nextSlide, canGoNext } = useCarousel();

  return (
    <button
      aria-controls="carousel-slides"
      aria-label="Scroll to next items"
      className={cn(
        "relative h-10 w-10 rounded-full border-[0.5px] border-[color:oklch(from_var(--border)_l_c_h_/_0.8)]",
        "flex cursor-pointer items-center justify-center bg-[color:var(--card)] text-[color:var(--foreground)]",
        "opacity-90 shadow-[var(--shadow-md)] transition-all duration-200 ease-[var(--ease-out-quad)]",
        "hover:scale-105 hover:bg-[color:var(--muted)] hover:opacity-100",
        "focus-visible:outline-2 focus-visible:outline-[color:var(--ring)] focus-visible:outline-offset-2",
        "active:scale-95",
        "disabled:pointer-events-none disabled:cursor-default disabled:bg-[color:var(--muted)] disabled:text-[color:var(--muted-foreground)] disabled:opacity-30",
        "disabled:hover:scale-100 disabled:hover:bg-[color:var(--muted)] disabled:hover:opacity-30",
        "motion-reduce:transition-none [&_svg]:h-4 [&_svg]:w-4",
        className
      )}
      disabled={!canGoNext}
      onClick={nextSlide}
      type="button"
      {...props}
    >
      {children || (
        <svg aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
          <path d="m9 18 6-6-6-6" />
        </svg>
      )}
    </button>
  );
}

export type CarouselNavigationProps = React.ComponentProps<"div">;

/** Navigation wrapper. Renders default buttons if no children provided. Hidden with one item. */
export function Navigation({ className, children, ...props }: CarouselNavigationProps) {
  const { totalItems, variant } = useCarousel();

  if (totalItems <= 1) {
    return null;
  }

  return (
    <div
      className={cn("mt-12 flex justify-center gap-2", className)}
      style={
        variant === "inset"
          ? ({
              "--inset-padding": "var(--calculated-inset-padding, 0)",
            } as React.CSSProperties)
          : undefined
      }
      {...props}
    >
      {children || (
        <>
          <Previous />
          <Next />
        </>
      )}
    </div>
  );
}

export type CarouselIndicatorsProps = React.ComponentProps<"div">;

/** Dot indicators for each item. Hidden with one item. */
export function Indicators({ className, ...props }: CarouselIndicatorsProps) {
  const { totalItems, currentIndex, goToIndex } = useCarousel();

  if (totalItems <= 1) {
    return null;
  }

  return (
    <div
      aria-label="Choose slide to display"
      className={cn("-translate-x-1/2 absolute bottom-4 left-1/2 z-10 flex gap-2", className)}
      role="tablist"
      {...props}
    >
      {Array.from({ length: totalItems }, (_, index) => (
        <button
          aria-controls="carousel-slides"
          aria-label={`Scroll to item ${index + 1}`}
          aria-selected={currentIndex === index}
          className={cn(
            "relative h-3 w-3 cursor-pointer rounded-full border-none",
            "bg-white/50 transition-all duration-200 ease-in-out",
            "hover:scale-110 hover:bg-white/70",
            "focus-visible:outline-2 focus-visible:outline-[color:var(--color-ring)] focus-visible:outline-offset-2",
            "data-[active]:scale-[1.2] data-[active]:bg-[color:var(--color-primary)] data-[active]:hover:bg-[color:var(--color-primary)]",
            "motion-reduce:transition-none"
          )}
          data-active={currentIndex === index ? "" : undefined}
          // biome-ignore lint/suspicious/noArrayIndexKey: Indicators are stable and don't reorder
          key={`indicator-${index}`}
          onClick={() => goToIndex(index)}
          role="tab"
          type="button"
        />
      ))}
    </div>
  );
}

/**
 * Composable carousel component with horizontal scrolling.
 * Built-in keyboard navigation with arrow keys, Home, and End.
 * Built-in screen reader announcements for current position.
 * Required: Carousel.Root, Carousel.Viewport, Carousel.Content, Carousel.Item.
 * Optional: Carousel.Bleed, Carousel.Navigation, Carousel.Previous, Carousel.Next, Carousel.Indicators.
 */
export const Carousel = {
  Root,
  Bleed,
  Viewport,
  Content,
  Item,
  Previous,
  Next,
  Navigation,
  Indicators,
};

Installation

npx shadcn@latest add @roiui/carousel-tailwind

Usage

import { CarouselTailwind } from "@/components/carousel-tailwind"
<CarouselTailwind />