Web InnoventixFreeCode

Cards Carousel

Original · free

peeking card carousel

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

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent,
} from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  type PanInfo,
} from "motion/react";

type Slide = {
  id: string;
  index: string;
  place: string;
  region: string;
  country: string;
  tag: string;
  season: string;
  coords: string;
  note: string;
  img: string;
};

const SLIDES: Slide[] = [
  {
    id: "reine",
    index: "01",
    place: "Reine",
    region: "Lofoten Archipelago",
    country: "Norway",
    tag: "Arctic",
    season: "Feb – Apr · aurora season",
    coords: "67.93° N, 13.09° E",
    note: "Red rorbu cabins crouch under granite spires that fall straight into the sea. In deep winter the sun barely clears the ridge, and the whole fjord turns the colour of cold steel.",
    img: "/img/gallery/g01.webp",
  },
  {
    id: "gasadalur",
    index: "02",
    place: "Gásadalur",
    region: "Vágar, Faroe Islands",
    country: "Denmark",
    tag: "Cliffside",
    season: "May – Aug · long light",
    coords: "62.10° N, 7.27° W",
    note: "Until a tunnel was blasted through the mountain in 2004, the postman crossed on foot. The Múlafossur waterfall still drops off the headland straight into the open Atlantic.",
    img: "/img/gallery/g02.webp",
  },
  {
    id: "biei",
    index: "03",
    place: "Biei",
    region: "Kamikawa, Hokkaidō",
    country: "Japan",
    tag: "Highland",
    season: "Dec – Feb · powder",
    coords: "43.59° N, 142.46° E",
    note: "Rolling patchwork hills the locals call the undulating fields. Under a metre of dry snow the lone trees on the ridgeline read like ink strokes drawn across rice paper.",
    img: "/img/gallery/g03.webp",
  },
  {
    id: "paine",
    index: "04",
    place: "Torres del Paine",
    region: "Última Esperanza",
    country: "Chile",
    tag: "Patagonia",
    season: "Nov – Mar · austral summer",
    coords: "50.94° S, 73.40° W",
    note: "Three granite towers hold the weather hostage. Guanaco graze the pampas below while the wind, funnelled straight off the Southern Ice Field, quietly rearranges the lakes.",
    img: "/img/gallery/g04.webp",
  },
  {
    id: "tre-cime",
    index: "05",
    place: "Tre Cime di Lavaredo",
    region: "Dolomites, South Tyrol",
    country: "Italy",
    tag: "Alpine",
    season: "Jun – Sep · refuges open",
    coords: "46.62° N, 12.30° E",
    note: "Three pale limestone monoliths that flush rose at dusk — the enrosadira. The circular trail rounds a First World War refuge still perched on the saddle between them.",
    img: "/img/gallery/g05.webp",
  },
  {
    id: "trotternish",
    index: "06",
    place: "Trotternish Ridge",
    region: "Isle of Skye",
    country: "Scotland",
    tag: "Moorland",
    season: "Sep – Nov · clear spells",
    coords: "57.61° N, 6.19° W",
    note: "A landslip coast of basalt pinnacles, the Old Man of Storr among them. Fog rolls off the Sound of Raasay, swallows the pinnacles whole, then slowly hands them back.",
    img: "/img/gallery/g06.webp",
  },
];

const GAP = 28;

function clamp(value: number, lo: number, hi: number): number {
  return Math.min(Math.max(value, lo), hi);
}

