Web InnoventixFreeCode

Grayscale Scroll Effect

Original · free

Image gains colour from greyscale as you scroll through it.

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

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

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

  const { scrollYProgress } = useScroll({
    target: trackRef,
    offset: ["start 85%", "center 55%"],
  });

  // 1 = fully desaturated, 0 = full colour.
  const gray = useTransform(scrollYProgress, [0, 1], [1, 0]);
  const sat = useTransform(scrollYProgress, [0, 1], [0.85, 1.12]);
  const contrast = useTransform(scrollYProgress, [0, 1], [0.94, 1.04]);
  const filter = useMotionTemplate`grayscale(${gray}) saturate(${sat}) contrast(${contrast})`;

  // A colour bar that fills as scroll progress advances.
  const meterWidth = useTransform(scrollYProgress, [0, 1], ["8%", "100%"]);
  const meterOpacity = useTransform(scrollYProgress, [0, 0.15], [0.4, 1]);

  const staticFilter = reduce ? "grayscale(0) saturate(1.05)" : undefined;

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-6 py-24 text-neutral-900 sm:px-10 lg:py-32 dark:bg-neutral-950 dark:text-neutral-100">
      <style>{`
        @keyframes gsc_scrollHint {
          0%, 100% { transform: translateY(0); opacity: 0.55; }
          50% { transform: translateY(6px); opacity: 1; }
        }
        @keyframes gsc_sweep {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(120%); }
        }
        .gsc_hint { animation: gsc_scrollHint 1.8s ease-in-out infinite; }
        .gsc_sweep { animation: gsc_sweep 3.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .gsc_hint, .gsc_sweep { animation: none !important; }
        }
      `}</style>

      {/* soft ambient wash */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(60%_50%_at_50%_0%,rgba(99,102,241,0.10),transparent_70%)] dark:bg-[radial-gradient(60%_50%_at_50%_0%,rgba(129,140,248,0.14),transparent_70%)]"
      />

      <div className="relative mx-auto max-w-4xl">
        <header className="mb-12 text-center">
          <p className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
            Scroll to reveal
          </p>
          <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl">
            Colour blooms as you read down the page
          </h2>
          <p className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
            The photo starts muted and grey. Keep scrolling and its true palette
            floods back in, tying the image to the moment it enters view.
          </p>
        </header>

        <div ref={trackRef} className="group relative">
          {/* colour progress meter */}
          <div className="mb-4 flex items-center gap-3">
            <span className="text-[11px] font-medium uppercase tracking-widest text-neutral-500 dark:text-neutral-500">
              Grey
            </span>
            <div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
              <motion.div
                style={reduce ? { width: "100%" } : { width: meterWidth, opacity: meterOpacity }}
                className="h-full rounded-full bg-gradient-to-r from-indigo-400 via-violet-500 to-rose-400"
              />
            </div>
            <span className="text-[11px] font-medium uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
              Colour
            </span>
          </div>

          <figure className="relative overflow-hidden rounded-3xl ring-1 ring-black/5 shadow-2xl shadow-neutral-900/10 dark:ring-white/10 dark:shadow-black/40">
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <motion.img
              src="/img/gallery/g14.webp"
              alt="Sunlit landscape that shifts from monochrome to full colour on scroll"
              loading="lazy"
              draggable={false}
              style={reduce ? { filter: staticFilter } : { filter }}
              className="block aspect-[4/3] w-full object-cover"
            />

            {/* moving sheen to add life on hover */}
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-0 overflow-hidden opacity-0 transition-opacity duration-500 group-hover:opacity-100"
            >
              <div className="gsc_sweep absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent dark:via-white/10" />
            </div>

            {/* subtle bottom gradient for caption legibility */}
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/40 to-transparent"
            />

            <figcaption className="absolute bottom-4 left-5 right-5 flex items-end justify-between gap-4">
              <span className="text-sm font-medium text-white drop-shadow-sm">
                Golden hour, Dolomites
              </span>
              <span className="hidden text-xs text-white/75 sm:inline">
                g14.webp
              </span>
            </figcaption>
          </figure>

          <p className="mt-5 flex items-center justify-center gap-2 text-sm text-neutral-500 dark:text-neutral-500">
            <span className="gsc_hint inline-block" aria-hidden="true">
              ↓
            </span>
            Scroll the image through the viewport to paint it back to life
          </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 →