Todos os componentes

Dithered Image Upload

Dithered Image Upload é um componente de formulário para React e Tailwind CSS, da biblioteca Cult UI, com licença MIT. Copia e cola no teu projeto.

Cult UI MIT forms

O que é

Dithered Image Upload é um componente de formulário para React e Tailwind CSS, da biblioteca Cult UI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Não precisa de pacotes além do que o projeto já tem

Licença

Este componente vem de Cult UI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

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>
  )
}

Componentes parecidos