Web InnoventixFreeCode

Center Focus Carousel

Original · free

centre-focused scaling carousel

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

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

type Slide = {
  id: string;
  img: string;
  title: string;
  place: string;
  year: string;
  caption: string;
};

const SLIDES: Slide[] = [
  {
    id: "reykjavik",
    img: "/img/gallery/g01.webp",
    title: "Blue Hour Over the Harbour",
    place: "Reykjavík, Iceland",
    year: "2022",
    caption:
      "Nineteen minutes after sunset the water turns cobalt and the streetlights haven't caught up yet. You get one window a night to shoot it.",
  },
  {
    id: "tegallalang",
    img: "/img/gallery/g02.webp",
    title: "Terraces Before the Rain",
    place: "Tegallalang, Bali",
    year: "2023",
    caption:
      "The farmers flood the upper paddies at first light so the whole hillside mirrors the sky. By nine the reflection is gone.",
  },
  {
    id: "marrakech",
    img: "/img/gallery/g03.webp",
    title: "Rooftops of the Medina",
    place: "Marrakech, Morocco",
    year: "2019",
    caption:
      "You lose the call to prayer down in the alleys, then find it again three storeys up, coming from every direction at once.",
  },
  {
    id: "geiranger",
    img: "/img/gallery/g04.webp",
    title: "Still Water, Geiranger",
    place: "Geirangerfjord, Norway",
    year: "2021",
    caption:
      "No wind for six hours. The fjord held the cliffs like a second sky and the ferry wake took a full minute to reach the shore.",
  },
  {
    id: "wadirum",
    img: "/img/gallery/g05.webp",
    title: "First Light on the Dunes",
    place: "Wadi Rum, Jordan",
    year: "2023",
    caption:
      "The sand stays grey until the exact second the sun clears the eastern ridge. Then, for about ninety seconds, everything is copper.",
  },
  {
    id: "kyoto",
    img: "/img/gallery/g06.webp",
    title: "Canal in Full Bloom",
    place: "Kyoto, Japan",
    year: "2024",
    caption:
      "The petals arrive before you do and leave the day after. Timing is the whole art — a week early is bare, a week late is snow.",
  },
  {
    id: "lofoten",
    img: "/img/gallery/g07.webp",
    title: "Green Sky Over the Water",
    place: "Lofoten, Norway",
    year: "2020",
    caption:
      "We waited three clouded-out nights in the car. On the fourth the aurora came down almost to the waterline and stayed for an hour.",
  },
];

function IconChevronLeft() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-5 w-5">
      <path
        d="m15 18-6-6 6-6"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconChevronRight() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-5 w-5">
      <path
        d="m9 6 6 6-6 6"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconPlay() {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-4 w-4">
      <path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.75-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
    </svg>
  );
}

function IconPause() {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-4 w-4">
      <path d="M7 4.5a1.25 1.25 0 0 0-1.25 1.25v12.5a1.25 1.25 0 0 0 2.5 0V5.75A1.25 1.25 0 0 0 7 4.5Zm10 0a1.25 1.25 0 0 0-1.25 1.25v12.5a1.25 1.25 0 0 0 2.5 0V5.75A1.25 1.25 0 0 0 17 4.5Z" />
    </svg>
  );
}

function IconAperture() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
      <path
        d="M12 3v9m6.36-4.5L12 12m6.36 7.5L12 12m-6.36 7.5L12 12M3 12h9m-6.36-4.5L12 12"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

