OZP

000%

OZP Studios

Engineering15 min read

Build an animated WebGL gradient background in Next.js with ShaderGradient

A full walkthrough of the gradient behind this page: getting the canvas mounted, detecting the first painted frame so it never flashes, fading it on scroll with one motion value, and keeping it cheap enough to leave running.

Written by OZP Studios, Seattle, WA

The animated purple field behind this page is a WebGL plane fixed to the viewport. Dropping one on a page is about fifteen lines. Making it behave — no flash of black on load, no restart when you navigate, no dropped frames while scrolling, and an off switch for people who asked for less motion — is the rest of this post.

By the end you'll have a background component you can mount once in a layout and forget about. Complete, runnable source for all three files is at the bottom, so the snippets along the way can stay short.

You're also looking at the finished version right now, so if you'd rather poke at it than read about it: the shader lab is the page we built to tune this, and it's open. Every parameter is on a slider and there's a button that copies the resulting URL, which is the same URL you'll paste into the component in step 1.

What you need

  • Next.js 15 with the App Router (this works on 14 too; the dynamic import is the only version-sensitive part).
  • React 19.
  • @shadergradient/react 2.0.x, plus @react-three/fiber and three as peers.
  • framer-motion 12 for the scroll and fade logic.
  • Tailwind is used for classes in the examples, but nothing here depends on it.
npm install @shadergradient/react @react-three/fiber three framer-motion

Step 1 — Design the gradient in the browser, not in code

ShaderGradient has a customizer that hands you a URL containing every parameter: colors, camera position, wave density, speed, lighting. You tune it visually, copy the URL, and paste it into your component. That's the whole configuration story, and it's much better than guessing at uniform values in a file.

Set control="query" and pass the URL as urlString. The library parses the query string for you.

components/ShaderScene.tsx

"use client";

import { ShaderGradientCanvas, ShaderGradient } from "@shadergradient/react";

const SHADER_URL =
  "https://www.shadergradient.co/customize?animate=on" +
  "&type=waterPlane&color1=%237659eb&color2=%23b168eb&color3=%23000000" +
  "&bgColor1=%23000000&uDensity=1.3&uFrequency=5.5&uSpeed=0.2&uStrength=2.4" +
  "&cDistance=3.9&cPolarAngle=90&cAzimuthAngle=180&positionX=-1.4" +
  "&rotationZ=50&lightType=3d&envPreset=city&reflection=0.1";

export default function ShaderScene() {
  // Safe to read window here only because this file is imported with ssr: false.
  const pixelDensity = Math.min(2, window.devicePixelRatio || 1);

  return (
    <ShaderGradientCanvas
      pixelDensity={pixelDensity}
      pointerEvents="none"
      style={{ width: "100%", height: "100%", background: "#000" }}
    >
      <ShaderGradient control="query" urlString={SHADER_URL} />
    </ShaderGradientCanvas>
  );
}

Two things in there that matter later: the canvas background is set to the same black as the page, so any frame where the shader hasn't drawn yet is black rather than transparent-white. And pixel density is clamped, which we'll come back to in the performance step.

Step 2 — Keep it off the server

WebGL needs a real browser. If this component renders on the server you get a hydration mismatch at best, and a crash on window at worst. Import it dynamically with SSR disabled, and give it a wrapper that owns the positioning.

components/ShaderBackground.tsx

"use client";

import dynamic from "next/dynamic";

const ShaderScene = dynamic(() => import("./ShaderScene"), {
  ssr: false,
  loading: () => null,
});

export default function ShaderBackground() {
  return (
    <div
      className="pointer-events-none fixed inset-0 z-0 overflow-hidden bg-black"
      aria-hidden
    >
      <ShaderScene />
    </div>
  );
}

pointer-events-none so it never eats a click, aria-hidden because it carries no information, and fixed inset-0 z-0 so it sits under content without joining the scroll. Mount it once in your root layout above {children}, not per page — remounting a GL context on every navigation is the most expensive mistake available here.

Content that should sit on top of a z-0 fixed layer needs its own stacking context, so wrap {children} in something like relative z-10. Skip that and statically-positioned content ends up behind the canvas, which looks identical to the canvas failing to be transparent and sends you debugging the wrong thing.

