Web InnoventixFreeCode

Coverflow Carousel

Original · free

3D coverflow carousel

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

import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Tone = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";

interface Series {
  id: string;
  img: string;
  title: string;
  location: string;
  year: string;
  frames: string;
  blurb: string;
  tone: Tone;
}

const SERIES: Series[] = [
  {
    id: "northern-silence",
    img: "g01.webp",
    title: "Northern Silence",
    location: "Lofoten, Norway",
    year: "2024",
    frames: "38 frames",
    blurb:
      "Twelve nights above the Arctic Circle chasing a blue hour that never fully closes. Shot on a hand-wound medium-format body at minus eighteen degrees.",
    tone: "sky",
  },
  {
    id: "concrete-bloom",
    img: "g02.webp",
    title: "Concrete Bloom",
    location: "Osaka, Japan",
    year: "2023",
    frames: "26 frames",
    blurb:
      "Brutalist housing blocks softened by the plants their residents refuse to give up. A quiet study in how people domesticate raw concrete.",
    tone: "emerald",
  },
  {
    id: "salt-and-static",
    img: "g03.webp",
    title: "Salt & Static",
    location: "Atacama, Chile",
    year: "2024",
    frames: "31 frames",
    blurb:
      "The driest desert on earth, photographed the one week an unseasonal fog rolled in. For about forty minutes the salt flats turned to mirrors.",
    tone: "amber",
  },
  {
    id: "midnight-market",
    img: "g04.webp",
    title: "Midnight Market",
    location: "Marrakech, Morocco",
    year: "2022",
    frames: "44 frames",
    blurb:
      "Jemaa el-Fnaa after the tour buses leave, lit only by gas lamps and phone screens. Available light, no flash, a single fast prime.",
    tone: "rose",
  },
  {
    id: "tidal-drift",
    img: "g05.webp",
    title: "Tidal Drift",
    location: "Cornwall, United Kingdom",
    year: "2023",
    frames: "22 frames",
    blurb:
      "A six-week residency tracking a single cove through the spring tides. Long exposures that flatten the sea into slow, moving weather.",
    tone: "indigo",
  },
  {
    id: "amber-transit",
    img: "g06.webp",
    title: "Amber Transit",
    location: "Reykjavik, Iceland",
    year: "2024",
    frames: "29 frames",
    blurb:
      "Sodium streetlights against fresh snow, the last winter before the city swapped them for cold white LEDs. An accidental farewell.",
    tone: "amber",
  },
  {
    id: "paper-districts",
    img: "g07.webp",
    title: "Paper Districts",
    location: "Lisbon, Portugal",
    year: "2023",
    frames: "35 frames",
    blurb:
      "Hand-painted shopfront signage across Alfama and Mouraria, some of it decades old. Documented the season before the next round of renovations.",
    tone: "violet",
  },
  {
    id: "low-country",
    img: "g08.webp",
    title: "Low Country",
    location: "Mekong Delta, Vietnam",
    year: "2022",
    frames: "41 frames",
    blurb:
      "Life at water level from a wooden boat at dawn — floating markets, stilt houses, and the slow commerce that moves along the river.",
    tone: "emerald",
  },
];

const TONES: Record<Tone, { text: string; dot: string; ring: string; chip: string }> = {
  indigo: {
    text: "text-indigo-600 dark:text-indigo-300",
    dot: "bg-indigo-500",
    ring: "ring-indigo-400/70",
    chip: "bg-indigo-500/10 text-indigo-700 ring-1 ring-inset ring-indigo-500/25 dark:text-indigo-300",
  },
  violet: {
    text: "text-violet-600 dark:text-violet-300",
    dot: "bg-violet-500",
    ring: "ring-violet-400/70",
    chip: "bg-violet-500/10 text-violet-700 ring-1 ring-inset ring-violet-500/25 dark:text-violet-300",
  },
  emerald: {
    text: "text-emerald-600 dark:text-emerald-300",
    dot: "bg-emerald-500",
    ring: "ring-emerald-400/70",
    chip: "bg-emerald-500/10 text-emerald-700 ring-1 ring-inset ring-emerald-500/25 dark:text-emerald-300",
  },
  rose: {
    text: "text-rose-600 dark:text-rose-300",
    dot: "bg-rose-500",
    ring: "ring-rose-400/70",
    chip: "bg-rose-500/10 text-rose-700 ring-1 ring-inset ring-rose-500/25 dark:text-rose-300",
  },
  amber: {
    text: "text-amber-600 dark:text-amber-300",
    dot: "bg-amber-500",
    ring: "ring-amber-400/70",
    chip: "bg-amber-500/10 text-amber-700 ring-1 ring-inset ring-amber-500/25 dark:text-amber-300",
  },
  sky: {
    text: "text-sky-600 dark:text-sky-300",
    dot: "bg-sky-500",
    ring: "ring-sky-400/70",
    chip: "bg-sky-500/10 text-sky-700 ring-1 ring-inset ring-sky-500/25 dark:text-sky-300",
  },
};

