Web InnoventixFreeCode

Fade Carousel

Original · free

crossfade carousel

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

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

type Slide = {
  id: string;
  img: string;
  kicker: string;
  title: string;
  location: string;
  description: string;
};

const SLIDES: Slide[] = [
  {
    id: "amalfi",
    img: "/img/gallery/g03.webp",
    kicker: "Coastal · Nº 01",
    title: "Amalfi Before the Crowds",
    location: "Praiano, Italy",
    description:
      "Lemon terraces spill toward a harbor that hasn't woken yet — the softest hour on the whole coast, gone by nine.",
  },
  {
    id: "patagonia",
    img: "/img/gallery/g09.webp",
    kicker: "Highlands · Nº 02",
    title: "The Long Light of Torres",
    location: "Patagonia, Chile",
    description:
      "Granite spires catch the first sun while the valley floor holds onto night for one more stubborn minute.",
  },
  {
    id: "vestrahorn",
    img: "/img/gallery/g14.webp",
    kicker: "Nightfall · Nº 03",
    title: "Aurora over Vestrahorn",
    location: "Stokksnes, Iceland",
    description:
      "Black volcanic sand mirrors a sky that refuses to sit still. Tripods stay planted until the last green ribbon fades.",
  },
  {
    id: "arashiyama",
    img: "/img/gallery/g21.webp",
    kicker: "Groves · Nº 04",
    title: "A Green Corridor in Arashiyama",
    location: "Kyoto, Japan",
    description:
      "Fifty feet of bamboo overhead turns a plain midday into a cathedral of filtered, moving green.",
  },
  {
    id: "erg-chebbi",
    img: "/img/gallery/g27.webp",
    kicker: "Desert · Nº 05",
    title: "Dunes at Erg Chebbi",
    location: "Merzouga, Morocco",
    description:
      "The wind redraws every ridge line by afternoon. By the time camels head home, the sand runs pure copper.",
  },
  {
    id: "oia",
    img: "/img/gallery/g33.webp",
    kicker: "Islands · Nº 06",
    title: "Blue Hour in Oia",
    location: "Santorini, Greece",
    description:
      "The whitewashed lanes empty out and the caldera turns the exact color of the sea a thousand feet below.",
  },
];

const AUTOPLAY_MS = 5200;
const EASE = [0.4, 0, 0.2, 1] as const;

const CSS = `
@keyframes cxf-ken { from { transform: scale(1.04); } to { transform: scale(1.16); } }
@keyframes cxf-bar { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes cxf-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
.cxf-ken { animation: cxf-ken 9s ease-out forwards; }
.cxf-bar { animation-name: cxf-bar; animation-timing-function: linear; animation-fill-mode: forwards; will-change: transform; }
.cxf-pulse { animation: cxf-pulse 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .cxf-ken, .cxf-bar, .cxf-pulse { animation: none !important; }
  .cxf-bar { transform: scaleX(1); }
}
`;

const pad = (n: number) => String(n).padStart(2, "0");

const FOCUS =
  "focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400";

