Web InnoventixFreeCode

Follow Zoom Effect

Original · free

The image zooms toward the point under the cursor.

byWeb InnoventixReact + Tailwind
followzoomimageeffect
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-follow-zoom.json
imgfx-follow-zoom.tsx
"use client";

import { useRef, useState, useCallback, type MouseEvent } from "react";
import { useReducedMotion } from "motion/react";

type Origin = { x: number; y: number };

export default function ImgfxFollowZoom() {
  const reduceMotion = useReducedMotion();
  const frameRef = useRef<HTMLDivElement>(null);
  const imgRef = useRef<HTMLImageElement>(null);
  const dotRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef<number | null>(null);
  const [active, setActive] = useState(false);

  const apply = useCallback((origin: Origin) => {
    if (imgRef.current) {
      imgRef.current.style.transformOrigin = `${origin.x}% ${origin.y}%`;
    }
    if (dotRef.current) {
      dotRef.current.style.left = `${origin.x}%`;
      dotRef.current.style.top = `${origin.y}%`;
    }
  }, []);

  const handleMove = useCallback(
    (e: MouseEvent<HTMLDivElement>) => {
      if (reduceMotion) return;
      const frame = frameRef.current;
      if (!frame) return;
      const rect = frame.getBoundingClientRect();
      const x = ((e.clientX - rect.left) / rect.width) * 100;
      const y = ((e.clientY - rect.top) / rect.height) * 100;
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(() =>
        apply({ x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }),
      );
    },
    [apply, reduceMotion],
  );

  const handleEnter = useCallback(() => {
    if (reduceMotion) return;
    setActive(true);
  }, [reduceMotion]);

  const handleLeave = useCallback(() => {
    if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    setActive(false);
    apply({ x: 50, y: 50 });
  }, [apply]);

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-6 py-20 dark:bg-neutral-950 sm:px-10 md:py-28">
      <style>{`
        @keyframes ifz_ping {
          0% { transform: translate(-50%, -50%) scale(0.6); opacity: 0.9; }
          70% { transform: translate(-50%, -50%) scale(1.9); opacity: 0; }
          100% { transform: translate(-50%, -50%) scale(1.9); opacity: 0; }
        }
        @keyframes ifz_hint {
          0%, 100% { opacity: 0.55; }
          50% { opacity: 1; }
        }
        .ifz-ping { animation: ifz_ping 1.8s ease-out infinite; }
        .ifz-hint-dot { animation: ifz_hint 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ifz-ping, .ifz-hint-dot { animation: none !important; }
          .ifz-img { transition: none !important; }
        }
      `}</style>

      <div className="relative mx-auto flex max-w-4xl flex-col items-center">
        <span className="mb-4 inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
          <span className="relative flex h-1.5 w-1.5">
            <span className="ifz-hint-dot absolute inline-flex h-full w-full rounded-full bg-indigo-500" />
          </span>
          Follow Zoom
        </span>

        <h2 className="max-w-2xl text-center text-3xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 sm:text-4xl">
          The image magnifies wherever you point
        </h2>
        <p className="mt-3 max-w-md text-center text-sm text-neutral-500 dark:text-neutral-400">
          Hover and glide across the photo — it zooms toward the exact spot under your
          cursor and eases back when you leave.
        </p>

        <div
          ref={frameRef}
          onMouseEnter={handleEnter}
          onMouseMove={handleMove}
          onMouseLeave={handleLeave}
          className="group relative mt-10 aspect-[4/3] w-full cursor-zoom-in overflow-hidden rounded-2xl border border-neutral-200 bg-neutral-100 shadow-xl shadow-neutral-900/5 ring-1 ring-black/5 dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-black/40 dark:ring-white/5"
        >
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            ref={imgRef}
            src="/img/gallery/g14.webp"
            alt="Aerial coastline where turquoise shallows meet dark open water"
            loading="lazy"
            draggable={false}
            className="ifz-img h-full w-full select-none object-cover transition-transform duration-[400ms] ease-out"
            style={{
              transformOrigin: "50% 50%",
              transform: active ? "scale(2.15)" : "scale(1)",
            }}
          />

          {/* focus reticle following the cursor */}
          <div
            ref={dotRef}
            aria-hidden="true"
            className={`pointer-events-none absolute z-10 h-10 w-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/70 bg-white/5 shadow-[0_0_0_1px_rgba(0,0,0,0.25)] backdrop-blur-[1px] transition-opacity duration-300 ${
              active ? "opacity-100" : "opacity-0"
            }`}
            style={{ left: "50%", top: "50%" }}
          >
            {active ? (
              <span className="ifz-ping absolute left-1/2 top-1/2 h-6 w-6 rounded-full border border-white/80" />
            ) : null}
          </div>

          {/* idle hint chip */}
          <div
            className={`pointer-events-none absolute bottom-3 left-3 z-20 rounded-md bg-neutral-900/70 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur transition-opacity duration-300 dark:bg-black/60 ${
              active ? "opacity-0" : "opacity-100"
            }`}
          >
            Move to magnify
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-neutral-400 dark:text-neutral-500">
          Pure transform-origin tracking — no image swap, no blur, gracefully still for
          reduced-motion viewers.
        </p>
      </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 →