{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "duotone-image",
  "title": "Duotone & Print Filter",
  "description": "Two-ink colour treatment for photos in the spirit of risograph and screen prints: seven ink presets or any two colours, a mix slider and optional misregistration that can slide into register when the image scrolls into view or as you scroll. Pure SVG filters.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/duotone-image/duotone-image.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type DuotonePreset =\n  | \"riso\"\n  | \"newsprint\"\n  | \"cyanotype\"\n  | \"sepia\"\n  | \"fluoro\"\n  | \"forest\"\n  | \"midnight\"\n\n/** Two-ink pairs: the ink that fills shadows and the ink (or paper) that fills highlights. */\nexport const duotonePresets: Record<DuotonePreset, { shadow: string; highlight: string }> = {\n  riso: { shadow: \"#1d3fb0\", highlight: \"#ffe0e6\" },\n  newsprint: { shadow: \"#15130f\", highlight: \"#f2ede2\" },\n  cyanotype: { shadow: \"#0a2a6e\", highlight: \"#dbe7f5\" },\n  sepia: { shadow: \"#3a2616\", highlight: \"#f1e3c6\" },\n  fluoro: { shadow: \"#2a0c3f\", highlight: \"#ff6fbb\" },\n  forest: { shadow: \"#0f3b2e\", highlight: \"#f1e6b8\" },\n  midnight: { shadow: \"#0b1230\", highlight: \"#8fd3ff\" },\n}\n\ntype RGB = [number, number, number]\n\n/** Parses #rgb, #rrggbb, #rrggbbaa, rgb() and rgba() into 0 to 1 channels. */\nexport function parseColor(input: string): RGB | null {\n  const s = input.trim().toLowerCase()\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1)\n    const full = hex.length === 3 || hex.length === 4 ? hex.split(\"\").map((c) => c + c).join(\"\") : hex\n    if (full.length !== 6 && full.length !== 8) return null\n    const n = Number.parseInt(full.slice(0, 6), 16)\n    if (Number.isNaN(n)) return null\n    return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n  }\n  const m = s.match(/^rgba?\\(\\s*([\\d.]+%?)[\\s,]+([\\d.]+%?)[\\s,]+([\\d.]+%?)/)\n  if (m) {\n    const ch = (v: string) => (v.endsWith(\"%\") ? Number.parseFloat(v) / 100 : Number.parseFloat(v) / 255)\n    return [ch(m[1]), ch(m[2]), ch(m[3])]\n  }\n  return null\n}\n\nconst fmt = (n: number) => Math.min(1, Math.max(0, n)).toFixed(4)\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n))\n\n/** Opacity of the misregistered pass while it is out of register, before it settles. */\nconst PULL_OPACITY = 0.7\n\nexport interface DuotoneImageProps 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  /** A named ink pair. Overridden by `shadow` and `highlight`. Default \"riso\". */\n  preset?: DuotonePreset\n  /** Ink for the shadows. Hex or rgb(); CSS variables cannot be read on the server. */\n  shadow?: string\n  /** Ink for the highlights. Hex or rgb(). */\n  highlight?: string\n  /** Strength of the effect over the original, 0 to 1. Default 1. Ignored when wrapping `children`. */\n  mix?: number\n  /** Tone contrast before inking, roughly 0.5 to 2. Default 1.1. */\n  contrast?: number\n  /** Riso-style misregistration: offsets a second pass of the shadow ink by this many px. Default 0. */\n  offset?: number\n  /** Direction of the misregistration in degrees. Default 30. */\n  offsetAngle?: number\n  /**\n   * Animate the shadow pass into register. \"view\" slides it in once, the first time the image scrolls\n   * into view. \"scroll\" ties it to the image's position in the viewport. Needs `src`. Default \"static\".\n   */\n  registration?: \"static\" | \"view\" | \"scroll\"\n  /** How far out of register the pass starts, in px. It also starts turned by a degree or two. Default 24. */\n  registrationOffset?: number\n  /** Length of the \"view\" slide in ms. Default 1200. */\n  registrationDuration?: 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  /** Filter an existing element (e.g. next/image) instead of rendering an img. */\n  children?: React.ReactNode\n}\n\n/**\n * Two-ink colour treatment for photos, in the spirit of risograph and screen prints.\n * Pure SVG filters, so it works on any image element without CORS.\n */\nexport function DuotoneImage({\n  src,\n  alt = \"\",\n  preset = \"riso\",\n  shadow,\n  highlight,\n  mix = 1,\n  contrast = 1.1,\n  offset = 0,\n  offsetAngle = 30,\n  registration = \"static\",\n  registrationOffset = 24,\n  registrationDuration = 1200,\n  fit = \"cover\",\n  imageClassName,\n  className,\n  style,\n  children,\n  ...props\n}: DuotoneImageProps) {\n  const id = React.useId().replace(/:/g, \"\")\n  const duoId = `duo-${id}`\n  const inkId = `ink-${id}`\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const passRef = React.useRef<HTMLDivElement>(null)\n  const imgRef = React.useRef<HTMLImageElement>(null)\n  const animated = registration !== \"static\" && !children\n  const [registered, setRegistered] = React.useState(!animated)\n\n  const base = duotonePresets[preset]\n  const dark = parseColor(shadow ?? base.shadow) ?? [0, 0, 0]\n  const light = parseColor(highlight ?? base.highlight) ?? [1, 1, 1]\n  const slope = contrast\n  const intercept = 0.5 - 0.5 * contrast\n\n  const rad = (offsetAngle * Math.PI) / 180\n  const dx = Math.cos(rad) * offset\n  const dy = Math.sin(rad) * offset\n  // Where the pass rests, and where it starts: further out along the same line, turned a little,\n  // like a second drum pulled before the paper was squared up.\n  const restOpacity = offset > 0 ? 0.55 : 0\n  const pullX = dx + Math.cos(rad) * registrationOffset\n  const pullY = dy + Math.sin(rad) * registrationOffset\n  const pullTurn = registrationOffset * 0.06\n  const rest = `translate(${dx}px, ${dy}px)`\n\n  React.useEffect(() => {\n    const el = rootRef.current\n    if (!animated || !el || window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return\n\n    if (registration === \"scroll\") {\n      const pass = passRef.current\n      let raf = 0\n      const measure = () => {\n        raf = 0\n        if (!pass) return\n        const r = el.getBoundingClientRect()\n        const vh = window.innerHeight || 1\n        // Out of register as the image enters, in register once its top reaches the upper half.\n        const p = clamp01((vh * 0.95 - r.top) / (vh * 0.55))\n        const t = 1 - (1 - p) ** 3\n        const fade = clamp01((p - 0.5) / 0.5)\n        pass.style.transform = `translate(${pullX + (dx - pullX) * t}px, ${pullY + (dy - pullY) * t}px) rotate(${pullTurn * (1 - t)}deg)`\n        pass.style.opacity = String(PULL_OPACITY + (restOpacity - PULL_OPACITY) * fade * fade * (3 - 2 * fade))\n      }\n      const schedule = () => {\n        if (!raf) raf = requestAnimationFrame(measure)\n      }\n      const observer = new IntersectionObserver(([entry]) => {\n        if (entry.isIntersecting) {\n          window.addEventListener(\"scroll\", schedule, { passive: true })\n          window.addEventListener(\"resize\", schedule)\n          schedule()\n        } else {\n          window.removeEventListener(\"scroll\", schedule)\n          window.removeEventListener(\"resize\", schedule)\n        }\n      })\n      observer.observe(el)\n      return () => {\n        observer.disconnect()\n        window.removeEventListener(\"scroll\", schedule)\n        window.removeEventListener(\"resize\", schedule)\n        cancelAnimationFrame(raf)\n      }\n    }\n\n    if (registered) return\n    const img = imgRef.current\n    let observer: IntersectionObserver | undefined\n    // Wait until there is something to watch: a visible tab and a loaded image.\n    const start = () => {\n      if (document.visibilityState !== \"visible\" || (img && !img.complete)) return\n      document.removeEventListener(\"visibilitychange\", start)\n      img?.removeEventListener(\"load\", start)\n      observer = new IntersectionObserver(\n        ([entry]) => {\n          if (!entry.isIntersecting) return\n          observer?.disconnect()\n          setRegistered(true)\n        },\n        { threshold: 0.35 }\n      )\n      observer.observe(el)\n    }\n    document.addEventListener(\"visibilitychange\", start)\n    img?.addEventListener(\"load\", start)\n    start()\n    return () => {\n      document.removeEventListener(\"visibilitychange\", start)\n      img?.removeEventListener(\"load\", start)\n      observer?.disconnect()\n    }\n  }, [animated, registration, registered, dx, dy, pullX, pullY, pullTurn, restOpacity])\n\n  const filter = (\n    <svg aria-hidden className=\"pointer-events-none absolute size-0 overflow-hidden\" focusable=\"false\">\n      <defs>\n        <filter id={duoId} colorInterpolationFilters=\"sRGB\">\n          <feColorMatrix\n            type=\"matrix\"\n            values=\"0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0  0.2126 0.7152 0.0722 0 0  0 0 0 1 0\"\n          />\n          <feComponentTransfer>\n            <feFuncR type=\"linear\" slope={slope} intercept={intercept} />\n            <feFuncG type=\"linear\" slope={slope} intercept={intercept} />\n            <feFuncB type=\"linear\" slope={slope} intercept={intercept} />\n          </feComponentTransfer>\n          <feComponentTransfer>\n            <feFuncR type=\"table\" tableValues={`${fmt(dark[0])} ${fmt(light[0])}`} />\n            <feFuncG type=\"table\" tableValues={`${fmt(dark[1])} ${fmt(light[1])}`} />\n            <feFuncB type=\"table\" tableValues={`${fmt(dark[2])} ${fmt(light[2])}`} />\n          </feComponentTransfer>\n        </filter>\n        {(offset > 0 || animated) && (\n          <filter\n            id={inkId}\n            colorInterpolationFilters=\"sRGB\"\n            // Clear pixels read as full ink, so the default 10% filter margin would print a solid bar\n            // along the offset edges. Clip the pass to the image.\n            x=\"0\"\n            y=\"0\"\n            width=\"100%\"\n            height=\"100%\"\n          >\n            {/* Shadow ink only, with alpha = darkness, for the misregistered pass. */}\n            <feColorMatrix\n              type=\"matrix\"\n              values={`0 0 0 0 ${fmt(dark[0])}  0 0 0 0 ${fmt(dark[1])}  0 0 0 0 ${fmt(dark[2])}  ${(-0.2126 * slope).toFixed(4)} ${(-0.7152 * slope).toFixed(4)} ${(-0.0722 * slope).toFixed(4)} 0 ${(1 - intercept).toFixed(4)}`}\n            />\n          </filter>\n        )}\n      </defs>\n    </svg>\n  )\n\n  const imgClass = cn(\"block size-full\", fit === \"cover\" ? \"object-cover\" : \"object-contain\", imageClassName)\n\n  if (children) {\n    return (\n      <div data-slot=\"duotone-image\" className={cn(\"relative\", className)} style={style} {...props}>\n        {filter}\n        <div className=\"size-full\" style={{ filter: `url(#${duoId})` }}>\n          {children}\n        </div>\n      </div>\n    )\n  }\n\n  const original = (\n    // eslint-disable-next-line @next/next/no-img-element\n    <img ref={imgRef} src={src} alt={alt} draggable={false} className={imgClass} />\n  )\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"duotone-image\"\n      data-state={animated ? (registered ? \"registered\" : \"pulled\") : undefined}\n      className={cn(\"relative overflow-hidden\", animated && \"group/duotone\", className)}\n      style={style}\n      {...props}\n    >\n      {filter}\n      {mix < 1 ? (\n        <>\n          {original}\n          <div aria-hidden className=\"absolute inset-0\" style={{ opacity: mix, filter: `url(#${duoId})` }}>\n            {/* eslint-disable-next-line @next/next/no-img-element */}\n            <img src={src} alt=\"\" draggable={false} className={imgClass} />\n          </div>\n        </>\n      ) : (\n        <div className=\"size-full\" style={{ filter: `url(#${duoId})` }}>\n          {original}\n        </div>\n      )}\n      {animated ? (\n        <div\n          ref={passRef}\n          aria-hidden\n          // Reduced motion never sees the pulled state, so the print is in register from the first paint.\n          className={cn(\n            \"absolute inset-0 mix-blend-multiply transform-(--duo-rest) opacity-(--duo-rest-opacity)\",\n            \"motion-safe:group-data-[state=pulled]/duotone:transform-(--duo-pull) motion-safe:group-data-[state=pulled]/duotone:opacity-(--duo-pull-opacity)\"\n          )}\n          style={\n            {\n              filter: `url(#${inkId})`,\n              \"--duo-rest\": rest,\n              \"--duo-pull\": `translate(${pullX}px, ${pullY}px) rotate(${pullTurn}deg)`,\n              \"--duo-rest-opacity\": restOpacity,\n              \"--duo-pull-opacity\": PULL_OPACITY,\n              // The plate eases off the mark and settles into register; the doubled ink clears once it is nearly home.\n              transition:\n                registration === \"view\"\n                  ? `transform ${registrationDuration}ms cubic-bezier(0.77, 0, 0.175, 1), opacity ${registrationDuration / 2}ms cubic-bezier(0.23, 1, 0.32, 1) ${registrationDuration / 2}ms`\n                  : undefined,\n            } as React.CSSProperties\n          }\n        >\n          {/* eslint-disable-next-line @next/next/no-img-element */}\n          <img src={src} alt=\"\" draggable={false} className={imgClass} />\n        </div>\n      ) : (\n        offset > 0 && (\n          <div\n            aria-hidden\n            className=\"absolute inset-0 mix-blend-multiply\"\n            style={{ filter: `url(#${inkId})`, transform: rest, opacity: 0.55 }}\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element */}\n            <img src={src} alt=\"\" draggable={false} className={imgClass} />\n          </div>\n        )\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-23"
  },
  "type": "registry:component"
}