{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dither-image",
  "type": "registry:ui",
  "description": "Compound Next.js image figure with CSS Bayer dither via dither-plugin, partial reveal overlays, and typed tuning props",
  "dependencies": [
    "dither-plugin"
  ],
  "files": [
    {
      "path": "registry/default/ui/dither-image.tsx",
      "content": "\"use client\"\n\n/**\n * `DitherImage` — compound figure that applies a CSS-only Bayer dither effect\n * to an image via the `dither-plugin` Tailwind utility. Safari-compatible (no\n * SVG filters), fully static (no JS runtime cost), and respects all the\n * plugin's tunable CSS custom properties as typed props.\n *\n * ## Installation\n *\n * ```bash\n * bun add dither-plugin\n * # or: npm install dither-plugin\n * # or: pnpm add dither-plugin\n * # or: yarn add dither-plugin\n * ```\n *\n * Then register the plugin in your Tailwind v4 stylesheet (alongside\n * `tailwindcss`):\n *\n * ```css\n * @import \"tailwindcss\";\n * @import \"dither-plugin\";\n * ```\n *\n * ## Usage\n *\n * ```tsx\n * <DitherImage>\n *   <DitherImageFrame aspectRatio=\"square\" size=\"md\">\n *     <DitherImageContent\n *       src=\"/images/apple-wallpaper.jpg\"\n *       alt=\"Apple wallpaper\"\n *       fill\n *       sizes=\"(min-width: 768px) 33vw, 100vw\"\n *     />\n *   </DitherImageFrame>\n *   <DitherImageCaption>Apple wallpaper, dithered</DitherImageCaption>\n * </DitherImage>\n * ```\n *\n * Partial dither (masked clean layer + optional `invertOnDark`):\n *\n * ```tsx\n * <DitherImageReveal className=\"size-72 overflow-hidden rounded-xl\">\n *   <DitherImageFrame invertOnDark size=\"lg\" aspectRatio=\"square\">\n *     <DitherImageContent src=\"/photo.jpg\" alt=\"\" fill sizes=\"288px\" />\n *   </DitherImageFrame>\n *   <DitherImageOverlay src=\"/photo.jpg\" alt=\"\" fill sizes=\"288px\" from={0} to={65} />\n * </DitherImageReveal>\n * ```\n *\n * ## Notes\n *\n * - The dither class must live on a **wrapper** around the image. The plugin\n *   paints the dot matrix via a `::after` pseudo-element, which `<img>` /\n *   `<video>` elements do not render.\n * - The wrapper applies `filter: grayscale() brightness() blur() contrast()`\n *   to all children. Render captions / overlay text **outside** the\n *   `DitherImageFrame` (as `DitherImageCaption` does) so they stay crisp.\n * - `background: #000` ships from the plugin to give the `screen` blend-mode\n *   something to lift against. Override with an inline background if needed.\n *\n * @see https://github.com/flornkm/dither-plugin\n */\nimport {\n  createContext,\n  forwardRef,\n  useContext,\n  type ComponentProps,\n  type CSSProperties,\n  type HTMLAttributes,\n} from \"react\"\nimport Image, { type ImageProps } from \"next/image\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** Cell size of the underlying dither matrix — maps to plugin `--dither-cell-*` theme tokens. */\nexport type DitherSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\"\n\nconst NUMERIC_SIZE_RE = /^\\d+$/\n\nconst DITHER_SIZE_CLASS: Record<DitherSize, string> = {\n  xs: \"dither-xs\",\n  sm: \"dither-sm\",\n  md: \"dither-md\",\n  lg: \"dither-lg\",\n  xl: \"dither-xl\",\n  \"2xl\": \"dither-2xl\",\n}\n\n/** Shorthand aspect-ratio values; pass any valid `aspect-ratio` string for custom. */\nexport type DitherAspectRatio =\n  | \"square\"\n  | \"video\"\n  | \"portrait\"\n  | \"wide\"\n  | (string & {})\n  | number\n\nfunction resolveAspectRatio(ratio: DitherAspectRatio): string {\n  if (typeof ratio === \"number\") {\n    return String(ratio)\n  }\n  if (ratio === \"square\") {\n    return \"1 / 1\"\n  }\n  if (ratio === \"video\") {\n    return \"16 / 9\"\n  }\n  if (ratio === \"portrait\") {\n    return \"3 / 4\"\n  }\n  if (ratio === \"wide\") {\n    return \"21 / 9\"\n  }\n  return ratio\n}\n\n/** CSS custom properties exposed by `dither-plugin`. Numbers are used directly by the plugin's `filter`. */\ninterface DitherVars {\n  \"--dither-gray\"?: number | string\n  \"--dither-contrast\"?: number | string\n  \"--dither-bright\"?: number | string\n  \"--dither-blur\"?: string\n  \"--dither-cell\"?: string\n  \"--dither-opacity\"?: number | string\n  \"--dither-image\"?: string\n}\n\n/* ─── Frame context (invert on dark) ───────────────────────────────────── */\n\nconst DitherImageFrameContext = createContext<{ invertOnDark: boolean } | null>(\n  null\n)\n\n/* ─── Root figure ──────────────────────────────────────────────────────── */\n\nexport type DitherImageProps = ComponentProps<\"figure\">\n\n/**\n * `<figure>` wrapper grouping a dithered frame with its caption. Stays\n * unfiltered so child captions read at full fidelity.\n */\nconst DitherImage = forwardRef<HTMLElement, DitherImageProps>(\n  function DitherImage({ className, ...props }, ref) {\n    return (\n      <figure\n        className={cn(\"inline-flex flex-col gap-3\", className)}\n        data-slot=\"dither-image\"\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nDitherImage.displayName = \"DitherImage\"\n\n/* ─── Frame (the dither surface) ───────────────────────────────────────── */\n\nexport interface DitherImageFrameProps\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"style\"> {\n  /** Cell size — maps to `dither-{size}` utility. Defaults to `lg` (matches the plugin's bare `dither` class). */\n  size?: DitherSize\n  /** Shorthand: `\"square\" | \"video\" | \"portrait\" | \"wide\"` or any valid `aspect-ratio` string. */\n  aspectRatio?: DitherAspectRatio\n  /** `--dither-gray` (0 = color, 1 = grayscale). Default `1`. */\n  grayscale?: number\n  /** `--dither-contrast` — unitless CSS `contrast()` value. Plugin default `120` (crushes to 1-bit). */\n  contrast?: number\n  /** `--dither-bright` — unitless CSS `brightness()` value. Default `1`. */\n  brightness?: number\n  /** `--dither-blur` — accepts a number (px) or any CSS length. Default `0`. */\n  blur?: number | string\n  /** `--dither-opacity` — dot-pattern overlay opacity (0–1). Default `1`. */\n  opacity?: number\n  /** Round the frame corners. `true` uses `rounded-xl`; pass a string for a custom class. */\n  rounded?: boolean | string\n  /**\n   * Wrap the dither surface in `dark:invert` and counter-invert the image in\n   * dark mode so the dither dots read correctly while photo colors stay true.\n   */\n  invertOnDark?: boolean\n  /** Merged with generated CSS variables; your values take precedence. */\n  style?: CSSProperties & DitherVars\n}\n\n/**\n * The element that actually wears the dither class. Must be a direct parent\n * of the `<img>`/`<video>` — the plugin paints via `::after` which media\n * elements don't support.\n */\nconst DitherImageFrame = forwardRef<HTMLDivElement, DitherImageFrameProps>(\n  function DitherImageFrame(\n    {\n      className,\n      size = \"lg\",\n      aspectRatio,\n      grayscale,\n      contrast,\n      brightness,\n      blur,\n      opacity,\n      rounded = true,\n      invertOnDark = false,\n      style,\n      ...props\n    },\n    ref\n  ) {\n    const vars: CSSProperties & DitherVars = { ...style }\n\n    if (grayscale !== undefined) {\n      vars[\"--dither-gray\"] = grayscale\n    }\n    if (contrast !== undefined) {\n      vars[\"--dither-contrast\"] = contrast\n    }\n    if (brightness !== undefined) {\n      vars[\"--dither-bright\"] = brightness\n    }\n    if (blur !== undefined) {\n      vars[\"--dither-blur\"] = typeof blur === \"number\" ? `${blur}px` : blur\n    }\n    if (opacity !== undefined) {\n      vars[\"--dither-opacity\"] = opacity\n    }\n    if (aspectRatio !== undefined && vars.aspectRatio === undefined) {\n      vars.aspectRatio = resolveAspectRatio(aspectRatio)\n    }\n\n    let roundedClass: string | undefined\n    if (rounded === true) {\n      roundedClass = \"rounded-xl\"\n    } else if (typeof rounded === \"string\") {\n      roundedClass = rounded\n    }\n\n    const frame = (\n      <div\n        className={cn(\n          DITHER_SIZE_CLASS[size],\n          \"relative block w-full\",\n          roundedClass,\n          className\n        )}\n        data-size={size}\n        data-slot=\"dither-image-frame\"\n        ref={ref}\n        style={vars}\n        {...props}\n      />\n    )\n\n    return (\n      <DitherImageFrameContext.Provider value={{ invertOnDark }}>\n        {invertOnDark ? <div className=\"dark:invert\">{frame}</div> : frame}\n      </DitherImageFrameContext.Provider>\n    )\n  }\n)\nDitherImageFrame.displayName = \"DitherImageFrame\"\n\n/* ─── Reveal stage ─────────────────────────────────────────────────────── */\n\nexport type DitherImageRevealProps = ComponentProps<\"div\"> & {\n  /** Tailwind size shorthand (`72` → `size-72`). Non-numeric strings are applied as extra classes. */\n  size?: number | string\n}\n\n/**\n * Positioning stage for partial dither: stacks the dithered frame with a\n * masked clean `DitherImageOverlay` as siblings inside `relative overflow-hidden`.\n */\nconst DitherImageReveal = forwardRef<HTMLDivElement, DitherImageRevealProps>(\n  function DitherImageReveal({ className, size, ...props }, ref) {\n    let sizeClass: string | undefined\n    if (size !== undefined) {\n      if (typeof size === \"number\") {\n        sizeClass = `size-${size}`\n      } else if (NUMERIC_SIZE_RE.test(size)) {\n        sizeClass = `size-${size}`\n      } else {\n        sizeClass = size\n      }\n    }\n\n    return (\n      <div\n        className={cn(\"relative overflow-hidden\", sizeClass, className)}\n        data-slot=\"dither-image-reveal\"\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nDitherImageReveal.displayName = \"DitherImageReveal\"\n\n/* ─── Overlay (masked clean copy) ─────────────────────────────────────── */\n\nexport type DitherRevealDirection =\n  | \"l\"\n  | \"r\"\n  | \"t\"\n  | \"b\"\n  /** Top-left → bottom-right diagonal (clean top-left). */\n  | \"tl-br\"\n  /** Top-right → bottom-left diagonal (clean top-right). */\n  | \"tr-bl\"\n  /** Bottom-left → top-right diagonal (clean bottom-left). */\n  | \"bl-tr\"\n  /** Bottom-right → top-left diagonal (clean bottom-right). */\n  | \"br-tl\"\n  | \"radial\"\n\nexport type DitherImageOverlayProps = Omit<ImageProps, \"style\"> & {\n  /**\n   * Mask axis: clean image strongest where the gradient starts.\n   * Axis-aligned: `l` | `r` | `t` | `b`; diagonals: `tl-br` | `tr-bl` | `bl-tr` | `br-tl`; `radial`.\n   * Default `\"r\"` (clean left → dither right).\n   */\n  direction?: DitherRevealDirection\n  /** Mask start % (0–100). Default `0`. */\n  from?: number\n  /** Mask end % (0–100). Default `65`. */\n  to?: number\n  /** Overrides typed mask utilities — use Tailwind `mask-*` classes or arbitrary values. */\n  maskClassName?: string\n  style?: CSSProperties\n}\n\nfunction revealMaskImage(\n  direction: DitherRevealDirection,\n  from: number,\n  to: number\n): string {\n  const a = Math.min(from, to)\n  const b = Math.max(from, to)\n  switch (direction) {\n    case \"r\":\n      return `linear-gradient(to right, black ${a}%, transparent ${b}%)`\n    case \"l\":\n      return `linear-gradient(to left, black ${a}%, transparent ${b}%)`\n    case \"t\":\n      return `linear-gradient(to bottom, black ${a}%, transparent ${b}%)`\n    case \"b\":\n      return `linear-gradient(to top, black ${a}%, transparent ${b}%)`\n    case \"tl-br\":\n      return `linear-gradient(to bottom right, black ${a}%, transparent ${b}%)`\n    case \"tr-bl\":\n      return `linear-gradient(to bottom left, black ${a}%, transparent ${b}%)`\n    case \"bl-tr\":\n      return `linear-gradient(to top right, black ${a}%, transparent ${b}%)`\n    case \"br-tl\":\n      return `linear-gradient(to top left, black ${a}%, transparent ${b}%)`\n    case \"radial\":\n      return `radial-gradient(circle at center, black ${a}%, transparent ${b}%)`\n    default: {\n      const _never: never = direction\n      return _never\n    }\n  }\n}\n\nfunction revealMaskStyle(\n  direction: DitherRevealDirection,\n  from: number,\n  to: number\n): CSSProperties {\n  const img = revealMaskImage(direction, from, to)\n  return {\n    WebkitMaskImage: img,\n    maskImage: img,\n    WebkitMaskSize: \"100% 100%\",\n    maskSize: \"100% 100%\",\n    WebkitMaskRepeat: \"no-repeat\",\n    maskRepeat: \"no-repeat\",\n  }\n}\n\n/**\n * Absolutely positioned clean copy of the image, masked so the dithered\n * layer underneath shows through where the mask is transparent.\n */\nconst DitherImageOverlay = forwardRef<\n  HTMLImageElement,\n  DitherImageOverlayProps\n>(function DitherImageOverlay(\n  {\n    className,\n    direction = \"r\",\n    from = 0,\n    to = 65,\n    maskClassName,\n    style,\n    ...props\n  },\n  ref\n) {\n  const typedMaskStyle =\n    maskClassName === undefined ? revealMaskStyle(direction, from, to) : {}\n\n  return (\n    <Image\n      className={cn(\n        \"pointer-events-none absolute inset-0 h-full w-full object-cover\",\n        maskClassName === undefined && \"mask\",\n        maskClassName,\n        className\n      )}\n      data-slot=\"dither-image-overlay\"\n      ref={ref}\n      style={{ ...typedMaskStyle, ...style }}\n      {...props}\n    />\n  )\n})\nDitherImageOverlay.displayName = \"DitherImageOverlay\"\n\n/* ─── Image content ────────────────────────────────────────────────────── */\n\nexport type DitherImageContentProps = ImageProps\n\n/**\n * `next/image` tuned for a `DitherImageFrame`. Fills the frame by default; pass\n * `width`/`height` explicitly for intrinsic sizing (and drop `fill`).\n */\nconst DitherImageContent = forwardRef<\n  HTMLImageElement,\n  DitherImageContentProps\n>(function DitherImageContent({ className, alt, ...props }, ref) {\n  const ctx = useContext(DitherImageFrameContext)\n  const counterInvert = ctx?.invertOnDark === true ? \"dark:invert\" : undefined\n\n  return (\n    <Image\n      alt={alt}\n      className={cn(\n        \"block h-full w-full object-cover\",\n        counterInvert,\n        className\n      )}\n      data-slot=\"dither-image-content\"\n      ref={ref}\n      {...props}\n    />\n  )\n})\nDitherImageContent.displayName = \"DitherImageContent\"\n\n/* ─── Caption ──────────────────────────────────────────────────────────── */\n\nexport type DitherImageCaptionProps = ComponentProps<\"figcaption\">\n\n/**\n * `<figcaption>` sibling to the frame. Renders **outside** the filtered\n * surface so text stays crisp and fully readable.\n */\nconst DitherImageCaption = forwardRef<HTMLElement, DitherImageCaptionProps>(\n  function DitherImageCaption({ className, ...props }, ref) {\n    return (\n      <figcaption\n        className={cn(\n          \"text-muted-foreground text-sm leading-relaxed text-pretty\",\n          className\n        )}\n        data-slot=\"dither-image-caption\"\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nDitherImageCaption.displayName = \"DitherImageCaption\"\n\nexport {\n  DitherImage,\n  DitherImageCaption,\n  DitherImageContent,\n  DitherImageFrame,\n  DitherImageOverlay,\n  DitherImageReveal,\n}\n",
      "type": "registry:ui"
    }
  ]
}