# Dither Image

> next/image wrapper for React and shadcn/ui that applies a CSS-only Bayer dither effect, with full-frame and partial masked reveals.

Source: https://www.cult-ui.com/docs/components/dither-image

## Example

```tsx title="dither-image-demo.tsx"
"use client"

import {
  DitherImage,
  DitherImageCaption,
  DitherImageContent,
  DitherImageFrame,
  DitherImageOverlay,
  DitherImageReveal,
  type DitherRevealDirection,
} from "@/components/ui/dither-image"

const GHIBLI_STILLS: { alt: string; src: string }[] = Array.from(
  { length: 9 },
  (_, i) => ({
    src: `/images/gibli/gibli-${i + 1}.jpg`,
    alt: `Studio Ghibli-style still ${i + 1}, dithered`,
  })
)

const FRAME_SIZES = "(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 90vw"
/** Fixed box for `DitherImageReveal` + overlay demos (`size-56` → 14rem). */
const REVEAL_SIZES = "224px"

const REVEAL_OVERLAY_VARIANTS: {
  direction: DitherRevealDirection
  from: number
  to: number
  label: string
  hint: string
}[] = [
  {
    direction: "r",
    from: 0,
    to: 65,
    label: "r",
    hint: "Clean left → dither right",
  },
  {
    direction: "l",
    from: 0,
    to: 65,
    label: "l",
    hint: "Clean right → dither left",
  },
  {
    direction: "t",
    from: 0,
    to: 65,
    label: "t",
    hint: "Clean top → dither bottom",
  },
  {
    direction: "b",
    from: 0,
    to: 65,
    label: "b",
    hint: "Clean bottom → dither top",
  },
  {
    direction: "tl-br",
    from: 0,
    to: 70,
    label: "tl-br",
    hint: "Diagonal (clean top-left)",
  },
  {
    direction: "radial",
    from: 25,
    to: 75,
    label: "radial",
    hint: "Center clean, edges dither",
  },
]

export default function DitherImageDemo() {
  return (
    <main className="flex w-full items-center justify-center">
      <div className="flex w-full max-w-5xl flex-col gap-10">
        <header className="flex flex-col gap-2">
          <h1 className="text-foreground text-2xl font-semibold tracking-tight text-balance">
            DitherImage
          </h1>
          <p className="text-muted-foreground max-w-prose text-sm leading-relaxed">
            CSS-only Bayer dither via{" "}
            <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
              dither-plugin
            </code>
            . Compound:{" "}
            <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
              DitherImage
            </code>{" "}
            wraps a{" "}
            <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
              DitherImageFrame
            </code>{" "}
            (the dithered surface) containing a{" "}
            <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
              DitherImageContent
            </code>
            , with an optional{" "}
            <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
              DitherImageCaption
            </code>{" "}
            that stays outside the filter.
          </p>
        </header>

        <section className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
          <DitherImage>
            <DitherImageFrame aspectRatio="square" size="xs">
              <DitherImageContent
                alt={GHIBLI_STILLS[0].alt}
                fill
                sizes={FRAME_SIZES}
                src={GHIBLI_STILLS[0].src}
              />
            </DitherImageFrame>
            <DitherImageCaption>
              <span className="text-foreground font-medium">xs</span> · 8px
              cell, defaults
            </DitherImageCaption>
          </DitherImage>

          <DitherImage>
            <DitherImageFrame aspectRatio="square" size="sm">
              <DitherImageContent
                alt={GHIBLI_STILLS[1].alt}
                fill
                sizes={FRAME_SIZES}
                src={GHIBLI_STILLS[1].src}
              />
            </DitherImageFrame>
            <DitherImageCaption>
              <span className="text-foreground font-medium">sm</span> · 12px
              cell, defaults
            </DitherImageCaption>
          </DitherImage>

          <DitherImage>
            <DitherImageFrame aspectRatio="square" size="md">
              <DitherImageContent
                alt={GHIBLI_STILLS[2].alt}
                fill
                sizes={FRAME_SIZES}
                src={GHIBLI_STILLS[2].src}
              />
            </DitherImageFrame>
            <DitherImageCaption>
              <span className="text-foreground font-medium">md</span> · 16px
              cell, defaults
            </DitherImageCaption>
          </DitherImage>

          <DitherImage>
            <DitherImageFrame aspectRatio="square" size="lg">
              <DitherImageContent
                alt={GHIBLI_STILLS[3].alt}
                fill
                sizes={FRAME_SIZES}
                src={GHIBLI_STILLS[3].src}
              />
            </DitherImageFrame>
            <DitherImageCaption>
              <span className="text-foreground font-medium">lg</span> · 20px
              cell, defaults
            </DitherImageCaption>
          </DitherImage>
        </section>

        <section className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
          <DitherImage>
            <DitherImageFrame
              aspectRatio="square"
              contrast={80}
              grayscale={0}
              size="md"
            >
              <DitherImageContent
                alt={GHIBLI_STILLS[4].alt}
                fill
                sizes={FRAME_SIZES}
                src={GHIBLI_STILLS[4].src}
              />
            </DitherImageFrame>
            <DitherImageCaption>
              <span className="text-foreground font-medium">Color + soft</span>{" "}
              · grayscale 0, contrast 80
            </DitherImageCaption>
          </DitherImage>

          <DitherImage>
            <DitherImageFrame
              aspectRatio="square"
              brightness={1.15}
              opacity={0.6}
              size="lg"
            >
              <DitherImageContent
                alt={GHIBLI_STILLS[5].alt}
                fill
                sizes={FRAME_SIZES}
                src={GHIBLI_STILLS[5].src}
              />
            </DitherImageFrame>
            <DitherImageCaption>
              <span className="text-foreground font-medium">Faded overlay</span>{" "}
              · opacity 0.6, brightness 1.15
            </DitherImageCaption>
          </DitherImage>

          <DitherImage>
            <DitherImageFrame
              aspectRatio="square"
              invertOnDark
              rounded="rounded-full"
              size="sm"
            >
              <DitherImageContent
                alt={GHIBLI_STILLS[6].alt}
                fill
                sizes={FRAME_SIZES}
                src={GHIBLI_STILLS[6].src}
              />
            </DitherImageFrame>
            <DitherImageCaption>
              <span className="text-foreground font-medium">
                Circle · invert on dark
              </span>{" "}
              · <code className="font-mono text-[0.7rem]">rounded-full</code>,
              12px cell, dots flip in dark mode
            </DitherImageCaption>
          </DitherImage>
        </section>

        <section className="flex flex-col gap-6">
          <header className="flex flex-col gap-2">
            <h2 className="text-foreground text-lg font-semibold tracking-tight text-balance">
              Reveal · DitherImageOverlay
            </h2>
            <p className="text-muted-foreground max-w-prose text-sm leading-relaxed">
              Partial dither:{" "}
              <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
                DitherImageReveal
              </code>{" "}
              stacks a masked{" "}
              <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
                DitherImageOverlay
              </code>{" "}
              on the dithered frame.{" "}
              <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
                direction
              </code>{" "}
              controls the gradient axis;{" "}
              <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
                from
              </code>{" "}
              /{" "}
              <code className="bg-muted rounded px-1 py-0.5 font-mono text-[0.75rem]">
                to
              </code>{" "}
              set mask stops (see component types for diagonals).
            </p>
          </header>

          <div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
            {REVEAL_OVERLAY_VARIANTS.map(
              ({ direction, from, to, label, hint }, i) => {
                const img = GHIBLI_STILLS[(7 + i) % GHIBLI_STILLS.length]
                return (
                  <DitherImage key={direction}>
                    <DitherImageReveal className="mx-auto size-56 overflow-hidden rounded-xl">
                      <DitherImageFrame
                        aspectRatio="square"
                        className="rounded-none"
                        invertOnDark
                        rounded={false}
                        size="md"
                      >
                        <DitherImageContent
                          alt={img.alt}
                          className="object-cover"
                          fill
                          sizes={REVEAL_SIZES}
                          src={img.src}
                        />
                      </DitherImageFrame>
                      <DitherImageOverlay
                        alt={img.alt}
                        className="object-cover"
                        direction={direction}
                        fill
                        from={from}
                        sizes={REVEAL_SIZES}
                        src={img.src}
                        to={to}
                      />
                    </DitherImageReveal>
                    <DitherImageCaption>
                      <span className="text-foreground font-medium">
                        {label}
                      </span>{" "}
                      · {hint}
                      <span className="tabular-nums">
                        {" "}
                        — {from}%→{to}%
                      </span>
                    </DitherImageCaption>
                  </DitherImage>
                )
              }
            )}
          </div>
        </section>
      </div>
    </main>
  )
}
```