export default function CarCenterFocus() {
  const reduce = useReducedMotion();
  const uid = useId();
  const n = SLIDES.length;

  const [active, setActive] = useState(3);
  const [playing, setPlaying] = useState(false);
  const [hovering, setHovering] = useState(false);
  const [focusWithin, setFocusWithin] = useState(false);

  const stageRef = useRef<HTMLDivElement>(null);
  const [stageWidth, setStageWidth] = useState(0);

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

  const rotating = playing && !reduce && !hovering && !focusWithin;

  useEffect(() => {
    if (!rotating) return;
    const t = window.setInterval(() => {
      setActive((a) => (a + 1) % n);
    }, 4200);
    return () => window.clearInterval(t);
  }, [rotating, n]);

  function go(dir: number) {
    setActive((a) => (a + dir + n) % n);
  }

  const cardW = stageWidth
    ? Math.min(Math.max(stageWidth * 0.52, 236), 460)
    : 360;
  const cardH = Math.round(cardW * 1.28);
  const spacing = cardW * 0.72;
  const current = SLIDES[active];

  const btnBase =
    "inline-flex items-center justify-center rounded-full border transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-950";
  const arrowBtn =
    btnBase +
    " h-12 w-12 border-zinc-300 bg-white/80 text-zinc-700 backdrop-blur hover:bg-zinc-100 hover:text-zinc-950 active:scale-95 dark:border-zinc-700 dark:bg-zinc-900/80 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:hover:text-white";

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-4 py-20 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 sm:px-6 sm:py-28 lg:px-8">
      <style>{`
        @keyframes ccf-shimmer {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        @keyframes ccf-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.3; transform: scale(0.65); }
        }
        @keyframes ccf-float {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(0, -10px, 0); }
        }
        .ccf-anim-shimmer { animation: ccf-shimmer 3s linear infinite; }
        .ccf-anim-pulse { animation: ccf-pulse 1.4s ease-in-out infinite; }
        .ccf-anim-float { animation: ccf-float 9s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ccf-anim-shimmer, .ccf-anim-pulse, .ccf-anim-float { animation: none !important; }
        }
      `}</style>

      {/* decorative atmosphere */}
      <div
        aria-hidden="true"
        className="ccf-anim-float pointer-events-none absolute -left-24 top-10 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-sky-400/20 blur-3xl dark:bg-violet-500/15"
      />

      <div className="relative mx-auto max-w-6xl">
        {/* header */}
        <header className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-zinc-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-zinc-600 backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-300">
            <IconAperture />
            Field Journal · 2019–2024
          </span>
          <h2 className="mt-5 text-4xl font-semibold tracking-tight sm:text-5xl">
            Latitudes
          </h2>
          <p className="mt-4 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
            Seven frames from four years of chasing light. Tap a photo to bring
            it forward, drag the arrows, or let the reel play.
          </p>
        </header>

        {/* interactive region: hover / focus pauses autoplay, arrows navigate */}
        <div
          role="group"
          aria-roledescription="carousel"
          aria-label="Latitudes photography carousel"
          className="mt-12"
          onMouseEnter={() => setHovering(true)}
          onMouseLeave={() => setHovering(false)}
          onFocus={() => setFocusWithin(true)}
          onBlur={(e) => {
            if (!e.currentTarget.contains(e.relatedTarget)) setFocusWithin(false);
          }}
          onKeyDown={(e) => {
            if (e.key === "ArrowLeft") {
              e.preventDefault();
              go(-1);
            } else if (e.key === "ArrowRight") {
              e.preventDefault();
              go(1);
            } else if (e.key === "Home") {
              e.preventDefault();
              setActive(0);
            } else if (e.key === "End") {
              e.preventDefault();
              setActive(n - 1);
            }
          }}
        >
          {/* stage */}
          <div
            ref={stageRef}
            className="relative w-full overflow-hidden"
            style={{ height: cardH + 56, perspective: 1300 }}
          >
            {SLIDES.map((slide, i) => {
              let off = i - active;
              if (off > n / 2) off -= n;
              else if (off < -n / 2) off += n;

              const abs = Math.abs(off);
              const visible = abs <= 2;
              const isActive = off === 0;
              const clamp = Math.max(-2, Math.min(2, off));

              const x = off * spacing;
              const scale = 1 - Math.min(abs, 3) * 0.12;
              const opacity = abs > 2 ? 0 : isActive ? 1 : abs === 1 ? 0.72 : 0.4;
              const rotateY = clamp * -9;
              const zIndex = 30 - abs;

              return (
                <motion.div
                  key={slide.id}
                  className="absolute left-1/2 top-1/2"
                  style={{
                    width: cardW,
                    height: cardH,
                    marginLeft: -cardW / 2,
                    marginTop: -cardH / 2,
                    zIndex,
                    transformStyle: "preserve-3d",
                    pointerEvents: visible ? "auto" : "none",
                  }}
                  aria-hidden={visible ? undefined : true}
                  initial={false}
                  animate={{ x, scale, rotateY, opacity }}
                  transition={
                    reduce
                      ? { duration: 0 }
                      : { type: "spring", stiffness: 210, damping: 26, mass: 0.9 }
                  }
                >
                  <button
                    type="button"
                    tabIndex={visible ? 0 : -1}
                    onClick={() => setActive(i)}
                    aria-current={isActive || undefined}
                    aria-label={`Show frame ${String(i + 1).padStart(2, "0")}: ${slide.title}, ${slide.place}`}
                    className={
                      "group relative block h-full w-full select-none overflow-hidden rounded-2xl border text-left transition-shadow duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-950 " +
                      (isActive
                        ? "border-white/60 shadow-2xl shadow-indigo-900/25 ring-1 ring-black/5 dark:border-white/20 dark:shadow-black/60"
                        : "border-zinc-200/70 shadow-xl shadow-zinc-900/10 dark:border-zinc-800 dark:shadow-black/40")
                    }
                  >
                    {/* image with graceful gradient fallback */}
                    <span
                      aria-hidden="true"
                      className="absolute inset-0 bg-gradient-to-br from-zinc-200 to-zinc-400 dark:from-zinc-800 dark:to-zinc-950"
                    />
                    {/* eslint-disable-next-line @next/next/no-img-element */}
                    <img
                      src={slide.img}
                      alt={`${slide.title} — ${slide.place}`}
                      loading="lazy"
                      decoding="async"
                      draggable={false}
                      className="absolute inset-0 h-full w-full object-cover"
                    />

                    {/* dimming veil on non-active frames */}
                    <span
                      aria-hidden="true"
                      className={
                        "absolute inset-0 transition-colors duration-500 " +
                        (isActive
                          ? "bg-transparent"
                          : "bg-zinc-950/25 group-hover:bg-zinc-950/10")
                      }
                    />

                    {/* caption gradient */}
                    <span
                      aria-hidden="true"
                      className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-black/75 via-black/25 to-transparent"
                    />

                    {/* frame index */}
                    <span className="absolute left-4 top-4 rounded-full bg-black/40 px-2.5 py-1 font-mono text-[11px] font-medium tracking-widest text-white/90 backdrop-blur">
                      {String(i + 1).padStart(2, "0")} / {String(n).padStart(2, "0")}
                    </span>

                    {/* on-card title */}
                    <span className="absolute inset-x-4 bottom-4 block">
                      <span className="block text-[11px] font-medium uppercase tracking-[0.16em] text-white/70">
                        {slide.place}
                      </span>
                      <span className="mt-1 block text-lg font-semibold leading-snug text-white drop-shadow">
                        {slide.title}
                      </span>
                    </span>

                    {/* active accent bar */}
                    {isActive && (
                      <span
                        aria-hidden="true"
                        className="ccf-anim-shimmer pointer-events-none absolute inset-x-0 top-0 h-1"
                        style={{
                          background:
                            "linear-gradient(90deg,#6366f1,#8b5cf6,#38bdf8,#6366f1)",
                          backgroundSize: "200% 100%",
                        }}
                      />
                    )}
                  </button>
                </motion.div>
              );
            })}
          </div>

          {/* live caption */}
          <div className="mx-auto mt-8 min-h-[132px] max-w-xl text-center sm:min-h-[116px]">
            <p className="text-sm font-medium uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
              {current.place} · {current.year}
            </p>
            <h3 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
              {current.title}
            </h3>
            <p
              aria-live={rotating ? "off" : "polite"}
              aria-atomic="true"
              className="mt-2 text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-400"
            >
              <span className="sr-only">{`Frame ${active + 1} of ${n}. `}</span>
              {"“"}
              {current.caption}
              {"”"}
            </p>
          </div>

          {/* controls */}
          <div className="mt-6 flex items-center justify-center gap-4">
            <button
              type="button"
              onClick={() => go(-1)}
              className={arrowBtn}
              aria-label="Previous frame"
            >
              <IconChevronLeft />
            </button>

            <div
              role="group"
              aria-label="Choose a frame"
              className="flex items-center gap-2"
            >
              {SLIDES.map((slide, i) => {
                const on = i === active;
                return (
                  <button
                    key={slide.id}
                    type="button"
                    onClick={() => setActive(i)}
                    aria-label={`Go to frame ${String(i + 1).padStart(2, "0")}: ${slide.title}`}
                    aria-current={on || undefined}
                    className="group/dot relative flex h-6 items-center justify-center focus-visible:outline-none"
                  >
                    <span
                      className={
                        "block rounded-full transition-all duration-300 group-focus-visible/dot:ring-2 group-focus-visible/dot:ring-indigo-500 group-focus-visible/dot:ring-offset-2 group-focus-visible/dot:ring-offset-zinc-50 dark:group-focus-visible/dot:ring-offset-zinc-950 " +
                        (on
                          ? "h-2.5 w-7 bg-indigo-600 dark:bg-indigo-400"
                          : "h-2.5 w-2.5 bg-zinc-300 hover:bg-zinc-400 dark:bg-zinc-700 dark:hover:bg-zinc-500")
                      }
                    />
                  </button>
                );
              })}
            </div>

            <button
              type="button"
              onClick={() => go(1)}
              className={arrowBtn}
              aria-label="Next frame"
            >
              <IconChevronRight />
            </button>
          </div>

          {/* playback */}
          <div className="mt-6 flex items-center justify-center gap-4">
            <button
              type="button"
              onClick={() => setPlaying((p) => !p)}
              aria-pressed={playing}
              aria-describedby={`${uid}-hint`}
              className={
                btnBase +
                " gap-2 px-4 py-2 text-sm font-medium border-zinc-300 bg-white/80 text-zinc-700 backdrop-blur hover:bg-zinc-100 hover:text-zinc-950 dark:border-zinc-700 dark:bg-zinc-900/80 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:hover:text-white"
              }
            >
              <span className="relative flex h-2 w-2 items-center justify-center">
                <span
                  className={
                    "block h-2 w-2 rounded-full " +
                    (rotating
                      ? "ccf-anim-pulse bg-emerald-500"
                      : "bg-zinc-400 dark:bg-zinc-500")
                  }
                />
              </span>
              {playing ? <IconPause /> : <IconPlay />}
              {playing ? "Pause reel" : "Play reel"}
            </button>

            <span className="font-mono text-sm tabular-nums text-zinc-500 dark:text-zinc-400">
              {String(active + 1).padStart(2, "0")}
              <span className="text-zinc-400 dark:text-zinc-600"> / </span>
              {String(n).padStart(2, "0")}
            </span>
          </div>

          <p id={`${uid}-hint`} className="sr-only">
            Use the left and right arrow keys to move between frames. Playback
            pauses while you hover or focus the carousel.
          </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 →