function place(offset: number, cardW: number) {
  const abs = Math.abs(offset);
  const dir = Math.sign(offset);
  const x = abs === 0 ? 0 : dir * (cardW * 0.6 + (abs - 1) * cardW * 0.34);
  const rotateY = abs === 0 ? 0 : -dir * 43;
  const z = abs === 0 ? 40 : -abs * 80;
  const scale = abs === 0 ? 1 : Math.max(0.62, 0.84 - (abs - 1) * 0.07);
  const opacity = abs > 3 ? 0 : abs === 0 ? 1 : Math.max(0.22, 1 - abs * 0.22);
  return { x, rotateY, z, scale, opacity, visible: abs <= 3, zi: 100 - abs };
}

export default function CarCoverflow() {
  const reduce = useReducedMotion();
  const n = SERIES.length;
  const [active, setActive] = useState(Math.floor(n / 2));

  const stageRef = useRef<HTMLDivElement | null>(null);
  const [stageW, setStageW] = useState(0);

  const startX = useRef<number | null>(null);
  const moved = useRef(false);

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

  const go = (dir: number) => setActive((a) => Math.min(n - 1, Math.max(0, a + dir)));

  const cardW = Math.round(Math.min(268, Math.max(172, stageW * 0.4)));
  const cardH = Math.round(cardW * 1.3);
  const reflectionH = Math.round(cardH * 0.34);
  const stageHeight = cardH + reflectionH + 56;

  const activeSeries = SERIES[active];
  const tone = TONES[activeSeries.tone];
  const count = `${String(active + 1).padStart(2, "0")} / ${String(n).padStart(2, "0")}`;
  const springLike = "linear-gradient(to right, transparent, rgba(255,255,255,0.4), transparent)";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white py-20 dark:from-slate-950 dark:to-slate-900 sm:py-28">
      <style>{`
        @keyframes cvf-drift {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(0, -26px, 0) scale(1.08); }
        }
        @keyframes cvf-sheen {
          0% { transform: translateX(-160%) skewX(-16deg); opacity: 0; }
          45% { opacity: 0.55; }
          60% { opacity: 0.55; }
          100% { transform: translateX(260%) skewX(-16deg); opacity: 0; }
        }
        @keyframes cvf-pulse {
          0%, 100% { opacity: 0.5; }
          50% { opacity: 1; }
        }
        .cvf-orb { animation: cvf-drift 15s ease-in-out infinite; }
        .cvf-orb-2 { animation: cvf-drift 19s ease-in-out infinite reverse; }
        .cvf-sheen { animation: cvf-sheen 4.6s ease-in-out infinite; }
        .cvf-live { animation: cvf-pulse 2.2s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cvf-orb, .cvf-orb-2, .cvf-sheen, .cvf-live { animation: none !important; }
        }
      `}</style>

      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="cvf-orb absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15" />
        <div className="cvf-orb-2 absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-sky-400/20 blur-3xl dark:bg-violet-500/15" />
      </div>

      <div className="relative mx-auto max-w-6xl px-4 sm:px-6">
        <div className="mx-auto mb-10 max-w-2xl text-center sm:mb-12">
          <span className="inline-flex items-center gap-2 rounded-full bg-slate-900/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 ring-1 ring-inset ring-slate-900/10 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" className="h-3.5 w-3.5">
              <rect x="7" y="4" width="10" height="16" rx="1.5" />
              <path d="M4 7v10" />
              <path d="M20 7v10" />
            </svg>
            Field Archive
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            A field archive, arranged in coverflow
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-300">
            Eight editorial series from four years on assignment. Drag, swipe, or use the arrow keys to move through the covers.
          </p>
        </div>

        <div
          role="region"
          aria-roledescription="carousel"
          aria-label="Editorial photo archive"
          onKeyDown={(e) => {
            if (e.key === "ArrowRight" || e.key === "ArrowDown") {
              e.preventDefault();
              go(1);
            } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
              e.preventDefault();
              go(-1);
            } else if (e.key === "Home") {
              e.preventDefault();
              setActive(0);
            } else if (e.key === "End") {
              e.preventDefault();
              setActive(n - 1);
            }
          }}
        >
          <div aria-live="polite" aria-atomic="true" className="sr-only">
            {`Showing ${activeSeries.title}, ${activeSeries.location}, ${activeSeries.year}. Cover ${active + 1} of ${n}.`}
          </div>

          <div
            ref={stageRef}
            tabIndex={0}
            role="group"
            aria-roledescription="slides"
            aria-label="Series covers. Use the left and right arrow keys to browse."
            className="relative mx-auto w-full max-w-4xl select-none rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950"
            style={{ height: stageHeight, perspective: 1300, perspectiveOrigin: "50% 42%", touchAction: "pan-y" }}
            onPointerDown={(e) => {
              startX.current = e.clientX;
              moved.current = false;
            }}
            onPointerMove={(e) => {
              if (startX.current !== null && Math.abs(e.clientX - startX.current) > 8) moved.current = true;
            }}
            onPointerUp={(e) => {
              if (startX.current !== null) {
                const dx = e.clientX - startX.current;
                if (moved.current && Math.abs(dx) > 44) go(dx < 0 ? 1 : -1);
              }
              startX.current = null;
            }}
            onPointerLeave={() => {
              startX.current = null;
            }}
          >
            {SERIES.map((s, i) => {
              const p = place(i - active, cardW);
              const isActive = i === active;
              const st = TONES[s.tone];
              return (
                <div
                  key={s.id}
                  className="pointer-events-none absolute inset-0 flex items-center justify-center"
                  style={{ transformStyle: "preserve-3d" }}
                >
                  <motion.button
                    type="button"
                    onClick={() => {
                      if (moved.current) return;
                      setActive(i);
                    }}
                    aria-label={`${s.title}, ${s.location}, ${s.year}. Cover ${i + 1} of ${n}`}
                    aria-current={isActive ? "true" : undefined}
                    aria-hidden={p.visible ? undefined : true}
                    tabIndex={p.visible ? 0 : -1}
                    className="relative flex flex-col items-center rounded-2xl 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"
                    style={{
                      width: cardW,
                      zIndex: p.zi,
                      transformStyle: "preserve-3d",
                      pointerEvents: p.visible ? "auto" : "none",
                    }}
                    animate={{ x: p.x, z: p.z, rotateY: p.rotateY, scale: p.scale, opacity: p.opacity }}
                    transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 210, damping: 26, mass: 0.9 }}
                  >
                    <div
                      className="relative overflow-hidden rounded-2xl bg-slate-200 shadow-2xl shadow-slate-950/40 ring-1 ring-slate-900/10 dark:bg-slate-800 dark:ring-white/10"
                      style={{ width: cardW, height: cardH }}
                    >
                      {/* eslint-disable-next-line @next/next/no-img-element */}
                      <img
                        src={`/img/gallery/${s.img}`}
                        alt={`${s.title} — ${s.location}, ${s.year}`}
                        loading="lazy"
                        draggable={false}
                        className="h-full w-full object-cover"
                        style={{ width: cardW, height: cardH }}
                      />
                      <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/85 via-slate-950/10 to-transparent" />
                      <div className="pointer-events-none absolute inset-x-0 bottom-0 p-3 text-left">
                        <p className="truncate text-sm font-semibold text-white">{s.title}</p>
                        <p className="truncate text-[11px] text-slate-300">{s.location}</p>
                      </div>
                      {isActive && (
                        <div className={`pointer-events-none absolute inset-0 rounded-2xl ring-2 ${st.ring}`} />
                      )}
                      {isActive && !reduce && (
                        <div className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
                          <div
                            className="cvf-sheen absolute inset-y-0 -left-1/3 w-1/3"
                            style={{ backgroundImage: springLike }}
                          />
                        </div>
                      )}
                    </div>

                    <div
                      aria-hidden="true"
                      className="mt-2 overflow-hidden"
                      style={{
                        width: cardW,
                        height: reflectionH,
                        WebkitMaskImage: "linear-gradient(to bottom, rgba(0,0,0,0.45), transparent 88%)",
                        maskImage: "linear-gradient(to bottom, rgba(0,0,0,0.45), transparent 88%)",
                      }}
                    >
                      {/* eslint-disable-next-line @next/next/no-img-element */}
                      <img
                        src={`/img/gallery/${s.img}`}
                        alt=""
                        loading="lazy"
                        draggable={false}
                        className="rounded-2xl object-cover"
                        style={{ width: cardW, height: cardH, transform: "scaleY(-1)" }}
                      />
                    </div>
                  </motion.button>
                </div>
              );
            })}
          </div>

          <div className="mx-auto mt-8 min-h-[136px] max-w-xl text-center">
            <motion.div
              key={activeSeries.id}
              initial={reduce ? false : { opacity: 0, y: 12 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.45, ease: "easeOut" }}
            >
              <span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${tone.chip}`}>
                <span className={`h-1.5 w-1.5 rounded-full ${tone.dot} cvf-live`} />
                {activeSeries.frames} · {activeSeries.year}
              </span>
              <h3 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
                {activeSeries.title}
              </h3>
              <p className={`mt-1 text-sm font-medium ${tone.text}`}>{activeSeries.location}</p>
              <p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">{activeSeries.blurb}</p>
            </motion.div>
          </div>

          <div className="mt-8 flex items-center justify-center gap-5">
            <button
              type="button"
              onClick={() => go(-1)}
              disabled={active === 0}
              aria-label="Previous cover"
              className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-slate-700 shadow-sm ring-1 ring-inset ring-slate-900/10 transition hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-slate-800 dark:text-slate-200 dark:ring-white/10 dark:hover:bg-slate-700"
            >
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
                <path d="m15 18-6-6 6-6" />
              </svg>
            </button>

            <div role="group" aria-label="Select a cover" className="flex items-center gap-2">
              {SERIES.map((s, i) => {
                const isActive = i === active;
                return (
                  <button
                    key={s.id}
                    type="button"
                    onClick={() => setActive(i)}
                    aria-label={`Show ${s.title}`}
                    aria-current={isActive ? "true" : undefined}
                    className={`h-2 rounded-full transition-all duration-300 focus: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 ${
                      isActive
                        ? `w-7 ${TONES[s.tone].dot}`
                        : "w-2 bg-slate-300 hover:bg-slate-400 dark:bg-slate-600 dark:hover:bg-slate-500"
                    }`}
                  />
                );
              })}
            </div>

            <button
              type="button"
              onClick={() => go(1)}
              disabled={active === n - 1}
              aria-label="Next cover"
              className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-slate-700 shadow-sm ring-1 ring-inset ring-slate-900/10 transition hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-slate-800 dark:text-slate-200 dark:ring-white/10 dark:hover:bg-slate-700"
            >
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
                <path d="m9 18 6-6-6-6" />
              </svg>
            </button>
          </div>

          <p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
            <kbd className="rounded border border-slate-300 px-1.5 py-0.5 font-sans text-[10px] dark:border-slate-600">←</kbd>
            <span className="mx-1">/</span>
            <kbd className="rounded border border-slate-300 px-1.5 py-0.5 font-sans text-[10px] dark:border-slate-600">→</kbd>
            <span className="mx-2">to browse</span>
            <span className="tabular-nums">· {count}</span>
          </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 →