VisualSite
Dot shader playground
Interactive dot-shader preview with image upload, sample images, and SVG or PNG export.
$ npx context-ds add dot-shader-playgroundUsage
Use it for
A brand or press page section that lets visitors shade their own image. samples (id, label and the src of a shader SVG, or any image) and defaultSample come from the app.
Avoid
- Uploading the visitor's image anywhere (it stays in the browser)
- More than one playground on a page (paste goes to every one)
Dependencies: button, copy-button, dot-shader-image, image-dot-shader, lucide-react
Example
examples/dot-shader-playground.tsx
import { DotShaderPlayground, type DotShaderSample } from "@/components/visuals/dot-shader-playground";// The brand page's samples: shader SVGs the playground reads back into dots.const SAMPLES: DotShaderSample[] = [ { id: "logomark", label: "Logomark", src: "/dot-shader/logomark.svg" }, { id: "orbit", label: "Orbit", src: "/dot-shader/orbit.svg" }, { id: "extract", label: "Extract", src: "/dot-shader/extract.svg" }, { id: "pages", label: "Pages", src: "/dot-shader/pages.svg" }, { id: "integration", label: "Integration", src: "/dot-shader/integration.svg" },];export function Site() { return <DotShaderPlayground samples={SAMPLES} defaultSample="integration" className="w-full" />;}Source
components/visuals/dot-shader-playground.tsx
"use client";import * as React from "react";import { Check, Copy, Download, ImagePlus } from "lucide-react";import { Button } from "@/components/ui/button";import { useCopy } from "@/components/ui/copy-button";import { cn } from "@/lib/utils";import { DotShaderImage } from "@/components/visuals/dot-shader-image";import { countDots, type DotShaderSurface, type DotStates, dotsSvg, loadShaderImage, parseShadedDots, resolveSurfaceColors, SHADER_HEIGHT, SHADER_WIDTH, shadeImage, shadeImageSrc,} from "@/components/visuals/image-dot-shader";/** * The brand page's "Shader Graphics" playground: a large preview of the picked picture on a white, blue or black * surface, with copy SVG and SVG or PNG downloads under it, and a panel to switch the surface, pick a sample or shade * your own image (upload, drop it on the preview or paste it). Everything is shaded in the browser. */export type DotShaderSample = { id: string; label: string; /** An SVG the shader wrote (its `filled` and `hollow` paths are read back), or any image to shade. */ src?: string; /** The SVG's text, instead of fetching `src`. */ svg?: string;};const SURFACE_OPTIONS: { surface: DotShaderSurface; label: string }[] = [ { surface: "white", label: "White" }, { surface: "blue", label: "Blue" }, { surface: "black", label: "Black" },];const TILE: Record<DotShaderSurface, string> = { white: "bg-white ring-1 ring-hairline", blue: "bg-brand", black: "bg-black" };const ON_SURFACE_TEXT: Record<DotShaderSurface, string> = { white: "text-fg-brand", blue: "text-on-brand", black: "text-white" };const RING = "-m-2 rounded-card bg-ring-tint p-2";const PANEL = "flex h-full min-w-0 flex-col gap-6 rounded-card bg-surface p-6 ring-1 ring-hairline md:p-8";const FOCUS = "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring";const OPTION_TILE = cn( "relative flex aspect-[4/3] items-center justify-center overflow-hidden rounded-shell transition-shadow duration-200 ease-out motion-reduce:transition-none", FOCUS,);const SELECTED_TILE = "ring-2 ring-brand ring-offset-2";const ACTION = "h-10 min-w-0 gap-2 px-3 [&_svg]:size-4";const UPLOAD = "upload";const PNG_SCALE = 2;const svgDataUrl = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;function readAsDataUrl(file: File) { return new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error ?? new Error("unreadable file")); reader.readAsDataURL(file); });}function saveBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; // Safari ignores a programmatic click on an <a download> that is not in the document. document.body.append(link); link.click(); link.remove(); // Safari and Firefox start the download after click() returns, so revoking straight away can cancel it. window.setTimeout(() => URL.revokeObjectURL(url), 1000);}async function svgToPngBlob(svg: string) { const image = await loadShaderImage(svgDataUrl(svg)); const canvas = document.createElement("canvas"); canvas.width = SHADER_WIDTH * PNG_SCALE; canvas.height = SHADER_HEIGHT * PNG_SCALE; canvas.getContext("2d")?.drawImage(image, 0, 0, canvas.width, canvas.height); return new Promise<Blob>((resolve, reject) => canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("png export failed"))), "image/png"));}function baseName(filename: string) { return ( filename .replace(/\.[^.]+$/, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") || "image" );}function firstImageFile(files: Iterable<File> | ArrayLike<File> | null | undefined) { return Array.from(files ?? []).find((file) => file.type.startsWith("image/"));}// Safari and Firefox often leave clipboardData.files empty for copied screenshots and page images, and only expose// them as items.function clipboardFiles(clipboard: DataTransfer | null) { const itemFiles = Array.from(clipboard?.items ?? []) .flatMap((item) => (item.kind === "file" ? [item.getAsFile()] : [])) .filter((file): file is File => file !== null); return [...Array.from(clipboard?.files ?? []), ...itemFiles];}// A sample is an SVG the shader wrote, read straight back into dots; anything else is shaded like an upload.async function loadSample(sample: DotShaderSample): Promise<DotStates | null> { const text = sample.svg ?? (sample.src && /\.svg(\?|#|$)/i.test(sample.src) ? await fetch(sample.src).then((response) => (response.ok ? response.text() : "")) : ""); const parsed = text ? parseShadedDots(text) : null; if (parsed && countDots(parsed) > 0) return parsed; return sample.src ? shadeImageSrc(sample.src) : null;}export function DotShaderPlayground({ samples, defaultSample, defaultSurface = "blue", title = "Shade your own image", description = "Start from a sample or upload a logo, icon or product shot, then download it for a post or deck.", className,}: { samples: DotShaderSample[]; /** The sample shown first (its id); the first sample by default. */ defaultSample?: string; defaultSurface?: DotShaderSurface; title?: string; description?: string; className?: string;}) { const root = React.useRef<HTMLDivElement>(null); const fileInput = React.useRef<HTMLInputElement>(null); const latestShade = React.useRef(0); const [sampleDots, setSampleDots] = React.useState<Record<string, DotStates>>({}); const [upload, setUpload] = React.useState<{ name: string; states: DotStates } | null>(null); const [selection, setSelection] = React.useState(defaultSample ?? samples[0]?.id ?? UPLOAD); const [surface, setSurface] = React.useState<DotShaderSurface>(defaultSurface); const [isDragging, setIsDragging] = React.useState(false); const [message, setMessage] = React.useState<string | null>(null); const { hasCopied, copy } = useCopy(); // Keyed on the samples' content, so a parent that passes a new array each render does not refetch them. const sampleKey = samples.map((sample) => `${sample.id}:${sample.src ?? ""}:${sample.svg?.length ?? 0}`).join("|"); const latestSamples = React.useRef(samples); React.useEffect(() => { latestSamples.current = samples; }, [samples]); React.useEffect(() => { let current = true; for (const sample of latestSamples.current) { loadSample(sample) .then((states) => current && states && setSampleDots((loaded) => ({ ...loaded, [sample.id]: states }))) .catch(() => { if (current) setMessage("A sample image could not be loaded. Try another sample or upload an image."); }); } return () => { current = false; }; }, [sampleKey]); const selectedStates = selection === UPLOAD ? upload?.states : sampleDots[selection]; const selectedName = selection === UPLOAD ? (upload?.name ?? "image") : (samples.find((sample) => sample.id === selection)?.label ?? selection); const filename = `${baseName(selection === UPLOAD ? selectedName : selection)}-dots-on-${surface}`; // A file leaves the page, so it carries plain colors read from the brand tokens at the moment it is made. const fileSvg = () => (selectedStates ? dotsSvg(selectedStates, resolveSurfaceColors(surface, root.current ?? document.body)) : null); const shadeFile = React.useCallback(async (file: File | undefined) => { if (!file) { setMessage("Choose a PNG, JPG, SVG or WebP image."); return; } const request = ++latestShade.current; try { const image = await loadShaderImage(await readAsDataUrl(file)); if (request !== latestShade.current) return; const states = shadeImage(image, image.naturalWidth || SHADER_WIDTH, image.naturalHeight || SHADER_HEIGHT); setMessage(countDots(states) === 0 ? "Nothing to shade. Try an image with a clear subject." : null); setUpload({ name: file.name, states }); setSelection(UPLOAD); } catch { if (request === latestShade.current) setMessage("That image could not be read."); } }, []); React.useEffect(() => { const shadePastedImage = (event: ClipboardEvent) => { const file = firstImageFile(clipboardFiles(event.clipboardData)); if (file) void shadeFile(file); }; window.addEventListener("paste", shadePastedImage); return () => window.removeEventListener("paste", shadePastedImage); }, [shadeFile]); const downloadSvg = () => { const svg = fileSvg(); if (svg) saveBlob(new Blob([svg], { type: "image/svg+xml" }), `${filename}.svg`); }; const downloadPng = async () => { const svg = fileSvg(); if (!svg) return; try { saveBlob(await svgToPngBlob(svg), `${filename}.png`); } catch { setMessage("The PNG could not be exported."); } }; const copySvg = () => { const svg = fileSvg(); if (svg) void copy(svg); }; const pillOnSurface = (selected: boolean) => cn(ACTION, selected && "bg-blue-10"); return ( <div ref={root} data-slot="dot-shader-playground" className={cn("grid grid-cols-1 items-start gap-6 lg:grid-cols-3", className)}> <div className={cn(RING, "lg:col-span-2")}> <div className="flex flex-col gap-3"> <div onDragOver={(event) => { event.preventDefault(); setIsDragging(true); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsDragging(false); }} onDrop={(event) => { event.preventDefault(); setIsDragging(false); void shadeFile(firstImageFile(event.dataTransfer.files)); }} className={cn( "relative flex aspect-[4/3] items-center justify-center overflow-hidden rounded-card transition-shadow duration-200 ease-out motion-reduce:transition-none", TILE[surface], isDragging && "ring-2 ring-brand ring-offset-2", )} > <DotShaderImage states={selectedStates ?? null} surface={surface} alt={selectedStates ? `${selectedName} drawn in dots` : undefined} className="pointer-events-none size-full" /> {isDragging && <span className={cn("pointer-events-none absolute text-body", ON_SURFACE_TEXT[surface])}>Drop to shade your image</span>} </div> <div className="grid grid-cols-3 gap-2"> <Button variant="pill-outline" size="sm" onClick={copySvg} disabled={!selectedStates} aria-label="Copy SVG code" className={ACTION}> {hasCopied ? <Check aria-hidden /> : <Copy aria-hidden />} <span aria-live="polite">{hasCopied ? "Copied" : "Copy SVG"}</span> </Button> <Button variant="pill-outline" size="sm" onClick={downloadSvg} disabled={!selectedStates} aria-label="Download as SVG" className={ACTION}> <Download aria-hidden /> SVG </Button> <Button variant="pill-outline" size="sm" onClick={downloadPng} disabled={!selectedStates} aria-label="Download as PNG" className={ACTION}> <Download aria-hidden /> PNG </Button> </div> </div> </div> <div className={RING}> <div className={PANEL}> <div className="flex flex-col gap-2"> <h3 className="text-h4 font-normal text-fg">{title}</h3> <p className="text-body-sm text-fg-muted">{description}</p> </div> <fieldset> <legend className="mb-3 text-body-sm text-fg-muted">Background</legend> <div className="grid grid-cols-3 gap-2"> {SURFACE_OPTIONS.map((option) => ( <Button key={option.surface} variant="pill-outline" size="sm" aria-pressed={surface === option.surface} onClick={() => setSurface(option.surface)} className={pillOnSurface(surface === option.surface)} > {option.label} </Button> ))} </div> </fieldset> <fieldset> <legend className="mb-3 text-body-sm text-fg-muted">Image</legend> <div className="grid grid-cols-3 gap-2"> {samples.map((sample) => ( <button key={sample.id} type="button" aria-pressed={selection === sample.id} aria-label={sample.label} title={sample.label} onClick={() => setSelection(sample.id)} className={cn(OPTION_TILE, TILE[surface], selection === sample.id && SELECTED_TILE)} > <DotShaderImage states={sampleDots[sample.id] ?? null} surface={surface} className="size-full" /> </button> ))} {upload && ( <button type="button" aria-pressed={selection === UPLOAD} aria-label={upload.name} title={upload.name} onClick={() => setSelection(UPLOAD)} className={cn(OPTION_TILE, TILE[surface], selection === UPLOAD && SELECTED_TILE)} > <DotShaderImage states={upload.states} surface={surface} className="size-full" /> </button> )} <button type="button" onClick={() => fileInput.current?.click()} className={cn(OPTION_TILE, "flex-col gap-1 bg-white text-fg-brand ring-1 ring-hairline transition-[background-color,box-shadow] hover:bg-blue-10")} > <ImagePlus className="size-5" aria-hidden /> <span className="text-caption">{upload ? "Upload another" : "Upload"}</span> </button> </div> </fieldset> <div className="mt-auto flex flex-col gap-2"> {message && ( <p role="status" className="text-body-sm text-fg"> {message} </p> )} <p className="text-caption text-fg-subtle">Drop an image on the preview or paste one. It stays in your browser. A PNG with a transparent background works best.</p> </div> <input ref={fileInput} tabIndex={-1} aria-hidden="true" type="file" accept="image/*" className="sr-only" onChange={(event) => { void shadeFile(firstImageFile(event.target.files)); event.target.value = ""; }} /> </div> </div> </div> );}Reference: src/components/sections/brand/dot-shader-playground.tsx; src/components/sections/brand/dot-shader-samples.ts; src/app/(marketing)/brand/page.tsx (Graphics).