Dither Image is a React image component for shadcn/ui built on next/image and the dither-plugin Tailwind utility. Use it for editorial photos, portfolio grids, or landing page heroes, including partial reveals that dither only part of an image.

## Installation

The UI layer is a thin React wrapper; the visual effect comes from [`dither-plugin`](https://github.com/flornkm/dither-plugin). Install the package and import it in your Tailwind v4 stylesheet **alongside** `tailwindcss`.

### CLI

```bash
npx shadcn@latest add @cult-ui/dither-image
```

After the CLI finishes, ensure `dither-plugin` is installed and `@import "dither-plugin";` is present in your global CSS (see Manual).

### Manual

**Install `dither-plugin` and keep `next` available for `next/image`.**

```bash
npm install dither-plugin
```

**In your Tailwind v4 entry CSS, register the plugin import.**

```css
@import "tailwindcss";
@import "dither-plugin";
```

**Copy the component into your project.**

````tsx title="dither-image.tsx"
"use client"

/**
 * `DitherImage` — compound figure that applies a CSS-only Bayer dither effect
 * to an image via the `dither-plugin` Tailwind utility. Safari-compatible (no
 * SVG filters), fully static (no JS runtime cost), and respects all the
 * plugin's tunable CSS custom properties as typed props.
 *
 * ## Installation
 *
 * ```bash
 * bun add dither-plugin
 * # or: npm install dither-plugin
 * # or: pnpm add dither-plugin
 * # or: yarn add dither-plugin
 * ```
 *
 * Then register the plugin in your Tailwind v4 stylesheet (alongside
 * `tailwindcss`):
 *
 * ```css
 * @import "tailwindcss";
 * @import "dither-plugin";
 * ```
 *
 * ## Usage
 *
 * ```tsx
 * <DitherImage>
 *   <DitherImageFrame aspectRatio="square" size="md">
 *     <DitherImageContent
 *       src="/images/apple-wallpaper.jpg"
 *       alt="Apple wallpaper"
 *       fill
 *       sizes="(min-width: 768px) 33vw, 100vw"
 *     />
 *   </DitherImageFrame>
 *   <DitherImageCaption>Apple wallpaper, dithered</DitherImageCaption>
 * </DitherImage>
 * ```
 *
 * Partial dither (masked clean layer + optional `invertOnDark`):
 *
 * ```tsx
 * <DitherImageReveal className="size-72 overflow-hidden rounded-xl">
 *   <DitherImageFrame invertOnDark size="lg" aspectRatio="square">
 *     <DitherImageContent src="/photo.jpg" alt="" fill sizes="288px" />
 *   </DitherImageFrame>
 *   <DitherImageOverlay src="/photo.jpg" alt="" fill sizes="288px" from={0} to={65} />
 * </DitherImageReveal>
 * ```
 *
 * ## Notes
 *
 * - The dither class must live on a **wrapper** around the image. The plugin
 *   paints the dot matrix via a `::after` pseudo-element, which `<img>` /
 *   `<video>` elements do not render.
 * - The wrapper applies `filter: grayscale() brightness() blur() contrast()`
 *   to all children. Render captions / overlay text **outside** the
 *   `DitherImageFrame` (as `DitherImageCaption` does) so they stay crisp.
 * - `background: #000` ships from the plugin to give the `screen` blend-mode
 *   something to lift against. Override with an inline background if needed.
 *
 * @see https://github.com/flornkm/dither-plugin
 */
import {
  createContext,
  forwardRef,
  useContext,
  type ComponentProps,
  type CSSProperties,
  type HTMLAttributes,
} from "react"
import Image, { type ImageProps } from "next/image"

import { cn } from "@/lib/utils"

/** Cell size of the underlying dither matrix — maps to plugin `--dither-cell-*` theme tokens. */
export type DitherSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"

const NUMERIC_SIZE_RE = /^\d+$/

const DITHER_SIZE_CLASS: Record<DitherSize, string> = {
  xs: "dither-xs",
  sm: "dither-sm",
  md: "dither-md",
  lg: "dither-lg",
  xl: "dither-xl",
  "2xl": "dither-2xl",
}

/** Shorthand aspect-ratio values; pass any valid `aspect-ratio` string for custom. */
export type DitherAspectRatio =
  | "square"
  | "video"
  | "portrait"
  | "wide"
  | (string & {})
  | number

function resolveAspectRatio(ratio: DitherAspectRatio): string {
  if (typeof ratio === "number") {
    return String(ratio)
  }
  if (ratio === "square") {
    return "1 / 1"
  }
  if (ratio === "video") {
    return "16 / 9"
  }
  if (ratio === "portrait") {
    return "3 / 4"
  }
  if (ratio === "wide") {
    return "21 / 9"
  }
  return ratio
}

/** CSS custom properties exposed by `dither-plugin`. Numbers are used directly by the plugin's `filter`. */
interface DitherVars {
  "--dither-gray"?: number | string
  "--dither-contrast"?: number | string
  "--dither-bright"?: number | string
  "--dither-blur"?: string
  "--dither-cell"?: string
  "--dither-opacity"?: number | string
  "--dither-image"?: string
}

/* ─── Frame context (invert on dark) ───────────────────────────────────── */

const DitherImageFrameContext = createContext<{ invertOnDark: boolean } | null>(
  null
)

/* ─── Root figure ──────────────────────────────────────────────────────── */

export type DitherImageProps = ComponentProps<"figure">

/**
 * `<figure>` wrapper grouping a dithered frame with its caption. Stays
 * unfiltered so child captions read at full fidelity.
 */
const DitherImage = forwardRef<HTMLElement, DitherImageProps>(
  function DitherImage({ className, ...props }, ref) {
    return (
      <figure
        className={cn("inline-flex flex-col gap-3", className)}
        data-slot="dither-image"
        ref={ref}
        {...props}
      />
    )
  }
)
DitherImage.displayName = "DitherImage"

/* ─── Frame (the dither surface) ───────────────────────────────────────── */

export interface DitherImageFrameProps
  extends Omit<HTMLAttributes<HTMLDivElement>, "style"> {
  /** Cell size — maps to `dither-{size}` utility. Defaults to `lg` (matches the plugin's bare `dither` class). */
  size?: DitherSize
  /** Shorthand: `"square" | "video" | "portrait" | "wide"` or any valid `aspect-ratio` string. */
  aspectRatio?: DitherAspectRatio
  /** `--dither-gray` (0 = color, 1 = grayscale). Default `1`. */
  grayscale?: number
  /** `--dither-contrast` — unitless CSS `contrast()` value. Plugin default `120` (crushes to 1-bit). */
  contrast?: number
  /** `--dither-bright` — unitless CSS `brightness()` value. Default `1`. */
  brightness?: number
  /** `--dither-blur` — accepts a number (px) or any CSS length. Default `0`. */
  blur?: number | string
  /** `--dither-opacity` — dot-pattern overlay opacity (0–1). Default `1`. */
  opacity?: number
  /** Round the frame corners. `true` uses `rounded-xl`; pass a string for a custom class. */
  rounded?: boolean | string
  /**
   * Wrap the dither surface in `dark:invert` and counter-invert the image in
   * dark mode so the dither dots read correctly while photo colors stay true.
   */
  invertOnDark?: boolean
  /** Merged with generated CSS variables; your values take precedence. */
  style?: CSSProperties & DitherVars
}

/**
 * The element that actually wears the dither class. Must be a direct parent
 * of the `<img>`/`<video>` — the plugin paints via `::after` which media
 * elements don't support.
 */
const DitherImageFrame = forwardRef<HTMLDivElement, DitherImageFrameProps>(
  function DitherImageFrame(
    {
      className,
      size = "lg",
      aspectRatio,
      grayscale,
      contrast,
      brightness,
      blur,
      opacity,
      rounded = true,
      invertOnDark = false,
      style,
      ...props
    },
    ref
  ) {
    const vars: CSSProperties & DitherVars = { ...style }

    if (grayscale !== undefined) {
      vars["--dither-gray"] = grayscale
    }
    if (contrast !== undefined) {
      vars["--dither-contrast"] = contrast
    }
    if (brightness !== undefined) {
      vars["--dither-bright"] = brightness
    }
    if (blur !== undefined) {
      vars["--dither-blur"] = typeof blur === "number" ? `${blur}px` : blur
    }
    if (opacity !== undefined) {
      vars["--dither-opacity"] = opacity
    }
    if (aspectRatio !== undefined && vars.aspectRatio === undefined) {
      vars.aspectRatio = resolveAspectRatio(aspectRatio)
    }

    let roundedClass: string | undefined
    if (rounded === true) {
      roundedClass = "rounded-xl"
    } else if (typeof rounded === "string") {
      roundedClass = rounded
    }

    const frame = (
      <div
        className={cn(
          DITHER_SIZE_CLASS[size],
          "relative block w-full",
          roundedClass,
          className
        )}
        data-size={size}
        data-slot="dither-image-frame"
        ref={ref}
        style={vars}
        {...props}
      />
    )

    return (
      <DitherImageFrameContext.Provider value={{ invertOnDark }}>
        {invertOnDark ? <div className="dark:invert">{frame}</div> : frame}
      </DitherImageFrameContext.Provider>
    )
  }
)
DitherImageFrame.displayName = "DitherImageFrame"

/* ─── Reveal stage ─────────────────────────────────────────────────────── */

export type DitherImageRevealProps = ComponentProps<"div"> & {
  /** Tailwind size shorthand (`72` → `size-72`). Non-numeric strings are applied as extra classes. */
  size?: number | string
}

/**
 * Positioning stage for partial dither: stacks the dithered frame with a
 * masked clean `DitherImageOverlay` as siblings inside `relative overflow-hidden`.
 */
const DitherImageReveal = forwardRef<HTMLDivElement, DitherImageRevealProps>(
  function DitherImageReveal({ className, size, ...props }, ref) {
    let sizeClass: string | undefined
    if (size !== undefined) {
      if (typeof size === "number") {
        sizeClass = `size-${size}`
      } else if (NUMERIC_SIZE_RE.test(size)) {
        sizeClass = `size-${size}`
      } else {
        sizeClass = size
      }
    }

    return (
      <div
        className={cn("relative overflow-hidden", sizeClass, className)}
        data-slot="dither-image-reveal"
        ref={ref}
        {...props}
      />
    )
  }
)
DitherImageReveal.displayName = "DitherImageReveal"

/* ─── Overlay (masked clean copy) ─────────────────────────────────────── */

export type DitherRevealDirection =
  | "l"
  | "r"
  | "t"
  | "b"
  /** Top-left → bottom-right diagonal (clean top-left). */
  | "tl-br"
  /** Top-right → bottom-left diagonal (clean top-right). */
  | "tr-bl"
  /** Bottom-left → top-right diagonal (clean bottom-left). */
  | "bl-tr"
  /** Bottom-right → top-left diagonal (clean bottom-right). */
  | "br-tl"
  | "radial"

export type DitherImageOverlayProps = Omit<ImageProps, "style"> & {
  /**
   * Mask axis: clean image strongest where the gradient starts.
   * Axis-aligned: `l` | `r` | `t` | `b`; diagonals: `tl-br` | `tr-bl` | `bl-tr` | `br-tl`; `radial`.
   * Default `"r"` (clean left → dither right).
   */
  direction?: DitherRevealDirection
  /** Mask start % (0–100). Default `0`. */
  from?: number
  /** Mask end % (0–100). Default `65`. */
  to?: number
  /** Overrides typed mask utilities — use Tailwind `mask-*` classes or arbitrary values. */
  maskClassName?: string
  style?: CSSProperties
}

function revealMaskImage(
  direction: DitherRevealDirection,
  from: number,
  to: number
): string {
  const a = Math.min(from, to)
  const b = Math.max(from, to)
  switch (direction) {
    case "r":
      return `linear-gradient(to right, black ${a}%, transparent ${b}%)`
    case "l":
      return `linear-gradient(to left, black ${a}%, transparent ${b}%)`
    case "t":
      return `linear-gradient(to bottom, black ${a}%, transparent ${b}%)`
    case "b":
      return `linear-gradient(to top, black ${a}%, transparent ${b}%)`
    case "tl-br":
      return `linear-gradient(to bottom right, black ${a}%, transparent ${b}%)`
    case "tr-bl":
      return `linear-gradient(to bottom left, black ${a}%, transparent ${b}%)`
    case "bl-tr":
      return `linear-gradient(to top right, black ${a}%, transparent ${b}%)`
    case "br-tl":
      return `linear-gradient(to top left, black ${a}%, transparent ${b}%)`
    case "radial":
      return `radial-gradient(circle at center, black ${a}%, transparent ${b}%)`
    default: {
      const _never: never = direction
      return _never
    }
  }
}

function revealMaskStyle(
  direction: DitherRevealDirection,
  from: number,
  to: number
): CSSProperties {
  const img = revealMaskImage(direction, from, to)
  return {
    WebkitMaskImage: img,
    maskImage: img,
    WebkitMaskSize: "100% 100%",
    maskSize: "100% 100%",
    WebkitMaskRepeat: "no-repeat",
    maskRepeat: "no-repeat",
  }
}

/**
 * Absolutely positioned clean copy of the image, masked so the dithered
 * layer underneath shows through where the mask is transparent.
 */
const DitherImageOverlay = forwardRef<
  HTMLImageElement,
  DitherImageOverlayProps
>(function DitherImageOverlay(
  {
    className,
    direction = "r",
    from = 0,
    to = 65,
    maskClassName,
    style,
    ...props
  },
  ref
) {
  const typedMaskStyle =
    maskClassName === undefined ? revealMaskStyle(direction, from, to) : {}

  return (
    <Image
      className={cn(
        "pointer-events-none absolute inset-0 h-full w-full object-cover",
        maskClassName === undefined && "mask",
        maskClassName,
        className
      )}
      data-slot="dither-image-overlay"
      ref={ref}
      style={{ ...typedMaskStyle, ...style }}
      {...props}
    />
  )
})
DitherImageOverlay.displayName = "DitherImageOverlay"

/* ─── Image content ────────────────────────────────────────────────────── */

export type DitherImageContentProps = ImageProps

/**
 * `next/image` tuned for a `DitherImageFrame`. Fills the frame by default; pass
 * `width`/`height` explicitly for intrinsic sizing (and drop `fill`).
 */
const DitherImageContent = forwardRef<
  HTMLImageElement,
  DitherImageContentProps
>(function DitherImageContent({ className, alt, ...props }, ref) {
  const ctx = useContext(DitherImageFrameContext)
  const counterInvert = ctx?.invertOnDark === true ? "dark:invert" : undefined

  return (
    <Image
      alt={alt}
      className={cn(
        "block h-full w-full object-cover",
        counterInvert,
        className
      )}
      data-slot="dither-image-content"
      ref={ref}
      {...props}
    />
  )
})
DitherImageContent.displayName = "DitherImageContent"

/* ─── Caption ──────────────────────────────────────────────────────────── */

export type DitherImageCaptionProps = ComponentProps<"figcaption">

/**
 * `<figcaption>` sibling to the frame. Renders **outside** the filtered
 * surface so text stays crisp and fully readable.
 */
const DitherImageCaption = forwardRef<HTMLElement, DitherImageCaptionProps>(
  function DitherImageCaption({ className, ...props }, ref) {
    return (
      <figcaption
        className={cn(
          "text-muted-foreground text-sm leading-relaxed text-pretty",
          className
        )}
        data-slot="dither-image-caption"
        ref={ref}
        {...props}
      />
    )
  }
)
DitherImageCaption.displayName = "DitherImageCaption"

export {
  DitherImage,
  DitherImageCaption,
  DitherImageContent,
  DitherImageFrame,
  DitherImageOverlay,
  DitherImageReveal,
}
````

**Update import paths (`@/lib/utils`, `@/components/ui/...`) to match your app.**

## Usage

```tsx
import {
  DitherImage,
  DitherImageCaption,
  DitherImageContent,
  DitherImageFrame,
} from "@/components/ui/dither-image";
```

### Full-frame dither

Wrap **`DitherImageContent`** in **`DitherImageFrame`**. The frame owns the `dither-*` utilities; captions belong in **`DitherImageCaption`** **outside** the frame so text is not blurred or grayscale-filtered.

```tsx
<DitherImage>
  <DitherImageFrame aspectRatio="square" size="md">
    <DitherImageContent
      src="/images/photo.jpg"
      alt="Example"
      fill
      sizes="(min-width: 768px) 33vw, 100vw"
    />
  </DitherImageFrame>
  <DitherImageCaption>
    Caption stays crisp outside the filtered surface.
  </DitherImageCaption>
</DitherImage>
```

### Partial reveal (clean layer + dither)

Use **`DitherImageReveal`** as a `relative overflow-hidden` stage. Stack **`DitherImageFrame`** (dithered) and **`DitherImageOverlay`** (same image, masked) as siblings. The overlay’s `direction`, `from`, and `to` props control the gradient mask.

```tsx
import {
  DitherImageContent,
  DitherImageFrame,
  DitherImageOverlay,
  DitherImageReveal,
} from "@/components/ui/dither-image";

export function DitherRevealExample() {
  return (
    <DitherImageReveal className="size-72 overflow-hidden rounded-xl">
      <DitherImageFrame invertOnDark size="lg" aspectRatio="square">
        <DitherImageContent src="/photo.jpg" alt="" fill sizes="288px" />
      </DitherImageFrame>
      <DitherImageOverlay
        src="/photo.jpg"
        alt=""
        fill
        sizes="288px"
        from={0}
        to={65}
      />
    </DitherImageReveal>
  );
}
```

### Dark mode and `invertOnDark`

When **`invertOnDark`** is set on **`DitherImageFrame`**, the frame is wrapped so the dither reads correctly in dark mode while photo colors are counter-inverted on **`DitherImageContent`**.

### `next/image` and arbitrary sources

For remote URLs, blobs, or data URLs, the image optimizer may need **`unoptimized`** or **`remotePatterns`** in `next.config`. The upload playground below uses the same pattern you can copy into product code.

## API

| Export | Role |
| --- | --- |
| `DitherImage` | `<figure>` wrapper; groups frame + caption without applying dither filters. |
| `DitherImageFrame` | Dither surface: `size`, `aspectRatio`, `grayscale`, `contrast`, `brightness`, `blur`, `opacity`, `rounded`, `invertOnDark`, and CSS variables via `style`. |
| `DitherImageContent` | `next/image` child; use `fill` + `object-cover` sizing inside the frame. |
| `DitherImageReveal` | Relative, overflow-hidden stage for partial dither layouts. |
| `DitherImageOverlay` | Absolutely positioned duplicate image with a gradient **`maskImage`**; props `direction`, `from`, `to`, `maskClassName`. |
| `DitherImageCaption` | `<figcaption>` with muted typography defaults. |

**Types:** `DitherSize`, `DitherAspectRatio`, `DitherRevealDirection` are exported for props and demos.

## Notes

- The dither class must sit on a **wrapper** around the media: the plugin paints with `::after`, which does not apply to raw `<img>` / `<video>` nodes.
- The frame applies `filter: grayscale() brightness() blur() contrast()` to **all** descendants—keep captions and UI chrome outside **`DitherImageFrame`**.
- Effect is CSS-only and Safari-friendly (no SVG filters). Runtime cost is negligible once styles are loaded.

## Interactive playground (upload & URL)

## Example

```tsx title="dither-image-demo-upload.tsx"
"use client"

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ChangeEvent,
  type DragEvent,
  type ReactNode,
} from "react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
  DitherImage,
  DitherImageContent,
  DitherImageFrame,
  DitherImageOverlay,
  DitherImageReveal,
  type DitherRevealDirection,
  type DitherSize,
} from "@/components/ui/dither-image"

/** Layered edge + lift (SKILL-DESIGN) — replaces flat card borders. */
const PANEL_SURFACE = cn(
  "bg-card/65 rounded-2xl p-4 md:p-5",
  "shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_-1px_rgba(0,0,0,0.06),0px_2px_4px_0px_rgba(0,0,0,0.04)]",
  "dark:shadow-[0px_0px_0px_1px_rgba(255,255,255,0.06),0px_1px_2px_-1px_rgba(255,255,255,0.03),0px_2px_4px_0px_rgba(0,0,0,0.2)]",
  "transition-shadow duration-200",
  "hover:shadow-[0px_0px_0px_1px_rgba(0,0,0,0.08),0px_1px_2px_-1px_rgba(0,0,0,0.08),0px_2px_4px_0px_rgba(0,0,0,0.06)]",
  "dark:hover:shadow-[0px_0px_0px_1px_rgba(255,255,255,0.1),0px_1px_2px_-1px_rgba(255,255,255,0.05),0px_2px_4px_0px_rgba(0,0,0,0.25)]"
)

const BTN_PRESS =
  "active:scale-[0.96] transition-transform duration-150 ease-[cubic-bezier(0.2,0,0,1)]"

const SELECT_SURFACE = cn(
  "border-input/80 bg-input/15 h-10 w-full rounded-[calc(var(--radius)-2px)] border px-2.5 text-sm outline-none",
  "focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-2"
)

const FALLBACK_SRC = "/images/gibli/gibli-1.jpg"
const FALLBACK_ALT = "Sample wallpaper — upload or paste a URL to preview"

const REMOTE_HTTP_RE = /^https?:\/\//i

function labelFromRemoteUrl(href: string): string {
  if (href.startsWith("/")) {
    const file = href.split("/").pop()
    return file ?? href
  }
  try {
    const u = new URL(href)
    const path =
      u.pathname.length > 48 ? `${u.pathname.slice(0, 48)}…` : u.pathname
    return `${u.hostname}${path}`
  } catch {
    return "Remote image"
  }
}

/** Use with `next/image` when the optimizer cannot fetch the host (arbitrary URLs, blobs, data). */
function needsUnoptimizedImage(src: string): boolean {
  return (
    src.startsWith("blob:") ||
    src.startsWith("data:") ||
    REMOTE_HTTP_RE.test(src)
  )
}

const SIZE_OPTIONS: { value: DitherSize; label: string }[] = [
  { value: "xs", label: "xs (8px)" },
  { value: "sm", label: "sm (12px)" },
  { value: "md", label: "md (16px)" },
  { value: "lg", label: "lg (20px)" },
  { value: "xl", label: "xl (28px)" },
  { value: "2xl", label: "2xl (40px)" },
]

const ASPECT_OPTIONS = [
  { value: "square" as const, label: "1:1" },
  { value: "video" as const, label: "16:9" },
  { value: "portrait" as const, label: "3:4" },
  { value: "wide" as const, label: "21:9" },
]

const REVEAL_DIRECTION_OPTIONS: {
  value: DitherRevealDirection
  label: string
}[] = [
  { value: "r", label: "Right fade (clean left)" },
  { value: "l", label: "Left fade (clean right)" },
  { value: "t", label: "Top fade (clean top)" },
  { value: "b", label: "Bottom fade (clean bottom)" },
  { value: "tl-br", label: "Diagonal ↘ (clean top-left)" },
  { value: "tr-bl", label: "Diagonal ↙ (clean top-right)" },
  { value: "bl-tr", label: "Diagonal ↗ (clean bottom-left)" },
  { value: "br-tl", label: "Diagonal ↖ (clean bottom-right)" },
  { value: "radial", label: "Radial" },
]

const QUICK_GIFS = [
  {
    href: "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExMzBrNXo4dzFia3BpYzg5YnR6aGptcmxxY2VuN2d0ZDZvZnlhZTNzaiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/6ipe9NBXm39n0f6JCw/giphy.gif",
    label: "GIF 0",
  },
  {
    href: "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ3A5eWlqY2lyaW9mNHJkeGczanQ5azkxdDV1MzVnd3dpZWk0dTZ1dyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/x2N5CFzAjQ0NSaGKAO/giphy.gif",
    label: "GIF 1",
  },
  {
    href: "https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExdm5tbTZ1ZGlibDB6bTJ0ZjluMnduYXNncjJwZDlqcHN2ZDZoMndnaSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/26xBs1E58r3ZHYvgQ/giphy.gif",
    label: "GIF 2",
  },
  {
    href: "https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ2FyMjN6ZDc4YTdiMzY5dWtwbjhrb29pc2xlNDg1aTl0ZmU3eHVjMCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/xhzaJAhrcuDMUs1IjI/giphy.gif",
    label: "GIF 3",
  },
  {
    href: "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExcGxmaTUybzN3ZmVqMDV4aHd6eXBwcDhuMWNicWZueTk4eWppNmQ2ciZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LUl2tRY5oVlBu/giphy.gif",
    label: "GIF 4",
  },
] as const

/** Local stills: `public/images/gibli/gibli-1.jpg` … `gibli-9.jpg` → `/images/gibli/...`. */
const QUICK_GHIBLI: { href: string; label: string }[] = Array.from(
  { length: 9 },
  (_, i) => {
    const n = i + 1
    return {
      href: `/images/gibli/gibli-${n}.jpg`,
      label: String(n),
    }
  }
)

function RangeField({
  id,
  label,
  value,
  onChange,
  min,
  max,
  step,
  suffix,
}: {
  id: string
  label: string
  value: number
  onChange: (v: number) => void
  min: number
  max: number
  step: number
  suffix?: string
}) {
  return (
    <div className="flex flex-col gap-1.5">
      <div className="flex items-baseline justify-between gap-2">
        <Label className="text-foreground text-xs" htmlFor={id}>
          {label}
        </Label>
        <span className="text-muted-foreground font-mono text-[0.65rem] tabular-nums">
          {value}
          {suffix ?? ""}
        </span>
      </div>
      <input
        className={cn(
          "bg-muted accent-primary h-2 w-full cursor-pointer appearance-none rounded-full",
          "[&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full"
        )}
        id={id}
        max={max}
        min={min}
        onChange={(e) => onChange(Number(e.target.value))}
        step={step}
        type="range"
        value={value}
      />
    </div>
  )
}

/** Single stat pill — wraps as one unit so lines never break mid-value (e.g. “reveal r”). */
function CaptionMetaChip({
  kicker,
  children,
}: {
  kicker: string
  children: ReactNode
}) {
  return (
    <span
      className={cn(
        "inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md whitespace-nowrap",
        "border-border/45 bg-muted/40 border px-2 py-1",
        "text-[0.7rem] leading-none tracking-tight",
        "dark:bg-muted/25 shadow-[0_1px_0_0_rgba(0,0,0,0.05)] dark:shadow-[0_1px_0_0_rgba(255,255,255,0.06)]"
      )}
    >
      <span className="text-muted-foreground shrink-0 text-[0.6rem] font-medium tracking-[0.1em] uppercase">
        {kicker}
      </span>
      <span className="text-foreground min-w-0 font-medium tabular-nums">
        {children}
      </span>
    </span>
  )
}

function PanelSection({
  eyebrow,
  title,
  description,
  children,
  className,
}: {
  eyebrow: string
  title: string
  description?: string
  children: ReactNode
  className?: string
}) {
  return (
    <div className={cn("flex flex-col gap-4", className)}>
      <div className="flex flex-col gap-1">
        <p className="text-muted-foreground text-[0.65rem] font-medium tracking-[0.14em] uppercase">
          {eyebrow}
        </p>
        <h2 className="text-foreground text-lg font-semibold tracking-tight text-balance">
          {title}
        </h2>
        {description ? (
          <p className="text-muted-foreground text-xs leading-relaxed text-pretty">
            {description}
          </p>
        ) : null}
      </div>
      {children}
    </div>
  )
}

export interface DitherImageDemoUploadProps {
  /** Starting image: same-origin path (e.g. `/images/foo.jpg`) or `https://…` image / GIF. */
  initialSrc?: string
  /** Caption label when `initialSrc` is set; if omitted and `initialSrc` is remote, a short URL label is derived. */
  initialLabel?: string
}

