{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "countdown-vapor-digits",
  "title": "Countdown Vapor Digits",
  "description": "Live countdown where each digit change is a phase transition: the outgoing digit sublimates into grains on curl-noise wind while the incoming digit condenses from the same cloud.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/countdown-vapor-digits/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// deterministic 2-octave value noise — house field, no deps\n// ---------------------------------------------------------------------------\nfunction hash2(x: number, y: number) {\n  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;\n  return n - Math.floor(n);\n}\nfunction vnoise(x: number, y: number) {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  const xf = x - xi;\n  const yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf);\n  const v = yf * yf * (3 - 2 * yf);\n  const a = hash2(xi, yi);\n  const b = hash2(xi + 1, yi);\n  const c = hash2(xi, yi + 1);\n  const d = hash2(xi + 1, yi + 1);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\nfunction noise2(x: number, y: number) {\n  return 0.65 * vnoise(x, y) + 0.35 * vnoise(x * 2.1 + 19.7, y * 2.1 + 7.3);\n}\n\n// ---------------------------------------------------------------------------\n// VaporCountdown — live countdown where every digit change is a phase\n// transition: the outgoing digit sublimates into monochrome grains drifting up\n// on curl-noise wind while the incoming digit condenses from the same cloud,\n// grains spring-seeking their new glyph homes. Canvas 2D over a real <time>\n// element (screen-reader truth). Direct-DOM rAF loop, no React state on the\n// hot path; hour/minute columns sleep between their rare transitions.\n// ---------------------------------------------------------------------------\n\nconst FLOATS = 6; // grains: x, y, vx, vy, hx, hy · vapor: x, y, vx, vy, age, life\nconst MAX_GRAINS = 2500; // per digit\nconst SAMPLE_STRIDE = 3; // px between alpha samples on the glyph raster\nconst SPRING_K = 90; // s⁻² — hero-gravity-well constants exactly\nconst ZETA = 0.55; // damping ratio; < 1 gives a soft condensation overshoot\nconst DRAG = 0.92; // per-frame velocity drag\nconst DT_MAX = 0.032; // s — clamp tab-switch jumps\nconst FIELD_SCALE = 0.008; // px → noise units for the wind field\nconst CURL_EPS = 0.75; // noise-space finite-difference step for curl\nconst PAD_X = 40; // canvas overdraw so vapor is not clipped at the digits' box\nconst PAD_TOP = 110;\nconst PAD_BOTTOM = 20;\n\nconst DEFAULT_LABELS = [\"HOURS\", \"MINUTES\", \"SECONDS\"] as const;\n\ntype Slot = {\n  g: Float32Array; // incoming/settled grains\n  v: Float32Array; // sublimating vapor grains\n  gc: number;\n  vc: number;\n  active: boolean;\n};\n\nexport function VaporCountdown({\n  targetDate,\n  labels = DEFAULT_LABELS,\n  className = \"\",\n}: {\n  /** countdown target — Date, ISO string, or epoch ms; defaults to 24h from mount */\n  targetDate?: Date | string | number;\n  /** mono labels under the HH / MM / SS groups; null hides the row */\n  labels?: readonly [string, string, string] | null;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const timeRef = useRef<HTMLTimeElement>(null);\n\n  // stable primitive for the deps array (Date identity churns per render)\n  const targetKey =\n    targetDate instanceof Date ? targetDate.getTime() : targetDate;\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const timeEl = timeRef.current;\n    if (!root || !canvas || !timeEl) return;\n    const digitEls = Array.from(\n      root.querySelectorAll<HTMLSpanElement>(\"[data-vc-digit]\")\n    );\n    if (digitEls.length !== 6) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const targetMs =\n      targetKey == null\n        ? Date.now() + 86_400_000\n        : typeof targetKey === \"number\"\n          ? targetKey\n          : new Date(targetKey).getTime();\n\n    let disposed = false;\n    let timer: ReturnType<typeof setTimeout> | undefined;\n    let digits = \"000000\";\n\n    const remaining = () => Math.max(0, targetMs - Date.now());\n    const toDigits = () => {\n      const rem = Math.round(remaining() / 1000);\n      const h = Math.min(99, Math.floor(rem / 3600));\n      const m = Math.floor((rem % 3600) / 60);\n      const s = rem % 60;\n      return (\n        String(h).padStart(2, \"0\") +\n        String(m).padStart(2, \"0\") +\n        String(s).padStart(2, \"0\")\n      );\n    };\n    const applyDateTime = () => {\n      timeEl.setAttribute(\n        \"datetime\",\n        `PT${digits.slice(0, 2)}H${digits.slice(2, 4)}M${digits.slice(4)}S`\n      );\n    };\n\n    // ---- canvas / particle state (unused under reduced motion) -------------\n    const ctx = canvas.getContext(\"2d\");\n\n    // grain color tracks the real foreground token — read fresh on theme\n    // change so light/dark both draw legible ink, never a hardcoded hex.\n    let grainColor = \"#ededed\";\n    const updateGrainColor = () => {\n      grainColor = getComputedStyle(digitEls[0]).color || grainColor;\n    };\n    updateGrainColor();\n\n    const slots: Slot[] = Array.from({ length: 6 }, () => ({\n      g: new Float32Array(MAX_GRAINS * FLOATS),\n      v: new Float32Array(MAX_GRAINS * FLOATS),\n      gc: 0,\n      vc: 0,\n      active: false,\n    }));\n    let glyphs: Float32Array[] | null = null; // cell-relative homes per digit 0-9\n    let cells: { x: number; y: number }[] = [];\n    let cw = 0;\n    let ch = 0;\n    let lastW = 0;\n    let lastH = 0;\n    let raf = 0;\n    let last = 0;\n    const dampC = 2 * ZETA * Math.sqrt(SPRING_K); // s⁻¹\n\n    const loop = (now: number) => {\n      if (!ctx) return;\n      const dt = Math.min(Math.max((now - last) / 1000, 0), DT_MAX);\n      last = now;\n      ctx.clearRect(0, 0, cw, ch);\n      ctx.fillStyle = grainColor;\n      let anyActive = false;\n\n      for (let s = 0; s < 6; s++) {\n        const slot = slots[s];\n        const g = slot.g;\n        const v = slot.v;\n\n        if (!slot.active) {\n          // asleep: grains sit on their homes — one flat pass, no physics\n          ctx.globalAlpha = 0.5;\n          for (let i = 0; i < slot.gc; i++) {\n            const o = i * FLOATS;\n            ctx.fillRect(g[o] - 1, g[o + 1] - 1, 2, 2);\n          }\n          continue;\n        }\n        anyActive = true;\n        let settled = true;\n\n        // incoming grains: underdamped spring toward glyph homes\n        for (let i = 0; i < slot.gc; i++) {\n          const o = i * FLOATS;\n          let x = g[o];\n          let y = g[o + 1];\n          let vx = g[o + 2];\n          let vy = g[o + 3];\n          const hx = g[o + 4];\n          const hy = g[o + 5];\n\n          vx = (vx + (SPRING_K * (hx - x) - dampC * vx) * dt) * DRAG;\n          vy = (vy + (SPRING_K * (hy - y) - dampC * vy) * dt) * DRAG;\n          x += vx * dt;\n          y += vy * dt;\n\n          g[o] = x;\n          g[o + 1] = y;\n          g[o + 2] = vx;\n          g[o + 3] = vy;\n\n          if (\n            Math.abs(x - hx) > 0.5 ||\n            Math.abs(y - hy) > 0.5 ||\n            Math.abs(vx) > 2 ||\n            Math.abs(vy) > 2\n          ) {\n            settled = false;\n          }\n\n          const speed = Math.hypot(vx, vy);\n          ctx.globalAlpha = 0.5 + 0.5 * Math.min(1, speed / 500);\n          ctx.fillRect(x - 1, y - 1, 2, 2);\n        }\n\n        // vapor grains: curl-noise wind, biased upward, alpha fade over life\n        let j = 0;\n        while (j < slot.vc) {\n          const o = j * FLOATS;\n          const age = v[o + 4] + dt * 1000;\n          const life = v[o + 5];\n          if (age >= life) {\n            // swap-remove with the last live vapor grain\n            const lo = (slot.vc - 1) * FLOATS;\n            for (let k = 0; k < FLOATS; k++) v[o + k] = v[lo + k];\n            slot.vc--;\n            continue;\n          }\n          v[o + 4] = age;\n          let x = v[o];\n          let y = v[o + 1];\n          let vx = v[o + 2];\n          let vy = v[o + 3];\n\n          // wind = curl of the noise field: (∂n/∂y, -∂n/∂x) — divergence-free\n          const nx = x * FIELD_SCALE;\n          const ny = y * FIELD_SCALE;\n          const dndx = noise2(nx + CURL_EPS, ny) - noise2(nx - CURL_EPS, ny);\n          const dndy = noise2(nx, ny + CURL_EPS) - noise2(nx, ny - CURL_EPS);\n          let wx = dndy;\n          let wy = -dndx;\n          const cl = Math.hypot(wx, wy) || 1;\n          const sp = 40 + 50 * hash2(j * 1.31, s * 7.7); // 40-90 px/s per grain\n          wx = (wx / cl) * sp * 0.6;\n          wy = (wy / cl) * sp * 0.6 - sp * 0.8; // upward bias\n          const mix = Math.min(1, dt * 5);\n          vx += (wx - vx) * mix;\n          vy += (wy - vy) * mix;\n          x += vx * dt;\n          y += vy * dt;\n\n          v[o] = x;\n          v[o + 1] = y;\n          v[o + 2] = vx;\n          v[o + 3] = vy;\n\n          const speed = Math.hypot(vx, vy);\n          ctx.globalAlpha =\n            (1 - age / life) * (0.5 + 0.5 * Math.min(1, speed / 500));\n          ctx.fillRect(x - 1, y - 1, 2, 2);\n          j++;\n        }\n        if (slot.vc > 0) settled = false;\n\n        if (settled) {\n          // snap to homes and let the column sleep\n          for (let i = 0; i < slot.gc; i++) {\n            const o = i * FLOATS;\n            g[o] = g[o + 4];\n            g[o + 1] = g[o + 5];\n            g[o + 2] = 0;\n            g[o + 3] = 0;\n          }\n          slot.active = false;\n        }\n      }\n      ctx.globalAlpha = 1;\n      raf = anyActive ? requestAnimationFrame(loop) : 0;\n    };\n\n    const wake = () => {\n      if (!raf && ctx) {\n        last = performance.now();\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const transition = (s: number, d: number) => {\n      if (!glyphs) return;\n      const slot = slots[s];\n      const g = slot.g;\n      const v = slot.v;\n\n      // outgoing digit sublimates: grains move to the vapor pool\n      let vc = 0;\n      for (let i = 0; i < slot.gc && vc < MAX_GRAINS; i++) {\n        const o = i * FLOATS;\n        const vo = vc * FLOATS;\n        v[vo] = g[o];\n        v[vo + 1] = g[o + 1];\n        v[vo + 2] = g[o + 2] * 0.4;\n        v[vo + 3] = g[o + 3] * 0.4 - 12; // small upward kick at release\n        v[vo + 4] = 0;\n        v[vo + 5] = 600 + Math.random() * 300; // ms lifespan\n        vc++;\n      }\n      slot.vc = vc;\n\n      // incoming digit condenses out of the departing cloud region\n      const homes = glyphs[d];\n      const cell = cells[s];\n      const n = homes.length / 2;\n      slot.gc = n;\n      for (let i = 0; i < n; i++) {\n        const o = i * FLOATS;\n        const hx = cell.x + homes[i * 2];\n        const hy = cell.y + homes[i * 2 + 1];\n        if (vc > 0) {\n          const src = ((Math.random() * vc) | 0) * FLOATS;\n          g[o] = v[src] + (Math.random() - 0.5) * 10;\n          g[o + 1] = v[src + 1] + (Math.random() - 0.5) * 10;\n          g[o + 2] = (Math.random() - 0.5) * 40;\n          g[o + 3] = -20 - Math.random() * 40;\n        } else {\n          g[o] = hx;\n          g[o + 1] = hy;\n          g[o + 2] = 0;\n          g[o + 3] = 0;\n        }\n        g[o + 4] = hx;\n        g[o + 5] = hy;\n      }\n      slot.active = true;\n    };\n\n    const init = () => {\n      if (!ctx) return;\n      const rootRect = root.getBoundingClientRect();\n      const w = Math.round(rootRect.width);\n      const h = Math.round(rootRect.height);\n      if (w < 2 || h < 2) return;\n      lastW = w;\n      lastH = h;\n      cw = w + PAD_X * 2;\n      ch = h + PAD_TOP + PAD_BOTTOM;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = cw * dpr;\n      canvas.height = ch * dpr;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      cells = digitEls.map((el) => {\n        const r = el.getBoundingClientRect();\n        return {\n          x: r.left - rootRect.left + PAD_X,\n          y: r.top - rootRect.top + PAD_TOP,\n        };\n      });\n\n      // rasterize glyphs 0-9 once — tabular figures make every cell identical\n      const cellRect = digitEls[0].getBoundingClientRect();\n      const gw = Math.max(2, Math.ceil(cellRect.width));\n      const gh = Math.max(2, Math.ceil(cellRect.height));\n      const off = document.createElement(\"canvas\");\n      off.width = gw;\n      off.height = gh;\n      const octx = off.getContext(\"2d\", { willReadFrequently: true });\n      if (!octx) return;\n      const cs = getComputedStyle(digitEls[0]);\n      octx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;\n      octx.textAlign = \"center\";\n      octx.textBaseline = \"middle\";\n      octx.fillStyle = \"#ffffff\";\n      glyphs = [];\n      for (let d = 0; d < 10; d++) {\n        octx.clearRect(0, 0, gw, gh);\n        octx.fillText(String(d), gw / 2, gh / 2);\n        const alpha = octx.getImageData(0, 0, gw, gh).data;\n        const pts: number[] = [];\n        for (let y = 1; y < gh; y += SAMPLE_STRIDE) {\n          const row = y * gw;\n          for (let x = 1; x < gw; x += SAMPLE_STRIDE) {\n            if (alpha[(row + x) * 4 + 3] > 128) pts.push(x, y);\n          }\n        }\n        const total = pts.length / 2;\n        const keepEvery = Math.max(1, Math.ceil(total / MAX_GRAINS));\n        const homes = new Float32Array(Math.ceil(total / keepEvery) * 2);\n        let n = 0;\n        for (let i = 0; i < total; i += keepEvery) {\n          homes[n * 2] = pts[i * 2];\n          homes[n * 2 + 1] = pts[i * 2 + 1];\n          n++;\n        }\n        glyphs.push(homes.subarray(0, n * 2));\n      }\n\n      // seat every slot on its current digit, settled — calm first paint\n      for (let s = 0; s < 6; s++) {\n        const slot = slots[s];\n        const homes = glyphs[Number(digits.charAt(s))];\n        const cell = cells[s];\n        const n = homes.length / 2;\n        slot.gc = n;\n        slot.vc = 0;\n        slot.active = false;\n        for (let i = 0; i < n; i++) {\n          const o = i * FLOATS;\n          const hx = cell.x + homes[i * 2];\n          const hy = cell.y + homes[i * 2 + 1];\n          slot.g[o] = hx;\n          slot.g[o + 1] = hy;\n          slot.g[o + 2] = 0;\n          slot.g[o + 3] = 0;\n          slot.g[o + 4] = hx;\n          slot.g[o + 5] = hy;\n        }\n      }\n      wake(); // one paint, then the loop sleeps until the next tick\n    };\n\n    // ---- clock: rAF wakes on each second boundary --------------------------\n    const schedule = () => {\n      timer = setTimeout(tick, 1000 - (Date.now() % 1000) + 15);\n    };\n    const tick = () => {\n      const next = toDigits();\n      if (next !== digits) {\n        for (let s = 0; s < 6; s++) {\n          if (next.charAt(s) !== digits.charAt(s)) {\n            digitEls[s].textContent = next.charAt(s);\n            if (!reduced) transition(s, Number(next.charAt(s)));\n          }\n        }\n        digits = next;\n        applyDateTime();\n        if (!reduced) wake();\n      }\n      if (remaining() > 0) schedule();\n    };\n\n    digits = toDigits();\n    for (let s = 0; s < 6; s++) digitEls[s].textContent = digits.charAt(s);\n    applyDateTime();\n    if (remaining() > 0) schedule();\n\n    if (reduced || !ctx) {\n      // reduced motion: canvas stays hidden (CSS), the real <time> is visible\n      return () => {\n        disposed = true;\n        if (timer) clearTimeout(timer);\n      };\n    }\n\n    let ro: ResizeObserver | undefined;\n    // raster only after Geist has loaded — a fallback-font raster is wrong\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      init();\n      ro = new ResizeObserver((entries) => {\n        const entry = entries[0];\n        if (!entry) return;\n        const w = Math.round(entry.contentRect.width);\n        const h = Math.round(entry.contentRect.height);\n        if (w !== lastW || h !== lastH) init();\n      });\n      ro.observe(root);\n    });\n\n    // re-derive grain color on theme toggle — class/data-theme flips on\n    // documentElement, and OS-level scheme changes fire through matchMedia.\n    const onThemeChange = () => {\n      updateGrainColor();\n      wake();\n    };\n    const mo = new MutationObserver(onThemeChange);\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-theme\"],\n    });\n    const colorScheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    colorScheme.addEventListener(\"change\", onThemeChange);\n\n    return () => {\n      disposed = true;\n      if (timer) clearTimeout(timer);\n      cancelAnimationFrame(raf);\n      raf = 0;\n      ro?.disconnect();\n      mo.disconnect();\n      colorScheme.removeEventListener(\"change\", onThemeChange);\n    };\n  }, [targetKey]);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative inline-grid select-none ${className}`}\n      style={{\n        gridTemplateColumns: \"repeat(3, auto)\",\n        columnGap: \"0.45em\",\n        rowGap: \"0.75rem\",\n        fontSize: \"clamp(2.5rem, 8vw, 5rem)\",\n      }}\n    >\n      {/* real element: screen-reader truth; visible static under reduced motion */}\n      <time ref={timeRef} aria-live=\"off\" style={{ display: \"contents\" }}>\n        {[0, 1, 2].map((group) => (\n          <span\n            key={group}\n            className=\"flex justify-center font-semibold leading-none tracking-tight text-foreground opacity-0 tabular-nums motion-reduce:opacity-100\"\n          >\n            <span data-vc-digit>0</span>\n            <span data-vc-digit>0</span>\n          </span>\n        ))}\n      </time>\n      {labels &&\n        labels.map((label) => (\n          <span\n            key={label}\n            className=\"text-center font-mono text-[10px] tracking-[0.25em] text-ns-muted\"\n          >\n            {label}\n          </span>\n        ))}\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"pointer-events-none absolute motion-reduce:hidden\"\n        style={{\n          left: -PAD_X,\n          top: -PAD_TOP,\n          width: `calc(100% + ${PAD_X * 2}px)`,\n          height: `calc(100% + ${PAD_TOP + PAD_BOTTOM}px)`,\n        }}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/countdown-vapor-digits.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "canvas",
      "particles",
      "countdown",
      "typography",
      "noise",
      "spring",
      "time"
    ],
    "instruction": "A live HH MM SS countdown rendered as monochrome grains on a DPR-aware Canvas 2D over a real <time> element (aria-live=off, tabular Geist Sans 600, visually transparent) so screen readers get truth and reduced motion gets a static visible countdown with the canvas hidden. After document.fonts.ready, rasterize digits 0-9 once on an offscreen canvas sized to one tabular digit cell, sampling alpha > 128 at a 3px stride, capped at 2500 grains per digit, stored in Float32Array pools (x, y, vx, vy, hx, hy). Each second boundary a setTimeout tick updates the DOM digits and, for every changed column, runs a phase transition: outgoing grains move to a vapor pool with a 600-900ms per-grain lifespan, driven by wind equal to the curl of a 2-octave value-noise field (field scale 0.008, per-grain speed 40-90 px/s, biased upward) with alpha fading over life; incoming grains spawn from the departing cloud region with slight jitter and spring to their new glyph homes with k=90 s^-2, zeta=0.55, per-frame drag 0.92, dt clamped to 32ms, so a full swap reads settled in ~800ms. Grains draw as 2x2 fillRect in the live computed foreground color (read via getComputedStyle on a digit element, re-read on documentElement class/data-theme mutation and OS color-scheme change so a live theme toggle repaints correctly with one forced wake) with alpha 0.5 + 0.5*min(1, speed/500); vapor multiplies in its life fade. The rAF loop wakes on each tick and sleeps when every column has grains within 0.5px of home with |v| < 2 and no live vapor; settled columns (hours, minutes) skip physics entirely and draw one flat pass, so seconds churn constantly while hours stay typographically calm. Canvas overdraws the layout box (110px headroom) so rising vapor never clips. Props: targetDate (Date | string | epoch ms, default 24h out), labels row in font-mono text-ns-muted beneath the groups. Zero dependencies."
  },
  "type": "registry:ui"
}