Step 3 — Know when it has actually painted

This is the part nobody warns you about. If you fade the canvas in on mount, you fade in an empty canvas: mount fires, then the shader compiles, then the first frame draws. On a desktop GPU that gap is short. On a throttled phone it is long enough to watch. Either way the user sees black, then a hard cut to a fully-lit gradient.

React Three Fiber's <Canvas> has an onCreated callback, so the obvious move is to pass it through. You can't. ShaderGradientCanvas destructures a fixed prop list — children, style, pixelDensity, fov, pointerEvents, className, envBasePath, lazyLoad, threshold — and never spreads the remainder onto the Canvas underneath. Anything else you hand it is dropped silently; search the published bundle for onCreated and you'll find it doesn't appear at all.

That's less of a loss than it sounds, because onCreated fires when the renderer has been constructed, which is still before the shader has compiled and before anything has reached the screen. What you actually want is evidence of a painted frame, and there's a reliable one: a canvas element reports width and height of 0 until the renderer sizes its drawing buffer, so a non-zero width means GL is live. Poll for it.

components/ShaderScene.tsx

const containerRef = useRef<HTMLDivElement>(null);
const readySent = useRef(false);

useEffect(() => {
  if (!onReady) return;

  let tries = 0;
  const id = window.setInterval(() => {
    tries += 1;
    const canvas = containerRef.current?.querySelector("canvas");
    const painted = Boolean(canvas && canvas.width > 0 && canvas.height > 0);

    // ~4s ceiling. A slow GPU must not strand the loader forever.
    if (!painted && tries <= 80) return;

    window.clearInterval(id);
    if (readySent.current) return;
    readySent.current = true;

    // One more frame so the first draw is actually on screen.
    if (painted) requestAnimationFrame(() => onReady());
    else onReady();
  }, 50);

  return () => window.clearInterval(id);
}, [onReady]);

Four details make this work rather than sort-of work. Query inside a ref rather than document.querySelector("canvas"), or you'll find the wrong canvas the moment someone adds a chart to a page. Wait one requestAnimationFrame after the size check, because a sized buffer isn't a composited frame. Guard with readySent so a late interval tick can't fire the callback twice. And always cap the polling, because the failure mode of an uncapped version is a permanent loading screen on hardware you don't own.

Now onReady is a real signal. Hold your preloader until it fires, then fade the canvas up over a second or two. Slow is good here — a gradient that eases in over 1500ms reads as atmosphere, and the same gradient snapping on in 200ms reads as a bug.

Step 4 — Fade on scroll with exactly one motion value

The effect: the gradient is at full strength at the top of the page and gone by the time you're a screen down, so text sits on flat black while you read. The naive version of this ships a bug that took us two attempts to kill, so here's the shape that works.

components/ShaderBackground.tsx

const { scrollY } = useScroll();

// Scroll position to a 0–1 strength curve.
const scrollFactor = useTransform(scrollY, [0, 280, 560, 900], [1, 0.75, 0.25, 0]);

// One value owns opacity for the life of the app.
const target = useMotionValue(intensity);
const opacity = useSpring(target, {
  stiffness: 70,
  damping: 28,
  mass: 0.5,
  restDelta: 0.001,
});

const apply = useCallback(
  (value: number, instant: boolean) => {
    if (instant) {
      target.jump(value);
      opacity.jump(value);
      return;
    }
    target.set(value);
  },
  [opacity, target],
);

useMotionValueEvent(scrollFactor, "change", (factor) => {
  if (!fadeOnScroll) return;
  apply((Number.isFinite(factor) ? factor : 1) * intensity, reducedMotion);
});

return (
  <motion.div className="fixed inset-0 z-0 bg-black" style={{ opacity }} aria-hidden>
    {/* canvas */}
  </motion.div>
);

The rule that matters: the element's style.opacity is bound to one spring, permanently. Routes and props change what that spring is travelling toward. They never change which value is driving the style.

Violate that and you get a flash. We had scroll-linked opacity on the home page and a fixed intensity on inner pages, and we swapped which value fed the style depending on the route. Every navigation restarted the animation from wherever the incoming value happened to be sitting, which showed up as one black frame followed by the gradient punching in. It's invisible in code review and obvious in a screen recording played at quarter speed.