export default function DitherImageDemoUpload({
  initialSrc,
  initialLabel,
}: DitherImageDemoUploadProps = {}) {
  const baseId = useId()
  const blobUrlRef = useRef<string | null>(null)

  const [previewSrc, setPreviewSrc] = useState(() => initialSrc ?? FALLBACK_SRC)
  const [fileLabel, setFileLabel] = useState<string | null>(() => {
    if (initialLabel) {
      return initialLabel
    }
    if (initialSrc && REMOTE_HTTP_RE.test(initialSrc)) {
      return labelFromRemoteUrl(initialSrc)
    }
    if (initialSrc?.startsWith("data:")) {
      return "Data URL"
    }
    return null
  })
  const [urlDraft, setUrlDraft] = useState(() => {
    if (initialSrc && REMOTE_HTTP_RE.test(initialSrc)) {
      return initialSrc
    }
    return ""
  })

  const [size, setSize] = useState<DitherSize>("md")
  const [aspect, setAspect] =
    useState<(typeof ASPECT_OPTIONS)[number]["value"]>("square")
  const [grayscale, setGrayscale] = useState(1)
  const [contrast, setContrast] = useState(120)
  const [brightness, setBrightness] = useState(1)
  const [blurPx, setBlurPx] = useState(0)
  const [opacity, setOpacity] = useState(1)

  const [revealEnabled, setRevealEnabled] = useState(true)
  const [revealDir, setRevealDir] = useState<DitherRevealDirection>("r")
  const [revealFrom, setRevealFrom] = useState(0)
  const [revealTo, setRevealTo] = useState(65)
  const [fileDragging, setFileDragging] = useState(false)
  const fileDropDepth = useRef(0)

  const revokeBlob = useCallback(() => {
    if (blobUrlRef.current) {
      URL.revokeObjectURL(blobUrlRef.current)
      blobUrlRef.current = null
    }
  }, [])

  useEffect(() => () => revokeBlob(), [revokeBlob])

  const applyFile = useCallback(
    (file: File) => {
      if (!file.type.startsWith("image/")) {
        return
      }
      revokeBlob()
      const url = URL.createObjectURL(file)
      blobUrlRef.current = url
      setPreviewSrc(url)
      setFileLabel(file.name)
      setUrlDraft("")
    },
    [revokeBlob]
  )

  const onFileChange = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0]
      if (file) {
        applyFile(file)
      }
      e.target.value = ""
    },
    [applyFile]
  )

  const onFileDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    fileDropDepth.current += 1
    if (e.dataTransfer.types.includes("Files")) {
      setFileDragging(true)
    }
  }, [])

  const onFileDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    fileDropDepth.current -= 1
    if (fileDropDepth.current <= 0) {
      fileDropDepth.current = 0
      setFileDragging(false)
    }
  }, [])

  const onFileDragOver = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    e.dataTransfer.dropEffect = "copy"
  }, [])

  const onFileDrop = useCallback(
    (e: DragEvent) => {
      e.preventDefault()
      e.stopPropagation()
      fileDropDepth.current = 0
      setFileDragging(false)
      const file = e.dataTransfer.files?.[0]
      if (file) {
        applyFile(file)
      }
    },
    [applyFile]
  )

  const loadRemoteImage = useCallback(
    (href: string) => {
      revokeBlob()
      setPreviewSrc(href)
      setFileLabel(labelFromRemoteUrl(href))
      setUrlDraft(href)
    },
    [revokeBlob]
  )

  const applyImageUrl = useCallback(() => {
    const raw = urlDraft.trim()
    if (!raw) {
      return
    }
    if (raw.startsWith("data:")) {
      revokeBlob()
      setPreviewSrc(raw)
      setFileLabel("Pasted data URL")
      return
    }
    let href: string
    try {
      const u = new URL(raw)
      if (u.protocol === "http:" || u.protocol === "https:") {
        href = u.href
      } else {
        return
      }
    } catch {
      try {
        const u = new URL(`https://${raw}`)
        href = u.href
      } catch {
        return
      }
    }
    loadRemoteImage(href)
  }, [loadRemoteImage, urlDraft, revokeBlob])

  const resetToSample = useCallback(() => {
    revokeBlob()
    setPreviewSrc(FALLBACK_SRC)
    setFileLabel(null)
    setUrlDraft("")
  }, [revokeBlob])

  const resetSliders = useCallback(() => {
    setSize("md")
    setAspect("square")
    setGrayscale(1)
    setContrast(120)
    setBrightness(1)
    setBlurPx(0)
    setOpacity(1)
    setRevealEnabled(true)
    setRevealDir("r")
    setRevealFrom(0)
    setRevealTo(65)
  }, [])

  const unoptimized = needsUnoptimizedImage(previewSrc)

  return (
    <main className="bg-background min-h-screen antialiased">
      <div className="mx-auto max-w-7xl px-4 py-8 md:px-6 md:py-10">
        <header className="mb-8 flex max-w-3xl flex-col gap-2 md:mb-10">
          <p className="text-muted-foreground text-[0.65rem] font-medium tracking-[0.14em] uppercase">
            Cult UI · sandbox
          </p>
          <h1 className="text-foreground text-2xl font-semibold tracking-tight text-balance md:text-3xl">
            DitherImage — upload
          </h1>
          <p className="text-muted-foreground text-sm leading-relaxed text-pretty">
            Upload a file, paste an image or GIF URL, or use the sample. Tune{" "}
            <code className="bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]">
              dither-plugin
            </code>{" "}
            live. Remote URLs, blobs, and{" "}
            <code className="bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]">
              data:
            </code>{" "}
            use{" "}
            <code className="bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]">
              next/image
            </code>{" "}
            with{" "}
            <code className="bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]">
              unoptimized
            </code>{" "}
            so any host works without changing{" "}
            <code className="bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]">
              next.config
            </code>
            .
          </p>
        </header>

        <div
          className={cn(
            "flex flex-col gap-6",
            "xl:grid xl:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]",
            "xl:gap-x-10 xl:gap-y-8"
          )}
        >
          {/* Left rail — source */}
          <aside
            className={cn(
              "order-2 flex min-h-0 min-w-0 flex-col xl:col-start-1 xl:row-start-1 xl:h-full",
              PANEL_SURFACE
            )}
          >
            <PanelSection
              className="min-h-0 flex-1 xl:min-h-0"
              description="Paste a URL, pick a preset, or upload a file."
              eyebrow="01"
              title="Source"
            >
              <div className="flex min-h-0 flex-1 flex-col gap-3">
                <div className="flex shrink-0 flex-col gap-1.5">
                  <Label
                    className="text-foreground text-xs"
                    htmlFor={`${baseId}-url`}
                  >
                    Image or GIF URL
                  </Label>
                  <div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
                    <Input
                      className="min-w-0 flex-1 rounded-[calc(var(--radius)-2px)] font-mono text-xs"
                      id={`${baseId}-url`}
                      onChange={(e) => setUrlDraft(e.target.value)}
                      onKeyDown={(e) => {
                        if (e.key === "Enter") {
                          e.preventDefault()
                          applyImageUrl()
                        }
                      }}
                      placeholder="https://example.com/photo.jpg or animated.gif"
                      type="url"
                      value={urlDraft}
                    />
                    <Button
                      className={cn("shrink-0 sm:w-auto", BTN_PRESS)}
                      onClick={applyImageUrl}
                      type="button"
                      variant="secondary"
                    >
                      Load URL
                    </Button>
                  </div>
                </div>

                <div className="flex shrink-0 flex-col gap-1.5">
                  <span className="text-foreground text-xs">Quick GIFs</span>
                  <div className="flex flex-wrap gap-2">
                    {QUICK_GIFS.map((g) => (
                      <Button
                        className={BTN_PRESS}
                        key={g.href}
                        onClick={() => loadRemoteImage(g.href)}
                        size="sm"
                        type="button"
                        variant={previewSrc === g.href ? "default" : "outline"}
                      >
                        {g.label}
                      </Button>
                    ))}
                  </div>
                </div>

                <div className="flex shrink-0 flex-col gap-1.5">
                  <span className="text-foreground text-xs">
                    Ghibli stills (local)
                  </span>
                  <div className="flex flex-wrap gap-2">
                    {QUICK_GHIBLI.map((g) => (
                      <Button
                        className={BTN_PRESS}
                        key={g.href}
                        onClick={() => loadRemoteImage(g.href)}
                        size="sm"
                        type="button"
                        variant={previewSrc === g.href ? "default" : "outline"}
                      >
                        {g.label}
                      </Button>
                    ))}
                  </div>
                </div>

                <div className="border-border/60 flex min-h-0 flex-1 flex-col gap-2 border-t pt-3">
                  <p className="text-foreground shrink-0 text-xs font-medium">
                    Upload image or GIF
                  </p>
                  <label
                    className={cn(
                      "flex min-h-32 flex-1 cursor-pointer flex-col items-center justify-center gap-1.5 rounded-xl border-2 border-dashed px-4 py-10 text-center transition-colors",
                      "border-border/55 bg-muted/15 hover:border-border hover:bg-muted/30",
                      "focus-within:border-ring focus-within:ring-ring/25 focus-within:ring-2",
                      fileDragging &&
                        "border-primary bg-primary/8 hover:border-primary"
                    )}
                    htmlFor={`${baseId}-file`}
                    onDragEnter={onFileDragEnter}
                    onDragLeave={onFileDragLeave}
                    onDragOver={onFileDragOver}
                    onDrop={onFileDrop}
                  >
                    <input
                      accept="image/*"
                      aria-label="Upload image or GIF"
                      className="sr-only"
                      id={`${baseId}-file`}
                      onChange={onFileChange}
                      type="file"
                    />
                    <span className="text-foreground text-sm font-medium">
                      {fileDragging ? "Drop to load" : "Drop image or GIF here"}
                    </span>
                    <span className="text-muted-foreground max-w-[14rem] text-xs leading-snug">
                      or click to choose — same formats as URL presets
                    </span>
                  </label>
                </div>
                <div className="shrink-0 pt-2">
                  <Button
                    className={cn("w-full", BTN_PRESS)}
                    onClick={resetSliders}
                    type="button"
                  >
                    Reset settings
                  </Button>
                </div>
              </div>
            </PanelSection>
          </aside>

          {/* Preview — shares top row with source */}
          <section
            className={cn(
              "order-1 w-full min-w-0 xl:col-start-2 xl:row-start-1",
              "xl:w-full xl:max-w-none"
            )}
          >
            <div
              className={cn(
                "bg-muted/20 rounded-[calc(var(--radius)+6px)] p-1.5",
                "shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_2px_8px_-2px_rgba(0,0,0,0.08)]",
                "dark:shadow-[0px_0px_0px_1px_rgba(255,255,255,0.08),0px_2px_12px_-2px_rgba(0,0,0,0.45)]",
                "ring-1 ring-black/10 ring-inset dark:ring-white/10"
              )}
            >
              <div className="overflow-hidden rounded-xl">
                <DitherImage className="w-full">
                  {revealEnabled ? (
                    <DitherImageReveal className="w-full overflow-hidden rounded-xl">
                      <DitherImageFrame
                        aspectRatio={aspect}
                        blur={blurPx}
                        brightness={brightness}
                        className="rounded-none"
                        contrast={contrast}
                        grayscale={grayscale}
                        invertOnDark
                        opacity={opacity}
                        rounded={false}
                        size={size}
                      >
                        <DitherImageContent
                          alt={fileLabel ?? FALLBACK_ALT}
                          fill
                          sizes="(max-width: 1280px) 100vw, 60vw"
                          src={previewSrc}
                          unoptimized={unoptimized}
                        />
                      </DitherImageFrame>
                      <DitherImageOverlay
                        alt={fileLabel ?? FALLBACK_ALT}
                        direction={revealDir}
                        fill
                        from={revealFrom}
                        sizes="(max-width: 1280px) 100vw, 60vw"
                        src={previewSrc}
                        to={revealTo}
                        unoptimized={unoptimized}
                      />
                    </DitherImageReveal>
                  ) : (
                    <DitherImageFrame
                      aspectRatio={aspect}
                      blur={blurPx}
                      brightness={brightness}
                      contrast={contrast}
                      grayscale={grayscale}
                      invertOnDark
                      opacity={opacity}
                      size={size}
                    >
                      <DitherImageContent
                        alt={fileLabel ?? FALLBACK_ALT}
                        fill
                        sizes="(max-width: 1280px) 100vw, 60vw"
                        src={previewSrc}
                        unoptimized={unoptimized}
                      />
                    </DitherImageFrame>
                  )}
                  {/* <DitherImageCaption
                    className={cn(
                      "w-full min-w-0 max-w-full border-border/50 border-t pt-3",
                      "text-sm leading-snug"
                    )}
                  >
                    <div className="flex min-w-0 flex-col gap-2.5">
                      <p
                        className="min-w-0 text-balance font-medium text-foreground text-sm leading-snug tracking-tight"
                        title={fileLabel ?? undefined}
                      >
                        {fileLabel ?? "Sample image"}
                      </p>
                      <ul
                        aria-label="Dither settings"
                        className="m-0 flex list-none flex-wrap gap-1.5 p-0"
                      >
                        <li className="inline-flex">
                          <CaptionMetaChip kicker="Cell">
                            {size}
                          </CaptionMetaChip>
                        </li>
                        <li className="inline-flex">
                          <CaptionMetaChip kicker="Contrast">
                            {contrast}
                          </CaptionMetaChip>
                        </li>
                        <li className="inline-flex">
                          <CaptionMetaChip kicker="Grayscale">
                            {grayscale.toFixed(2)}
                          </CaptionMetaChip>
                        </li>
                        {revealEnabled ? (
                          <li className="inline-flex">
                            <CaptionMetaChip kicker="Mask">
                              {revealDir}
                              <span className="text-muted-foreground"> · </span>
                              <span className="tabular-nums">
                                {revealFrom}→{revealTo}%
                              </span>
                            </CaptionMetaChip>
                          </li>
                        ) : null}
                      </ul>
                    </div>
                  </DitherImageCaption> */}
                </DitherImage>
              </div>
            </div>
          </section>

          {/* Below preview: reveal, dither, frame */}
          <footer
            className={cn(
              "order-3 min-w-0 xl:col-span-2 xl:col-start-1 xl:row-start-2",
              PANEL_SURFACE
            )}
          >
            <div className="flex flex-col gap-8">
              <PanelSection
                description="Layer a masked clean copy over the dither stack."
                eyebrow="02"
                title="Reveal (partial dither)"
              >
                <div className="flex flex-col gap-4">
                  <div className="border-border/60 flex flex-wrap items-center justify-between gap-3 border-b pb-4">
                    <div className="flex flex-col gap-0.5">
                      <Label
                        className="text-foreground text-xs"
                        htmlFor={`${baseId}-reveal`}
                      >
                        Partial reveal
                      </Label>
                      <p className="text-muted-foreground text-[0.65rem] leading-snug">
                        Show dither on one side and the original photo on the
                        other via a CSS mask.
                      </p>
                    </div>
                    <Switch
                      checked={revealEnabled}
                      id={`${baseId}-reveal`}
                      onCheckedChange={setRevealEnabled}
                    />
                  </div>

                  <div
                    className={cn(
                      "grid gap-4 sm:grid-cols-2 lg:grid-cols-3",
                      !revealEnabled && "pointer-events-none opacity-50"
                    )}
                  >
                    <div className="flex flex-col gap-1.5">
                      <Label
                        className="text-foreground text-xs"
                        htmlFor={`${baseId}-reveal-dir`}
                      >
                        Mask direction
                      </Label>
                      <select
                        className={SELECT_SURFACE}
                        disabled={!revealEnabled}
                        id={`${baseId}-reveal-dir`}
                        onChange={(e) =>
                          setRevealDir(e.target.value as DitherRevealDirection)
                        }
                        value={revealDir}
                      >
                        {REVEAL_DIRECTION_OPTIONS.map((o) => (
                          <option key={o.value} value={o.value}>
                            {o.label}
                          </option>
                        ))}
                      </select>
                    </div>
                    <RangeField
                      id={`${baseId}-reveal-from`}
                      label="Mask from %"
                      max={100}
                      min={0}
                      onChange={setRevealFrom}
                      step={1}
                      suffix="%"
                      value={revealFrom}
                    />
                    <RangeField
                      id={`${baseId}-reveal-to`}
                      label="Mask to %"
                      max={100}
                      min={0}
                      onChange={setRevealTo}
                      step={1}
                      suffix="%"
                      value={revealTo}
                    />
                  </div>
                </div>
              </PanelSection>

              <div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between lg:gap-10">
                <div className="min-w-0 flex-1">
                  <PanelSection
                    description="Filters run on the source before the dot screen. All transitions are plain state updates — tweak freely."
                    eyebrow="03"
                    title="Dither parameters"
                  >
                    <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
                      <RangeField
                        id={`${baseId}-gray`}
                        label="Grayscale (0 = color, 1 = B&W)"
                        max={1}
                        min={0}
                        onChange={setGrayscale}
                        step={0.05}
                        value={grayscale}
                      />
                      <RangeField
                        id={`${baseId}-contrast`}
                        label="Contrast"
                        max={220}
                        min={40}
                        onChange={setContrast}
                        step={1}
                        value={contrast}
                      />
                      <RangeField
                        id={`${baseId}-bright`}
                        label="Brightness"
                        max={1.8}
                        min={0.4}
                        onChange={setBrightness}
                        step={0.05}
                        value={brightness}
                      />
                      <RangeField
                        id={`${baseId}-blur`}
                        label="Blur (px)"
                        max={4}
                        min={0}
                        onChange={setBlurPx}
                        step={0.25}
                        suffix="px"
                        value={blurPx}
                      />
                      <RangeField
                        id={`${baseId}-opacity`}
                        label="Dot overlay opacity"
                        max={1}
                        min={0}
                        onChange={setOpacity}
                        step={0.05}
                        value={opacity}
                      />
                    </div>
                  </PanelSection>
                </div>
              </div>

              <PanelSection
                description="Cell size maps to the Bayer matrix; aspect crops the frame without stretching your asset."
                eyebrow="04"
                title="Frame"
              >
                <div className="grid gap-4 sm:grid-cols-2">
                  <div className="flex flex-col gap-1.5">
                    <Label
                      className="text-foreground text-xs"
                      htmlFor={`${baseId}-size`}
                    >
                      Cell size
                    </Label>
                    <select
                      className={SELECT_SURFACE}
                      id={`${baseId}-size`}
                      onChange={(e) => setSize(e.target.value as DitherSize)}
                      value={size}
                    >
                      {SIZE_OPTIONS.map((o) => (
                        <option key={o.value} value={o.value}>
                          {o.label}
                        </option>
                      ))}
                    </select>
                  </div>
                  <div className="flex flex-col gap-1.5">
                    <Label
                      className="text-foreground text-xs"
                      htmlFor={`${baseId}-aspect`}
                    >
                      Aspect ratio
                    </Label>
                    <select
                      className={SELECT_SURFACE}
                      id={`${baseId}-aspect`}
                      onChange={(e) =>
                        setAspect(
                          e.target
                            .value as (typeof ASPECT_OPTIONS)[number]["value"]
                        )
                      }
                      value={aspect}
                    >
                      {ASPECT_OPTIONS.map((o) => (
                        <option key={o.value} value={o.value}>
                          {o.label}
                        </option>
                      ))}
                    </select>
                  </div>
                </div>
              </PanelSection>
            </div>
          </footer>
        </div>
      </div>
    </main>
  )
}
```

This example depends on your local shadcn-style primitives (`button`, `input`, `label`, `switch`). Copy those from the demo or wire your own controls—the **`dither-image`** registry item is only the figure primitives above.
