{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "winnow",
  "type": "registry:ui",
  "title": "Winnow",
  "description": "A search input whose empty placeholder cycles (each sliding up as the next arrives), and whose typed text — on submit — rasterizes into fine grains that pour off left-to-right and blow away, clearing the field. A hairline underline fills in from center to one cool accent on focus. The submit is genuine; the vanish is decoration.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/inputs/winnow/winnow.tsx",
      "type": "registry:ui",
      "target": "components/crucible/winnow.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface WinnowProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"input\">,\n    \"value\" | \"defaultValue\" | \"onChange\" | \"onSubmit\" | \"color\" | \"size\"\n  > {\n  /** Controlled value. Omit for uncontrolled use (see `defaultValue`). */\n  value?: string;\n  /** Initial value when uncontrolled. @default \"\" */\n  defaultValue?: string;\n  /** Fires on every keystroke with the current string value. */\n  onChange?: (value: string) => void;\n  /**\n   * Fires on genuine submit (Enter or the submit button) with the field value\n   * BEFORE it is cleared — the vanish is purely decorative over this call.\n   */\n  onSubmit?: (value: string) => void;\n  /**\n   * Rotating placeholder suggestions. Each slides up and out as the next\n   * arrives while the field is empty. @default a few search prompts\n   */\n  placeholders?: string[];\n  /** Milliseconds each placeholder is shown before cycling. @default 2800 */\n  cycleInterval?: number;\n  /** Base grain size in px (each grain jitters around it). @default 1.7 */\n  particleSize?: number;\n  /** Grain density multiplier — higher packs more, finer grains. @default 1 */\n  density?: number;\n  /** Base scatter speed in px/frame at 60fps. @default 3.4 */\n  scatterVelocity?: number;\n  /**\n   * Scatter direction in degrees. 0 = straight right, negative = upward.\n   * Grains blow off along this heading before gravity takes over. @default -14\n   */\n  scatterDirection?: number;\n  /** Downward acceleration applied to freed grains per frame. @default 0.09 */\n  gravity?: number;\n  /** Seconds a single grain takes to scatter and fade. @default 0.9 */\n  dissolveDuration?: number;\n  /** Clear the field on submit (via the dissolve). @default true */\n  submitClears?: boolean;\n  /**\n   * Grain palette, assigned per grain. Keep it a narrow near-white ramp —\n   * the vanish should read as fine sand, not confetti. @default soft whites\n   */\n  colors?: string[];\n  /** The single cool accent for the focus underline. @default sky */\n  accent?: string;\n  /** Error state — reddens the underline and sets aria-invalid. @default false */\n  error?: boolean;\n  /** Freeze the placeholder cycle and skip the dissolve (submit clears instantly). @default false */\n  paused?: boolean;\n  /** Deterministic seed for grain jitter. @default 7 */\n  seed?: number;\n  className?: string;\n}\n\n/** Fine near-white grains — a narrow neutral ramp, never multicolor. */\nconst DEFAULT_COLORS = [\"#fafafa\", \"#e5e5e5\", \"#d4d4d4\", \"#a3a3a3\"];\nconst DEFAULT_ACCENT = \"#38bdf8\";\nconst ERROR_COLOR = \"#f87171\";\nconst DEFAULT_PLACEHOLDERS = [\n  \"Search components…\",\n  \"Try a background\",\n  \"Find a loader\",\n  \"What are you building?\",\n];\n\nconst MAX_PARTICLES = 6000;\n\n/** Deterministic mulberry32 PRNG — same seed always casts the same grains. */\nfunction mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\ninterface Grains {\n  count: number;\n  x: Float32Array;\n  y: Float32Array;\n  hx: Float32Array; // home — where the grain rests as crisp glyph\n  hy: Float32Array;\n  vx: Float32Array;\n  vy: Float32Array;\n  size: Float32Array;\n  release: Float32Array; // seconds before this grain is set free (the pour)\n  released: Uint8Array;\n  colorIndex: Uint8Array;\n}\n\n/**\n * Winnow — a search input whose empty placeholder cycles (each sliding up as\n * the next arrives), and whose typed characters, on submit, rasterize into\n * fine grains that pour off left-to-right and blow away under a slight gravity\n * bias, clearing the field. The submit is genuine: `onSubmit` fires with the\n * value BEFORE the dissolve. A hairline underline draws in from center like a\n * filled gauge and brightens to one cool accent on focus. The scatter canvas\n * mounts only during the brief dissolve and tears down after — no rAF at rest.\n * Reduced motion crossfades placeholders and clears the field instantly with\n * no particles.\n */\nexport function Winnow({\n  value,\n  defaultValue = \"\",\n  onChange,\n  onSubmit,\n  placeholders = DEFAULT_PLACEHOLDERS,\n  cycleInterval = 2800,\n  particleSize = 1.7,\n  density = 1,\n  scatterVelocity = 3.4,\n  scatterDirection = -14,\n  gravity = 0.09,\n  dissolveDuration = 0.9,\n  submitClears = true,\n  colors = DEFAULT_COLORS,\n  accent = DEFAULT_ACCENT,\n  error = false,\n  paused = false,\n  seed = 7,\n  disabled,\n  className,\n  onFocus,\n  onBlur,\n  ...rest\n}: WinnowProps) {\n  const reducedMotion = useReducedMotion();\n\n  const wrapperRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  const grainsRef = React.useRef<Grains | null>(null);\n  const rafRef = React.useRef(0);\n  const elapsedRef = React.useRef(0);\n  const lastRef = React.useRef(0);\n\n  const [index, setIndex] = React.useState(0);\n  const [focused, setFocused] = React.useState(false);\n  const [dissolving, setDissolving] = React.useState(false);\n  const [internal, setInternal] = React.useState(defaultValue);\n\n  const isControlled = value !== undefined;\n  const currentValue = isControlled ? value : internal;\n\n  const palette = React.useMemo(\n    () => (colors.length ? colors : DEFAULT_COLORS),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [colors.join(\",\")]\n  );\n  const prompts = React.useMemo(\n    () => (placeholders.length ? placeholders : DEFAULT_PLACEHOLDERS),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [placeholders.join(\"\u0000\")]\n  );\n\n  const setValue = React.useCallback(\n    (next: string) => {\n      if (!isControlled) setInternal(next);\n      onChange?.(next);\n    },\n    [isControlled, onChange]\n  );\n\n  // Placeholder cycle — advances only while empty and not paused, and never\n  // when the tab is hidden. Never runs at rest cost; a single interval.\n  React.useEffect(() => {\n    if (paused || disabled) return;\n    if (currentValue !== \"\") return;\n    if (prompts.length <= 1) return;\n    const id = window.setInterval(() => {\n      if (typeof document !== \"undefined\" && document.hidden) return;\n      setIndex((i) => (i + 1) % prompts.length);\n    }, Math.max(cycleInterval, 400));\n    return () => window.clearInterval(id);\n  }, [paused, disabled, currentValue, prompts, cycleInterval]);\n\n  const teardown = React.useCallback(() => {\n    cancelAnimationFrame(rafRef.current);\n    rafRef.current = 0;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (canvas && ctx) {\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      canvas.style.opacity = \"0\";\n    }\n    grainsRef.current = null;\n    setDissolving(false);\n  }, []);\n\n  // Cancel any in-flight dissolve on unmount — no dangling rAF.\n  React.useEffect(() => teardown, [teardown]);\n\n  const startDissolve = React.useCallback(\n    (text: string): boolean => {\n      const wrapper = wrapperRef.current;\n      const input = inputRef.current;\n      const canvas = canvasRef.current;\n      if (!wrapper || !input || !canvas) return false;\n\n      const W = wrapper.clientWidth;\n      const H = wrapper.clientHeight;\n      if (W < 2 || H < 2) return false;\n\n      const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n      if (!ctx) return false;\n\n      const cs = getComputedStyle(input);\n      const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;\n      const padL = parseFloat(cs.paddingLeft) || 0;\n      // Account for horizontal scroll when the text overflows the field.\n      const textX = input.offsetLeft + padL - input.scrollLeft;\n      const textY = input.offsetTop + input.clientHeight / 2;\n\n      const dpr = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, 2);\n      canvas.width = Math.round(W * dpr);\n      canvas.height = Math.round(H * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      // Rasterize the typed text at the input's exact glyph position.\n      ctx.font = font;\n      ctx.textAlign = \"left\";\n      ctx.textBaseline = \"middle\";\n      ctx.fillStyle = \"#fff\";\n      ctx.clearRect(0, 0, W, H);\n      // Clip to the input's text box so overflowed glyphs don't spill out.\n      ctx.save();\n      ctx.beginPath();\n      ctx.rect(\n        input.offsetLeft + padL,\n        input.offsetTop,\n        Math.max(0, input.clientWidth - padL - (parseFloat(cs.paddingRight) || 0)),\n        input.clientHeight\n      );\n      ctx.clip();\n      ctx.fillText(text, textX, textY);\n      ctx.restore();\n\n      const img = ctx.getImageData(0, 0, Math.round(W * dpr), Math.round(H * dpr)).data;\n      const iw = Math.round(W * dpr);\n      const ih = Math.round(H * dpr);\n      const step = Math.max(1, Math.round((2.6 / Math.max(density, 0.2)) * dpr));\n\n      // Collect solid cells (in device px), tracking horizontal extent so the\n      // pour can be staggered left-to-right.\n      const cells: number[] = [];\n      let minX = Infinity;\n      let maxX = -Infinity;\n      for (let py = 0; py < ih; py += step) {\n        for (let px = 0; px < iw; px += step) {\n          if (img[(py * iw + px) * 4 + 3] > 130) {\n            cells.push(px, py);\n            if (px < minX) minX = px;\n            if (px > maxX) maxX = px;\n          }\n        }\n      }\n      let total = cells.length / 2;\n      if (total === 0) return false;\n\n      const stride = total > MAX_PARTICLES ? Math.ceil(total / MAX_PARTICLES) : 1;\n      total = Math.floor(total / stride);\n      if (total === 0) return false;\n\n      const rand = mulberry32((seed >>> 0) + text.length * 97 + 1);\n      const spanX = Math.max(maxX - minX, 1);\n      const stagger = dissolveDuration * 0.55;\n      const dir = (scatterDirection * Math.PI) / 180;\n\n      const g: Grains = {\n        count: total,\n        x: new Float32Array(total),\n        y: new Float32Array(total),\n        hx: new Float32Array(total),\n        hy: new Float32Array(total),\n        vx: new Float32Array(total),\n        vy: new Float32Array(total),\n        size: new Float32Array(total),\n        release: new Float32Array(total),\n        released: new Uint8Array(total),\n        colorIndex: new Uint8Array(total),\n      };\n      for (let i = 0; i < total; i++) {\n        const src = i * stride * 2;\n        const cx = cells[src] / dpr;\n        const cy = cells[src + 1] / dpr;\n        g.hx[i] = cx + ((rand() - 0.5) * step) / dpr;\n        g.hy[i] = cy + ((rand() - 0.5) * step) / dpr;\n        g.x[i] = g.hx[i];\n        g.y[i] = g.hy[i];\n        g.size[i] = Math.max(particleSize * (0.6 + rand() * 0.9), 0.5);\n        g.colorIndex[i] = Math.floor(rand() * palette.length);\n        const norm = (cells[src] - minX) / spanX; // 0 at left edge → 1 at right\n        g.release[i] = norm * stagger + rand() * 0.06;\n      }\n      grainsRef.current = g;\n\n      const dur = Math.max(dissolveDuration, 0.15);\n      canvas.style.opacity = \"1\";\n      elapsedRef.current = 0;\n      lastRef.current = performance.now();\n\n      const loop = (now: number) => {\n        const grains = grainsRef.current;\n        if (!grains) return;\n        const dt = Math.min((now - lastRef.current) / 1000, 0.05);\n        lastRef.current = now;\n        elapsedRef.current += dt;\n        const elapsed = elapsedRef.current;\n        const f = dt * 60;\n        const drag = Math.pow(0.985, f);\n\n        ctx.clearRect(0, 0, W, H);\n        let alive = false;\n        let currentColor = -1;\n        for (let i = 0; i < grains.count; i++) {\n          const rel = grains.release[i];\n          if (elapsed < rel) {\n            // Not yet poured — still part of the crisp word.\n            alive = true;\n            const c = grains.colorIndex[i];\n            if (c !== currentColor) {\n              currentColor = c;\n              ctx.fillStyle = palette[c] ?? palette[0];\n            }\n            ctx.globalAlpha = 1;\n            const s = grains.size[i];\n            ctx.fillRect(grains.hx[i] - s / 2, grains.hy[i] - s / 2, s, s);\n            continue;\n          }\n          const local = elapsed - rel;\n          const life = local / dur;\n          if (life >= 1) continue; // gone\n          alive = true;\n\n          if (!grains.released[i]) {\n            grains.released[i] = 1;\n            // Deterministic per-grain angle + speed jitter around the heading\n            // so the pour reads as loose sand, not a rigid sheet.\n            const jitter = (((i * 2654435761) >>> 0) / 4294967296 - 0.5) * 0.9;\n            const speed = scatterVelocity * (0.55 + (((i * 40503) >>> 0) % 1000) / 1000);\n            grains.vx[i] = Math.cos(dir + jitter) * speed;\n            grains.vy[i] = Math.sin(dir + jitter) * speed;\n          }\n\n          grains.vy[i] += gravity * f;\n          grains.vx[i] *= drag;\n          grains.vy[i] *= drag;\n          grains.x[i] += grains.vx[i] * f;\n          grains.y[i] += grains.vy[i] * f;\n\n          const c = grains.colorIndex[i];\n          if (c !== currentColor) {\n            currentColor = c;\n            ctx.fillStyle = palette[c] ?? palette[0];\n          }\n          ctx.globalAlpha = Math.max(0, 1 - life * life);\n          const s = grains.size[i];\n          ctx.fillRect(grains.x[i] - s / 2, grains.y[i] - s / 2, s, s);\n        }\n        ctx.globalAlpha = 1;\n\n        if (!alive) {\n          teardown();\n          return;\n        }\n        rafRef.current = requestAnimationFrame(loop);\n      };\n\n      cancelAnimationFrame(rafRef.current);\n      rafRef.current = requestAnimationFrame(loop);\n      return true;\n    },\n    [density, dissolveDuration, gravity, palette, particleSize, scatterDirection, scatterVelocity, seed, teardown]\n  );\n\n  const handleSubmit = React.useCallback(\n    (event: React.FormEvent<HTMLFormElement>) => {\n      event.preventDefault();\n      if (disabled) return;\n      const submitted = currentValue;\n      onSubmit?.(submitted);\n\n      if (!submitClears || submitted === \"\") return;\n\n      if (reducedMotion || paused) {\n        setValue(\"\");\n        return;\n      }\n      // Fire the dissolve on the captured text, then clear the field. The\n      // canvas keeps painting the vanishing glyphs while the input goes empty.\n      const started = startDissolve(submitted);\n      if (started) setDissolving(true);\n      setValue(\"\");\n    },\n    [currentValue, disabled, onSubmit, paused, reducedMotion, setValue, startDissolve, submitClears]\n  );\n\n  const accentColor = error ? ERROR_COLOR : accent;\n  const gaugeActive = focused || error;\n  const showPlaceholder = currentValue === \"\" && !dissolving;\n\n  return (\n    <form\n      role=\"search\"\n      onSubmit={handleSubmit}\n      className={cn(\"w-full max-w-md\", className)}\n      data-crucible=\"winnow\"\n    >\n      <div\n        ref={wrapperRef}\n        className={cn(\n          \"relative flex items-center\",\n          disabled && \"opacity-50\"\n        )}\n      >\n        {/* The vanishing glyph canvas — mounted always but painted (and rAF-driven)\n            only during the brief dissolve, then cleared. Purely decorative. */}\n        <canvas\n          ref={canvasRef}\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 h-full w-full opacity-0\"\n        />\n\n        {/* Rotating placeholder overlay. Real placeholder text is transparent\n            (kept for accessible name / autofill); this is the visible one. */}\n        {showPlaceholder && (\n          <div\n            aria-hidden\n            className=\"pointer-events-none absolute inset-y-0 left-1 right-10 flex items-center overflow-hidden\"\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={index}\n                className=\"block truncate text-base text-neutral-500\"\n                initial={reducedMotion ? { opacity: 0 } : { y: \"0.9em\", opacity: 0 }}\n                animate={{ y: 0, opacity: 1 }}\n                exit={reducedMotion ? { opacity: 0 } : { y: \"-0.9em\", opacity: 0 }}\n                transition={\n                  reducedMotion\n                    ? { duration: 0.2 }\n                    : { type: \"spring\", stiffness: 320, damping: 32 }\n                }\n              >\n                {prompts[index % prompts.length]}\n              </motion.span>\n            </AnimatePresence>\n          </div>\n        )}\n\n        <input\n          ref={inputRef}\n          type=\"text\"\n          value={currentValue}\n          disabled={disabled}\n          aria-invalid={error || undefined}\n          onChange={(event) => setValue(event.target.value)}\n          onFocus={(event) => {\n            setFocused(true);\n            onFocus?.(event);\n          }}\n          onBlur={(event) => {\n            setFocused(false);\n            onBlur?.(event);\n          }}\n          className={cn(\n            \"w-full appearance-none bg-transparent px-1 py-3 pr-10 text-base text-neutral-100\",\n            \"outline-none placeholder:text-transparent\",\n            \"disabled:cursor-not-allowed\"\n          )}\n          {...rest}\n          placeholder={prompts[0]}\n        />\n\n        {/* Submit affordance for pointer users; Enter submits the form too. */}\n        <button\n          type=\"submit\"\n          disabled={disabled || currentValue === \"\"}\n          aria-label=\"Submit\"\n          className={cn(\n            \"absolute right-0 top-1/2 -translate-y-1/2 flex h-8 w-8 items-center justify-center rounded-md\",\n            \"text-neutral-500 transition-colors duration-200\",\n            \"hover:text-neutral-200 disabled:pointer-events-none disabled:opacity-40\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\"\n          )}\n          style={{ [\"--tw-ring-color\" as string]: accentColor }}\n        >\n          <svg\n            aria-hidden\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.8\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className=\"h-4 w-4\"\n          >\n            <path d=\"M5 12h14\" />\n            <path d=\"m13 6 6 6-6 6\" />\n          </svg>\n        </button>\n\n        {/* Hairline underline: a static dim base, plus an accent gauge that\n            fills in from center on focus (or stays lit for the error state). */}\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 h-px bg-neutral-700\"\n        />\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-x-0 bottom-0 h-[1.5px] origin-center transition-transform ease-out\",\n            gaugeActive ? \"scale-x-100\" : \"scale-x-0\"\n          )}\n          style={{\n            backgroundColor: accentColor,\n            boxShadow: `0 0 8px ${accentColor}`,\n            transitionDuration: reducedMotion ? \"0ms\" : \"480ms\",\n          }}\n        />\n      </div>\n    </form>\n  );\n}\n"
    }
  ]
}