Web InnoventixFreeCode

Multi Carousel

Original · free

multi-item responsive carousel

byWeb InnoventixReact + Tailwind
carmulticarousels
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-multi.json
car-multi.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import type {
  FocusEvent as ReactFocusEvent,
  KeyboardEvent as ReactKeyboardEvent,
  PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type Slide = {
  id: string;
  category: string;
  discipline: string;
  title: string;
  blurb: string;
  img: string;
};

const SLIDES: Slide[] = [
  {
    id: "ledgerline",
    category: "Fintech",
    discipline: "Product design",
    title: "Ledgerline dashboard rebuild",
    blurb:
      "Rebuilt the analytics surface for a payments team drowning in spreadsheets. Reconciliation now runs 40% faster.",
    img: "g01.webp",
  },
  {
    id: "intake",
    category: "Healthcare",
    discipline: "UX & research",
    title: "Patient intake, reimagined",
    blurb:
      "A guided, accessible form flow that cut new-patient onboarding from twenty-two minutes down to six.",
    img: "g02.webp",
  },
  {
    id: "aperture",
    category: "E-commerce",
    discipline: "Web engineering",
    title: "Aperture storefront",
    blurb:
      "A headless storefront that paints in under a second and lifted checkout completion by eighteen percent.",
    img: "g03.webp",
  },
  {
    id: "northwind",
    category: "SaaS",
    discipline: "Growth design",
    title: "Northwind onboarding",
    blurb:
      "An interactive product tour that pushed first-week activation from 31% to 54% in a single quarter.",
    img: "g04.webp",
  },
  {
    id: "wander",
    category: "Travel",
    discipline: "Full-stack build",
    title: "Wander trip planner",
    blurb:
      "A map-first itinerary builder handling twelve thousand daily routes without dropping a frame.",
    img: "g05.webp",
  },
  {
    id: "frequency",
    category: "Media",
    discipline: "Cross-platform",
    title: "Frequency reader app",
    blurb:
      "Distraction-free reading with offline sync that stays in step across web, iOS, and Android.",
    img: "g06.webp",
  },
  {
    id: "freightpath",
    category: "Logistics",
    discipline: "Data & design",
    title: "Freightpath control tower",
    blurb:
      "Live shipment tracking for more than three hundred carriers, all on one calm, real-time board.",
    img: "g07.webp",
  },
  {
    id: "coursework",
    category: "Education",
    discipline: "Design systems",
    title: "Coursework studio",
    blurb:
      "Authoring tools that let an instructor publish a full course module in a single afternoon.",
    img: "g08.webp",
  },
];

const AUTOPLAY_MS = 5000;

function ChevronLeft({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      <path d="M15 18l-6-6 6-6" />
    </svg>
  );
}

function ChevronRight({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      <path d="M9 18l6-6-6-6" />
    </svg>
  );
}

function PlayIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      focusable="false"
    >
      <path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14z" />
    </svg>
  );
}

function PauseIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      focusable="false"
    >
      <rect x="6" y="5" width="4" height="14" rx="1" />
      <rect x="14" y="5" width="4" height="14" rx="1" />
    </svg>
  );
}

function ArrowUpRight({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      <path d="M7 17L17 7" />
      <path d="M8 7h9v9" />
    </svg>
  );
}

