Web InnoventixFreeCode

Basic Carousel

Original · free

basic image carousel with arrows and dots

byWeb InnoventixReact + Tailwind
carbasiccarousels
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/car-basic.json
car-basic.tsx
"use client";

import { useCallback, useEffect, useState, type KeyboardEvent } from "react";
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type PanInfo,
  type Variants,
} from "motion/react";

interface Slide {
  src: string;
  place: string;
  title: string;
  description: string;
}

const SLIDES: ReadonlyArray<Slide> = [
  {
    src: "/img/gallery/g01.webp",
    place: "Kyoto, Japan",
    title: "Arashiyama at first light",
    description:
      "The bamboo grove is quietest just after dawn, before the tour buses arrive around eight.",
  },
  {
    src: "/img/gallery/g02.webp",
    place: "Santorini, Greece",
    title: "The whitewashed cliffs of Oia",
    description:
      "Book a caldera-facing room a full day ahead of the equinox and the sunset lines up perfectly.",
  },
  {
    src: "/img/gallery/g03.webp",
    place: "Banff, Canada",
    title: "Moraine Lake in early autumn",
    description:
      "The glacial blue peaks in the second week of September, right as the larches turn gold.",
  },
  {
    src: "/img/gallery/g04.webp",
    place: "Marrakech, Morocco",
    title: "Through the souks of the medina",
    description:
      "Hire a local guide for the first hour; after that the tangle of alleys starts to make sense.",
  },
  {
    src: "/img/gallery/g05.webp",
    place: "Lofoten, Norway",
    title: "Midnight sun over the fjords",
    description:
      "From late May the sun never fully sets, so the golden hour stretches across the whole night.",
  },
  {
    src: "/img/gallery/g06.webp",
    place: "Queenstown, New Zealand",
    title: "Above Lake Wakatipu",
    description:
      "The gondola to Bob's Peak takes ten minutes and frames the Remarkables end to end.",
  },
];

const AUTOPLAY_MS = 5000;
const SWIPE_THRESHOLD = 80;
const SWIPE_POWER = 8000;

const slideVariants: Variants = {
  enter: (dir: number) => ({ x: dir >= 0 ? "100%" : "-100%", opacity: 0 }),
  center: { x: "0%", opacity: 1 },
  exit: (dir: number) => ({ x: dir >= 0 ? "-100%" : "100%", opacity: 0 }),
};

const fadeVariants: Variants = {
  enter: { x: "0%", opacity: 0 },
  center: { x: "0%", opacity: 1 },
  exit: { x: "0%", opacity: 0 },
};