export default function CarCards() {
  const reduced = useReducedMotion();
  const uid = useId();
  const headingId = `${uid}-heading`;

  const slides = SLIDES;
  const last = slides.length - 1;

  const [active, setActive] = useState(2);
  const [viewportW, setViewportW] = useState(0);

  const viewportRef = useRef<HTMLDivElement | null>(null);
  const draggedRef = useRef(false);
  const x = useMotionValue(0);

  useEffect(() => {
    const el = viewportRef.current;
    if (!el) return;
    setViewportW(el.clientWidth);
    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) setViewportW(entry.contentRect.width);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const cardW =
    viewportW === 0 ? 0 : clamp(Math.round(viewportW * 0.66), 236, 432);
  const step = cardW + GAP;
  const trackX =
    viewportW === 0 ? 0 : viewportW / 2 - (active * step + cardW / 2);
  const maxX = viewportW === 0 ? 0 : viewportW / 2 - cardW / 2;
  const minX =
    viewportW === 0 ? 0 : viewportW / 2 - (last * step + cardW / 2);

  useEffect(() => {
    if (viewportW === 0) return;
    const controls = animate(
      x,
      trackX,
      reduced
        ? { duration: 0 }
        : { type: "spring" as const, stiffness: 260, damping: 34 },
    );
    return () => controls.stop();
  }, [trackX, viewportW, reduced, x]);

  const goTo = useCallback((i: number) => setActive(clamp(i, 0, last)), [last]);
  const go = useCallback(
    (dir: number) => setActive((prev) => clamp(prev + dir, 0, last)),
    [last],
  );

  const onKeyDown = useCallback(
    (e: KeyboardEvent<HTMLDivElement>) => {
      if (e.key === "ArrowLeft") {
        e.preventDefault();
        go(-1);
      } else if (e.key === "ArrowRight") {
        e.preventDefault();
        go(1);
      } else if (e.key === "Home") {
        e.preventDefault();
        goTo(0);
      } else if (e.key === "End") {
        e.preventDefault();
        goTo(last);
      }
    },
    [go, goTo, last],
  );

  const onDragEnd = useCallback(
    (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
      draggedRef.current = Math.abs(info.offset.x) > 6;
      if (viewportW === 0 || step === 0) return;
      const ideal = (viewportW / 2 - cardW / 2 - x.get()) / step;
      let next = Math.round(ideal);
      if (Math.abs(info.velocity.x) > 320) {
        next = info.velocity.x < 0 ? active + 1 : active - 1;
      }
      setActive(clamp(next, 0, last));
    },
    [viewportW, step, cardW, x, active, last],
  );

  const onCardClick = useCallback(
    (i: number) => {
      if (draggedRef.current) {
        draggedRef.current = false;
        return;
      }
      setActive(i);
    },
    [],
  );

  const cardTransition = reduced
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 220, damping: 30 };

  const progress = ((active + 1) / slides.length) * 100;

  return (
    <section
      aria-labelledby={headingId}
      className="relative w-full overflow-hidden bg-gradient-to-b from-neutral-50 via-amber-50/40 to-neutral-100 py-20 text-neutral-900 sm:py-28 dark:from-neutral-950 dark:via-neutral-950 dark:to-black dark:text-neutral-100"
    >
      <style>{`
        @keyframes carc-reveal {
          from { opacity: 0; transform: translateY(16px); }
          to { opacity: 1; transform: none; }
        }
        @keyframes carc-drift {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(0, -26px, 0) scale(1.06); }
        }
        .carc-reveal { animation: carc-reveal 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
        .carc-drift { animation: carc-drift 15s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .carc-reveal, .carc-drift { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
        <div className="carc-drift absolute -left-24 top-4 h-72 w-72 rounded-full bg-amber-300/30 blur-3xl dark:bg-amber-500/10" />
        <div
          className="carc-drift absolute -right-24 bottom-0 h-80 w-80 rounded-full bg-sky-300/25 blur-3xl dark:bg-indigo-500/10"
          style={{ animationDelay: "-6s" }}
        />
        <div className="absolute inset-0 opacity-[0.04] mix-blend-overlay [background-image:radial-gradient(circle_at_1px_1px,currentColor_1px,transparent_0)] [background-size:22px_22px] dark:opacity-[0.06]" />
      </div>

      <div className="mx-auto w-full max-w-6xl px-4 sm:px-6">
        {/* header */}
        <header className="mb-12 max-w-2xl">
          <div className="carc-reveal flex items-center gap-3 text-xs font-medium uppercase tracking-[0.28em] text-amber-700 dark:text-amber-400">
            <span className="h-px w-8 bg-amber-600/60 dark:bg-amber-400/50" />
            Field Guide · Vol. IV
          </div>
          <h2
            id={headingId}
            className="carc-reveal mt-5 font-serif text-4xl font-medium leading-[1.05] tracking-tight text-neutral-900 sm:text-5xl dark:text-neutral-50"
            style={{ animationDelay: "0.06s" }}
          >
            Where the map goes quiet
          </h2>
          <p
            className="carc-reveal mt-4 text-base leading-relaxed text-neutral-600 dark:text-neutral-400"
            style={{ animationDelay: "0.12s" }}
          >
            Six places at the far edge of the road, arranged for the long way
            round. Drag the deck, tap a neighbour, or use the arrow keys.
          </p>
        </header>

        {/* carousel */}
        <div
          role="group"
          aria-roledescription="carousel"
          aria-label="Remote destinations"
          tabIndex={0}
          onKeyDown={onKeyDown}
          className="carc-reveal relative rounded-[2rem] outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-4 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950"
          style={{ animationDelay: "0.18s" }}
        >
          <div
            ref={viewportRef}
            className="overflow-hidden py-6 [mask-image:linear-gradient(to_right,transparent,black_9%,black_91%,transparent)]"
          >
            <motion.div
              className="flex cursor-grab items-stretch select-none active:cursor-grabbing"
              style={{ x, gap: GAP }}
              drag="x"
              dragConstraints={{ left: minX, right: maxX }}
              dragElastic={0.12}
              onPointerDown={() => {
                draggedRef.current = false;
              }}
              onDragEnd={onDragEnd}
            >
              {slides.map((s, i) => {
                const rel = i - active;
                const dist = Math.abs(rel);
                const isActive = rel === 0;
                const scale = isActive ? 1 : Math.max(0.82, 1 - dist * 0.09);
                const opacity = isActive
                  ? 1
                  : Math.max(0.3, 1 - dist * 0.32);

                return (
                  <motion.article
                    key={s.id}
                    role="group"
                    aria-roledescription="slide"
                    aria-label={`${i + 1} of ${slides.length}`}
                    className="shrink-0"
                    style={{ width: cardW || undefined, zIndex: 100 - dist }}
                    animate={{ scale, opacity }}
                    transition={cardTransition}
                  >
                    <button
                      type="button"
                      onClick={() => onCardClick(i)}
                      aria-current={isActive ? "true" : undefined}
                      aria-label={
                        isActive
                          ? `Current destination: ${s.place}, ${s.country}`
                          : `Go to ${s.place}, ${s.country}`
                      }
                      tabIndex={isActive ? 0 : -1}
                      className={`group block w-full overflow-hidden rounded-3xl border text-left transition-shadow duration-300 outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950 ${
                        isActive
                          ? "border-amber-500/50 bg-white shadow-2xl shadow-amber-900/10 dark:border-amber-400/40 dark:bg-neutral-900 dark:shadow-black/60"
                          : "border-neutral-200/80 bg-white/70 shadow-lg shadow-neutral-900/5 dark:border-neutral-800 dark:bg-neutral-900/70"
                      }`}
                    >
                      <div className="relative aspect-[4/5] w-full overflow-hidden bg-neutral-200 dark:bg-neutral-800">
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img
                          src={s.img}
                          alt={`${s.place}, ${s.country}`}
                          loading="lazy"
                          draggable={false}
                          className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
                        />
                        <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />
                        <span className="absolute left-4 top-4 inline-flex items-center gap-1.5 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-800 backdrop-blur-sm dark:bg-neutral-950/80 dark:text-neutral-100">
                          <svg
                            viewBox="0 0 24 24"
                            className="h-3 w-3 text-amber-600 dark:text-amber-400"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={2}
                            aria-hidden
                          >
                            <path
                              d="M12 21s-6.5-5.5-6.5-10a6.5 6.5 0 1 1 13 0c0 4.5-6.5 10-6.5 10Z"
                              strokeLinejoin="round"
                            />
                            <circle cx="12" cy="11" r="2" />
                          </svg>
                          {s.tag}
                        </span>
                        <div className="absolute bottom-0 left-0 right-0 flex items-end justify-between p-5">
                          <div>
                            <p className="text-xs uppercase tracking-[0.2em] text-white/70">
                              {s.region}
                            </p>
                            <h3 className="mt-1 font-serif text-2xl font-medium leading-tight text-white">
                              {s.place}
                            </h3>
                          </div>
                          <span className="font-serif text-3xl font-medium leading-none text-amber-300/90">
                            {s.index}
                          </span>
                        </div>
                      </div>

                      <div className="p-5">
                        <p className="text-[15px] leading-relaxed text-neutral-700 dark:text-neutral-300">
                          {s.note}
                        </p>
                        <dl className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-neutral-200 pt-4 text-[13px] dark:border-neutral-800">
                          <div className="flex items-center gap-1.5">
                            <dt className="sr-only">Best season</dt>
                            <svg
                              viewBox="0 0 24 24"
                              className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400"
                              fill="none"
                              stroke="currentColor"
                              strokeWidth={2}
                              aria-hidden
                            >
                              <circle cx="12" cy="12" r="4" />
                              <path
                                d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"
                                strokeLinecap="round"
                              />
                            </svg>
                            <dd className="font-medium text-neutral-700 dark:text-neutral-300">
                              {s.season}
                            </dd>
                          </div>
                          <div className="flex items-center gap-1.5">
                            <dt className="sr-only">Coordinates</dt>
                            <dd className="font-mono text-neutral-500 dark:text-neutral-400">
                              {s.coords}
                            </dd>
                          </div>
                        </dl>
                      </div>
                    </button>
                  </motion.article>
                );
              })}
            </motion.div>
          </div>
        </div>

        {/* controls */}
        <div className="mt-10 flex flex-col items-center gap-6 sm:flex-row sm:justify-between">
          <div className="flex items-center gap-3">
            <button
              type="button"
              onClick={() => go(-1)}
              disabled={active === 0}
              aria-label="Previous destination"
              className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-neutral-300 bg-white text-neutral-800 transition hover:border-amber-500 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:border-neutral-300 disabled:hover:text-neutral-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:border-amber-400 dark:hover:text-amber-400 dark:focus-visible:ring-offset-neutral-950"
            >
              <svg
                viewBox="0 0 24 24"
                className="h-5 w-5"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                aria-hidden
              >
                <path
                  d="M15 5l-7 7 7 7"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>
            <button
              type="button"
              onClick={() => go(1)}
              disabled={active === last}
              aria-label="Next destination"
              className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-neutral-300 bg-white text-neutral-800 transition hover:border-amber-500 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:border-neutral-300 disabled:hover:text-neutral-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:border-amber-400 dark:hover:text-amber-400 dark:focus-visible:ring-offset-neutral-950"
            >
              <svg
                viewBox="0 0 24 24"
                className="h-5 w-5"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                aria-hidden
              >
                <path
                  d="M9 5l7 7-7 7"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>

            <div
              className="ml-2 flex items-center gap-2"
              role="tablist"
              aria-label="Choose destination"
            >
              {slides.map((s, i) => (
                <button
                  key={s.id}
                  type="button"
                  role="tab"
                  aria-selected={i === active}
                  aria-label={`${s.place}, ${s.country}`}
                  onClick={() => goTo(i)}
                  className={`h-2 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950 ${
                    i === active
                      ? "w-7 bg-amber-500 dark:bg-amber-400"
                      : "w-2 bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
                  }`}
                />
              ))}
            </div>
          </div>

          <div className="flex w-full items-center gap-4 sm:w-auto">
            <div className="h-px flex-1 overflow-hidden rounded-full bg-neutral-200 sm:w-40 sm:flex-none dark:bg-neutral-800">
              <div
                className="h-full rounded-full bg-amber-500 transition-[width] duration-500 ease-out dark:bg-amber-400"
                style={{ width: `${progress}%` }}
              />
            </div>
            <p className="shrink-0 font-mono text-sm tabular-nums text-neutral-500 dark:text-neutral-400">
              <span className="font-semibold text-neutral-900 dark:text-neutral-100">
                {String(active + 1).padStart(2, "0")}
              </span>
              {" / "}
              {String(slides.length).padStart(2, "0")}
            </p>
          </div>
        </div>

        <div aria-live="polite" className="sr-only">
          {`Showing slide ${active + 1} of ${slides.length}: ${slides[active].place}, ${slides[active].country}`}
        </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 →