backgrounds
Grain Overlay
A subtle animated film-grain noise overlay: an inline SVG <feTurbulence> fractal-noise filter cycles through 12 seeds at 12fps via SMIL discrete <animate> for a living-grain feel. Achromatic alpha noise, tinted by --foreground so it reads on both dark (gild/cream) and light (ink/paper) themes. Sits at z-50 with pointer-events:none — purely visual, never blocks clicks or focus. An IntersectionObserver pauses the SVG animations offscreen.
grain-overlay
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/grain-overlayUsage
Usagetsx
<GrainOverlay />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| opacity | number | 0.12 | Overall grain visibility (0–1). Default 0.12 — subtle film-grain veil. |
| baseFrequency | number | 0.65 | Turbulence spatial frequency: higher = finer grain, lower = coarser. Default 0.65 (classic 35mm film-grain range). |
Source
grain-overlay.tsxtsx
"use client";
import { useId, useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./grain-overlay.module.css";
export interface GrainOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
/** Overall grain visibility (0–1). Default 0.12 — subtle film-grain veil. */
opacity?: number;
/** Turbulence spatial frequency: higher = finer grain, lower = coarser.
* Default 0.65 (classic 35mm film-grain range). */
baseFrequency?: number;
}
/**
* Film-grain noise overlay (client component).
*
* Renders an animated SVG <feTurbulence> fractal-noise filter over the parent
* container. The turbulence seed cycles through 12 values at 12fps via SMIL
* discrete <animate> for a living-grain feel.
*
* Decorative: root is position:absolute; inset:0; pointer-events:none;
* aria-hidden; role="presentation". Sits at z-50 so it layers above sibling
* content visually — `pointer-events:none` means it NEVER blocks clicks or focus.
*
* Grain is achromatic alpha noise tinted by --foreground so it reads on both
* the dark (Atelier gild/cream) and light (ink/warm-paper) themes.
*
* An IntersectionObserver pauses the SVG animations when scrolled offscreen.
*
* Reduced-motion: static grain (no seed jitter) — same texture, no flicker.
*/
export function GrainOverlay({
opacity = 0.12,
baseFrequency = 0.65,
className = "",
style,
"aria-hidden": ariaHidden = true,
...props
}: GrainOverlayProps) {
// useId is SSR-stable → safe for filter IDs (no hydration mismatch).
const rawId = useId();
const filterId = `grain${rawId.replace(/:/g, "")}`;
// Contract mount-gate + null-safe reduce check (avoids React #418 + flash).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
// IntersectionObserver: pause SMIL animations offscreen to save GPU.
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!animate) return;
const svg = svgRef.current;
if (!svg || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
svg.unpauseAnimations?.();
} else {
svg.pauseAnimations?.();
}
},
{ rootMargin: "0px" },
);
io.observe(svg);
return () => io.disconnect();
}, [animate]);
return (
<div
role="presentation"
aria-hidden={ariaHidden}
className={[styles.root, className].filter(Boolean).join(" ")}
style={style}
{...props}
>
<svg
ref={svgRef}
className={styles.grain}
width="100%"
height="100%"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<filter
id={filterId}
x="0%"
y="0%"
width="100%"
height="100%"
colorInterpolationFilters="sRGB"
>
<feTurbulence
type="fractalNoise"
baseFrequency={baseFrequency}
numOctaves={3}
stitchTiles="stitch"
result="noise"
>
{/* Discrete seed cycling — shifts the grain pattern at 12fps for a
living film-grain feel. Omitted under reduced-motion → static. */}
{animate && (
<animate
attributeName="seed"
values="1;2;3;4;5;6;7;8;9;10;11;12"
dur="1s"
calcMode="discrete"
repeatCount="indefinite"
/>
)}
</feTurbulence>
{/* luma→alpha: converts turbulence luminance into the alpha channel.
Grain is achromatic alpha noise — exempt from color token rule.
The −0.3 bias raises contrast so only the brightest noise speckles
show through, creating crisp grain dots rather than a flat wash. */}
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.213 0.715 0.072 0 -0.3"
result="grainAlpha"
/>
{/* Flood with currentColor — inherits --foreground from .root so grain
is tinted toward the theme foreground on both dark and light themes. */}
<feFlood floodColor="currentColor" floodOpacity="1" result="color" />
{/* in="color" in2="grainAlpha" operator="in": masks the flood color by
the grain alpha, producing foreground-tinted noise specks. */}
<feComposite in="color" in2="grainAlpha" operator="in" />
</filter>
</defs>
{/* The opacity prop scales the overall grain intensity. */}
<rect
width="100%"
height="100%"
filter={`url(#${filterId})`}
opacity={opacity}
/>
</svg>
</div>
);
}
Dependencies
- motion
- @bottega/tokens