# Winnow — Animated Input for React

> 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.

- Page: https://crucibleui.com/components/inputs/winnow
- Category: [inputs](https://crucibleui.com/components/inputs)
- Tier: free (full source)
- Engine: canvas
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/winnow
```

Registry JSON: https://crucibleui.com/r/winnow.json

Import from `@/components/crucible/winnow`.

## Props (`WinnowProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value?` | `string` | — | Controlled value. Omit for uncontrolled use (see `defaultValue`). |
| `defaultValue?` | `string` | `""` | Initial value when uncontrolled. |
| `onChange?` | `(value: string) => void` | — | Fires on every keystroke with the current string value. |
| `onSubmit?` | `(value: string) => void` | — | Fires on genuine submit (Enter or the submit button) with the field value BEFORE it is cleared — the vanish is purely decorative over this call. |
| `placeholders?` | `string[]` | `a few search prompts` | Rotating placeholder suggestions. Each slides up and out as the next arrives while the field is empty. |
| `cycleInterval?` | `number` | `2800` | Milliseconds each placeholder is shown before cycling. |
| `particleSize?` | `number` | `1.7` | Base grain size in px (each grain jitters around it). |
| `density?` | `number` | `1` | Grain density multiplier — higher packs more, finer grains. |
| `scatterVelocity?` | `number` | `3.4` | Base scatter speed in px/frame at 60fps. |
| `scatterDirection?` | `number` | `-14` | Scatter direction in degrees. 0 = straight right, negative = upward. Grains blow off along this heading before gravity takes over. |
| `gravity?` | `number` | `0.09` | Downward acceleration applied to freed grains per frame. |
| `dissolveDuration?` | `number` | `0.9` | Seconds a single grain takes to scatter and fade. |
| `submitClears?` | `boolean` | `true` | Clear the field on submit (via the dissolve). |
| `colors?` | `string[]` | `soft whites` | Grain palette, assigned per grain. Keep it a narrow near-white ramp — the vanish should read as fine sand, not confetti. |
| `accent?` | `string` | `sky` | The single cool accent for the focus underline. |
| `error?` | `boolean` | `false` | Error state — reddens the underline and sets aria-invalid. |
| `paused?` | `boolean` | `false` | Freeze the placeholder cycle and skip the dissolve (submit clears instantly). |
| `seed?` | `number` | `7` | Deterministic seed for grain jitter. |
| `className?` | `string` | — | — |

Also accepts: `Omit< React.ComponentPropsWithoutRef<"input">, "value" | "defaultValue" | "onChange" | "onSubmit" | "color" | "size" >`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Winnow } from "@/components/crucible/winnow";

const LABEL = "text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600";

export default function WinnowDemo() {
  const [last, setLast] = React.useState<string | null>(null);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full max-w-md flex-col items-center gap-8 px-6">
        <div className="flex w-full flex-col gap-2">
          <label htmlFor="winnow-search" className={LABEL}>
            Search
          </label>
          <Winnow
            id="winnow-search"
            name="q"
            aria-label="Search"
            onSubmit={(value) => setLast(value)}
          />
        </div>

        <p className="text-sm text-neutral-500">
          Type and press Enter — the text scatters into sand and the field clears.
        </p>

        <span className="h-5 text-xs text-neutral-600">
          {last !== null ? (
            <>
              Submitted: <span className="text-neutral-300">“{last}”</span>
            </>
          ) : (
            "Awaiting a search…"
          )}
        </span>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`winnow.tsx`)

```tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

export interface WinnowProps
  extends Omit<
    React.ComponentPropsWithoutRef<"input">,
    "value" | "defaultValue" | "onChange" | "onSubmit" | "color" | "size"
  > {
  /** Controlled value. Omit for uncontrolled use (see `defaultValue`). */
  value?: string;
  /** Initial value when uncontrolled. @default "" */
  defaultValue?: string;
  /** Fires on every keystroke with the current string value. */
  onChange?: (value: string) => void;
  /**
   * Fires on genuine submit (Enter or the submit button) with the field value
   * BEFORE it is cleared — the vanish is purely decorative over this call.
   */
  onSubmit?: (value: string) => void;
  /**
   * Rotating placeholder suggestions. Each slides up and out as the next
   * arrives while the field is empty. @default a few search prompts
   */
  placeholders?: string[];
  /** Milliseconds each placeholder is shown before cycling. @default 2800 */
  cycleInterval?: number;
  /** Base grain size in px (each grain jitters around it). @default 1.7 */
  particleSize?: number;
  /** Grain density multiplier — higher packs more, finer grains. @default 1 */
  density?: number;
  /** Base scatter speed in px/frame at 60fps. @default 3.4 */
  scatterVelocity?: number;
  /**
   * Scatter direction in degrees. 0 = straight right, negative = upward.
   * Grains blow off along this heading before gravity takes over. @default -14
   */
  scatterDirection?: number;
  /** Downward acceleration applied to freed grains per frame. @default 0.09 */
  gravity?: number;
  /** Seconds a single grain takes to scatter and fade. @default 0.9 */
  dissolveDuration?: number;
  /** Clear the field on submit (via the dissolve). @default true */
  submitClears?: boolean;
  /**
   * Grain palette, assigned per grain. Keep it a narrow near-white ramp —
   * the vanish should read as fine sand, not confetti. @default soft whites
   */
  colors?: string[];
  /** The single cool accent for the focus underline. @default sky */
  accent?: string;
  /** Error state — reddens the underline and sets aria-invalid. @default false */
  error?: boolean;
  /** Freeze the placeholder cycle and skip the dissolve (submit clears instantly). @default false */
  paused?: boolean;
  /** Deterministic seed for grain jitter. @default 7 */
  seed?: number;
  className?: string;
}

/** Fine near-white grains — a narrow neutral ramp, never multicolor. */
const DEFAULT_COLORS = ["#fafafa", "#e5e5e5", "#d4d4d4", "#a3a3a3"];
const DEFAULT_ACCENT = "#38bdf8";
const ERROR_COLOR = "#f87171";
const DEFAULT_PLACEHOLDERS = [
  "Search components…",
  "Try a background",
  "Find a loader",
  "What are you building?",
];

const MAX_PARTICLES = 6000;

/** Deterministic mulberry32 PRNG — same seed always casts the same grains. */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

interface Grains {
  count: number;
  x: Float32Array;
  y: Float32Array;
  hx: Float32Array; // home — where the grain rests as crisp glyph
  hy: Float32Array;
  vx: Float32Array;
  vy: Float32Array;
  size: Float32Array;
  release: Float32Array; // seconds before this grain is set free (the pour)
  released: Uint8Array;
  colorIndex: Uint8Array;
}

/**
 * Winnow — a search input whose empty placeholder cycles (each sliding up as
 * the next arrives), and whose typed characters, on submit, rasterize into
 * fine grains that pour off left-to-right and blow away under a slight gravity
 * bias, clearing the field. The submit is genuine: `onSubmit` fires with the
 * value BEFORE the dissolve. A hairline underline draws in from center like a
 * filled gauge and brightens to one cool accent on focus. The scatter canvas
 * mounts only during the brief dissolve and tears down after — no rAF at rest.
 * Reduced motion crossfades placeholders and clears the field instantly with
 * no particles.
 */
export function Winnow({
  value,
  defaultValue = "",
  onChange,
  onSubmit,
  placeholders = DEFAULT_PLACEHOLDERS,
  cycleInterval = 2800,
  particleSize = 1.7,
  density = 1,
  scatterVelocity = 3.4,
  scatterDirection = -14,
  gravity = 0.09,
  dissolveDuration = 0.9,
  submitClears = true,
  colors = DEFAULT_COLORS,
  accent = DEFAULT_ACCENT,
  error = false,
  paused = false,
  seed = 7,
  disabled,
  className,
  onFocus,
  onBlur,
  ...rest
}: WinnowProps) {
  const reducedMotion = useReducedMotion();

  const wrapperRef = React.useRef<HTMLDivElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);

  const grainsRef = React.useRef<Grains | null>(null);
  const rafRef = React.useRef(0);
  const elapsedRef = React.useRef(0);
  const lastRef = React.useRef(0);

  const [index, setIndex] = React.useState(0);
  const [focused, setFocused] = React.useState(false);
  const [dissolving, setDissolving] = React.useState(false);
  const [internal, setInternal] = React.useState(defaultValue);

  const isControlled = value !== undefined;
  const currentValue = isControlled ? value : internal;

  const palette = React.useMemo(
    () => (colors.length ? colors : DEFAULT_COLORS),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [colors.join(",")]
  );
  const prompts = React.useMemo(
    () => (placeholders.length ? placeholders : DEFAULT_PLACEHOLDERS),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [placeholders.join(" ")]
  );

  const setValue = React.useCallback(
    (next: string) => {
      if (!isControlled) setInternal(next);
      onChange?.(next);
    },
    [isControlled, onChange]
  );

  // Placeholder cycle — advances only while empty and not paused, and never
  // when the tab is hidden. Never runs at rest cost; a single interval.
  React.useEffect(() => {
    if (paused || disabled) return;
    if (currentValue !== "") return;
    if (prompts.length <= 1) return;
    const id = window.setInterval(() => {
      if (typeof document !== "undefined" && document.hidden) return;
      setIndex((i) => (i + 1) % prompts.length);
    }, Math.max(cycleInterval, 400));
    return () => window.clearInterval(id);
  }, [paused, disabled, currentValue, prompts, cycleInterval]);

  const teardown = React.useCallback(() => {
    cancelAnimationFrame(rafRef.current);
    rafRef.current = 0;
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (canvas && ctx) {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      canvas.style.opacity = "0";
    }
    grainsRef.current = null;
    setDissolving(false);
  }, []);

  // Cancel any in-flight dissolve on unmount — no dangling rAF.
  React.useEffect(() => teardown, [teardown]);

  const startDissolve = React.useCallback(
    (text: string): boolean => {
      const wrapper = wrapperRef.current;
      const input = inputRef.current;
      const canvas = canvasRef.current;
      if (!wrapper || !input || !canvas) return false;

      const W = wrapper.clientWidth;
      const H = wrapper.clientHeight;
      if (W < 2 || H < 2) return false;

      const ctx = canvas.getContext("2d", { willReadFrequently: true });
      if (!ctx) return false;

      const cs = getComputedStyle(input);
      const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
      const padL = parseFloat(cs.paddingLeft) || 0;
      // Account for horizontal scroll when the text overflows the field.
      const textX = input.offsetLeft + padL - input.scrollLeft;
      const textY = input.offsetTop + input.clientHeight / 2;

      const dpr = Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 2);
      canvas.width = Math.round(W * dpr);
      canvas.height = Math.round(H * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      // Rasterize the typed text at the input's exact glyph position.
      ctx.font = font;
      ctx.textAlign = "left";
      ctx.textBaseline = "middle";
      ctx.fillStyle = "#fff";
      ctx.clearRect(0, 0, W, H);
      // Clip to the input's text box so overflowed glyphs don't spill out.
      ctx.save();
      ctx.beginPath();
      ctx.rect(
        input.offsetLeft + padL,
        input.offsetTop,
        Math.max(0, input.clientWidth - padL - (parseFloat(cs.paddingRight) || 0)),
        input.clientHeight
      );
      ctx.clip();
      ctx.fillText(text, textX, textY);
      ctx.restore();

      const img = ctx.getImageData(0, 0, Math.round(W * dpr), Math.round(H * dpr)).data;
      const iw = Math.round(W * dpr);
      const ih = Math.round(H * dpr);
      const step = Math.max(1, Math.round((2.6 / Math.max(density, 0.2)) * dpr));

      // Collect solid cells (in device px), tracking horizontal extent so the
      // pour can be staggered left-to-right.
      const cells: number[] = [];
      let minX = Infinity;
      let maxX = -Infinity;
      for (let py = 0; py < ih; py += step) {
        for (let px = 0; px < iw; px += step) {
          if (img[(py * iw + px) * 4 + 3] > 130) {
            cells.push(px, py);
            if (px < minX) minX = px;
            if (px > maxX) maxX = px;
          }
        }
      }
      let total = cells.length / 2;
      if (total === 0) return false;

      const stride = total > MAX_PARTICLES ? Math.ceil(total / MAX_PARTICLES) : 1;
      total = Math.floor(total / stride);
      if (total === 0) return false;

      const rand = mulberry32((seed >>> 0) + text.length * 97 + 1);
      const spanX = Math.max(maxX - minX, 1);
      const stagger = dissolveDuration * 0.55;
      const dir = (scatterDirection * Math.PI) / 180;

      const g: Grains = {
        count: total,
        x: new Float32Array(total),
        y: new Float32Array(total),
        hx: new Float32Array(total),
        hy: new Float32Array(total),
        vx: new Float32Array(total),
        vy: new Float32Array(total),
        size: new Float32Array(total),
        release: new Float32Array(total),
        released: new Uint8Array(total),
        colorIndex: new Uint8Array(total),
      };
      for (let i = 0; i < total; i++) {
        const src = i * stride * 2;
        const cx = cells[src] / dpr;
        const cy = cells[src + 1] / dpr;
        g.hx[i] = cx + ((rand() - 0.5) * step) / dpr;
        g.hy[i] = cy + ((rand() - 0.5) * step) / dpr;
        g.x[i] = g.hx[i];
        g.y[i] = g.hy[i];
        g.size[i] = Math.max(particleSize * (0.6 + rand() * 0.9), 0.5);
        g.colorIndex[i] = Math.floor(rand() * palette.length);
        const norm = (cells[src] - minX) / spanX; // 0 at left edge → 1 at right
        g.release[i] = norm * stagger + rand() * 0.06;
      }
      grainsRef.current = g;

      const dur = Math.max(dissolveDuration, 0.15);
      canvas.style.opacity = "1";
      elapsedRef.current = 0;
      lastRef.current = performance.now();

      const loop = (now: number) => {
        const grains = grainsRef.current;
        if (!grains) return;
        const dt = Math.min((now - lastRef.current) / 1000, 0.05);
        lastRef.current = now;
        elapsedRef.current += dt;
        const elapsed = elapsedRef.current;
        const f = dt * 60;
        const drag = Math.pow(0.985, f);

        ctx.clearRect(0, 0, W, H);
        let alive = false;
        let currentColor = -1;
        for (let i = 0; i < grains.count; i++) {
          const rel = grains.release[i];
          if (elapsed < rel) {
            // Not yet poured — still part of the crisp word.
            alive = true;
            const c = grains.colorIndex[i];
            if (c !== currentColor) {
              currentColor = c;
              ctx.fillStyle = palette[c] ?? palette[0];
            }
            ctx.globalAlpha = 1;
            const s = grains.size[i];
            ctx.fillRect(grains.hx[i] - s / 2, grains.hy[i] - s / 2, s, s);
            continue;
          }
          const local = elapsed - rel;
          const life = local / dur;
          if (life >= 1) continue; // gone
          alive = true;

          if (!grains.released[i]) {
            grains.released[i] = 1;
            // Deterministic per-grain angle + speed jitter around the heading
            // so the pour reads as loose sand, not a rigid sheet.
            const jitter = (((i * 2654435761) >>> 0) / 4294967296 - 0.5) * 0.9;
            const speed = scatterVelocity * (0.55 + (((i * 40503) >>> 0) % 1000) / 1000);
            grains.vx[i] = Math.cos(dir + jitter) * speed;
            grains.vy[i] = Math.sin(dir + jitter) * speed;
          }

          grains.vy[i] += gravity * f;
          grains.vx[i] *= drag;
          grains.vy[i] *= drag;
          grains.x[i] += grains.vx[i] * f;
          grains.y[i] += grains.vy[i] * f;

          const c = grains.colorIndex[i];
          if (c !== currentColor) {
            currentColor = c;
            ctx.fillStyle = palette[c] ?? palette[0];
          }
          ctx.globalAlpha = Math.max(0, 1 - life * life);
          const s = grains.size[i];
          ctx.fillRect(grains.x[i] - s / 2, grains.y[i] - s / 2, s, s);
        }
        ctx.globalAlpha = 1;

        if (!alive) {
          teardown();
          return;
        }
        rafRef.current = requestAnimationFrame(loop);
      };

      cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(loop);
      return true;
    },
    [density, dissolveDuration, gravity, palette, particleSize, scatterDirection, scatterVelocity, seed, teardown]
  );

  const handleSubmit = React.useCallback(
    (event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      if (disabled) return;
      const submitted = currentValue;
      onSubmit?.(submitted);

      if (!submitClears || submitted === "") return;

      if (reducedMotion || paused) {
        setValue("");
        return;
      }
      // Fire the dissolve on the captured text, then clear the field. The
      // canvas keeps painting the vanishing glyphs while the input goes empty.
      const started = startDissolve(submitted);
      if (started) setDissolving(true);
      setValue("");
    },
    [currentValue, disabled, onSubmit, paused, reducedMotion, setValue, startDissolve, submitClears]
  );

  const accentColor = error ? ERROR_COLOR : accent;
  const gaugeActive = focused || error;
  const showPlaceholder = currentValue === "" && !dissolving;

  return (
    <form
      role="search"
      onSubmit={handleSubmit}
      className={cn("w-full max-w-md", className)}
      data-crucible="winnow"
    >
      <div
        ref={wrapperRef}
        className={cn(
          "relative flex items-center",
          disabled && "opacity-50"
        )}
      >
        {/* The vanishing glyph canvas — mounted always but painted (and rAF-driven)
            only during the brief dissolve, then cleared. Purely decorative. */}
        <canvas
          ref={canvasRef}
          aria-hidden
          className="pointer-events-none absolute inset-0 h-full w-full opacity-0"
        />

        {/* Rotating placeholder overlay. Real placeholder text is transparent
            (kept for accessible name / autofill); this is the visible one. */}
        {showPlaceholder && (
          <div
            aria-hidden
            className="pointer-events-none absolute inset-y-0 left-1 right-10 flex items-center overflow-hidden"
          >
            <AnimatePresence initial={false} mode="popLayout">
              <motion.span
                key={index}
                className="block truncate text-base text-neutral-500"
                initial={reducedMotion ? { opacity: 0 } : { y: "0.9em", opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={reducedMotion ? { opacity: 0 } : { y: "-0.9em", opacity: 0 }}
                transition={
                  reducedMotion
                    ? { duration: 0.2 }
                    : { type: "spring", stiffness: 320, damping: 32 }
                }
              >
                {prompts[index % prompts.length]}
              </motion.span>
            </AnimatePresence>
          </div>
        )}

        <input
          ref={inputRef}
          type="text"
          value={currentValue}
          disabled={disabled}
          aria-invalid={error || undefined}
          onChange={(event) => setValue(event.target.value)}
          onFocus={(event) => {
            setFocused(true);
            onFocus?.(event);
          }}
          onBlur={(event) => {
            setFocused(false);
            onBlur?.(event);
          }}
          className={cn(
            "w-full appearance-none bg-transparent px-1 py-3 pr-10 text-base text-neutral-100",
            "outline-none placeholder:text-transparent",
            "disabled:cursor-not-allowed"
          )}
          {...rest}
          placeholder={prompts[0]}
        />

        {/* Submit affordance for pointer users; Enter submits the form too. */}
        <button
          type="submit"
          disabled={disabled || currentValue === ""}
          aria-label="Submit"
          className={cn(
            "absolute right-0 top-1/2 -translate-y-1/2 flex h-8 w-8 items-center justify-center rounded-md",
            "text-neutral-500 transition-colors duration-200",
            "hover:text-neutral-200 disabled:pointer-events-none disabled:opacity-40",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950"
          )}
          style={{ ["--tw-ring-color" as string]: accentColor }}
        >
          <svg
            aria-hidden
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.8"
            strokeLinecap="round"
            strokeLinejoin="round"
            className="h-4 w-4"
          >
            <path d="M5 12h14" />
            <path d="m13 6 6 6-6 6" />
          </svg>
        </button>

        {/* Hairline underline: a static dim base, plus an accent gauge that
            fills in from center on focus (or stays lit for the error state). */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-neutral-700"
        />
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-x-0 bottom-0 h-[1.5px] origin-center transition-transform ease-out",
            gaugeActive ? "scale-x-100" : "scale-x-0"
          )}
          style={{
            backgroundColor: accentColor,
            boxShadow: `0 0 8px ${accentColor}`,
            transitionDuration: reducedMotion ? "0ms" : "480ms",
          }}
        />
      </div>
    </form>
  );
}
```
