{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dither-develop-card","type":"registry:component","title":"Dither Develop Card","description":"A card whose image prints in cell by cell the first time it is seen, ordered-dithered with CSS alone, with a soft light that follows the pointer through the dither.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/dither-develop-card.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, ReactNode, RefObject } from \"react\";\n\n/**\n * DitherDevelopCard — a card whose image prints in the first time it's seen, lit by the pointer through the dither.\n *\n * The picture (your image, or one of three code-drawn stills) is ordered-\n * dithered with CSS alone at 2px cells on a tone curve. On first view its\n * light rises from nothing over about 1.1s, so the cells print in brightest\n * first; hover it and a soft light follows the pointer, dithered with the\n * picture. A mono caption names the transform.\n *\n * Part of the Dither & ASCII kit: one ink, paper #f3f0e6, on a warm black\n * ground #0b0b0d, with vermillion #ff4f2b as a second ink inside the\n * pictures and for a live state. Cells snap to whole device pixels;\n * scenes dither in linear light, photographs on a gentler curve. Funnel Display through\n * var(--font-funnel) for display over Geist; Geist Mono for every glyph.\n * Respects prefers-reduced-motion. No dependencies beyond React.\n * Paste it as its own file: it repeats the kit's small helpers, which would\n * clash in one module. Load Funnel Display with next/font (variable:\n * \"--font-funnel\") on a parent, or from Google Fonts or @fontsource.\n */\n\nconst SANS = 'var(--font-sans, \"Geist\", \"Inter\", ui-sans-serif, system-ui, sans-serif)';\n\nconst MONO = 'var(--font-mono, \"Geist Mono\", ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace)';\n\nconst DISPLAY = 'var(--font-funnel, \"Funnel Display\", var(--font-sans, \"Geist\"), ui-sans-serif, system-ui, sans-serif)';\n\nconst EASE = \"cubic-bezier(0.22, 1, 0.36, 1)\";\n\n/** One ink on a warm black ground, and one signal colour. */\nconst SHEET = {\n  ground: \"#0b0b0d\",\n  panel: \"#121214\",\n  raised: \"#19191c\",\n  paper: \"#f3f0e6\",\n  ink: \"rgba(243,240,230,0.92)\",\n  ink2: \"rgba(243,240,230,0.6)\",\n  ink3: \"rgba(243,240,230,0.38)\",\n  line: \"rgba(243,240,230,0.08)\",\n  line2: \"rgba(243,240,230,0.16)\",\n  signal: \"#ff4f2b\",\n} as const;\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return v < lo ? lo : v > hi ? hi : v;\n}\n\n/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */\nfunction safeHref(raw: string): string {\n  const v = raw.replace(/[\\t\\n\\r]/g, \"\").trim();\n  if (!v || v.includes(\"\\\\\")) return \"#\";\n  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;\n  if (/^[/#?]/.test(v) && !/^\\/\\//.test(v)) return v;\n  return \"#\";\n}\n\nfunction subscribeMotion(cb: () => void) {\n  const m = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n}\n\n/** True when the visitor asked for reduced motion. False on the server. */\nfunction useReducedMotion(): boolean {\n  return useSyncExternalStore(\n    subscribeMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n// Each part carries only the CSS it needs, so any one of them works on its own.\n// Nothing is interpolated into these sheets; colours arrive as CSS variables.\n// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.\nconst FOCUS_CSS = \".da-scope :focus-visible,.da-scope:focus-visible{outline:2px solid var(--da-ring,#ff4f2b)!important;outline-offset:3px;border-radius:revert-layer}\";\n\n/** The 8x8 Bayer index of a cell (0-63). Recursive, so every 2x2 and 4x4 block is itself in order. */\nfunction bayer8(x: number, y: number): number {\n  let v = 0;\n  for (let bit = 0; bit < 3; bit++) {\n    const xb = (x >> bit) & 1;\n    const yb = (y >> bit) & 1;\n    v |= (((xb ^ yb) << 1) | yb) << (2 * (2 - bit));\n  }\n  return v;\n}\n\nconst daTileCache = new Map<number, string>();\n\n/**\n * The 8x8 Bayer thresholds as a tile of greys, inverted, for dithering any\n * element with CSS alone (see DitherLayer). Each threshold t is stored as\n * t^(1/gamma): at 2.2 the comparison is in linear light (the share of lit\n * cells matches the light exactly, which thins every midtone); at 1 it is\n * straight sRGB; 1.5 keeps photographs full without crushing the shadows.\n */\nfunction daBayerTile(gamma: number): string {\n  const k = Math.round(clamp(gamma, 0.5, 3) * 10) / 10;\n  const hit = daTileCache.get(k);\n  if (hit) return hit;\n  let rects = \"\";\n  for (let y = 0; y < 8; y++) {\n    for (let x = 0; x < 8; x++) {\n      const t = Math.pow((bayer8(x, y) + 0.5) / 64, 1 / k);\n      const g = Math.round((1 - t) * 255);\n      rects += `<rect x='${x}' y='${y}' width='1' height='1' fill='rgb(${g},${g},${g})'/>`;\n    }\n  }\n  const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' shape-rendering='crispEdges'>${rects}</svg>`;\n  const url = `url(\"data:image/svg+xml,${encodeURIComponent(svg)}\")`;\n  daTileCache.set(k, url);\n  return url;\n}\n\n/** Image sources: http(s), same-site paths, blob: and data:image/ (for dropped files); nothing else. */\nfunction safeSrc(raw: string): string {\n  const v = raw.replace(/[\\t\\n\\r]/g, \"\").trim();\n  if (!v || v.includes(\"\\\\\")) return \"\";\n  if (/^(https?:|blob:)/i.test(v) || /^data:image\\/(png|jpe?g|gif|webp|avif);/i.test(v)) return v;\n  if (/^[/.]/.test(v) && !/^\\/\\//.test(v)) return v;\n  return \"\";\n}\n\n/**\n * A cell size in CSS pixels snapped to whole device pixels for this element\n * (the screen's pixel ratio times any CSS scale on the way), so every dither\n * cell lands on the pixel grid and never blurs into grey.\n */\nfunction useSnappedCell(ref: RefObject<HTMLElement | null>, cell: number): number {\n  const [px, setPx] = useState(cell);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    let raf = 0;\n    const measure = () => {\n      const k = el.offsetWidth ? el.getBoundingClientRect().width / el.offsetWidth : 1;\n      const dev = (window.devicePixelRatio || 1) * (k || 1);\n      setPx(Math.max(1, Math.round(cell * dev)) / dev);\n    };\n    const ro = new ResizeObserver(() => {\n      measure();\n      cancelAnimationFrame(raf);\n      raf = requestAnimationFrame(measure);\n    });\n    ro.observe(el);\n    return () => {\n      ro.disconnect();\n      cancelAnimationFrame(raf);\n    };\n  }, [ref, cell]);\n  return px;\n}\n\n/** True once the element has come into view (and stays true); true at once without IntersectionObserver. */\nfunction useFirstView(ref: RefObject<HTMLElement | null>, threshold = 0.3): boolean {\n  const [seen, setSeen] = useState(false);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || seen) return;\n    if (typeof IntersectionObserver === \"undefined\") {\n      const raf = requestAnimationFrame(() => setSeen(true));\n      return () => cancelAnimationFrame(raf);\n    }\n    const io = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((e) => e.isIntersecting)) {\n          setSeen(true);\n          io.disconnect();\n        }\n      },\n      { threshold },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, threshold, seen]);\n  return seen;\n}\n\n/**\n * DitherLayer: ordered-dithers whatever is inside it (an image, a gradient,\n * text, video) with CSS alone. The content is made grey, a tile of Bayer\n * thresholds is laid over it at half opacity, and a very high contrast on\n * the group turns every pixel into on or off by which of the two is lighter.\n * The result is then inked: lit cells in `ink`, the rest in `ground`.\n * `level` scales the light (0 prints nothing, 1 the full picture) and\n * transitions, so a change prints in or out cell by cell. With `light`, a\n * soft light follows the pointer and is dithered with the picture.\n */\nfunction DitherLayer({\n  children,\n  cell = 2,\n  ink = SHEET.paper,\n  ground = SHEET.ground,\n  level = 1,\n  light = false,\n  lightSize = 240,\n  duration = 900,\n  gamma = 1.5,\n  className = \"\",\n  style,\n}: {\n  children: ReactNode;\n  /** CSS pixels a cell, snapped to whole device pixels. */\n  cell?: number;\n  ink?: string;\n  ground?: string;\n  /** How much of the light prints: 0 nothing, 1 all of it; above 1 brightens. */\n  level?: number;\n  /** A light that follows the pointer, dithered with the picture. */\n  light?: boolean;\n  lightSize?: number;\n  /** How long a change of level takes to print, in ms. */\n  duration?: number;\n  /** Tone curve of the thresholds: 2.2 is strict linear light, 1 straight sRGB. */\n  gamma?: number;\n  className?: string;\n  style?: CSSProperties;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const lightRef = useRef<HTMLDivElement>(null);\n  const px = useSnappedCell(ref, cell);\n  const reduced = useReducedMotion();\n\n  useEffect(() => {\n    const el = ref.current;\n    const lamp = lightRef.current;\n    if (!light || !el || !lamp) return;\n    const move = (e: PointerEvent) => {\n      const r = el.getBoundingClientRect();\n      const k = el.offsetWidth ? r.width / el.offsetWidth : 1;\n      lamp.style.setProperty(\"--da-lx\", `${(e.clientX - r.left) / k}px`);\n      lamp.style.setProperty(\"--da-ly\", `${(e.clientY - r.top) / k}px`);\n      lamp.style.opacity = \"1\";\n    };\n    const leave = () => {\n      lamp.style.opacity = \"0\";\n    };\n    el.addEventListener(\"pointermove\", move);\n    el.addEventListener(\"pointerleave\", leave);\n    return () => {\n      el.removeEventListener(\"pointermove\", move);\n      el.removeEventListener(\"pointerleave\", leave);\n    };\n  }, [light]);\n\n  return (\n    <div ref={ref} className={\"relative isolate overflow-hidden \" + className} style={{ background: ground, ...style }}>\n      <div className=\"absolute inset-0\" style={{ filter: \"contrast(255)\", background: \"#000\" }}>\n        <div\n          className=\"absolute inset-0\"\n          style={{ filter: `grayscale(1) brightness(${level})`, transition: reduced ? \"none\" : `filter ${duration}ms ${EASE}` }}\n        >\n          {children}\n        </div>\n        {light ? (\n          <div\n            ref={lightRef}\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0\"\n            style={{\n              opacity: 0,\n              background: `radial-gradient(${lightSize}px circle at var(--da-lx, 50%) var(--da-ly, 50%), rgba(255,255,255,0.62), rgba(255,255,255,0.18) 40%, transparent 72%)`,\n              mixBlendMode: \"screen\",\n              transition: reduced ? \"none\" : `opacity 420ms ${EASE}`,\n            }}\n          />\n        ) : null}\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0\"\n          style={{ opacity: 0.5, backgroundImage: daBayerTile(gamma), backgroundSize: `${px * 8}px ${px * 8}px`, imageRendering: \"pixelated\" }}\n        />\n      </div>\n      <div aria-hidden className=\"pointer-events-none absolute inset-0\" style={{ background: ink, mixBlendMode: \"multiply\" }} />\n      <div aria-hidden className=\"pointer-events-none absolute inset-0\" style={{ background: ground, mixBlendMode: \"screen\" }} />\n    </div>\n  );\n}\n\n/** Code-drawn stills for the cards (greys only: the dither inks them). */\nexport type DaArt = \"sphere\" | \"rings\" | \"stack\";\n\nfunction DaStill({ kind }: { kind: DaArt }) {\n  // Gradient ids are per instance, so several stills on one page never share one.\n  const id = \"da\" + useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  if (kind === \"rings\") {\n    return (\n      <svg aria-hidden viewBox=\"0 0 320 200\" preserveAspectRatio=\"xMidYMid slice\" className=\"absolute inset-0 h-full w-full\">\n        <defs>\n          <radialGradient id={`${id}-rg-ring`} cx=\"0.38\" cy=\"0.3\" r=\"0.9\">\n            <stop offset=\"0\" stopColor=\"#fff\" />\n            <stop offset=\"0.55\" stopColor=\"#a6a6a6\" />\n            <stop offset=\"1\" stopColor=\"#2a2a2a\" />\n          </radialGradient>\n          <linearGradient id={`${id}-lg-floor`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n            <stop offset=\"0\" stopColor=\"#262626\" />\n            <stop offset=\"1\" stopColor=\"#7a7a7a\" />\n          </linearGradient>\n        </defs>\n        <rect width=\"320\" height=\"200\" fill=\"#050505\" />\n        <rect y=\"132\" width=\"320\" height=\"68\" fill={`url(#${id}-lg-floor)`} />\n        <ellipse cx=\"170\" cy=\"160\" rx=\"92\" ry=\"10\" fill=\"#000\" opacity=\"0.8\" />\n        {[0, 1, 2, 3].map((i) => (\n          <ellipse key={i} cx={160 + i * 8} cy={98 - i * 4} rx={72 - i * 13} ry={58 - i * 11} fill=\"none\" stroke={`url(#${id}-rg-ring)`} strokeWidth={17 - i * 2.5} />\n        ))}\n      </svg>\n    );\n  }\n  if (kind === \"stack\") {\n    return (\n      <svg aria-hidden viewBox=\"0 0 320 200\" preserveAspectRatio=\"xMidYMid slice\" className=\"absolute inset-0 h-full w-full\">\n        <defs>\n          <linearGradient id={`${id}-lg-top`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n            <stop offset=\"0\" stopColor=\"#fff\" />\n            <stop offset=\"1\" stopColor=\"#a8a8a8\" />\n          </linearGradient>\n          <linearGradient id={`${id}-lg-side`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n            <stop offset=\"0\" stopColor=\"#6a6a6a\" />\n            <stop offset=\"1\" stopColor=\"#1c1c1c\" />\n          </linearGradient>\n        </defs>\n        <rect width=\"320\" height=\"200\" fill=\"#060606\" />\n        {[0, 1, 2, 3, 4].map((i) => {\n          const y = 150 - i * 22;\n          return (\n            <g key={i} opacity={1 - i * 0.12}>\n              <path d={`M160 ${y - 26} L236 ${y} L160 ${y + 26} L84 ${y} Z`} fill={`url(#${id}-lg-top)`} />\n              <path d={`M84 ${y} L160 ${y + 26} L160 ${y + 34} L84 ${y + 8} Z`} fill={`url(#${id}-lg-side)`} />\n              <path d={`M236 ${y} L160 ${y + 26} L160 ${y + 34} L236 ${y + 8} Z`} fill=\"#3e3e3e\" />\n            </g>\n          );\n        })}\n      </svg>\n    );\n  }\n  return (\n    <svg aria-hidden viewBox=\"0 0 320 200\" preserveAspectRatio=\"xMidYMid slice\" className=\"absolute inset-0 h-full w-full\">\n      <defs>\n        <radialGradient id={`${id}-rg-ball`} cx=\"0.36\" cy=\"0.32\" r=\"0.72\">\n          <stop offset=\"0\" stopColor=\"#fff\" />\n          <stop offset=\"0.45\" stopColor=\"#c4c4c4\" />\n          <stop offset=\"0.85\" stopColor=\"#404040\" />\n          <stop offset=\"1\" stopColor=\"#181818\" />\n        </radialGradient>\n        <radialGradient id={`${id}-rg-shadow`} cx=\"0.5\" cy=\"0.5\" r=\"0.5\">\n          <stop offset=\"0\" stopColor=\"#000\" />\n          <stop offset=\"1\" stopColor=\"#000\" stopOpacity=\"0\" />\n        </radialGradient>\n        <linearGradient id={`${id}-lg-plane`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0\" stopColor=\"#141414\" />\n          <stop offset=\"0.45\" stopColor=\"#8e8e8e\" />\n          <stop offset=\"1\" stopColor=\"#3c3c3c\" />\n        </linearGradient>\n      </defs>\n      <rect width=\"320\" height=\"200\" fill=\"#050505\" />\n      <rect y=\"112\" width=\"320\" height=\"88\" fill={`url(#${id}-lg-plane)`} />\n      <ellipse cx=\"198\" cy=\"160\" rx=\"96\" ry=\"17\" fill={`url(#${id}-rg-shadow)`} />\n      <circle cx=\"160\" cy=\"100\" r=\"64\" fill={`url(#${id}-rg-ball)`} />\n    </svg>\n  );\n}\n\n/**\n * DitherDevelopCard — a card whose image prints in the first time it's\n * seen, and is lit through the dither by the pointer.\n *\n * The picture (your image, or one of three code-drawn stills) is ordered-\n * dithered with CSS alone at 2px cells snapped to device pixels, in linear\n * light. On first view its light rises from nothing over about 1.1s, so the\n * cells print in brightest first, like a proof developing; hover it and a\n * soft light follows the pointer, dithered with the picture. A mono caption\n * names the transform; a signal square marks it live.\n */\nexport function DitherDevelopCard({\n  title,\n  kicker,\n  body,\n  image,\n  alt = \"\",\n  art = \"sphere\",\n  caption = \"1-bit · Bayer 8×8 · 2px\",\n  href,\n  cell = 2,\n  className = \"\",\n}: {\n  title: string;\n  kicker?: string;\n  body?: string;\n  /** Your picture (http(s), a same-site path, blob: or data:image/). */\n  image?: string;\n  alt?: string;\n  /** A code-drawn still when there is no image. */\n  art?: DaArt;\n  caption?: string;\n  href?: string;\n  cell?: number;\n  className?: string;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const seen = useFirstView(ref, 0.35);\n  const reduced = useReducedMotion();\n  const src = image ? safeSrc(image) : \"\";\n  const [hot, setHot] = useState(false);\n  const inner = (\n    <>\n      <DitherLayer className=\"aspect-[16/10] w-full\" cell={cell} level={seen || reduced ? (hot ? 1.12 : 1) : 0} light duration={1100}>\n        {src ? (\n          // eslint-disable-next-line @next/next/no-img-element -- pasted into any project: a plain img needs no next/image config\n          <img src={src} alt={alt} className=\"absolute inset-0 h-full w-full object-cover\" draggable={false} />\n        ) : (\n          <DaStill kind={art} />\n        )}\n      </DitherLayer>\n      <div className=\"flex flex-col gap-2 p-5\">\n        <div className=\"flex items-center justify-between gap-3\">\n          {kicker ? (\n            <span className=\"text-[10.5px] uppercase\" style={{ fontFamily: MONO, letterSpacing: \"0.14em\", color: SHEET.ink3 }}>\n              {kicker}\n            </span>\n          ) : (\n            <span />\n          )}\n          <span aria-hidden className=\"h-[7px] w-[7px]\" style={{ background: seen ? SHEET.signal : SHEET.line2, transition: `background 600ms ${EASE}` }} />\n        </div>\n        <h3 className=\"m-0 text-[22px] font-semibold leading-[1.15]\" style={{ fontFamily: DISPLAY, letterSpacing: \"-0.02em\", color: SHEET.paper }}>\n          {title}\n        </h3>\n        {body ? (\n          <p className=\"m-0 text-[14px] leading-[1.55]\" style={{ color: SHEET.ink2 }}>\n            {body}\n          </p>\n        ) : null}\n        <p className=\"m-0 mt-1 text-[10.5px] uppercase\" style={{ fontFamily: MONO, letterSpacing: \"0.1em\", color: SHEET.ink3 }}>\n          {caption}\n        </p>\n      </div>\n    </>\n  );\n  const shell =\n    \"da-scope group relative flex flex-col overflow-hidden rounded-[14px] no-underline outline-none transition-transform duration-300 hover:-translate-y-0.5 focus-visible:-translate-y-0.5 \" + className;\n  const shellStyle: CSSProperties = { background: SHEET.panel, border: `1px solid ${SHEET.line}`, color: SHEET.ink, fontFamily: SANS };\n  return (\n    <div ref={ref} className=\"h-full\">\n      <style>{FOCUS_CSS}</style>\n      {href ? (\n        <a href={safeHref(href)} className={shell + \" h-full\"} style={shellStyle} onPointerEnter={() => setHot(true)} onPointerLeave={() => setHot(false)} onFocus={() => setHot(true)} onBlur={() => setHot(false)}>\n          {inner}\n        </a>\n      ) : (\n        <article className={shell + \" h-full\"} style={shellStyle} onPointerEnter={() => setHot(true)} onPointerLeave={() => setHot(false)}>\n          {inner}\n        </article>\n      )}\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/dither-develop-card.tsx"}],"meta":{"tier":"free","tags":["card","dither","bayer","image","reveal","hover","css"],"href":"https://viberdy.dev/library/dither-develop-card","aiPrompt":"Build a React + Tailwind card, `DitherDevelopCard({ title, kicker, body, image, alt, art, caption, href, cell, className })`.\n\nThe dither, in CSS: a group with filter contrast(255) over black holding (1) the picture with filter grayscale(1) brightness(level), transitioned over 1.1s; (2) optionally a radial light (screen blend) that follows the pointer; (3) a tile of the 8x8 Bayer thresholds as greys (each threshold t stored as 1 - t^(1/gamma), gamma 1.5 by default and 2.2 for strict linear light), at 50% opacity, cell size snapped to whole device pixels, image-rendering pixelated. Over the group, the ink in multiply and the ground in screen turn black and white into paper on the ground. level is 0 until the card first comes into view (IntersectionObserver at 35%), then 1; 1.12 on hover. Image sources are allowed only as http(s), same-site paths, blob: or data:image/. Below the picture: a mono kicker and a signal square, the title in Funnel Display 600, the body and a mono caption. Radius 14px, panel #121214, a hairline.\n\nDither & ASCII grammar: a warm black ground #0b0b0d (panels #121214, raised #19191c), one ink, paper #f3f0e6 (text at 92/60/38%, hairlines at 8% and 16%), and one signal colour, vermillion #ff4f2b, used as a second ink inside the pictures (the sun, a glint, a ball, a newly lit dot, a wordmark) and for a live state confined to a small mark (a square that lights, a peak character), never as a panel fill; a primary control's ink is paper, with signal kept for a momentary action. Every picture is computed and printed on purpose: rendered scenes dither in linear light and photographs on a gentler 1.5 curve so their midtones stay full, cells snap to whole device pixels (never blurred into grey), the 8x8 Bayer matrix for anything CSS, 64x64 void-and-cluster blue noise for the signature pictures, true error diffusion only on the CPU. Type stays crisp and undithered: Funnel Display through var(--font-funnel, \"Funnel Display\", ...) at 600-700 with -0.035em tracking for display, Geist for reading, Geist Mono uppercase with +0.14em tracking for labels and for every glyph an ASCII transform draws. Radii 10px controls, 14px cards, 18px panels. Motion: a transform change dissolves through blue noise in ~450ms, a fill prints in Bayer order in ~260ms and out in ~360ms, pictures print in on first view over ~900ms, ambient scenes loop over 30s or more. Respect prefers-reduced-motion (the picture still, changes instant). No dependencies beyond React.","license":"https://viberdy.dev/license"}}