nexus-labs
componentask-nexus-inputsource: ok · hit
Ask Nexus Input
A bold \
npx shadcn add "https://nexus-ui.com/r/ask-nexus-input.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
iconslucide-react
Registry dependencies
https://www.nexus-ui.com/api/registry/nexus-fontutils
Files2 · source cached 10/9/2026, 12:42:18 PM
next-ui/ask-nexus-input/ask-nexus-input.tsx
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AskNexusInputProps {
title?: string;
/** Rotating placeholder suggestions shown while the input is empty. */
placeholders?: string[];
/** Interval (ms) between placeholder rotations. */
placeholderInterval?: number;
defaultValue?: string;
/** Called with the trimmed query the instant the user submits — before the disintegration animation runs. */
onSubmit?: (value: string) => void;
className?: string;
}
type Phase = "idle" | "disintegrating";
const INPUT_FONT_PX = 14;
const PARTICLE_RGB = "17,24,39";
const DEFAULT_PLACEHOLDERS = [
"Who is Tyler Durden?",
"Explain quantum entanglement like I'm five",
"Write a haiku about the ocean at midnight",
"What's the best stack for a 2026 startup?",
"How do I become a 10x designer?",
];
export function AskNexusInput({
title = "Ask Nexus UI Anything",
placeholders = DEFAULT_PLACEHOLDERS,
placeholderInterval = 2800,
defaultValue = "",
onSubmit,
className,
}: AskNexusInputProps) {
const [value, setValue] = useState(defaultValue);
const [phase, setPhase] = useState<Phase>("idle");
const [snapshot, setSnapshot] = useState("");
const [phIndex, setPhIndex] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const activePlaceholder = placeholders[phIndex] ?? "";
// Cycle placeholder while the input is idle and empty.
useEffect(() => {
if (phase !== "idle" || value || placeholders.length <= 1) return;
const id = setInterval(
() => setPhIndex((i) => (i + 1) % placeholders.length),
placeholderInterval,
);
return () => clearInterval(id);
}, [phase, value, placeholders.length, placeholderInterval]);
const handleSubmit = (e?: FormEvent) => {
e?.preventDefault();
if (phase !== "idle") return;
// If empty, the live placeholder is what gets submitted + dissolved.
const submitted = value.trim() || activePlaceholder.trim();
if (!submitted) return;
onSubmit?.(submitted);
setSnapshot(submitted);
setPhase("disintegrating");
};
const handleComplete = useCallback(() => {
setSnapshot("");
setValue("");
setPhase("idle");
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
const showPlaceholder = phase === "idle" && !value;
return (
<div className={cn("flex w-full flex-col items-center gap-10 py-12", className)}>
<motion.h1
initial={{ opacity: 0, y: 12, filter: "blur(8px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="text-center text-3xl font-semibold tracking-tight text-gray-950 sm:text-4xl md:text-5xl"
>
{title}
</motion.h1>
<motion.form
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1], delay: 0.1 }}
className="w-full max-w-xl"
>
<motion.div
animate={
phase === "disintegrating"
? { scale: [1, 0.985, 1] }
: { scale: 1 }
}
transition={{ duration: 0.45, ease: "easeOut" }}
className="flex items-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-2.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] focus-within:border-gray-300"
>
<div className="relative h-6 flex-1">
{/* Inner clip wraps only the input + placeholder so the canvas can overflow. */}
<div className="absolute inset-0 overflow-hidden">
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={phase !== "idle"}
spellCheck={false}
className={cn(
"absolute inset-0 w-full bg-transparent text-sm text-gray-900 outline-none transition-opacity duration-300",
phase === "disintegrating" && "opacity-0",
)}
/>
{/* Cycling placeholder overlay */}
<AnimatePresence mode="wait">
{showPlaceholder && (
<motion.span
key={phIndex}
initial={{ y: 10, opacity: 0, filter: "blur(4px)" }}
animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
exit={{ y: -10, opacity: 0, filter: "blur(4px)" }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 truncate text-sm text-gray-400"
>
{activePlaceholder}
</motion.span>
)}
</AnimatePresence>
</div>
{/* Disintegration canvas — sibling of the clip so dust can drift past the pill. */}
{phase === "disintegrating" && (
<DisintegratingText text={snapshot} onComplete={handleComplete} />
)}
</div>
<motion.button
type="submit"
aria-label="Ask"
disabled={phase !== "idle"}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }}
transition={{ type: "spring", stiffness: 420, damping: 18 }}
className="grid size-8 shrink-0 place-items-center rounded-full bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700 disabled:opacity-60"
>
<ArrowRight className="size-4" strokeWidth={2.25} />
</motion.button>
</motion.div>
</motion.form>
</div>
);
}
type Particle = {
ox: number;
oy: number;
vx: number;
vy: number;
delay: number;
life: number;
size: number;
};
interface DisintegratingTextProps {
text: string;
onComplete: () => void;
}
// Right-edge overhang so a little dust can drift past before fading.
const RIGHT_OVERHANG_PX = 90;
// Time between each character snapping. Smaller = faster wave.
const CHAR_STEP_MS = 50;
// Random jitter on each character's snap so adjacent chars overlap a touch.
const CHAR_JITTER_MS = 22;
function DisintegratingText({ text, onComplete }: DisintegratingTextProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
useEffect(() => {
const canvas = canvasRef.current;
const parent = canvas?.parentElement;
if (!canvas || !parent) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const baseW = Math.max(parent.clientWidth, 1);
const H = Math.max(parent.clientHeight, 1);
const W = baseW + RIGHT_OVERHANG_PX;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = `${W}px`;
canvas.style.height = `${H}px`;
ctx.scale(dpr, dpr);
ctx.font = `${INPUT_FONT_PX}px ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
ctx.fillStyle = `rgb(${PARTICLE_RGB})`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(text, 0, H / 2);
// Map each x-range to the character that produced it, so the wave can snap
// one character at a time instead of bleeding across the whole phrase.
const chars = Array.from(text);
const charBounds: { start: number; end: number }[] = [];
let cursor = 0;
for (const ch of chars) {
const w = ctx.measureText(ch).width;
charBounds.push({ start: cursor, end: cursor + w });
cursor += w;
}
const totalChars = chars.length;
const charDelay = (charIdx: number) => {
const fromRight = totalChars - 1 - charIdx;
return fromRight * CHAR_STEP_MS + Math.random() * CHAR_JITTER_MS;
};
const findCharIdx = (lx: number) => {
// Binary search on character bounds.
let lo = 0, hi = charBounds.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const b = charBounds[mid];
if (lx < b.start) hi = mid - 1;
else if (lx >= b.end) lo = mid + 1;
else return mid;
}
return Math.min(Math.max(lo, 0), charBounds.length - 1);
};
const sampleW = baseW * dpr;
const img = ctx.getImageData(0, 0, sampleW, H * dpr);
const particles: Particle[] = [];
const stride = dpr;
for (let y = 0; y < H * dpr; y += stride) {
for (let x = 0; x < sampleW; x += stride) {
const i = (y * sampleW + x) * 4;
if (img.data[i + 3] > 110) {
const lx = x / dpr;
const ly = y / dpr;
const charIdx = findCharIdx(lx);
// Clean dispersion: small in-place burst with slight rightward bias.
// Most particles barely move; a few stragglers drift further.
const angle = (Math.random() - 0.5) * Math.PI * 0.6; // ±54°
const speed = 0.18 + Math.random() * 0.55;
const vx = Math.cos(angle) * speed + 0.12;
const vy = Math.sin(angle) * speed * 0.55; // squashed vertically — keeps the cloud near the text baseline
// Size variance — fine dust with a few larger flecks.
const sizeRoll = Math.random();
const size = sizeRoll < 0.65 ? 1 : sizeRoll < 0.92 ? 1.4 : 1.8;
particles.push({
ox: lx,
oy: ly,
vx,
vy,
delay: charDelay(charIdx),
// Short life — clean snap, not a long drifting cloud.
life: 480 + Math.random() * 320,
size,
});
}
}
}
ctx.clearRect(0, 0, W, H);
let rafId = 0;
let start = 0;
let cancelled = false;
const frame = (now: number) => {
if (cancelled) return;
if (!start) start = now;
const elapsed = now - start;
ctx.clearRect(0, 0, W, H);
let aliveCount = 0;
for (const p of particles) {
if (elapsed < p.delay) {
// Brief tension shimmer in the final 80ms before this particle snaps.
const ttd = p.delay - elapsed;
if (ttd < 80) {
const tension = 1 - ttd / 80;
const jx = (Math.random() - 0.5) * 0.5 * tension;
const jy = (Math.random() - 0.5) * 0.5 * tension;
ctx.fillStyle = `rgb(${PARTICLE_RGB})`;
ctx.fillRect(p.ox + jx, p.oy + jy, p.size, p.size);
} else {
ctx.fillStyle = `rgb(${PARTICLE_RGB})`;
ctx.fillRect(p.ox, p.oy, p.size, p.size);
}
aliveCount++;
continue;
}
const t = elapsed - p.delay;
if (t > p.life) continue;
aliveCount++;
const norm = t / p.life;
const motionT = t / 16;
// Small wobble only — keeps the dispersion clean instead of swirly.
const wobble = Math.sin((p.ox + motionT) * 0.22) * 0.4;
const x = p.ox + p.vx * motionT + wobble * norm;
const y = p.oy + p.vy * motionT;
// Cosine ease-out fade.
const opacity = Math.cos((norm * Math.PI) / 2);
ctx.fillStyle = `rgba(${PARTICLE_RGB},${opacity.toFixed(3)})`;
ctx.fillRect(x, y, p.size, p.size);
}
if (aliveCount === 0) {
onCompleteRef.current();
return;
}
rafId = requestAnimationFrame(frame);
};
rafId = requestAnimationFrame(frame);
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [text]);
return (
<canvas
ref={canvasRef}
className="pointer-events-none absolute left-0 top-0"
aria-hidden
/>
);
}
next-ui/ask-nexus-input/index.ts
export { AskNexusInput } from "./ask-nexus-input";
export type { AskNexusInputProps } from "./ask-nexus-input";