export default function CarBasic() {
  const reduced = useReducedMotion();
  const [index, setIndex] = useState(0);
  const [direction, setDirection] = useState(0);
  const [isPlaying, setIsPlaying] = useState(true);
  const [hovered, setHovered] = useState(false);

  const active = SLIDES[index];
  const total = SLIDES.length;
  const running = isPlaying && !hovered && !reduced;

  const paginate = useCallback((dir: number) => {
    setDirection(dir);
    setIndex((prev) => (prev + dir + SLIDES.length) % SLIDES.length);
  }, []);

  const goTo = useCallback(
    (target: number) => {
      setDirection(target > index ? 1 : -1);
      setIndex(target);
    },
    [index],
  );

  useEffect(() => {
    if (!running) return;
    const id = window.setInterval(() => paginate(1), AUTOPLAY_MS);
    return () => window.clearInterval(id);
  }, [running, index, paginate]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowLeft") {
      event.preventDefault();
      paginate(-1);
    } else if (event.key === "ArrowRight") {
      event.preventDefault();
      paginate(1);
    } else if (event.key === "Home") {
      event.preventDefault();
      goTo(0);
    } else if (event.key === "End") {
      event.preventDefault();
      goTo(total - 1);
    }
  };

  const onDragEnd = (
    _event: MouseEvent | TouchEvent | PointerEvent,
    info: PanInfo,
  ) => {
    const power = Math.abs(info.offset.x) * info.velocity.x;
    if (info.offset.x < -SWIPE_THRESHOLD || power < -SWIPE_POWER) {
      paginate(1);
    } else if (info.offset.x > SWIPE_THRESHOLD || power > SWIPE_POWER) {
      paginate(-1);
    }
  };

  const transition = reduced
    ? { duration: 0.25 }
    : {
        x: { type: "spring" as const, stiffness: 320, damping: 34 },
        opacity: { duration: 0.35 },
      };

  return (
    <section className="relative w-full bg-gradient-to-b from-slate-50 to-white px-4 py-16 sm:px-6 sm:py-24 lg:px-8 dark:from-slate-950 dark:to-slate-900">
      <style>{`
        @keyframes carbasic-kenburns {
          from { transform: scale(1.06); }
          to { transform: scale(1.14); }
        }
        @keyframes carbasic-progress {
          from { width: 0%; }
          to { width: 100%; }
        }
        @keyframes carbasic-caption {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .carbasic-kb { animation: carbasic-kenburns 7s ease-out forwards; }
        .carbasic-caption { animation: carbasic-caption 0.5s ease-out both; }
        .carbasic-fill { animation: carbasic-progress linear forwards; }
        @media (prefers-reduced-motion: reduce) {
          .carbasic-kb,
          .carbasic-caption,
          .carbasic-fill { animation: none !important; }
          .carbasic-kb { transform: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-8 max-w-2xl sm:mb-10">
          <p className="text-sm font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
            Featured escapes
          </p>
          <h2 className="mt-2 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Six places worth the flight
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-300">
            A short reel from this year&rsquo;s shoots. Use the arrows, the dots,
            or your keyboard&rsquo;s arrow keys to move between destinations.
          </p>
        </header>

        <div
          role="group"
          aria-roledescription="carousel"
          aria-label="Featured travel destinations"
          tabIndex={0}
          onKeyDown={onKeyDown}
          onMouseEnter={() => setHovered(true)}
          onMouseLeave={() => setHovered(false)}
          onFocus={() => setHovered(true)}
          onBlur={() => setHovered(false)}
          className="group relative overflow-hidden rounded-3xl bg-slate-200 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:bg-slate-800 dark:shadow-black/40 dark:ring-white/10 dark:focus-visible:ring-offset-slate-900"
        >
          <div className="relative aspect-[16/10] w-full sm:aspect-[16/9]">
            <AnimatePresence initial={false} custom={direction}>
              <motion.div
                key={index}
                custom={direction}
                variants={reduced ? fadeVariants : slideVariants}
                initial="enter"
                animate="center"
                exit="exit"
                transition={transition}
                drag="x"
                dragConstraints={{ left: 0, right: 0 }}
                dragElastic={0.18}
                onDragEnd={onDragEnd}
                aria-roledescription="slide"
                aria-label={`${index + 1} of ${total}`}
                className="absolute inset-0 cursor-grab active:cursor-grabbing"
              >
                <div className="relative h-full w-full overflow-hidden">
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={active.src}
                    alt={`${active.place} — ${active.title}`}
                    loading="lazy"
                    draggable={false}
                    className="carbasic-kb absolute inset-0 h-full w-full select-none object-cover"
                  />
                  <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/85 via-slate-950/25 to-transparent" />
                  <div className="carbasic-caption absolute inset-x-0 bottom-0 p-5 sm:p-8">
                    <span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white ring-1 ring-inset ring-white/25 backdrop-blur">
                      <svg
                        viewBox="0 0 24 24"
                        aria-hidden="true"
                        className="h-3.5 w-3.5"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={2}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      >
                        <path d="M21 10c0 6-9 12-9 12s-9-6-9-12a9 9 0 0 1 18 0Z" />
                        <circle cx="12" cy="10" r="3" />
                      </svg>
                      {active.place}
                    </span>
                    <h3 className="mt-3 text-xl font-bold text-white sm:text-2xl">
                      {active.title}
                    </h3>
                    <p className="mt-1.5 max-w-md text-sm leading-relaxed text-slate-200 sm:text-base">
                      {active.description}
                    </p>
                  </div>
                </div>
              </motion.div>
            </AnimatePresence>

            <motion.button
              type="button"
              onClick={() => paginate(-1)}
              whileTap={{ scale: reduced ? 1 : 0.9 }}
              aria-label="Previous slide"
              className="absolute left-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-slate-900 shadow-lg ring-1 ring-slate-900/10 backdrop-blur transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:bg-slate-900/80 dark:text-white dark:ring-white/15 dark:hover:bg-slate-900 dark:focus-visible:ring-offset-slate-900"
            >
              <svg
                viewBox="0 0 24 24"
                aria-hidden="true"
                className="h-5 w-5"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="m15 18-6-6 6-6" />
              </svg>
            </motion.button>

            <motion.button
              type="button"
              onClick={() => paginate(1)}
              whileTap={{ scale: reduced ? 1 : 0.9 }}
              aria-label="Next slide"
              className="absolute right-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-slate-900 shadow-lg ring-1 ring-slate-900/10 backdrop-blur transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:bg-slate-900/80 dark:text-white dark:ring-white/15 dark:hover:bg-slate-900 dark:focus-visible:ring-offset-slate-900"
            >
              <svg
                viewBox="0 0 24 24"
                aria-hidden="true"
                className="h-5 w-5"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="m9 18 6-6-6-6" />
              </svg>
            </motion.button>

            {!reduced && (
              <div className="absolute inset-x-0 bottom-0 z-10 h-1 bg-white/25">
                <div
                  key={index}
                  className="carbasic-fill h-full bg-indigo-400"
                  style={{
                    animationDuration: `${AUTOPLAY_MS}ms`,
                    animationPlayState: running ? "running" : "paused",
                  }}
                />
              </div>
            )}
          </div>
        </div>

        <div className="mt-5 flex items-center justify-between gap-4">
          <div
            className="flex items-center gap-2.5"
            role="tablist"
            aria-label="Choose a slide"
          >
            {SLIDES.map((slide, i) => {
              const isActive = i === index;
              return (
                <button
                  key={slide.src}
                  type="button"
                  role="tab"
                  aria-selected={isActive}
                  aria-label={`Go to slide ${i + 1}: ${slide.place}`}
                  onClick={() => goTo(i)}
                  className={`h-2.5 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900 ${
                    isActive
                      ? "w-7 bg-indigo-600 dark:bg-indigo-400"
                      : "w-2.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-600 dark:hover:bg-slate-500"
                  }`}
                />
              );
            })}
          </div>

          <div className="flex items-center gap-3">
            <span
              className="text-sm font-semibold tabular-nums text-slate-500 dark:text-slate-400"
              aria-hidden="true"
            >
              {String(index + 1).padStart(2, "0")}
              <span className="mx-1 text-slate-300 dark:text-slate-600">/</span>
              {String(total).padStart(2, "0")}
            </span>

            <button
              type="button"
              onClick={() => setIsPlaying((prev) => !prev)}
              aria-label={isPlaying ? "Pause autoplay" : "Start autoplay"}
              className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-3.5 py-2 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
            >
              {isPlaying ? (
                <svg
                  viewBox="0 0 24 24"
                  aria-hidden="true"
                  className="h-4 w-4"
                  fill="currentColor"
                >
                  <rect x="6" y="5" width="4" height="14" rx="1" />
                  <rect x="14" y="5" width="4" height="14" rx="1" />
                </svg>
              ) : (
                <svg
                  viewBox="0 0 24 24"
                  aria-hidden="true"
                  className="h-4 w-4"
                  fill="currentColor"
                >
                  <path d="M8 5.14v13.72a1 1 0 0 0 1.5.86l11-6.86a1 1 0 0 0 0-1.72l-11-6.86A1 1 0 0 0 8 5.14Z" />
                </svg>
              )}
              <span className="hidden sm:inline">
                {isPlaying ? "Pause" : "Play"}
              </span>
            </button>
          </div>
        </div>

        <div aria-live="polite" aria-atomic="true" className="sr-only">
          {`Slide ${index + 1} of ${total}: ${active.place}, ${active.title}`}
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →