Fazekit

Code

"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type PanInfo } from "motion/react";
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";

export type SlidesPerView = number | { base: number; sm?: number; md?: number; lg?: number };

export interface CarouselProps {
  children: React.ReactNode;
  /** Slides visible at once, optionally per breakpoint (640 / 768 / 1024 px). */
  slidesPerView?: SlidesPerView;
  /** Pixels between slides. */
  gap?: number;
  /** Milliseconds between automatic advances; `false` disables autoplay. */
  autoplay?: number | false;
  /** Wrap from the last position back to the first (and vice-versa). */
  loop?: boolean;
  /** Accessible name of the carousel. */
  label?: string;
  showDots?: boolean;
  showArrows?: boolean;
  onIndexChange?: (index: number) => void;
  className?: string;
}

function usePerView(spv: SlidesPerView) {
  const query = React.useCallback(() => {
    if (typeof spv === "number") return spv;
    const w = window.innerWidth;
    if (w >= 1024 && spv.lg) return spv.lg;
    if (w >= 768 && spv.md) return spv.md;
    if (w >= 640 && spv.sm) return spv.sm;
    return spv.base;
  }, [spv]);
  return React.useSyncExternalStore(
    (cb) => {
      window.addEventListener("resize", cb);
      return () => window.removeEventListener("resize", cb);
    },
    query,
    () => (typeof spv === "number" ? spv : spv.base),
  );
}