This is also why the full version at the bottom uses two nested elements: the outer one plays the one-time reveal when onReady fires, the inner one carries the scroll spring forever. Two jobs, two elements, neither fighting the other for the same property.

Also note useMotionValueEvent rather than a useEffect on a scroll listener. The transform runs on Framer Motion's frame loop and writes straight to the style, so scrolling doesn't schedule React renders. If you find yourself calling setState from a scroll handler for this, back out.

Step 5 — Make it cheap enough to leave running

A full-screen fragment shader runs every frame for as long as the page is open. Four things are worth knowing before you try to make that cheaper.

Clamp device pixel ratio. This is the big one. pixelDensity is handed straight to React Three Fiber as dpr, so clamping it is the same lever you'd pull in raw R3F. Rendering at a phone's native 3x means roughly 2.25x the pixels of a 2x buffer, for a soft gradient nobody is inspecting. Math.min(2, window.devicePixelRatio || 1) is free and invisible. On a low-end target, consider clamping to 1.5.

Know what the library sets on your behalf. Alongside dpr it hardcodes linear, flat, and gl: { preserveDrawingBuffer: true }. The first two are why the colors match the customizer exactly — no tone mapping, no sRGB conversion — and why they'd drift if you rebuilt this yourself in R3F and wondered why your purple looked washed. The third keeps the drawing buffer alive after each composite, which costs bandwidth and rules out a driver fast path. There is no prop to turn it off. Better to know that than to spend an afternoon looking for the switch.

Opacity zero is not free. The library never sets R3F's frameloop, so it stays on the default of always and the shader keeps drawing every frame whether or not anyone can see it. Fading the background out saves compositing, not shading. We still leave it mounted, because throwing away the GL context and rebuilding it when someone scrolls back up is worse than the steady cost. But if your background only appears in a hero that scrolls away for good, unmount it and take the win.

Defer prop churn. If the intensity or the fade mode is driven by route or state, wrap those values in useDeferredValue so a busy render doesn't fight the animation frame.

const viewIntensity = useDeferredValue(intensity);
const viewFadeOnScroll = useDeferredValue(fadeOnScroll);

Step 6 — Reduced motion, and the touch check everyone gets wrong

Someone who set prefers-reduced-motion did not ask for no gradient. They asked for nothing that eases, drifts, or springs. So keep the field, keep its color, and stop animating the transitions — pass instant: true into that apply function and the opacity jumps to each new target instead of springing toward it.

Whether you also stop the shader's own wave motion is a judgment call. We leave it running at a low speed and cut every UI transition around it. If you want to be strict, set animate=off in the URL when the query matches.

Both checks are media queries, and useSyncExternalStore is the right primitive for reading them in React — it subscribes properly and gives you a server snapshot without a hydration warning.

hooks/useMediaQuery.ts

"use client";

import { useCallback, useSyncExternalStore } from "react";

export function useMediaQuery(query: string) {
  const subscribe = useCallback(
    (onChange: () => void) => {
      const mq = window.matchMedia(query);
      mq.addEventListener("change", onChange);
      return () => mq.removeEventListener("change", onChange);
    },
    [query],
  );

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => false, // server snapshot
  );
}

export const useReducedMotion = () =>
  useMediaQuery("(prefers-reduced-motion: reduce)");

// Primary input is a finger. NOT "this device has a touchscreen".
export const useIsTouchDevice = () => useMediaQuery("(pointer: coarse)");

That last line is worth a paragraph, because the usual approaches are wrong. Checking ontouchstart or navigator.maxTouchPoints tells you the device has a digitizer, and a Windows laptop with a touchscreen reports true while the person is holding a mouse. We shipped that mistake and gave every Surface owner the phone code path for weeks. (pointer: coarse) asks about the primary pointing device, which is the question you meant to ask.

