shadimage
akoder
uicarouselsource: ok · refreshed

Fluid Carousel

Accessible touch-swipeable slide carousel with directional spring animations, keyboard arrow navigation, and pagination indicators.

npx shadcn add "https://akoder.xyz/r/carousel.json"
free

Where it goes

kanban

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files1 · source cached 10/8/2026, 6:38:42 AM

components/ui/carousel.tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  type KeyboardEvent,
  type ReactNode,
  type TouchEvent,
  useCallback,
  useMemo,
  useRef,
  useState,
} from "react";

import { cn } from "@/lib/utils";

export type CarouselItem = {
  id: string;
  /** Slide heading, also used for screen-reader labels. */
  title: string;
  description?: string;
  content: ReactNode;
};

export type CarouselProps = {
  items: CarouselItem[];
  /** Accessible name for the carousel region. */
  ariaLabel?: string;
  className?: string;
};

/** Minimum horizontal travel (px) before a touch counts as a swipe. */
const SWIPE_THRESHOLD = 48;

export function Carousel({
  items,
  ariaLabel = "carousel",
  className,
}: CarouselProps) {
  const reduceMotion = useReducedMotion();
  const [[index, direction], setSlide] = useState<[number, number]>([0, 0]);
  const touchStart = useRef<{ x: number; y: number } | null>(null);

  const total = items.length;

  const goToIndex = useCallback((next: number) => {
    setSlide(([current]) => [next, next > current ? 1 : -1]);
  }, []);

  const showNext = useCallback(() => {
    setSlide(([current]) =>
      total === 0 ? [current, 1] : [(current + 1) % total, 1],
    );
  }, [total]);

  const showPrevious = useCallback(() => {
    setSlide(([current]) =>
      total === 0 ? [current, -1] : [(current - 1 + total) % total, -1],
    );
  }, [total]);

  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.key === "ArrowRight") {
        event.preventDefault();
        showNext();
      } else if (event.key === "ArrowLeft") {
        event.preventDefault();
        showPrevious();
      } else if (event.key === "Home") {
        event.preventDefault();
        goToIndex(0);
      } else if (event.key === "End") {
        event.preventDefault();
        goToIndex(total - 1);
      }
    },
    [goToIndex, showNext, showPrevious, total],
  );

  const handleTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
    const touch = event.touches[0];
    touchStart.current = touch ? { x: touch.clientX, y: touch.clientY } : null;
  }, []);

  // Only treat the gesture as a swipe when it is mostly horizontal, so vertical
  // page scrolling on touch devices is never hijacked.
  const handleTouchEnd = useCallback(
    (event: TouchEvent<HTMLDivElement>) => {
      const start = touchStart.current;
      touchStart.current = null;
      const touch = event.changedTouches[0];
      if (!start || !touch) return;

      const deltaX = touch.clientX - start.x;
      const deltaY = touch.clientY - start.y;
      if (
        Math.abs(deltaX) < SWIPE_THRESHOLD ||
        Math.abs(deltaX) <= Math.abs(deltaY)
      )
        return;

      if (deltaX < 0) showNext();
      else showPrevious();
    },
    [showNext, showPrevious],
  );

  const variants = useMemo(
    () => ({
      enter: (dir: number) => ({
        opacity: 0,
        x: reduceMotion ? 0 : dir >= 0 ? 40 : -40,
      }),
      center: { opacity: 1, x: 0 },
      exit: (dir: number) => ({
        opacity: 0,
        x: reduceMotion ? 0 : dir >= 0 ? -40 : 40,
      }),
    }),
    [reduceMotion],
  );

  if (total === 0) return null;

  const active = items[index] ?? items[0];

  return (
    <div className={cn("w-full", className)}>
      <div
        role="group"
        aria-roledescription="carousel"
        aria-label={ariaLabel}
        tabIndex={0}
        onKeyDown={handleKeyDown}
        className="rounded-md outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
      >
        {/* Viewport — slides share one grid cell so height never collapses
            mid-transition. */}
        <div
          className="relative grid overflow-hidden px-6"
          onTouchStart={handleTouchStart}
          onTouchEnd={handleTouchEnd}
        >
          <AnimatePresence initial={false} custom={direction} mode="popLayout">
            <motion.div
              key={active.id}
              custom={direction}
              variants={variants}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{
                duration: reduceMotion ? 0 : 0.24,
                ease: [0.22, 1, 0.36, 1],
              }}
              role="group"
              aria-roledescription="slide"
              aria-label={`${index + 1} of ${total}: ${active.title}`}
              className="col-start-1 row-start-1 w-full"
            >
              <div className="mb-5 space-y-1">
                <h3 className="text-sm font-medium">{active.title}</h3>
                {active.description ? (
                  <p className="text-sm text-muted-foreground">
                    {active.description}
                  </p>
                ) : null}
              </div>
              {active.content}
            </motion.div>
          </AnimatePresence>
        </div>
      </div>

      {/* Controls: position dots on the left, counter + arrows on the right. */}
      <div className="mt-5 flex items-center justify-between gap-3 px-6">
        <div className="flex items-center gap-0.5">
          {items.map((item, itemIndex) => (
            <button
              key={item.id}
              type="button"
              onClick={() => goToIndex(itemIndex)}
              aria-label={`Show example: ${item.title}`}
              aria-current={itemIndex === index}
              className="group flex h-9 items-center px-1 outline-none sm:h-8"
            >
              <span
                aria-hidden="true"
                className={cn(
                  "h-1.5 rounded-full transition-all duration-200 motion-reduce:transition-none",
                  itemIndex === index
                    ? "w-5 bg-foreground/80"
                    : "w-1.5 bg-muted-foreground/30 group-hover:bg-muted-foreground/60",
                )}
              />
            </button>
          ))}
        </div>

        <div className="flex items-center gap-1.5">
          <span
            aria-hidden="true"
            className="text-xs tabular-nums text-muted-foreground mr-1"
          >
            {pad(index + 1)} / {pad(total)}
          </span>

        </div>
      </div>

      <p aria-live="polite" className="sr-only">
        Example {index + 1} of {total}: {active.title}
      </p>
    </div>
  );
}

function pad(value: number) {
  return String(value).padStart(2, "0");
}

More from akoder

Browse all components in this library →