Web InnoventixFreeCode

Tilt Scroll Effect

Original · free

Image tilts subtly based on scroll position.

byWeb InnoventixReact + Tailwind
tiltscrollimageeffect
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/imgfx-tilt-scroll.json
imgfx-tilt-scroll.tsx
"use client";

import { useRef } from "react";
import {
  motion,
  useScroll,
  useTransform,
  useSpring,
  useReducedMotion,
} from "motion/react";

export default function ImgfxTiltScroll() {
  const reduce = useReducedMotion();
  const sectionRef = useRef<HTMLDivElement>(null);

  const { scrollYProgress } = useScroll({
    target: sectionRef,
    offset: ["start end", "end start"],
  });

  const spring = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 24,
    mass: 0.4,
  });

  // As the image travels through the viewport it rocks from tilted-up,
  // through flat at center, to tilted-down — a subtle parallax lean.
  const rotateX = useTransform(spring, [0, 0.5, 1], [10, 0, -10]);
  const rotateY = useTransform(spring, [0, 0.5, 1], [-9, 0, 9]);
  const rotateZ = useTransform(spring, [0, 0.5, 1], [-2.2, 0, 2.2]);
  const translateY = useTransform(spring, [0, 0.5, 1], [26, 0, -26]);
  const glareX = useTransform(spring, [0, 1], [18, 82]);
  const glareOpacity = useTransform(spring, [0, 0.5, 1], [0.05, 0.34, 0.05]);
  const glareBackground = useTransform(
    glareX,
    (x) =>
      `radial-gradient(60% 80% at ${x}% 12%, rgba(255,255,255,0.9), transparent 70%)`
  );

  const flat = { rotateX: 0, rotateY: 0, rotateZ: 0, y: 0 } as const;

  return (
    <section
      ref={sectionRef}
      className="relative w-full overflow-hidden bg-neutral-50 px-6 py-24 sm:py-32 dark:bg-neutral-950"
    >
      <style>{`
        @keyframes tiltscroll_float {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-6px); }
        }
        .tiltscroll-hint { animation: tiltscroll_float 3.2s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tiltscroll-hint { animation: none !important; }
        }
      `}</style>

      {/* soft ambient glows */}
      <div
        aria-hidden
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden
        className="pointer-events-none absolute -bottom-32 right-0 h-80 w-80 rounded-full bg-violet-300/25 blur-3xl dark:bg-violet-700/15"
      />

      <div className="relative mx-auto flex max-w-2xl flex-col items-center text-center">
        <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-indigo-500/30 dark:bg-white/5 dark:text-indigo-300">
          <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
          Scroll Tilt
        </span>

        <h2 className="mt-6 text-balance text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl dark:text-neutral-50">
          The frame leans as you move
        </h2>
        <p className="mt-4 max-w-md text-pretty text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
          Scroll the page and watch the photo rock on its axis — a quiet
          parallax lean that flattens out right as it crosses the center.
        </p>
      </div>

      <div
        className="relative mx-auto mt-16 max-w-3xl"
        style={{ perspective: "1200px" }}
      >
        <motion.div
          className="group relative rounded-[1.75rem] will-change-transform"
          style={
            reduce
              ? undefined
              : {
                  rotateX,
                  rotateY,
                  rotateZ,
                  y: translateY,
                  transformStyle: "preserve-3d",
                }
          }
          initial={reduce ? undefined : flat}
        >
          {/* drop shadow plate behind the image, tilts with it */}
          <div
            aria-hidden
            className="absolute inset-x-6 bottom-3 top-8 -z-10 rounded-[1.5rem] bg-neutral-900/25 blur-2xl dark:bg-black/60"
          />

          <div className="relative overflow-hidden rounded-[1.75rem] border border-white/60 bg-white shadow-2xl ring-1 ring-black/5 dark:border-white/10 dark:bg-neutral-900 dark:ring-white/10">
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src="/img/gallery/g14.webp"
              alt="Photographer leaning over a railing at golden hour, city skyline behind"
              loading="lazy"
              draggable={false}
              className="aspect-[4/3] w-full select-none object-cover"
            />

            {/* moving glare that tracks scroll */}
            <motion.div
              aria-hidden
              className="pointer-events-none absolute inset-0 mix-blend-soft-light"
              style={
                reduce
                  ? { opacity: 0 }
                  : {
                      opacity: glareOpacity,
                      background: glareBackground,
                    }
              }
            />

            {/* thin inner edge highlight */}
            <div
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-[1.75rem] ring-1 ring-inset ring-white/25 dark:ring-white/5"
            />
          </div>

          {/* floating caption chip, sits slightly forward in 3D */}
          <div
            className="absolute -bottom-4 left-6 rounded-full border border-neutral-200 bg-white/90 px-4 py-2 text-xs font-medium text-neutral-700 shadow-lg backdrop-blur dark:border-white/10 dark:bg-neutral-900/90 dark:text-neutral-300"
            style={reduce ? undefined : { transform: "translateZ(48px)" }}
          >
            Golden hour, handheld
          </div>
        </motion.div>
      </div>

      <p className="tiltscroll-hint mx-auto mt-16 flex items-center justify-center gap-2 text-sm text-neutral-500 dark:text-neutral-500">
        <svg
          width="15"
          height="15"
          viewBox="0 0 24 24"
          fill="none"
          aria-hidden
          className="text-indigo-500"
        >
          <path
            d="M12 5v14m0 0l-5-5m5 5l5-5"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
        Keep scrolling to feel the lean
      </p>
    </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 →