Gotchas

  • Nothing renders and there's no error. The library wraps your canvas in a div at width: 100%; height: 100% and merges your style into it. If the parent has no resolved height that's a zero-pixel box, and you get silence — no warning, no console output. fixed inset-0 sidesteps this; a plain div in normal flow does not.
  • `lazyLoad` is on by default. ShaderGradientCanvas runs an IntersectionObserver at threshold: 0.1 and only mounts the Canvas while its wrapper is in view. For a fixed full-viewport background that's a no-op. For a canvas inside a section halfway down a long page it's why nothing appears until you scroll — usually what you want, occasionally the bug you're chasing. Pass lazyLoad={false} to opt out.
  • Text contrast. A moving gradient will eventually put light purple under white text. Lay a bg-gradient-to-b from-transparent to-background div over the canvas and let your content sit above it. Cheaper and more reliable than tuning shader colors to be safe everywhere.
  • Clipped waves at the edges. If ripples visibly cut off at the sides, your plane is smaller than the camera frustum at that angle. Either pull the camera in or scale the mesh up — and if you scale it, scale the subdivisions with it or the waves get blocky.
  • Grain. Film grain sells this effect, but doing it in the shader costs you per-pixel every frame. A tiled PNG or an SVG noise overlay in CSS costs nothing and looks the same.
  • Library console noise. ShaderGradient logs on every mount. You can filter it by wrapping console.log, but you're patching a global to hide someone else's debug line, so scope it tightly and be aware it will confuse the next person to open the file.
  • It patches three.js globals. On mount the library blanks out four ShaderChunk entries (uv2_pars_vertex, uv2_vertex, uv2_pars_fragment, encodings_fragment) to stay compatible across three versions. That's process-wide. If you're running other three.js content on the same page, that's a thing you now know about.
  • One canvas per page. If two shader backgrounds mount at once you have two GL contexts, and browsers cap how many they'll give you. Mount in the layout, control it with props.

Build a tuning page, or use ours

Comparing two values of uSpeed by editing a query string inside a template literal and waiting for a rebuild is miserable enough that you'll stop iterating, which means you ship the third-best version. Spend an hour on a route with sliders bound to the parameters, then copy the URL out when it looks right.

Or skip that hour. The shader lab is ours, and it's live: colors, wave density, frequency, speed, strength, camera angle and distance, plus the wrapper settings like plane size and parallax. Drag until it looks like something, hit copy, paste it in as SHADER_URL. Most of the settings behind this page came out of about ten minutes on it.

If you build your own, keep it out of your nav and mark it noindex — it's a workbench, not a page you want ranking — and don't feel bad that it's ugly.

Full source

Three files, trimmed of anything specific to this site — no preloader wiring, no grain overlay, no plane-bounds helper. Paste them in and you have the background running. The useReducedMotion import is the hook from step 6.

components/ShaderScene.tsx

"use client";

import { ShaderGradientCanvas, ShaderGradient } from "@shadergradient/react";
import { useEffect, useRef } from "react";

const SHADER_URL =
  "https://www.shadergradient.co/customize?animate=on" +
  "&type=waterPlane&color1=%237659eb&color2=%23b168eb&color3=%23000000" +
  "&bgColor1=%23000000&uDensity=1.3&uFrequency=5.5&uSpeed=0.2&uStrength=2.4" +
  "&cDistance=3.9&cPolarAngle=90&cAzimuthAngle=180&positionX=-1.4" +
  "&rotationZ=50&lightType=3d&envPreset=city&reflection=0.1";

type ShaderSceneProps = {
  /** Fires after the first frame is on screen. */
  onReady?: () => void;
  urlString?: string;
};

export default function ShaderScene({
  onReady,
  urlString = SHADER_URL,
}: ShaderSceneProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const readySent = useRef(false);

  useEffect(() => {
    if (!onReady) return;

    let tries = 0;
    const id = window.setInterval(() => {
      tries += 1;
      const canvas = containerRef.current?.querySelector("canvas");
      const painted = Boolean(canvas && canvas.width > 0 && canvas.height > 0);

      // ~4s ceiling. A slow GPU must not strand the loader forever.
      if (!painted && tries <= 80) return;

      window.clearInterval(id);
      if (readySent.current) return;
      readySent.current = true;

      if (painted) requestAnimationFrame(() => onReady());
      else onReady();
    }, 50);

    return () => window.clearInterval(id);
  }, [onReady]);

  // Safe to read window during render: this file is imported with ssr: false.
  const pixelDensity = Math.min(2, window.devicePixelRatio || 1);

  return (
    <div ref={containerRef} className="h-full w-full">
      <ShaderGradientCanvas
        pixelDensity={pixelDensity}
        pointerEvents="none"
        style={{ width: "100%", height: "100%", background: "#000" }}
      >
        <ShaderGradient control="query" urlString={urlString} />
      </ShaderGradientCanvas>
    </div>
  );
}

