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