export function Carousel({
  children,
  slidesPerView = { base: 1, sm: 2, lg: 3 },
  gap = 16,
  autoplay = 5000,
  loop = true,
  label = "Featured",
  showDots = true,
  showArrows = true,
  onIndexChange,
  className,
}: CarouselProps) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const slides = React.Children.toArray(children);
  const count = slides.length;
  const perView = Math.max(1, Math.min(usePerView(slidesPerView), count));
  const maxIndex = Math.max(0, count - perView);

  const viewportRef = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);
  const [rawIndex, setIndex] = React.useState(0);
  const index = Math.min(rawIndex, maxIndex);
  const [playing, setPlaying] = React.useState(autoplay !== false && !reduce);
  const [hovered, setHovered] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [dragging, setDragging] = React.useState(false);

  const x = useMotionValue(0);
  const progress = useMotionValue(0);
  const progressScale = useTransform(progress, [0, 1], [0, 1]);

  const slideW = width ? (width - gap * (perView - 1)) / perView : 0;
  const stride = slideW + gap;

  // measure
  React.useLayoutEffect(() => {
    const el = viewportRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // glide to index
  React.useEffect(() => {
    if (!stride) return;
    const target = -index * stride;
    if (reduce) {
      x.set(target);
      return;
    }
    const c = animate(x, target, { type: "spring", stiffness: 260, damping: 34, mass: 0.9 });
    return () => c.stop();
  }, [index, stride, reduce, x]);

  const go = React.useCallback(
    (to: number) => {
      let next = to;
      if (next > maxIndex) next = loop ? 0 : maxIndex;
      if (next < 0) next = loop ? maxIndex : 0;
      setIndex(next);
      onIndexChange?.(next);
    },
    [maxIndex, loop, onIndexChange],
  );

  // autoplay with a visible progress ring on the active dot
  const paused = !playing || hovered || focused || dragging || autoplay === false;
  const controls = React.useRef<AnimationPlaybackControls | null>(null);
  React.useEffect(() => {
    if (autoplay === false) return;
    progress.set(0);
    controls.current = animate(progress, 1, {
      duration: autoplay / 1000,
      ease: "linear",
      onComplete: () => go(index + 1),
    });
    controls.current.pause();
    return () => controls.current?.stop();
  }, [index, autoplay, go, progress]);
  React.useEffect(() => {
    if (paused) controls.current?.pause();
    else controls.current?.play();
  }, [paused, index]);

  const onDragEnd = (_: unknown, info: PanInfo) => {
    setDragging(false);
    if (!stride) return;
    const projected = -x.get() + -info.velocity.x * 0.25;
    let next = Math.round(projected / stride);
    if (next === index && Math.abs(info.offset.x) > stride * 0.15) next = index + (info.offset.x < 0 ? 1 : -1);
    next = Math.max(0, Math.min(maxIndex, next));
    if (next === index) {
      animate(x, -index * stride, { type: "spring", stiffness: 300, damping: 32 });
    } else go(next);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const map: Record<string, number> = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: maxIndex };
    if (map[e.key] === undefined) return;
    if ((e.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
    e.preventDefault();
    go(map[e.key]);
  };

  const canPrev = loop || index > 0;
  const canNext = loop || index < maxIndex;
  const positions = maxIndex + 1;

  const btn =
    "grid size-10 place-items-center rounded-full border bg-background/85 text-foreground shadow-md backdrop-blur outline-none transition hover:bg-background focus-visible:ring-4 focus-visible:ring-ring/30 active:scale-95 disabled:pointer-events-none disabled:opacity-0";

  return (
    <section
      aria-roledescription="carousel"
      aria-label={label}
      className={cn("relative w-full", className)}
      onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
      onPointerLeave={() => setHovered(false)}
      onFocus={() => setFocused(true)}
      onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setFocused(false)}
      onKeyDown={onKeyDown}
    >
      <div className="relative">
        <div ref={viewportRef} className="overflow-hidden rounded-2xl">
          <motion.div
            id={`${uid}-track`}
            aria-live={paused ? "polite" : "off"}
            className={cn("flex touch-pan-y", dragging ? "cursor-grabbing" : "cursor-grab")}
            style={{ x, gap }}
            drag={stride ? "x" : false}
            dragConstraints={{ left: -maxIndex * stride, right: 0 }}
            dragElastic={0.14}
            dragMomentum={false}
            onDragStart={() => setDragging(true)}
            onDragEnd={onDragEnd}
          >
            {slides.map((slide, i) => {
              const visible = i >= index && i < index + perView;
              return (
                <div
                  key={i}
                  role="group"
                  aria-roledescription="slide"
                  aria-label={`${i + 1} of ${count}`}
                  inert={!visible}
                  className="shrink-0 select-none"
                  style={{ width: slideW || `calc((100% - ${gap * (perView - 1)}px) / ${perView})` }}
                  onDragStart={(e) => e.preventDefault()}
                >
                  <motion.div
                    className="h-full"
                    animate={reduce ? undefined : { scale: visible ? 1 : 0.94, opacity: visible ? 1 : 0.55 }}
                    transition={{ type: "spring", stiffness: 260, damping: 30 }}
                  >
                    {slide}
                  </motion.div>
                </div>
              );
            })}
          </motion.div>
        </div>

        {showArrows && count > perView && (
          <>
            <button type="button" aria-controls={`${uid}-track`} aria-label="Previous slide" onClick={() => go(index - 1)} disabled={!canPrev} className={cn(btn, "absolute left-2 top-1/2 -translate-y-1/2 sm:-left-5")}>
              <ChevronLeft className="size-5" />
            </button>
            <button type="button" aria-controls={`${uid}-track`} aria-label="Next slide" onClick={() => go(index + 1)} disabled={!canNext} className={cn(btn, "absolute right-2 top-1/2 -translate-y-1/2 sm:-right-5")}>
              <ChevronRight className="size-5" />
            </button>
          </>
        )}
      </div>

      {count > perView && (
        <div className="mt-4 flex items-center justify-center gap-3">
          {autoplay !== false && (
            <button
              type="button"
              onClick={() => setPlaying((p) => !p)}
              aria-label={playing ? "Stop automatic slide show" : "Start automatic slide show"}
              className="grid size-8 place-items-center rounded-full border bg-background text-muted-foreground outline-none transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
            >
              {playing ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
            </button>
          )}
          {showDots && (
            <div className="flex items-center gap-1.5" role="group" aria-label="Choose slide">
              {Array.from({ length: positions }, (_, i) => {
                const on = i === index;
                return (
                  <button
                    key={i}
                    type="button"
                    aria-label={`Go to slide ${i + 1}`}
                    aria-current={on ? "true" : undefined}
                    aria-controls={`${uid}-track`}
                    onClick={() => go(i)}
                    className="group grid h-6 place-items-center outline-none"
                  >
                    <motion.span
                      layout
                      transition={{ type: "spring", stiffness: 500, damping: 36 }}
                      className={cn(
                        "relative block h-2 overflow-hidden rounded-full group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background",
                        on ? "w-7 bg-foreground/15" : "w-2 bg-foreground/20 group-hover:bg-foreground/40",
                      )}
                    >
                      {on && (
                        <motion.span
                          className="absolute inset-0 origin-left rounded-full bg-foreground"
                          style={{ scaleX: autoplay === false ? 1 : progressScale }}
                        />
                      )}
                    </motion.span>
                  </button>
                );
              })}
            </div>
          )}
          <span className="sr-only" aria-live="polite">
            {`Showing slide ${index + 1}${perView > 1 ? `–${index + perView}` : ""} of ${count}`}
          </span>
        </div>
      )}
    </section>
  );
}

More in Data Display

View all →