Grain Gradient
Effects & Borders

Grain Gradient

A slow-drifting multi-hue gradient wash under a fine self-contained film grain, paused when offscreen.

Install

npx shadcn@latest add @paragon/grain-gradient

grain-gradient.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface GrainGradientProps extends React.ComponentProps<"div"> {
  /** Seconds for the gradient to drift through one cycle. */
  duration?: number;
  /** Gradient hues, blended across a slow-drifting linear wash. */
  colors?: string[];
  /** Overall wash opacity, 0–1. */
  strength?: number;
  /** Film-grain opacity, 0–1. Kept low for a fine, tasteful tooth. */
  grain?: number;
  /** Grain cell size — higher is chunkier. */
  grainScale?: number;
  /** Corner radius in px. Match the parent's radius. */
  borderRadius?: number;
  /** Freeze the drift (grain and gradient stay, no motion). */
  static?: boolean;
}

const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);

/**
 * A slow-drifting multi-hue gradient wash under a fine film grain — the tooth
 * of print or film, laid over ambient color.
 *
 * Technique: the gradient is oversized (200%) and only its background-position
 * animates, so the color drifts without repainting a filter each frame. The
 * grain is a self-contained inline SVG feTurbulence (fractal noise, NO remote
 * image) rasterized once into a data-URI and tiled via mix-blend; it shimmers
 * only through a tiny opacity/transform breath on desynchronized periods, so
 * it never looks like a static screen. Pauses offscreen via
 * IntersectionObserver; reduced motion freezes both layers with color/tooth
 * intact. Absolutely positioned — parent needs position: relative (and usually
 * overflow-hidden).
 */
export function GrainGradient({
  duration = 16,
  colors = ["#4D80E6", "#2dd4bf", "#818cf8", "#f0abfc"],
  strength = 0.5,
  grain = 0.4,
  grainScale = 0.8,
  borderRadius = 12,
  static: isStatic = false,
  className,
  style,
  ref: forwardedRef,
  ...props
}: GrainGradientProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const localRef = React.useRef<HTMLDivElement | null>(null);
  const [inView, setInView] = React.useState(true);

  React.useEffect(() => {
    if (isStatic) return;
    const node = localRef.current;
    if (!node) return;
    const observer = new IntersectionObserver(([entry]) => {
      setInView(entry?.isIntersecting ?? true);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [isStatic]);

  const level = clamp01(strength);
  const grainLevel = clamp01(grain);
  // baseFrequency: higher = finer grain. grainScale 0.4..1.4 maps inversely.
  const freq = (0.9 / Math.max(0.35, grainScale)).toFixed(3);
  const noise = React.useMemo(() => {
    // Write the fragment ref as a literal #n; encodeURIComponent turns it into
    // %23n exactly once, so the filter reference resolves inside the data URI.
    const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='${freq}' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%' height='100%' filter='url(#n)'/></svg>`;
    return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
  }, [freq]);

  const wash =
    colors.length > 1
      ? `linear-gradient(115deg, ${colors.join(", ")}, ${colors[0]})`
      : (colors[0] ?? "#4D80E6");

  return (
    <>
      {!isStatic && (
        <style href={`paragon-grain-gradient-${id}`} precedence="paragon">{`
          @keyframes grain-drift-${id} {
            0%, 100% { background-position: 0% 50%; }
            50% { background-position: 100% 50%; }
          }
          @keyframes grain-flicker-${id} {
            0%, 100% { opacity: ${(grainLevel * 0.8).toFixed(3)}; transform: translate3d(0,0,0); }
            50% { opacity: ${grainLevel.toFixed(3)}; transform: translate3d(-1.5%, 1%, 0); }
          }
          [data-grain="${id}"][data-paused] > * { animation-play-state: paused; }
          @media (prefers-reduced-motion: reduce) {
            [data-grain="${id}"] > * { animation: none !important; }
          }
        `}</style>
      )}
      <div
        aria-hidden
        data-grain={id}
        data-paused={(!inView && !isStatic) || undefined}
        ref={(node) => {
          localRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        className={cn(
          "pointer-events-none absolute inset-0 overflow-hidden",
          className,
        )}
        style={{ borderRadius, ...style }}
        {...props}
      >
        {/* Drifting gradient wash. */}
        <div
          style={{
            position: "absolute",
            inset: 0,
            background: wash,
            backgroundSize: "200% 200%",
            opacity: level,
            willChange: isStatic ? undefined : "background-position",
            animation: isStatic
              ? undefined
              : `grain-drift-${id} ${duration}s var(--ease-in-out) infinite`,
          }}
        />
        {/* Film grain — self-contained SVG noise, tiled over the wash. */}
        <div
          style={{
            position: "absolute",
            inset: "-4%",
            backgroundImage: noise,
            backgroundSize: "140px 140px",
            opacity: grainLevel * 0.9,
            mixBlendMode: "overlay",
            willChange: isStatic ? undefined : "opacity, transform",
            animation: isStatic
              ? undefined
              : `grain-flicker-${id} ${(duration * 0.18).toFixed(2)}s steps(3, end) infinite`,
          }}
        />
      </div>
    </>
  );
}