{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cyanotype",
  "title": "Cyanotype",
  "description": "Turns any image into a hand-coated cyanotype: Prussian-blue tones, a brushed emulsion edge with an exposed border, uneven coating and paper tooth. Photogram mode prints objects as white silhouettes, and an optional develop wash plays when the print scrolls into view. Pure SVG filters, no CORS needed.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/cyanotype/cyanotype.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type CyanotypePreset = \"classic\" | \"deep\" | \"tea\" | \"faded\"\n\n/**\n * Tone ramps from full exposure (darkest) to clear paper. The last stop is also the paper colour,\n * because a washed cyanotype's highlights are bare paper.\n */\nexport const cyanotypePresets: Record<CyanotypePreset, string[]> = {\n  classic: [\"#0d1f4a\", \"#17407f\", \"#3f74ad\", \"#9dbcd8\", \"#f2efe6\"],\n  deep: [\"#06112c\", \"#0d2a63\", \"#22579a\", \"#7aa2ca\", \"#eef0ea\"],\n  tea: [\"#1b1a2c\", \"#363853\", \"#676577\", \"#b8a888\", \"#eadcbf\"],\n  faded: [\"#26568a\", \"#4979ab\", \"#7ea4c7\", \"#bed3e4\", \"#f4f1e8\"],\n}\n\n/** Unwashed ferric sensitizer, the yellow-green the paper shows before the water bath. */\nconst SENSITIZER = \"#d6d07c\"\n\ntype RGB = [number, number, number]\n\n/** Parses #rgb and #rrggbb into 0 to 1 channels. */\nfunction parseHex(input: string): RGB | null {\n  const hex = input.trim().replace(/^#/, \"\")\n  const full = hex.length === 3 ? hex.split(\"\").map((c) => c + c).join(\"\") : hex.slice(0, 6)\n  const n = Number.parseInt(full, 16)\n  if (full.length !== 6 || Number.isNaN(n)) return null\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\nconst fmt = (n: number) => Number(Math.min(1, Math.max(0, n)).toFixed(4))\n\n/** Paper tooth: dark and light specks on transparent, so it needs no blend mode. */\nconst PAPER_TOOTH = `url(\"data:image/svg+xml;utf8,${encodeURIComponent(\n  \"<svg xmlns='http://www.w3.org/2000/svg' width='320' height='320'>\" +\n    \"<filter id='p' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>\" +\n    \"<feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' seed='4' stitchTiles='stitch' result='t'/>\" +\n    \"<feColorMatrix in='t' type='matrix' values='0 0 0 0 0.2 0 0 0 0 0.16 0 0 0 0 0.1 -2 0 0 0 1' result='d'/>\" +\n    \"<feColorMatrix in='t' type='matrix' values='0 0 0 0 1 0 0 0 0 0.99 0 0 0 0 0.95 2 0 0 0 -1' result='l'/>\" +\n    \"<feMerge><feMergeNode in='d'/><feMergeNode in='l'/></feMerge>\" +\n    \"</filter><rect width='100%' height='100%' filter='url(#p)'/></svg>\"\n)}\")`\n\n/**\n * Filter primitives for a hand-brushed coating edge. Bristle noise (fast along y) pushes the left and\n * right edges into streaks; slow noise makes the top and bottom wavy. A blurred copy of the edge, roughened\n * with grain, thins the coating into dry-brush specks. Same seed, same edge, so layers line up exactly.\n */\nfunction brushEdge(seed: number, roughness: number) {\n  const r = Math.min(1, Math.max(0, roughness))\n  return (\n    <>\n      <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.004 0.045\" numOctaves={5} seed={seed} result=\"bristle\" />\n      <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.018\" numOctaves={4} seed={seed + 1} result=\"wobble\" />\n      <feColorMatrix in=\"bristle\" type=\"matrix\" values=\"1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 0 1\" result=\"bx\" />\n      <feColorMatrix in=\"wobble\" type=\"matrix\" values=\"0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 0 1\" result=\"wy\" />\n      <feComposite in=\"bx\" in2=\"wy\" operator=\"arithmetic\" k2={1} k3={1} result=\"map\" />\n      <feComponentTransfer in=\"map\" result=\"map\">\n        {/* fractalNoise sits near 0.5; stretch it so the streaks reach deep into the coating. */}\n        <feFuncR type=\"linear\" slope={1.9} intercept={-0.45} />\n        <feFuncG type=\"linear\" slope={1.6} intercept={-0.3} />\n      </feComponentTransfer>\n      <feDisplacementMap\n        in=\"SourceAlpha\"\n        in2=\"map\"\n        scale={r * 72}\n        xChannelSelector=\"R\"\n        yChannelSelector=\"G\"\n        result=\"ragged\"\n      />\n      <feGaussianBlur in=\"ragged\" stdDeviation={2 + r * 10} result=\"soft\" />\n      <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.35 0.6\" numOctaves={2} seed={seed + 2} result=\"grain\" />\n      <feColorMatrix in=\"grain\" type=\"matrix\" values=\"0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  1 0 0 0 0\" result=\"grainA\" />\n      <feComposite in=\"soft\" in2=\"grainA\" operator=\"arithmetic\" k2={1} k3={1.2 * r} k4={-0.6 * r} result=\"worn\" />\n      <feComponentTransfer in=\"worn\" result=\"coat\">\n        {/* Inside the coating this clamps to 1; in the thin band at the edge the grain breaks it up. */}\n        <feFuncA type=\"linear\" slope={3} intercept={-0.9} />\n      </feComponentTransfer>\n      <feComposite in=\"ragged\" in2=\"coat\" operator=\"in\" result=\"edge\" />\n    </>\n  )\n}\n\nexport interface CyanotypeProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Image URL. Omit when wrapping your own element with `children`. */\n  src?: string\n  /** Accessible description. Leave empty for decorative images. */\n  alt?: string\n  /**\n   * \"print\" contact-prints the image as a positive. \"photogram\" treats it as objects laid on the paper:\n   * dark or opaque shapes stay white, everything else exposes to blue. Default \"print\".\n   */\n  mode?: \"print\" | \"photogram\"\n  /** A named tone ramp. Overridden by `ramp`. Default \"classic\". */\n  preset?: CyanotypePreset\n  /** Custom tone stops from full exposure to bare paper, as hex. The last stop is the paper colour. */\n  ramp?: string[]\n  /** Exposure, -1 to 1. Higher prints darker and deeper, lower leaves it pale. Default 0. */\n  exposure?: number\n  /** Tone contrast, roughly 0.5 to 2. Default 1.15. */\n  contrast?: number\n  /** Ragged, streaky brush edge on the coating, 0 to 1. 0 is a clean rectangle. Default 0.6. */\n  roughness?: number\n  /** Uneven coating and exposure, 0 to 1. Default 0.4. */\n  mottle?: number\n  /** Paper tooth over the whole sheet, 0 to 1. Default 0.5. */\n  texture?: number\n  /** Bare paper around the brushed coating, in px. Default 24. */\n  margin?: number\n  /** Fully exposed blue border between the brush edge and the image, in px. Default 18. */\n  border?: number\n  /** Seed for the brush edge and mottle. Give each print in a set its own. Default 3. */\n  seed?: number\n  /** Wash the print from unexposed yellow-green to blue the first time it scrolls into view. Default false. */\n  develop?: boolean\n  /** Length of the develop wash in ms. Default 1800. */\n  developDuration?: number\n  /** How the image fills the box. Default \"cover\". */\n  fit?: \"cover\" | \"contain\"\n  /** Class names for the img element. */\n  imageClassName?: string\n  /** Print an existing element (e.g. next/image or an SVG) instead of rendering an img. */\n  children?: React.ReactNode\n}\n\n/**\n * A photo as a hand-coated cyanotype: Prussian-blue tones, a brushed coating edge with an exposed border,\n * uneven coating and paper tooth. SVG filters only, so any image works without CORS.\n */\nexport function Cyanotype({\n  src,\n  alt = \"\",\n  mode = \"print\",\n  preset = \"classic\",\n  ramp,\n  exposure = 0,\n  contrast = 1.15,\n  roughness = 0.6,\n  mottle = 0.4,\n  texture = 0.5,\n  margin = 24,\n  border = 18,\n  seed = 3,\n  develop = false,\n  developDuration = 1800,\n  fit = \"cover\",\n  imageClassName,\n  className,\n  style,\n  children,\n  ...props\n}: CyanotypeProps) {\n  const id = React.useId().replace(/[^\\w-]/g, \"\")\n  const toneId = `cyan-tone-${id}`\n  const brushId = `cyan-brush-${id}`\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const [developed, setDeveloped] = React.useState(!develop)\n\n  React.useEffect(() => {\n    const el = rootRef.current\n    if (developed || !el) return\n    let observer: IntersectionObserver | undefined\n    // A wash that plays in a background tab is a wash nobody saw. Wait for the tab to be visible.\n    const start = () => {\n      if (document.visibilityState !== \"visible\") return\n      document.removeEventListener(\"visibilitychange\", start)\n      observer = new IntersectionObserver(\n        ([entry]) => {\n          if (!entry.isIntersecting) return\n          observer?.disconnect()\n          setDeveloped(true)\n        },\n        { threshold: 0.35 }\n      )\n      observer.observe(el)\n    }\n    document.addEventListener(\"visibilitychange\", start)\n    start()\n    return () => {\n      document.removeEventListener(\"visibilitychange\", start)\n      observer?.disconnect()\n    }\n  }, [developed])\n\n  const stops = (ramp && ramp.length >= 2 ? ramp : cyanotypePresets[preset]).map((c) => parseHex(c) ?? [0, 0, 0])\n  const paper = ramp?.at(-1) ?? cyanotypePresets[preset].at(-1)\n  const table = (ch: 0 | 1 | 2) => stops.map((s) => fmt(s[ch])).join(\" \")\n\n  // Luminance, or its inverse for a photogram, with contrast and exposure folded into one linear step.\n  const sign = mode === \"photogram\" ? -1 : 1\n  const lum = `${0.2126 * sign} ${0.7152 * sign} ${0.0722 * sign} 0 ${mode === \"photogram\" ? 1 : 0}`\n  const intercept = 0.5 - 0.5 * contrast - Math.min(1, Math.max(-1, exposure)) * 0.35\n  const m = Math.min(1, Math.max(0, mottle)) * 0.5\n\n  const wash = `opacity ${developDuration}ms cubic-bezier(0.23, 1, 0.32, 1)`\n  const imgClass = cn(\"block size-full\", fit === \"cover\" ? \"object-cover\" : \"object-contain\", imageClassName)\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"cyanotype\"\n      data-state={developed ? \"developed\" : \"latent\"}\n      className={cn(\"group/cyanotype relative isolate overflow-hidden\", className)}\n      style={{ backgroundColor: paper, ...style }}\n      {...props}\n    >\n      <svg aria-hidden className=\"pointer-events-none absolute size-0 overflow-hidden\" focusable=\"false\">\n        <defs>\n          <filter id={toneId} x=\"0\" y=\"0\" width=\"100%\" height=\"100%\" colorInterpolationFilters=\"sRGB\">\n            {/* Transparent pixels read as clear film: paper in a print, full exposure in a photogram. */}\n            <feFlood floodColor=\"#fff\" result=\"white\" />\n            <feComposite in=\"SourceGraphic\" in2=\"white\" operator=\"over\" result=\"flat\" />\n            <feColorMatrix in=\"flat\" type=\"matrix\" values={`${lum}  ${lum}  ${lum}  0 0 0 0 1`} result=\"lum\" />\n            <feComponentTransfer in=\"lum\" result=\"lum\">\n              <feFuncR type=\"linear\" slope={contrast} intercept={intercept} />\n              <feFuncG type=\"linear\" slope={contrast} intercept={intercept} />\n              <feFuncB type=\"linear\" slope={contrast} intercept={intercept} />\n            </feComponentTransfer>\n            <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.007\" numOctaves={3} seed={seed + 5} result=\"patch\" />\n            <feColorMatrix\n              in=\"patch\"\n              type=\"matrix\"\n              values=\"1 0 0 0 0  1 0 0 0 0  1 0 0 0 0  0 0 0 0 1\"\n              result=\"patch\"\n            />\n            <feComposite in=\"lum\" in2=\"patch\" operator=\"arithmetic\" k2={1} k3={m} k4={-m / 2} result=\"coated\" />\n            <feComponentTransfer in=\"coated\" result=\"toned\">\n              <feFuncR type=\"table\" tableValues={table(0)} />\n              <feFuncG type=\"table\" tableValues={table(1)} />\n              <feFuncB type=\"table\" tableValues={table(2)} />\n            </feComponentTransfer>\n            {brushEdge(seed, roughness)}\n            <feComposite in=\"toned\" in2=\"edge\" operator=\"in\" />\n          </filter>\n          {develop && (\n            <filter id={brushId} x=\"0\" y=\"0\" width=\"100%\" height=\"100%\" colorInterpolationFilters=\"sRGB\">\n              {brushEdge(seed, roughness)}\n              <feComposite in=\"SourceGraphic\" in2=\"edge\" operator=\"in\" />\n            </filter>\n          )}\n        </defs>\n      </svg>\n\n      <div data-slot=\"cyanotype-coating\" className=\"absolute\" style={{ inset: margin }}>\n        <div\n          className={cn(\"size-full\", develop && \"motion-safe:group-data-[state=latent]/cyanotype:opacity-40\")}\n          style={develop ? { transition: wash } : undefined}\n        >\n          <div\n            className=\"size-full\"\n            style={{\n              filter: `url(#${toneId})`,\n              padding: border,\n              // Outside the negative the paper saw full light.\n              backgroundColor: mode === \"photogram\" ? \"#fff\" : \"#000\",\n            }}\n          >\n            <div className=\"relative size-full overflow-hidden\">\n              {children ?? (\n                // eslint-disable-next-line @next/next/no-img-element\n                <img src={src} alt={alt} draggable={false} className={imgClass} />\n              )}\n            </div>\n          </div>\n        </div>\n        {develop && (\n          <div\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 opacity-0 mix-blend-multiply motion-safe:group-data-[state=latent]/cyanotype:opacity-90\"\n            style={{\n              backgroundColor: SENSITIZER,\n              filter: `url(#${brushId})`,\n              // The yellow holds for a beat, then washes off like the water bath, while the blue deepens.\n              transition: `opacity ${developDuration}ms cubic-bezier(0.77, 0, 0.175, 1)`,\n            }}\n          />\n        )}\n      </div>\n\n      {texture > 0 && (\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 forced-colors:hidden\"\n          style={{ backgroundImage: PAPER_TOOTH, backgroundSize: \"320px 320px\", opacity: Math.min(1, texture) * 0.35 }}\n        />\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-27"
  },
  "type": "registry:component"
}