Winnow
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.
canvasfree
"use client";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/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. @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,
...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;
const textX = input.offsetLeft + padL;
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);
ctx.fillText(text, textX, textY);
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={() => setFocused(true)}
onBlur={() => setFocused(false)}
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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/winnowManual — install dependencies, then copy the source
npm install motionProps
| 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. |
| seed | number | 7 | Deterministic seed for grain jitter. |
| className | string | ||
Also accepts all props of Omit< React.ComponentPropsWithoutRef<"input">, "value" | "defaultValue" | "onChange" | "onSubmit" | "color" | "size" > — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.