my/ui

Command Palette

Search for a command to run...

All components

Dither Image

media

Compound Next.js image figure with CSS Bayer dither via dither-plugin, partial reveal overlays, and typed tuning props

responsive · 500px

Install

Same command in any shadcn project — Next.js:

$npx shadcn@latest add https://your-domain/r/dither-image.json

Usage

import {
  DitherImage,
  DitherImageCaption,
  DitherImageContent,
  DitherImageFrame,
  DitherImageReveal,
  DitherImageOverlay,
} from "@/registry/cult-ui/dither-image";

export default function Demo() {
  return (
    <div className="flex flex-wrap items-start justify-center gap-10 p-10">
      {/* Basic dithered image */}
      <DitherImage>
        <DitherImageFrame size="md" aspectRatio="square" className="w-56">
          <DitherImageContent
            src="https://images.unsplash.com/photo-1475924156734-496f6cac6ec1?w=400&h=400&fit=crop&auto=format"
            alt="Mountain landscape dithered"
            fill
            sizes="224px"
          />
        </DitherImageFrame>
        <DitherImageCaption>Mountain landscape, dithered</DitherImageCaption>
      </DitherImage>

      {/* Partial dither reveal */}
      <DitherImage>
        <DitherImageReveal className="w-56 overflow-hidden rounded-xl" style={{ aspectRatio: "1/1" }}>
          <DitherImageFrame size="lg" aspectRatio="square" className="w-full">
            <DitherImageContent
              src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=400&h=400&fit=crop&auto=format"
              alt="Forest path"
              fill
              sizes="224px"
            />
          </DitherImageFrame>
          <DitherImageOverlay
            src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=400&h=400&fit=crop&auto=format"
            alt=""
            fill
            sizes="224px"
            direction="r"
            from={0}
            to={60}
          />
        </DitherImageReveal>
        <DitherImageCaption>Partial reveal, left→right</DitherImageCaption>
      </DitherImage>
    </div>
  );
}

Component source

"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-pretty text-muted-foreground text-sm leading-relaxed",
          className
        )}
        data-slot="dither-image-caption"
        ref={ref}
        {...props}
      />
    )
  }
)
DitherImageCaption.displayName = "DitherImageCaption"

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

Dependencies

dither-plugin

Source: Cult UI