All components

Dithered Image Upload

Dithered Image Upload is a React and Tailwind CSS form component from Cult UI, licensed MIT. Copy it and paste it into your project.

Cult UI MIT forms

What it is

Dithered Image Upload is a React and Tailwind CSS form component from Cult UI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Four free copies a week. No card.

No packages beyond the project baseline

Licence

This component comes from Cult UI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Dithered Image Upload

apps/www/registry/default/example/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 "@/registry/default/ui/dither-image"

/** Layered edge + lift (SKILL-DESIGN) — replaces flat card borders. */
const PANEL_SURFACE = cn(
  "rounded-2xl bg-card/65 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(
  "h-10 w-full rounded-[calc(var(--radius)-2px)] border border-input/80 bg-input/15 px-2.5 text-sm outline-none",
  "focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
)

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="font-mono text-[0.65rem] text-muted-foreground tabular-nums">
          {value}
          {suffix ?? ""}
        </span>
      </div>
      <input
        className={cn(
          "h-2 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary",
          "[&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
        )}
        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 min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-md",
        "border border-border/45 bg-muted/40 px-2 py-1",
        "text-[0.7rem] leading-none tracking-tight",
        "shadow-[0_1px_0_0_rgba(0,0,0,0.05)] dark:bg-muted/25 dark:shadow-[0_1px_0_0_rgba(255,255,255,0.06)]"
      )}
    >
      <span className="shrink-0 font-medium text-[0.6rem] text-muted-foreground uppercase tracking-[0.1em]">
        {kicker}
      </span>
      <span className="min-w-0 font-medium text-foreground 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="font-medium text-[0.65rem] text-muted-foreground uppercase tracking-[0.14em]">
          {eyebrow}
        </p>
        <h2 className="text-balance font-semibold text-foreground text-lg tracking-tight">
          {title}
        </h2>
        {description ? (
          <p className="text-pretty text-muted-foreground text-xs leading-relaxed">
            {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="min-h-screen bg-background 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="font-medium text-[0.65rem] text-muted-foreground uppercase tracking-[0.14em]">
            Cult UI · sandbox
          </p>
          <h1 className="text-balance font-semibold text-2xl text-foreground tracking-tight md:text-3xl">
            DitherImage — upload
          </h1>
          <p className="text-pretty text-muted-foreground text-sm leading-relaxed">
            Upload a file, paste an image or GIF URL, or use the sample. Tune{" "}
            <code className="rounded-md bg-muted/80 px-1.5 py-0.5 font-mono text-[0.75rem]">
              dither-plugin
            </code>{" "}
            live. Remote URLs, blobs, and{" "}
            <code className="rounded-md bg-muted/80 px-1.5 py-0.5 font-mono text-[0.75rem]">
              data:
            </code>{" "}
            use{" "}
            <code className="rounded-md bg-muted/80 px-1.5 py-0.5 font-mono text-[0.75rem]">
              next/image
            </code>{" "}
            with{" "}
            <code className="rounded-md bg-muted/80 px-1.5 py-0.5 font-mono text-[0.75rem]">
              unoptimized
            </code>{" "}
            so any host works without changing{" "}
            <code className="rounded-md bg-muted/80 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="shrink-0 flex 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="shrink-0 flex 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="shrink-0 flex 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="flex min-h-0 flex-1 flex-col gap-2 border-border/60 border-t pt-3">
                  <p className="shrink-0 font-medium text-foreground text-xs">
                    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-2 focus-within:ring-ring/25",
                      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="font-medium text-foreground text-sm">
                      {fileDragging ? "Drop to load" : "Drop image or GIF here"}
                    </span>
                    <span className="max-w-[14rem] text-muted-foreground 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(
                "rounded-[calc(var(--radius)+6px)] bg-muted/20 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="flex flex-wrap items-center justify-between gap-3 border-border/60 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-[0.65rem] text-muted-foreground 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>
  )
}

Similar components