A slow-drifting multi-hue gradient wash under a fine self-contained film grain, paused when offscreen.
npx shadcn@latest add @paragon/grain-gradient"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>
</>
);
}