"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,
};