export default function CrossfadeCarousel() {
  const prefersReduced = useReducedMotion();
  const animate = !prefersReduced;

  const [index, setIndex] = useState(0);
  const [isPlaying, setIsPlaying] = useState(true);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);

  const len = SLIDES.length;
  const active = SLIDES[index];
  const paused = hovered || focused;
  const running = isPlaying && !paused && animate;

  const go = useCallback(
    (dir: number) => setIndex((i) => (i + dir + len) % len),
    [len],
  );

  useEffect(() => {
    if (!running) return;
    const id = window.setTimeout(
      () => setIndex((i) => (i + 1) % len),
      AUTOPLAY_MS,
    );
    return () => window.clearTimeout(id);
  }, [running, index, len]);

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "ArrowRight") {
      e.preventDefault();
      go(1);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      go(-1);
    } else if (e.key === "Home") {
      e.preventDefault();
      setIndex(0);
    } else if (e.key === "End") {
      e.preventDefault();
      setIndex(len - 1);
    }
  };

  const onBlurCapture = (e: FocusEvent<HTMLDivElement>) => {
    if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
      setFocused(false);
    }
  };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 py-16 dark:from-zinc-950 dark:to-zinc-900 sm:py-24">
      <style>{CSS}</style>

      <div className="mx-auto w-full max-w-5xl px-4 sm:px-6">
        <header className="mb-8 max-w-2xl sm:mb-10">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
            Field Notes · 2025
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-zinc-50 sm:text-4xl">
            Six mornings worth the early alarm
          </h2>
          <p className="mt-3 text-base text-slate-600 dark:text-zinc-400">
            A crossfade reel from a year spent chasing first light. Hover to
            pause, then wander with the arrows, arrow keys, or the thumbnails
            below.
          </p>
        </header>

        <div
          role="group"
          aria-roledescription="carousel"
          aria-label="Travel photography highlights"
          onKeyDown={onKeyDown}
          onMouseEnter={() => setHovered(true)}
          onMouseLeave={() => setHovered(false)}
          onFocusCapture={() => setFocused(true)}
          onBlurCapture={onBlurCapture}
        >
          <div className="relative aspect-[16/10] w-full overflow-hidden rounded-3xl bg-slate-200 shadow-2xl shadow-slate-900/20 ring-1 ring-black/10 dark:bg-zinc-800 dark:ring-white/10">
            {/* crossfading image layer */}
            <AnimatePresence>
              <motion.div
                key={active.id}
                className="absolute inset-0"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: animate ? 0.9 : 0, ease: EASE }}
              >
                <div className="absolute inset-0 overflow-hidden">
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={active.img}
                    alt={`${active.title} — ${active.location}`}
                    loading="lazy"
                    draggable={false}
                    className={`h-full w-full object-cover ${animate ? "cxf-ken" : ""}`}
                  />
                </div>
                <div
                  className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/10"
                  aria-hidden="true"
                />
              </motion.div>
            </AnimatePresence>

            {/* top bar: status + play/pause + counter */}
            <div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between p-4 sm:p-5">
              <span className="inline-flex items-center gap-2 rounded-full bg-black/30 px-3 py-1 text-xs font-medium text-white ring-1 ring-white/15 backdrop-blur">
                <span
                  className={`h-1.5 w-1.5 rounded-full bg-emerald-400 ${running ? "cxf-pulse" : ""}`}
                  aria-hidden="true"
                />
                {running ? "Playing" : "Paused"}
              </span>

              <div className="flex items-center gap-3">
                <button
                  type="button"
                  onClick={() => setIsPlaying((p) => !p)}
                  aria-pressed={isPlaying}
                  aria-label={isPlaying ? "Pause slideshow" : "Play slideshow"}
                  className={`grid h-9 w-9 place-items-center rounded-full bg-black/30 text-white ring-1 ring-white/20 backdrop-blur transition hover:bg-black/50 ${FOCUS}`}
                >
                  {isPlaying ? (
                    <svg
                      viewBox="0 0 24 24"
                      fill="currentColor"
                      className="h-4 w-4"
                      aria-hidden="true"
                    >
                      <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"
                      fill="currentColor"
                      className="h-4 w-4"
                      aria-hidden="true"
                    >
                      <path d="M8 5v14l11-7z" />
                    </svg>
                  )}
                </button>
                <span className="select-none rounded-full bg-black/30 px-3 py-1 text-xs font-semibold tabular-nums text-white ring-1 ring-white/15 backdrop-blur">
                  {pad(index + 1)} <span className="text-white/50">/</span>{" "}
                  {pad(len)}
                </span>
              </div>
            </div>

            {/* prev / next arrows */}
            <button
              type="button"
              onClick={() => go(-1)}
              aria-label="Previous photo"
              className={`absolute left-3 top-1/2 z-10 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/30 text-white ring-1 ring-white/20 backdrop-blur transition hover:bg-black/50 sm:left-4 sm:h-11 sm:w-11 ${FOCUS}`}
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-5 w-5"
                aria-hidden="true"
              >
                <path d="m15 18-6-6 6-6" />
              </svg>
            </button>
            <button
              type="button"
              onClick={() => go(1)}
              aria-label="Next photo"
              className={`absolute right-3 top-1/2 z-10 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/30 text-white ring-1 ring-white/20 backdrop-blur transition hover:bg-black/50 sm:right-4 sm:h-11 sm:w-11 ${FOCUS}`}
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-5 w-5"
                aria-hidden="true"
              >
                <path d="m9 18 6-6-6-6" />
              </svg>
            </button>

            {/* caption */}
            <div className="absolute inset-x-0 bottom-0 z-10 p-5 sm:p-8">
              <AnimatePresence mode="wait">
                <motion.div
                  key={active.id}
                  initial={{ opacity: 0, y: animate ? 16 : 0 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0 }}
                  transition={{
                    duration: animate ? 0.5 : 0,
                    ease: EASE,
                    delay: animate ? 0.12 : 0,
                  }}
                >
                  <p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/70">
                    {active.kicker}
                  </p>
                  <h3 className="mt-2 text-2xl font-semibold text-white drop-shadow sm:text-3xl">
                    {active.title}
                  </h3>
                  <p className="mt-1.5 flex items-center gap-1.5 text-sm text-white/85">
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={2}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-4 w-4 shrink-0 text-rose-300"
                      aria-hidden="true"
                    >
                      <path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" />
                      <circle cx="12" cy="10" r="2.5" />
                    </svg>
                    {active.location}
                  </p>
                  <p className="mt-3 max-w-xl text-sm leading-relaxed text-white/80 sm:text-base">
                    {active.description}
                  </p>
                </motion.div>
              </AnimatePresence>
            </div>

            {/* autoplay progress */}
            <div className="absolute inset-x-0 bottom-0 z-20 h-1 bg-white/20">
              <div
                key={`${index}-${isPlaying}`}
                className={`h-full origin-left bg-white ${animate ? "cxf-bar" : "scale-x-100"}`}
                style={
                  animate
                    ? {
                        animationDuration: `${AUTOPLAY_MS}ms`,
                        animationPlayState: running ? "running" : "paused",
                      }
                    : undefined
                }
              />
            </div>
          </div>

          {/* thumbnail navigation */}
          <div
            role="group"
            aria-label="Choose a photo"
            className="mt-4 flex gap-2 overflow-x-auto pb-1 sm:gap-3"
          >
            {SLIDES.map((s, i) => {
              const current = i === index;
              return (
                <button
                  key={s.id}
                  type="button"
                  onClick={() => setIndex(i)}
                  aria-label={`Show ${s.title}, ${s.location}`}
                  aria-current={current}
                  className={`relative h-14 w-20 shrink-0 overflow-hidden rounded-xl transition sm:h-16 sm:w-24 ${FOCUS} ${
                    current
                      ? "opacity-100 ring-2 ring-indigo-500 dark:ring-indigo-400"
                      : "opacity-55 ring-1 ring-black/10 hover:opacity-100 dark:ring-white/10"
                  }`}
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={s.img}
                    alt=""
                    loading="lazy"
                    draggable={false}
                    className="h-full w-full object-cover"
                  />
                </button>
              );
            })}
          </div>
        </div>

        {/* screen-reader live announcement */}
        <div aria-live="polite" className="sr-only">
          {`Slide ${index + 1} of ${len}: ${active.title}, ${active.location}`}
        </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 →