export default function CarMulti() {
  const prefersReduced = useReducedMotion();
  const uid = useId();
  const slidesId = `${uid}-slides`;

  const [perView, setPerView] = useState(1);
  const [index, setIndex] = useState(0);
  const [isPlaying, setIsPlaying] = useState(true);
  const [paused, setPaused] = useState(false);

  const total = SLIDES.length;
  const regionRef = useRef<HTMLDivElement | null>(null);
  const pointerStartX = useRef<number | null>(null);
  const didSwipe = useRef(false);

  // Responsive items-per-view via matchMedia (1 / 2 / 3).
  useEffect(() => {
    const wide = window.matchMedia("(min-width: 1024px)");
    const mid = window.matchMedia("(min-width: 640px)");
    const update = () => setPerView(wide.matches ? 3 : mid.matches ? 2 : 1);
    update();
    wide.addEventListener("change", update);
    mid.addEventListener("change", update);
    return () => {
      wide.removeEventListener("change", update);
      mid.removeEventListener("change", update);
    };
  }, []);

  const { pages, maxIndex, pageStarts } = useMemo(() => {
    const maxI = Math.max(0, total - perView);
    const pg = Math.max(1, Math.ceil(total / perView));
    const starts = Array.from({ length: pg }, (_, p) => Math.min(p * perView, maxI));
    return { pages: pg, maxIndex: maxI, pageStarts: starts };
  }, [total, perView]);

  // Keep the active index valid when the viewport (and perView) changes.
  useEffect(() => {
    setIndex((i) => Math.min(i, maxIndex));
  }, [maxIndex]);

  const activePage = useMemo(() => {
    let best = 0;
    for (let p = 0; p < pageStarts.length; p += 1) {
      if (Math.abs(pageStarts[p] - index) < Math.abs(pageStarts[best] - index)) best = p;
    }
    return best;
  }, [pageStarts, index]);

  const atStart = index <= 0;
  const atEnd = index >= maxIndex;

  const goTo = (i: number) => setIndex(Math.min(Math.max(i, 0), maxIndex));
  const goNext = () => setIndex((i) => Math.min(i + perView, maxIndex));
  const goPrev = () => setIndex((i) => Math.max(i - perView, 0));

  // Autoplay: advance one page, wrapping from the last page back to the first.
  useEffect(() => {
    if (!isPlaying || paused || prefersReduced) return;
    const id = window.setInterval(() => {
      setIndex((i) => (i >= maxIndex ? 0 : Math.min(i + perView, maxIndex)));
    }, AUTOPLAY_MS);
    return () => window.clearInterval(id);
  }, [isPlaying, paused, prefersReduced, perView, maxIndex]);

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case "ArrowRight":
        e.preventDefault();
        goNext();
        break;
      case "ArrowLeft":
        e.preventDefault();
        goPrev();
        break;
      case "Home":
        e.preventDefault();
        goTo(0);
        break;
      case "End":
        e.preventDefault();
        goTo(maxIndex);
        break;
      default:
        break;
    }
  };

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    pointerStartX.current = e.clientX;
    didSwipe.current = false;
  };

  const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (pointerStartX.current === null) return;
    const dx = e.clientX - pointerStartX.current;
    pointerStartX.current = null;
    if (Math.abs(dx) > 48) {
      didSwipe.current = true;
      if (dx < 0) goNext();
      else goPrev();
    }
  };

  const cancelSwipeClick = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (didSwipe.current) {
      e.preventDefault();
      e.stopPropagation();
      didSwipe.current = false;
    }
  };

  const onRegionFocus = () => setPaused(true);
  const onRegionBlur = (e: ReactFocusEvent<HTMLDivElement>) => {
    if (regionRef.current && !regionRef.current.contains(e.relatedTarget as Node | null)) {
      setPaused(false);
    }
  };

  const offsetPercent = index * (100 / perView);
  const liveMode = isPlaying && !paused && !prefersReduced ? "off" : "polite";
  const from = total === 0 ? 0 : index + 1;
  const to = Math.min(index + perView, total);

  const controlBtn =
    "inline-flex size-11 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950";

  return (
    <section className="relative w-full overflow-hidden bg-white py-20 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes carmulti-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
        @keyframes carmulti-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
        .carmulti-progress { animation-name: carmulti-progress; animation-timing-function: linear; animation-fill-mode: forwards; transform-origin: left center; }
        .carmulti-rise { animation: carmulti-rise 0.6s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .carmulti-progress, .carmulti-rise { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-gradient-to-b from-indigo-50 to-transparent dark:from-indigo-950/40"
      />

      <div className="relative mx-auto max-w-6xl px-6">
        <div className="carmulti-rise flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-xl">
            <p className="text-sm font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
              Selected work
            </p>
            <h2 className="mt-3 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
              Case studies from the studio floor
            </h2>
            <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-300">
              Eight recent builds where research, design, and engineering shipped
              together. Use the arrows, the dots, your arrow keys, or a swipe to browse.
            </p>
          </div>

          <div className="flex items-center gap-2" aria-label="Carousel controls" role="group">
            <button
              type="button"
              onClick={() => setIsPlaying((p) => !p)}
              disabled={Boolean(prefersReduced)}
              aria-label={isPlaying ? "Pause automatic slideshow" : "Start automatic slideshow"}
              className={controlBtn}
            >
              {isPlaying ? <PauseIcon className="size-4" /> : <PlayIcon className="size-4" />}
            </button>
            <button
              type="button"
              onClick={goPrev}
              disabled={atStart}
              aria-label="Previous slides"
              aria-controls={slidesId}
              className={controlBtn}
            >
              <ChevronLeft className="size-5" />
            </button>
            <button
              type="button"
              onClick={goNext}
              disabled={atEnd}
              aria-label="Next slides"
              aria-controls={slidesId}
              className={controlBtn}
            >
              <ChevronRight className="size-5" />
            </button>
          </div>
        </div>

        <div
          ref={regionRef}
          role="group"
          aria-roledescription="carousel"
          aria-label="Case studies"
          onKeyDown={onKeyDown}
          onMouseEnter={() => setPaused(true)}
          onMouseLeave={() => setPaused(false)}
          onFocus={onRegionFocus}
          onBlur={onRegionBlur}
          className="mt-10"
        >
          <div
            id={slidesId}
            className="overflow-hidden rounded-2xl"
            aria-atomic="false"
            aria-live={liveMode}
            onPointerDown={onPointerDown}
            onPointerUp={onPointerUp}
            onClickCapture={cancelSwipeClick}
          >
            <motion.ul
              role="presentation"
              className="flex flex-nowrap"
              initial={false}
              animate={{ x: `${-offsetPercent}%` }}
              transition={
                prefersReduced
                  ? { duration: 0 }
                  : { type: "spring", stiffness: 220, damping: 32, mass: 0.9 }
              }
            >
              {SLIDES.map((slide, i) => (
                <li
                  key={slide.id}
                  role="group"
                  aria-roledescription="slide"
                  aria-label={`${i + 1} of ${total}`}
                  style={{ flexBasis: `${100 / perView}%` }}
                  className="box-border shrink-0 grow-0 px-3 py-2"
                >
                  <article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow duration-300 hover:shadow-xl dark:border-slate-800 dark:bg-slate-900">
                    <div className="relative aspect-[16/10] overflow-hidden bg-slate-100 dark:bg-slate-800">
                      {/* eslint-disable-next-line @next/next/no-img-element */}
                      <img
                        src={`/img/gallery/${slide.img}`}
                        alt={`${slide.title} — ${slide.category} case study preview`}
                        loading="lazy"
                        draggable={false}
                        className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.05]"
                      />
                      <span className="absolute left-3 top-3 rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-slate-800 backdrop-blur dark:bg-slate-950/80 dark:text-slate-100">
                        {slide.category}
                      </span>
                    </div>
                    <div className="flex flex-1 flex-col gap-3 p-5">
                      <p className="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
                        {slide.discipline}
                      </p>
                      <h3 className="text-lg font-semibold text-slate-900 dark:text-white">
                        {slide.title}
                      </h3>
                      <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                        {slide.blurb}
                      </p>
                      <a
                        href={`#${slide.id}`}
                        aria-label={`View the ${slide.title} case study`}
                        className="mt-auto inline-flex items-center gap-1.5 pt-2 text-sm font-semibold text-slate-900 transition-colors hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-white dark:hover:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
                      >
                        View case study
                        <ArrowUpRight className="size-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
                      </a>
                    </div>
                  </article>
                </li>
              ))}
            </motion.ul>
          </div>

          {!prefersReduced && (
            <div
              aria-hidden="true"
              className="mt-6 h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
            >
              {isPlaying && !paused ? (
                <div
                  key={`${activePage}-${isPlaying}`}
                  className="carmulti-progress h-full w-full rounded-full bg-indigo-500 dark:bg-indigo-400"
                  style={{ animationDuration: `${AUTOPLAY_MS}ms` }}
                />
              ) : (
                <div
                  className="h-full rounded-full bg-indigo-400/50 dark:bg-indigo-500/50"
                  style={{ width: `${((activePage + 1) / pages) * 100}%` }}
                />
              )}
            </div>
          )}
        </div>

        <div className="mt-8 flex items-center justify-between gap-4">
          <div className="flex items-center gap-2" role="group" aria-label="Choose slide group">
            {pageStarts.map((start, p) => {
              const active = activePage === p;
              return (
                <button
                  key={start + "-" + p}
                  type="button"
                  onClick={() => goTo(start)}
                  aria-label={`Go to slide group ${p + 1} of ${pages}`}
                  aria-current={active ? "true" : undefined}
                  aria-controls={slidesId}
                  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 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 " +
                    (active
                      ? "w-7 bg-indigo-500 dark:bg-indigo-400"
                      : "w-2.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-700 dark:hover:bg-slate-600")
                  }
                />
              );
            })}
          </div>

          <p className="text-sm tabular-nums text-slate-500 dark:text-slate-400" aria-live={liveMode}>
            Showing{" "}
            <span className="font-semibold text-slate-800 dark:text-slate-200">
              {from}–{to}
            </span>{" "}
            of {total}
          </p>
        </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 →