{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "grain-overlay",
  "title": "Grain & Paper Overlay",
  "description": "A lightweight, pointer-transparent film grain, paper or fibre texture you can drop on any section or fix over the whole page. SVG noise, optional tint and flicker, server-component safe.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/grain-overlay/grain-overlay.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type GrainVariant = \"grain\" | \"paper\" | \"fiber\"\n\nexport interface GrainOverlayProps extends Omit<React.ComponentProps<\"div\">, \"color\"> {\n  /**\n   * \"grain\" is fine film grain, \"paper\" is warm stock with soft mottling and tooth,\n   * \"fiber\" is handmade paper with loose strands. Default \"grain\".\n   */\n  variant?: GrainVariant\n  /** Layer opacity, 0 to 1. Defaults per variant. */\n  opacity?: number\n  /** Frequency of the variant's main noise. Higher is finer. A pair gives different x/y frequencies. */\n  frequency?: number | [number, number]\n  /** Octaves of the variant's main noise. More octaves add finer detail. */\n  octaves?: number\n  /** How hard the specks are cut from the noise. Higher is crisper and stronger. Defaults per variant. */\n  contrast?: number\n  /** Noise seed, so two overlays can differ or match. Default 2. */\n  seed?: number\n  /** Tile size in px. Defaults per variant. */\n  size?: number\n  /** Paint the specks in one colour (any CSS colour, including variables). Default: dark and light specks. */\n  color?: string\n  /** CSS mix-blend-mode. Default \"normal\", which reads on light, dark and photographic surfaces alike. */\n  blend?: React.CSSProperties[\"mixBlendMode\"]\n  /** Cover the viewport instead of the nearest positioned ancestor. Default false. */\n  fixed?: boolean\n  /** Film flicker: the grain jumps a few frames a second. Still for prefers-reduced-motion. Default false. */\n  animate?: boolean\n}\n\ntype Rgb = [number, number, number]\n\ninterface NoiseLayer {\n  type: \"fractalNoise\" | \"turbulence\"\n  frequency: number | [number, number]\n  octaves: number\n  contrast: number\n  /**\n   * \"specks\" splits mid-grey noise into dark specks below the middle and light specks above it.\n   * \"strands\" keeps only the thin valleys of turbulence noise, which read as fibres.\n   */\n  shape: \"specks\" | \"strands\"\n  /** Strands only: peak alpha, so fine strands can sit behind coarse ones. */\n  strength?: number\n  /** Strands only: frequency of a patchy mask that cuts the strand network into short fibres. */\n  breakup?: number\n  seedOffset?: number\n}\n\ninterface GrainPreset {\n  size: number\n  opacity: number\n  ink: Rgb\n  light: Rgb\n  /** A mid tone, so strands read on light and dark surfaces. */\n  strand: Rgb\n  /** The first layer is the main one that `frequency`, `octaves` and `contrast` adjust. */\n  layers: NoiseLayer[]\n}\n\nconst neutral = { ink: [0, 0, 0] as Rgb, light: [1, 1, 1] as Rgb, strand: [0.5, 0.5, 0.5] as Rgb }\nconst warm = { ink: [0.24, 0.17, 0.09] as Rgb, light: [1, 0.98, 0.93] as Rgb, strand: [0.5, 0.45, 0.38] as Rgb }\nconst tooth: NoiseLayer = { type: \"fractalNoise\", frequency: 0.9, octaves: 3, contrast: 1.8, shape: \"specks\", seedOffset: 1 }\n\nexport const grainPresets: Record<GrainVariant, GrainPreset> = {\n  grain: {\n    size: 240,\n    opacity: 0.25,\n    ...neutral,\n    layers: [{ type: \"fractalNoise\", frequency: 0.85, octaves: 4, contrast: 2.2, shape: \"specks\" }],\n  },\n  paper: {\n    size: 480,\n    opacity: 0.45,\n    ...warm,\n    layers: [tooth, { type: \"fractalNoise\", frequency: 0.012, octaves: 4, contrast: 0.5, shape: \"specks\" }],\n  },\n  fiber: {\n    size: 480,\n    opacity: 0.5,\n    ...warm,\n    layers: [\n      { type: \"turbulence\", frequency: 0.03, octaves: 1, contrast: 28, shape: \"strands\", strength: 0.9, breakup: 0.025 },\n      {\n        type: \"turbulence\",\n        frequency: 0.07,\n        octaves: 1,\n        contrast: 30,\n        shape: \"strands\",\n        strength: 0.6,\n        breakup: 0.04,\n        seedOffset: 3,\n      },\n      { ...tooth, contrast: 1.6 },\n    ],\n  },\n}\n\nconst n = (value: number) => Number(value.toFixed(4))\nconst rgbRows = ([r, g, b]: Rgb) => `0 0 0 0 ${n(r)} 0 0 0 0 ${n(g)} 0 0 0 0 ${n(b)}`\n\n/**\n * Builds a tiling SVG noise texture as a CSS url(). Each pixel is transparent or a dark or light speck,\n * so it needs no blend mode. The alpha channel carries the texture, which also makes it a ready mask.\n */\nexport function noiseTexture({\n  variant = \"grain\",\n  frequency,\n  octaves,\n  contrast,\n  seed = 2,\n  size,\n}: {\n  variant?: GrainVariant\n  frequency?: number | [number, number]\n  octaves?: number\n  contrast?: number\n  seed?: number\n  size?: number\n  /** @deprecated The texture always works as a mask now. */\n  asMask?: boolean\n}) {\n  const preset = grainPresets[variant]\n  const tile = size ?? preset.size\n  const [main, ...rest] = preset.layers\n  const layers = [\n    { ...main, frequency: frequency ?? main.frequency, octaves: octaves ?? main.octaves, contrast: contrast ?? main.contrast },\n    ...rest,\n  ]\n\n  let primitives = \"\"\n  const results: string[] = []\n  layers.forEach((layer, i) => {\n    const bf = Array.isArray(layer.frequency) ? layer.frequency.join(\" \") : layer.frequency\n    const k = layer.contrast\n    const layerSeed = seed + (layer.seedOffset ?? 0)\n    primitives += `<feTurbulence type='${layer.type}' baseFrequency='${bf}' numOctaves='${layer.octaves}' seed='${layerSeed}' stitchTiles='stitch' result='t${i}'/>`\n    if (layer.shape === \"specks\") {\n      // Alpha rises as the red channel moves away from 0.5: below it paints ink, above it paints light.\n      primitives +=\n        `<feColorMatrix in='t${i}' type='matrix' values='${rgbRows(preset.ink)} ${n(-k)} 0 0 0 ${n(k / 2)}' result='d${i}'/>` +\n        `<feColorMatrix in='t${i}' type='matrix' values='${rgbRows(preset.light)} ${n(k)} 0 0 0 ${n(-k / 2)}' result='l${i}'/>`\n      results.push(`d${i}`, `l${i}`)\n      return\n    }\n    // Turbulence dips to zero along thin ridges. Keep only those dips.\n    primitives += `<feColorMatrix in='t${i}' type='matrix' values='${rgbRows(preset.strand)} ${n(-k)} 0 0 0 ${layer.strength ?? 1}' result='s${i}'/>`\n    if (!layer.breakup) {\n      results.push(`s${i}`)\n      return\n    }\n    // Without a mask the ridges join into a marbled net. Patches of noise cut them into loose fibres.\n    primitives +=\n      `<feTurbulence type='fractalNoise' baseFrequency='${layer.breakup}' numOctaves='2' seed='${layerSeed + 7}' stitchTiles='stitch' result='m${i}'/>` +\n      `<feColorMatrix in='m${i}' type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 5 0 0 0 -2.75' result='p${i}'/>` +\n      `<feComposite in='s${i}' in2='p${i}' operator='in' result='f${i}'/>`\n    results.push(`f${i}`)\n  })\n\n  const svg =\n    `<svg xmlns='http://www.w3.org/2000/svg' width='${tile}' height='${tile}'>` +\n    `<filter id='g' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>` +\n    primitives +\n    `<feMerge>${results.map((r) => `<feMergeNode in='${r}'/>`).join(\"\")}</feMerge>` +\n    `</filter><rect width='100%' height='100%' filter='url(#g)'/></svg>`\n  return `url(\"data:image/svg+xml;utf8,${encodeURIComponent(svg)}\")`\n}\n\n// Eight held frames at about 10fps. steps(1) jumps between them, which is what film grain does.\nconst filmCss =\n  \"@keyframes cui-grain{0%{transform:translate(0,0)}12.5%{transform:translate(-3%,2%)}25%{transform:translate(2%,-4%)}37.5%{transform:translate(-4%,-1%)}50%{transform:translate(3%,3%)}62.5%{transform:translate(-2%,4%)}75%{transform:translate(4%,-2%)}87.5%{transform:translate(-1%,-3%)}}\" +\n  \"[data-slot=grain-overlay-film]{animation:cui-grain .8s steps(1) infinite;will-change:transform}\" +\n  \"@media (prefers-reduced-motion:reduce){[data-slot=grain-overlay-film]{animation:none;will-change:auto}}\"\n\n/**\n * A pointer-transparent film grain or paper texture. Drop it inside any `relative` section,\n * or set `fixed` for the whole page. No client JavaScript, so it is server-component safe.\n */\nexport function GrainOverlay({\n  variant = \"grain\",\n  opacity,\n  frequency,\n  octaves,\n  contrast,\n  seed = 2,\n  size,\n  color,\n  blend,\n  fixed = false,\n  animate = false,\n  className,\n  style,\n  ...props\n}: GrainOverlayProps) {\n  const preset = grainPresets[variant]\n  const tileSize = size ?? preset.size\n  const texture = noiseTexture({ variant, frequency, octaves, contrast, seed, size: tileSize })\n  const tile = `${tileSize}px ${tileSize}px`\n\n  const surface: React.CSSProperties = color\n    ? { backgroundColor: color, WebkitMaskImage: texture, maskImage: texture, WebkitMaskSize: tile, maskSize: tile }\n    : { backgroundImage: texture, backgroundSize: tile }\n\n  return (\n    <div\n      aria-hidden\n      data-slot=\"grain-overlay\"\n      data-variant={variant}\n      className={cn(\n        \"pointer-events-none inset-0 select-none print:hidden forced-colors:hidden\",\n        fixed ? \"fixed z-50\" : \"absolute\",\n        animate && \"overflow-hidden\",\n        className\n      )}\n      style={{\n        opacity: opacity ?? preset.opacity,\n        mixBlendMode: blend,\n        ...(animate ? undefined : surface),\n        ...style,\n      }}\n      {...props}\n    >\n      {animate && (\n        <>\n          <style href=\"cui-grain-overlay\" precedence=\"medium\">\n            {filmCss}\n          </style>\n          {/* Oversized so the jumps never expose an edge; the parent clips it. */}\n          <div data-slot=\"grain-overlay-film\" className=\"absolute inset-[-6%]\" style={surface} />\n        </>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-23"
  },
  "type": "registry:component"
}