{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dither-reveal-card",
  "type": "registry:ui",
  "title": "Dither reveal card",
  "description": "Advances a pixel-dither threshold so imagery develops with a tactile texture.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "visual-ambient"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/dither-reveal-card/",
  "meta": {
    "engines": [
      "motion",
      "webgl"
    ],
    "runtimeCost": "heavy",
    "signature": "Native WebGL Bayer-threshold reveal",
    "sceneFamily": "spatial-dark",
    "motionRole": "ambient",
    "primaryState": "Still WebGL dither-reveal card",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/dither-reveal-card.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/dither-reveal-card.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { useReducedMotion } from \"motion/react\";\n\ntype DitherPalette = {\n  front: string;\n  back: string;\n  ink: string;\n};\n\ntype ColorChannels = [number, number, number, number];\n\ntype ResolvedColor = {\n  css: string;\n  channels: ColorChannels;\n};\n\ntype ResolvedPalette = {\n  front: ResolvedColor;\n  back: ResolvedColor;\n  ink: ResolvedColor;\n};\n\nconst DEFAULT_COLORS: ResolvedPalette = {\n  front: { css: \"rgb(29, 30, 25)\", channels: [29 / 255, 30 / 255, 25 / 255, 1] },\n  back: { css: \"rgb(203, 103, 65)\", channels: [203 / 255, 103 / 255, 65 / 255, 1] },\n  ink: { css: \"rgb(255, 242, 218)\", channels: [255 / 255, 242 / 255, 218 / 255, 1] },\n};\n\nexport type DitherRevealCardProps = {\n  front: ReactNode;\n  back: ReactNode;\n  label?: string;\n  palette?: Partial<DitherPalette>;\n  defaultRevealed?: boolean;\n  onRevealChange?: (revealed: boolean) => void;\n  className?: string;\n};\n\ntype WebGLState = {\n  gl: WebGLRenderingContext;\n  program: WebGLProgram;\n  buffer: WebGLBuffer;\n  vertex: WebGLShader;\n  fragment: WebGLShader;\n  progress: WebGLUniformLocation;\n  resolution: WebGLUniformLocation;\n  time: WebGLUniformLocation;\n  front: WebGLUniformLocation;\n  back: WebGLUniformLocation;\n  ink: WebGLUniformLocation;\n};\n\nconst VERTEX = `\nattribute vec2 a_position;\nvoid main() {\n  gl_Position = vec4(a_position, 0.0, 1.0);\n}`;\n\nconst FRAGMENT = `\nprecision highp float;\nuniform vec2 u_resolution;\nuniform float u_progress;\nuniform float u_time;\nuniform vec4 u_front;\nuniform vec4 u_back;\nuniform vec4 u_ink;\n\nfloat bayer4(vec2 p) {\n  vec2 cell = mod(floor(p), 4.0);\n  float x = cell.x;\n  float y = cell.y;\n  if (y < 1.0) {\n    if (x < 1.0) return 0.0 / 16.0;\n    if (x < 2.0) return 8.0 / 16.0;\n    if (x < 3.0) return 2.0 / 16.0;\n    return 10.0 / 16.0;\n  }\n  if (y < 2.0) {\n    if (x < 1.0) return 12.0 / 16.0;\n    if (x < 2.0) return 4.0 / 16.0;\n    if (x < 3.0) return 14.0 / 16.0;\n    return 6.0 / 16.0;\n  }\n  if (y < 3.0) {\n    if (x < 1.0) return 3.0 / 16.0;\n    if (x < 2.0) return 11.0 / 16.0;\n    if (x < 3.0) return 1.0 / 16.0;\n    return 9.0 / 16.0;\n  }\n  if (x < 1.0) return 15.0 / 16.0;\n  if (x < 2.0) return 7.0 / 16.0;\n  if (x < 3.0) return 13.0 / 16.0;\n  return 5.0 / 16.0;\n}\n\nvoid main() {\n  vec2 uv = gl_FragCoord.xy / max(u_resolution, vec2(1.0));\n  float sweep = uv.x * 0.64 + (1.0 - uv.y) * 0.36;\n  float softWave = sin((uv.y * 9.0) + (u_time * 0.001)) * 0.018;\n  float threshold = clamp((u_progress * 1.32) - sweep * 0.32 + softWave, 0.0, 1.0);\n  float pattern = bayer4(gl_FragCoord.xy);\n  float reveal = step(pattern, threshold);\n  if (u_progress <= 0.001) reveal = 0.0;\n  if (u_progress >= 0.999) reveal = 1.0;\n  float edge = 1.0 - smoothstep(0.0, 0.075, abs(pattern - threshold));\n  vec4 base = mix(u_front, u_back, reveal);\n  vec4 color = mix(base, u_ink, edge * 0.055 * (1.0 - abs(u_progress - 0.5) * 1.3));\n  gl_FragColor = color;\n}`;\n\nfunction compile(\n  gl: WebGLRenderingContext,\n  type: number,\n  source: string,\n): WebGLShader {\n  const shader = gl.createShader(type);\n  if (!shader) throw new Error(\"Unable to create WebGL shader.\");\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    const detail = gl.getShaderInfoLog(shader) ?? \"Shader compilation failed.\";\n    gl.deleteShader(shader);\n    throw new Error(detail);\n  }\n  return shader;\n}\n\nfunction uniform(\n  gl: WebGLRenderingContext,\n  program: WebGLProgram,\n  name: string,\n) {\n  const location = gl.getUniformLocation(program, name);\n  if (!location) throw new Error(`Missing WebGL uniform: ${name}`);\n  return location;\n}\n\nfunction createWebGL(canvas: HTMLCanvasElement): WebGLState | null {\n  const gl = canvas.getContext(\"webgl\", {\n    alpha: true,\n    antialias: false,\n    depth: false,\n    premultipliedAlpha: false,\n    powerPreference: \"low-power\",\n  });\n  if (!gl) return null;\n\n  const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);\n  const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);\n  const program = gl.createProgram();\n  const buffer = gl.createBuffer();\n  if (!program || !buffer) {\n    gl.deleteShader(vertex);\n    gl.deleteShader(fragment);\n    return null;\n  }\n\n  gl.attachShader(program, vertex);\n  gl.attachShader(program, fragment);\n  gl.linkProgram(program);\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    const detail = gl.getProgramInfoLog(program) ?? \"Program linking failed.\";\n    gl.deleteBuffer(buffer);\n    gl.deleteProgram(program);\n    gl.deleteShader(vertex);\n    gl.deleteShader(fragment);\n    throw new Error(detail);\n  }\n\n  gl.useProgram(program);\n  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n  gl.bufferData(\n    gl.ARRAY_BUFFER,\n    new Float32Array([-1, -1, 3, -1, -1, 3]),\n    gl.STATIC_DRAW,\n  );\n  const position = gl.getAttribLocation(program, \"a_position\");\n  gl.enableVertexAttribArray(position);\n  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);\n\n  return {\n    gl,\n    program,\n    buffer,\n    vertex,\n    fragment,\n    progress: uniform(gl, program, \"u_progress\"),\n    resolution: uniform(gl, program, \"u_resolution\"),\n    time: uniform(gl, program, \"u_time\"),\n    front: uniform(gl, program, \"u_front\"),\n    back: uniform(gl, program, \"u_back\"),\n    ink: uniform(gl, program, \"u_ink\"),\n  };\n}\n\nfunction parseComputedRgb(value: string): ColorChannels | null {\n  const match = value.match(\n    /^rgba?\\(\\s*([+-]?[\\d.]+)(%)?[,\\s]+([+-]?[\\d.]+)(%)?[,\\s]+([+-]?[\\d.]+)(%)?(?:\\s*[,/]\\s*([+-]?[\\d.]+)(%)?)?\\s*\\)$/i,\n  );\n  if (!match) return null;\n  const toByte = (part: string, percent: string | undefined) =>\n    Math.min(255, Math.max(0, Number.parseFloat(part) * (percent ? 2.55 : 1)));\n  const alphaText = match[7];\n  const alpha = alphaText\n    ? Math.min(1, Math.max(0, Number.parseFloat(alphaText) * (match[8] ? 0.01 : 1)))\n    : 1;\n  const bytes = [\n    toByte(match[1], match[2]),\n    toByte(match[3], match[4]),\n    toByte(match[5], match[6]),\n  ];\n  if (bytes.some((channel) => Number.isNaN(channel)) || Number.isNaN(alpha)) return null;\n  return [bytes[0] / 255, bytes[1] / 255, bytes[2] / 255, alpha];\n}\n\nfunction rgbaCss(channels: ColorChannels) {\n  const [red, green, blue, alpha] = channels;\n  return `rgba(${Math.round(red * 255)}, ${Math.round(green * 255)}, ${Math.round(blue * 255)}, ${Math.round(alpha * 1000) / 1000})`;\n}\n\nfunction sampleCssColor(value: string): ColorChannels | null {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = 1;\n  canvas.height = 1;\n  const context = canvas.getContext(\"2d\", { willReadFrequently: true });\n  if (!context) return null;\n  context.clearRect(0, 0, 1, 1);\n  context.fillStyle = value;\n  context.fillRect(0, 0, 1, 1);\n  const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;\n  return [red / 255, green / 255, blue / 255, alpha / 255];\n}\n\nfunction resolveCssColor(\n  value: string | undefined,\n  fallback: ResolvedColor,\n  scope: HTMLElement | null,\n): ResolvedColor {\n  const candidate = value?.trim();\n  if (!candidate || typeof document === \"undefined\") {\n    return candidate\n      ? { css: candidate, channels: fallback.channels }\n      : fallback;\n  }\n\n  const probeHost = document.createElement(\"span\");\n  probeHost.style.position = \"fixed\";\n  probeHost.style.pointerEvents = \"none\";\n  probeHost.style.visibility = \"hidden\";\n  const probe = document.createElement(\"span\");\n  probe.style.color = candidate;\n  if (!probe.style.color) return fallback;\n\n  const host = scope ?? document.body;\n  if (!host) return fallback;\n  probeHost.append(probe);\n  host.append(probeHost);\n  let computed: string;\n  if (usesCssVariable(candidate)) {\n    probeHost.style.color = \"rgb(1, 2, 3)\";\n    const first = window.getComputedStyle(probe).color;\n    probeHost.style.color = \"rgb(4, 5, 6)\";\n    const second = window.getComputedStyle(probe).color;\n    probeHost.remove();\n    if (!first || first !== second) return fallback;\n    computed = second;\n  } else {\n    computed = window.getComputedStyle(probe).color;\n    probeHost.remove();\n  }\n\n  const channels = sampleCssColor(computed) ?? parseComputedRgb(computed);\n  return channels ? { css: rgbaCss(channels), channels } : fallback;\n}\n\nfunction samePalette(left: ResolvedPalette, right: ResolvedPalette) {\n  return left.front.css === right.front.css &&\n    left.back.css === right.back.css &&\n    left.ink.css === right.ink.css;\n}\n\nfunction usesCssVariable(value: string | undefined) {\n  return value?.toLowerCase().includes(\"var(\") === true;\n}\n\nexport function DitherRevealCard({\n  front,\n  back,\n  label = \"Reveal card\",\n  palette,\n  defaultRevealed = false,\n  onRevealChange,\n  className = \"\",\n}: DitherRevealCardProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const rootRef = useRef<HTMLButtonElement>(null);\n  const stateRef = useRef<WebGLState | null>(null);\n  const frameRef = useRef<number | null>(null);\n  const contextLossRef = useRef<number | null>(null);\n  const visibleRef = useRef(true);\n  const progressRef = useRef(defaultRevealed ? 1 : 0);\n  const targetRef = useRef(defaultRevealed ? 1 : 0);\n  const reduced = useReducedMotion() === true;\n  const [pinned, setPinned] = useState(defaultRevealed);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [supported, setSupported] = useState(true);\n  const active = pinned || hovered || focused;\n  const paletteFront = palette?.front;\n  const paletteBack = palette?.back;\n  const paletteInk = palette?.ink;\n  const [colors, setColors] = useState<ResolvedPalette>(() => ({\n    front: paletteFront?.trim()\n      ? { css: paletteFront, channels: DEFAULT_COLORS.front.channels }\n      : DEFAULT_COLORS.front,\n    back: paletteBack?.trim()\n      ? { css: paletteBack, channels: DEFAULT_COLORS.back.channels }\n      : DEFAULT_COLORS.back,\n    ink: paletteInk?.trim()\n      ? { css: paletteInk, channels: DEFAULT_COLORS.ink.channels }\n      : DEFAULT_COLORS.ink,\n  }));\n  const colorsRef = useRef(colors);\n  const reducedRef = useRef(reduced);\n  reducedRef.current = reduced;\n\n  const render = useCallback((time = performance.now()) => {\n    const state = stateRef.current;\n    if (!state || !visibleRef.current) return;\n    const { gl } = state;\n    const currentColors = colorsRef.current;\n    gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);\n    gl.useProgram(state.program);\n    gl.uniform2f(state.resolution, gl.drawingBufferWidth, gl.drawingBufferHeight);\n    gl.uniform1f(state.progress, progressRef.current);\n    gl.uniform1f(state.time, time);\n    gl.uniform4fv(state.front, currentColors.front.channels);\n    gl.uniform4fv(state.back, currentColors.back.channels);\n    gl.uniform4fv(state.ink, currentColors.ink.channels);\n    gl.drawArrays(gl.TRIANGLES, 0, 3);\n  }, []);\n\n  const tick = useCallback(\n    (time: number) => {\n      frameRef.current = null;\n      const target = targetRef.current;\n      const current = progressRef.current;\n      const next = reducedRef.current\n        ? target\n        : current + (target - current) * 0.18;\n      progressRef.current = Math.abs(target - next) < 0.002 ? target : next;\n      render(time);\n      if (progressRef.current !== target && visibleRef.current) {\n        frameRef.current = requestAnimationFrame(tick);\n      }\n    },\n    [render],\n  );\n\n  const requestRender = useCallback(() => {\n    if (frameRef.current === null && visibleRef.current) {\n      frameRef.current = requestAnimationFrame(tick);\n    }\n  }, [tick]);\n\n  const resolvePalette = useCallback(() => {\n    const nextColors = {\n      front: resolveCssColor(paletteFront, DEFAULT_COLORS.front, rootRef.current),\n      back: resolveCssColor(paletteBack, DEFAULT_COLORS.back, rootRef.current),\n      ink: resolveCssColor(paletteInk, DEFAULT_COLORS.ink, rootRef.current),\n    };\n    if (samePalette(colorsRef.current, nextColors)) return;\n    colorsRef.current = nextColors;\n    setColors(nextColors);\n    requestRender();\n  }, [paletteBack, paletteFront, paletteInk, requestRender]);\n\n  useEffect(() => {\n    resolvePalette();\n    if (\n      !usesCssVariable(paletteFront) &&\n      !usesCssVariable(paletteBack) &&\n      !usesCssVariable(paletteInk)\n    ) {\n      return;\n    }\n\n    const root = rootRef.current;\n    if (!root || typeof MutationObserver === \"undefined\") return;\n    let paletteFrame: number | null = null;\n    const scheduleResolve = () => {\n      if (paletteFrame !== null) return;\n      paletteFrame = requestAnimationFrame(() => {\n        paletteFrame = null;\n        resolvePalette();\n      });\n    };\n    const observer = new MutationObserver(scheduleResolve);\n    for (let ancestor: HTMLElement | null = root; ancestor; ancestor = ancestor.parentElement) {\n      observer.observe(ancestor, { attributes: true });\n    }\n    const colorScheme = typeof window.matchMedia === \"function\"\n      ? window.matchMedia(\"(prefers-color-scheme: dark)\")\n      : null;\n    colorScheme?.addEventListener(\"change\", scheduleResolve);\n\n    return () => {\n      observer.disconnect();\n      colorScheme?.removeEventListener(\"change\", scheduleResolve);\n      if (paletteFrame !== null) cancelAnimationFrame(paletteFrame);\n    };\n  }, [paletteBack, paletteFront, paletteInk, resolvePalette]);\n\n  useEffect(() => {\n    if (contextLossRef.current !== null) {\n      window.clearTimeout(contextLossRef.current);\n      contextLossRef.current = null;\n    }\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const initialize = () => {\n      let nextState: WebGLState | null = null;\n      try {\n        nextState = createWebGL(canvas);\n      } catch {\n        nextState = null;\n      }\n      stateRef.current = nextState;\n      setSupported(Boolean(nextState));\n      return nextState;\n    };\n    if (!initialize()) return;\n\n    const onContextLost = (event: Event) => {\n      event.preventDefault();\n      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n      frameRef.current = null;\n      stateRef.current = null;\n      setSupported(false);\n    };\n    const onContextRestored = () => {\n      if (initialize()) requestRender();\n    };\n    canvas.addEventListener(\"webglcontextlost\", onContextLost);\n    canvas.addEventListener(\"webglcontextrestored\", onContextRestored);\n\n    const resize = new ResizeObserver(([entry]) => {\n      const width = Math.max(1, entry.contentRect.width);\n      const height = Math.max(1, entry.contentRect.height);\n      const dpr = Math.min(window.devicePixelRatio || 1, 1.5);\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      requestRender();\n    });\n    resize.observe(canvas);\n\n    const intersection = new IntersectionObserver(([entry]) => {\n      visibleRef.current = entry.isIntersecting;\n      if (entry.isIntersecting) requestRender();\n      else if (frameRef.current !== null) {\n        cancelAnimationFrame(frameRef.current);\n        frameRef.current = null;\n      }\n    });\n    intersection.observe(canvas);\n    requestRender();\n\n    return () => {\n      canvas.removeEventListener(\"webglcontextlost\", onContextLost);\n      canvas.removeEventListener(\"webglcontextrestored\", onContextRestored);\n      resize.disconnect();\n      intersection.disconnect();\n      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n      const current = stateRef.current;\n      if (current) {\n        const { gl } = current;\n        gl.deleteBuffer(current.buffer);\n        gl.deleteProgram(current.program);\n        gl.deleteShader(current.vertex);\n        gl.deleteShader(current.fragment);\n        const extension = gl.getExtension(\"WEBGL_lose_context\");\n        if (extension) {\n          const timer = window.setTimeout(() => {\n            extension.loseContext();\n            if (contextLossRef.current === timer) contextLossRef.current = null;\n          }, 0);\n          contextLossRef.current = timer;\n        }\n      }\n      stateRef.current = null;\n      frameRef.current = null;\n    };\n  }, [requestRender]);\n\n  useEffect(() => {\n    targetRef.current = active ? 1 : 0;\n    if (reduced) progressRef.current = targetRef.current;\n    requestRender();\n  }, [active, reduced, requestRender]);\n\n  const toggle = () => {\n    const next = !pinned;\n    setPinned(next);\n    onRevealChange?.(next);\n  };\n\n  return (\n    <button\n      ref={rootRef}\n      type=\"button\"\n      aria-label={label}\n      aria-pressed={pinned}\n      onClick={toggle}\n      onFocus={() => setFocused(true)}\n      onBlur={() => setFocused(false)}\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") setHovered(true);\n      }}\n      onPointerLeave={() => setHovered(false)}\n      className={`group relative isolate min-h-[220px] w-full overflow-hidden rounded-[18px] border border-[#f9e3bd]/25 bg-[#1d1e19] text-left outline-none focus-visible:ring-2 focus-visible:ring-[#8eb9ff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1d1e19] ${className}`}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className={`absolute inset-0 size-full ${supported ? \"opacity-100\" : \"opacity-0\"}`}\n      />\n      <span\n        aria-hidden\n        data-webgl-fallback=\"dither-reveal-card\"\n        className={`absolute inset-0 ${reduced ? \"\" : \"transition-colors duration-200 [transition-timing-function:cubic-bezier(.2,.8,.2,1)]\"}`}\n        style={{\n          backgroundColor: active ? colors.back.css : colors.front.css,\n          opacity: supported ? 0 : 1,\n        }}\n      />\n      <span className=\"relative z-10 grid min-h-[220px] p-5 sm:p-6\">\n        <span\n          aria-hidden={active}\n          className={`col-start-1 row-start-1 flex min-w-0 flex-col justify-between ${reduced ? \"\" : \"transition-[opacity,transform] duration-200 [transition-timing-function:cubic-bezier(.2,.8,.2,1)]\"}`}\n          style={{\n            opacity: active ? 0 : 1,\n            transform: reduced || !active ? \"translate3d(0,0,0)\" : \"translate3d(0,-6px,0)\",\n          }}\n        >\n          {front}\n        </span>\n        <span\n          aria-hidden={!active}\n          className={`col-start-1 row-start-1 flex min-w-0 flex-col justify-between ${reduced ? \"\" : \"transition-[opacity,transform] duration-200 [transition-timing-function:cubic-bezier(.2,.8,.2,1)]\"}`}\n          style={{\n            opacity: active ? 1 : 0,\n            transform: reduced || active ? \"translate3d(0,0,0)\" : \"translate3d(0,6px,0)\",\n          }}\n        >\n          {back}\n        </span>\n      </span>\n      <span\n        data-dither-arrow\n        className={`absolute bottom-4 right-4 z-20 grid size-11 place-items-center rounded-full border border-[#f7e4c7]/25 bg-[#fff0d9] text-[#252019] shadow-[0_10px_28px_-16px_rgba(0,0,0,.8)] ${reduced ? \"\" : \"transition-transform duration-150 [transition-timing-function:cubic-bezier(.2,.8,.2,1)] group-active:scale-[0.96]\"}`}\n      >\n        <svg viewBox=\"0 0 20 20\" fill=\"none\" className=\"size-4\" aria-hidden>\n          <path d=\"M5 10h10M11.5 6.5 15 10l-3.5 3.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        </svg>\n      </span>\n    </button>\n  );\n}\n"
    }
  ]
}