components/ShaderBackground.tsx

"use client";

import dynamic from "next/dynamic";
import {
  motion,
  useMotionValue,
  useMotionValueEvent,
  useScroll,
  useSpring,
  useTransform,
} from "framer-motion";
import { useCallback, useDeferredValue, useEffect, useState } from "react";
import { useReducedMotion } from "@/hooks/useMediaQuery";

const ShaderScene = dynamic(() => import("./ShaderScene"), {
  ssr: false,
  loading: () => null,
});

const REVEAL_MS = 1500;

type ShaderBackgroundProps = {
  intensity?: number;
  fadeOnScroll?: boolean;
};

export default function ShaderBackground({
  intensity = 1,
  fadeOnScroll = false,
}: ShaderBackgroundProps) {
  const reduced = useReducedMotion();
  const [ready, setReady] = useState(false);
  const handleReady = useCallback(() => setReady(true), []);

  const viewIntensity = useDeferredValue(intensity);
  const viewFadeOnScroll = useDeferredValue(fadeOnScroll);

  const { scrollY } = useScroll();
  const scrollFactor = useTransform(
    scrollY,
    [0, 280, 560, 900],
    [1, 0.75, 0.25, 0],
  );

  // This spring owns style.opacity for the life of the component.
  const target = useMotionValue(viewIntensity);
  const opacity = useSpring(target, {
    stiffness: 70,
    damping: 28,
    mass: 0.5,
    restDelta: 0.001,
  });

  const apply = useCallback(
    (value: number, instant: boolean) => {
      if (instant) {
        target.jump(value);
        opacity.jump(value);
        return;
      }
      target.set(value);
    },
    [opacity, target],
  );

  useMotionValueEvent(scrollFactor, "change", (factor) => {
    if (!viewFadeOnScroll) return;
    apply((Number.isFinite(factor) ? factor : 1) * viewIntensity, reduced);
  });

  // Re-settle when props or the motion preference change, without ever
  // swapping which value drives the style.
  useEffect(() => {
    const factor = viewFadeOnScroll ? scrollFactor.get() : 1;
    apply((Number.isFinite(factor) ? factor : 1) * viewIntensity, reduced);
  }, [apply, viewFadeOnScroll, viewIntensity, reduced, scrollFactor]);

  return (
    <motion.div
      className="pointer-events-none fixed inset-0 z-0 overflow-hidden"
      initial={{ opacity: 0 }}
      animate={{ opacity: ready ? 1 : 0 }}
      transition={{
        duration: ready ? REVEAL_MS / 1000 : 0,
        ease: [0.22, 1, 0.36, 1],
      }}
      aria-hidden
    >
      <motion.div className="absolute inset-0 bg-black" style={{ opacity }}>
        <div className="absolute inset-0">
          <ShaderScene onReady={handleReady} />
        </div>

        {/* Keeps text legible where the gradient runs light. */}
        <div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent from-50% to-black" />
      </motion.div>
    </motion.div>
  );
}

app/layout.tsx

import ShaderBackground from "@/components/ShaderBackground";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-black text-white">
        <ShaderBackground fadeOnScroll />

        {/* Without a stacking context, this ends up behind the canvas. */}
        <div className="relative z-10">{children}</div>
      </body>
    </html>
  );
}

Where this leaves you

A background that mounts once, waits until it has pixels before it shows itself, fades with scroll on a single spring, respects a reduced-motion request, and clamps its own resolution. The remaining honest limitation is mobile: on a mid-range Android this is still the most expensive thing on the page, and fading it out early is as much a performance decision as a design one. If you're targeting those devices seriously, measure on the actual hardware before you commit to shipping a canvas at all.