bottega

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-overlay

Usage

Usagetsx
<GrainOverlay />

Props

PropTypeDefaultDescription
opacitynumber0.12Overall grain visibility (0–1). Default 0.12 — subtle film-grain veil.
baseFrequencynumber0.65Turbulence 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