LLM index: /llms.txt
A flexible, composable input component for building chat interfaces. Includes an auto-resizing textarea with scroll support and customizable action slots for buttons like send, attach, and more.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
PromptInput,
PromptInputActions,
PromptInputAction,
PromptInputActionGroup,
PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import {
ArrowUp02Icon,
SquareIcon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
type InputStatus = "idle" | "loading" | "error" | "submitted";
export default function PromptInputDefault() {
const [input, setInput] = React.useState("");
const [status, setStatus] = React.useState<InputStatus>("idle");
const doSubmit = React.useCallback((value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
setInput("");
setStatus("loading");
setTimeout(() => {
setStatus("submitted");
setTimeout(() => setStatus("idle"), 800);
}, 2500);
}, []);
const isLoading = status === "loading";
return (
<PromptInput onSubmit={doSubmit}>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="How can I help you today?"
disabled={isLoading}
/>
<PromptInputActions>
<PromptInputActionGroup>
<PromptInputAction asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
>
<HugeiconsIcon
icon={PlusSignIcon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</PromptInputAction>
</PromptInputActionGroup>
<PromptInputActionGroup>
<PromptInputAction asChild>
<Button
type="button"
size="icon-sm"
className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
disabled={!isLoading && !input.trim()}
onClick={() => input.trim() && doSubmit(input)}
>
{isLoading ? (
<HugeiconsIcon
icon={SquareIcon}
strokeWidth={2.0}
className="size-3.5 fill-current"
/>
) : (
<HugeiconsIcon
icon={ArrowUp02Icon}
strokeWidth={2.0}
className="size-4"
/>
)}
</Button>
</PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
);
}
Installation
npx shadcn@latest add @nexus-ui/prompt-inputpnpm dlx shadcn@latest add @nexus-ui/prompt-inputyarn dlx shadcn@latest add @nexus-ui/prompt-inputbunx shadcn@latest add @nexus-ui/prompt-inputInstall the following dependencies:
npx shadcn@latest add textarea scroll-area tooltip kbd && npm install @radix-ui/react-slotpnpm dlx shadcn@latest add textarea scroll-area tooltip kbd && pnpm add @radix-ui/react-slotyarn dlx shadcn@latest add textarea scroll-area tooltip kbd && yarn add @radix-ui/react-slotbunx shadcn@latest add textarea scroll-area tooltip kbd && bun add @radix-ui/react-slotCopy and paste the following code into your project.
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { mergeRefs } from "@/lib/merge-refs";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea, ScrollViewport } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Kbd } from "@/components/ui/kbd";
type PromptInputContextValue = {
setTextareaNode: (node: HTMLTextAreaElement | null) => void;
onSubmit?: (value: string) => void;
};
const PromptInputContext = React.createContext<PromptInputContextValue | null>(
null,
);
type PromptInputProps = Omit<
React.HTMLAttributes<HTMLDivElement>,
"onSubmit"
> & {
/**
* Called when Enter is pressed in the textarea (without Shift). Use with
* value/onChange on PromptInputTextarea for controlled mode. Shift+Enter
* inserts a new line.
*/
onSubmit?: (value: string) => void;
};
function PromptInput({
className,
role: _role,
"aria-label": _ariaLabel,
onClick,
onSubmit,
...props
}: PromptInputProps) {
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (
!target.closest(
'button, a, input, textarea, [role="button"], [role="tab"]',
)
) {
textareaRef.current?.focus();
}
onClick?.(e);
},
[onClick],
);
const contextValue = React.useMemo<PromptInputContextValue>(
() => ({
setTextareaNode: (node) => {
textareaRef.current = node;
},
onSubmit,
}),
[onSubmit],
);
return (
<TooltipProvider delayDuration={100}>
<PromptInputContext.Provider value={contextValue}>
<div
role="group"
aria-label="Chat input"
className={cn(
"relative flex h-auto w-full cursor-text flex-col gap-0 overflow-hidden rounded-[24px] border border-border bg-card dark:border-border/50 dark:bg-input/30",
className,
)}
onClick={handleClick}
{...props}
/>
</PromptInputContext.Provider>
</TooltipProvider>
);
}
type PromptInputTextareaProps = React.ComponentProps<typeof Textarea>;
const PromptInputTextarea = React.forwardRef<
HTMLTextAreaElement,
PromptInputTextareaProps
>(function PromptInputTextarea(
{ className, "aria-label": _ariaLabel, onKeyDown, ...props },
ref,
) {
const context = React.useContext(PromptInputContext);
const setTextareaNode = context?.setTextareaNode;
const onSubmit = context?.onSubmit;
const handleTextareaRef = React.useCallback(
(node: HTMLTextAreaElement | null) => {
mergeRefs<HTMLTextAreaElement>(setTextareaNode, ref)(node);
},
[setTextareaNode, ref],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
e.preventDefault();
onSubmit(e.currentTarget.value);
}
onKeyDown?.(e);
},
[onSubmit, onKeyDown],
);
return (
<ScrollArea className="max-h-40">
<ScrollViewport>
<Textarea
ref={handleTextareaRef}
aria-label="Message input"
placeholder="How can I help you today?"
className={cn(
"min-h-14 w-full resize-none border-0 bg-transparent px-4 py-4 text-sm leading-6 font-normal text-primary shadow-none outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent",
className,
)}
onKeyDown={handleKeyDown}
{...props}
/>
</ScrollViewport>
</ScrollArea>
);
});
type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>;
function PromptInputActions({
className,
role: _role,
"aria-label": _ariaLabel,
...props
}: PromptInputActionsProps) {
return (
<div
role="group"
aria-label="Input actions"
className={cn(
"flex w-full shrink-0 items-center justify-between px-2 py-2",
className,
)}
{...props}
/>
);
}
type PromptInputActionGroupProps = React.HTMLAttributes<HTMLDivElement>;
function PromptInputActionGroup({
className,
...props
}: PromptInputActionGroupProps) {
return <div className={cn("flex gap-2", className)} {...props} />;
}
type PromptInputActionProps = React.HTMLAttributes<HTMLDivElement> & {
asChild?: boolean;
tooltip?:
| string
| {
content?: string;
side?: "top" | "right" | "bottom" | "left";
shortcut?: string;
};
};
function PromptInputAction({
asChild = false,
tooltip,
...props
}: PromptInputActionProps) {
const Comp = asChild ? Slot : "div";
const { content, side, shortcut } =
typeof tooltip === "string" ? { content: tooltip } : (tooltip ?? {});
if (!content) {
return <Comp {...props} />;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Comp {...props} />
</TooltipTrigger>
<TooltipContent className="rounded-full" side={side}>
{content}
{shortcut ? <Kbd className="rounded-md!">{shortcut}</Kbd> : null}
</TooltipContent>
</Tooltip>
);
}
export {
PromptInput,
PromptInputTextarea,
PromptInputActions,
PromptInputActionGroup,
PromptInputAction,
};
Update import paths to match your project setup.
Usage
import {
PromptInput,
PromptInputTextarea,
PromptInputActions,
PromptInputActionGroup,
PromptInputAction,
} from "@/components/nexus-ui/prompt-input";<PromptInput>
<PromptInputTextarea placeholder="Ask anything..." />
<PromptInputActions>
<PromptInputActionGroup>
{/* Left-aligned actions */}
</PromptInputActionGroup>
<PromptInputActionGroup>
{/* Right-aligned actions */}
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>Examples
Basic
A minimal prompt input with just a textarea and send button.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
PromptInput,
PromptInputActions,
PromptInputAction,
PromptInputActionGroup,
PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import { ArrowUp02Icon, SquareIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
type InputStatus = "idle" | "loading" | "error" | "submitted";
export default function PromptInputBasic() {
const [input, setInput] = React.useState("");
const [status, setStatus] = React.useState<InputStatus>("idle");
const doSubmit = React.useCallback((value: string) => {
if (!value.trim()) return;
setInput("");
setStatus("loading");
setTimeout(() => {
setStatus("submitted");
setTimeout(() => setStatus("idle"), 800);
}, 2500);
}, []);
const isLoading = status === "loading";
return (
<PromptInput onSubmit={doSubmit}>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="How can I help you today?"
disabled={isLoading}
/>
<PromptInputActions className="justify-end">
<PromptInputActionGroup>
<PromptInputAction asChild>
<Button
type="button"
size="icon-sm"
className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
disabled={!isLoading && !input.trim()}
onClick={() => input.trim() && doSubmit(input)}
>
{isLoading ? (
<HugeiconsIcon
icon={SquareIcon}
strokeWidth={2.0}
className="size-3.5 fill-current"
/>
) : (
<HugeiconsIcon
icon={ArrowUp02Icon}
strokeWidth={2.0}
className="size-4"
/>
)}
</Button>
</PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
);
}
With Multiple Actions
Combine multiple action buttons in a single group and add built-in tooltips using PromptInputAction's tooltip prop (string or object with content, optional side, and optional shortcut).
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
PromptInput,
PromptInputActions,
PromptInputAction,
PromptInputActionGroup,
PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import {
ArrowUp02Icon,
Image01Icon,
Mic02Icon,
PlusSignIcon,
SquareIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
type InputStatus = "idle" | "loading" | "error" | "submitted";
export default function PromptInputMultipleActions() {
const [input, setInput] = React.useState("");
const [status, setStatus] = React.useState<InputStatus>("idle");
const doSubmit = React.useCallback((value: string) => {
if (!value.trim()) return;
setInput("");
setStatus("loading");
setTimeout(() => {
setStatus("submitted");
setTimeout(() => setStatus("idle"), 800);
}, 2500);
}, []);
const isLoading = status === "loading";
return (
<PromptInput onSubmit={doSubmit}>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="How can I help you today?"
disabled={isLoading}
/>
<PromptInputActions>
<PromptInputActionGroup>
<PromptInputAction asChild tooltip="Attach file">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
>
<HugeiconsIcon
icon={PlusSignIcon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</PromptInputAction>
<PromptInputAction
asChild
tooltip={{ content: "Upload image", side: "bottom" }}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
>
<HugeiconsIcon
icon={Image01Icon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</PromptInputAction>
<PromptInputAction
asChild
tooltip={{ content: "Voice input", shortcut: "V", side: "right" }}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
>
<HugeiconsIcon
icon={Mic02Icon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</PromptInputAction>
</PromptInputActionGroup>
<PromptInputActionGroup>
<PromptInputAction
asChild
tooltip={{ content: "Send message", shortcut: "Enter" }}
>
<Button
type="button"
size="icon-sm"
className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
disabled={!isLoading && !input.trim()}
onClick={() => input.trim() && doSubmit(input)}
>
{isLoading ? (
<HugeiconsIcon
icon={SquareIcon}
strokeWidth={2.0}
className="size-3.5 fill-current"
/>
) : (
<HugeiconsIcon
icon={ArrowUp02Icon}
strokeWidth={2.0}
className="size-4"
/>
)}
</Button>
</PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
);
}
Vercel AI SDK Integration
Connect Prompt Input to the Vercel AI SDK for streaming chat interfaces.
Install the AI SDK
npm install ai @ai-sdk/react @ai-sdk/openaiCreate your chat API route
import { convertToModelMessages, streamText, UIMessage } from "ai";
import { openai } from "@ai-sdk/openai";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai("gpt-4o-mini"),
system: "You are a helpful assistant.",
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Wire Prompt Input to useChat
Use onSubmit for Enter-to-submit. Shift+Enter inserts a new line.
"use client";
import { useState, useCallback } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Button } from "@/components/ui/button";
import {
PromptInput,
PromptInputActions,
PromptInputAction,
PromptInputActionGroup,
PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import { ArrowUp02Icon, SquareIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export default function ChatWithPromptInput() {
const { sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
const [input, setInput] = useState("");
const isLoading = status !== "ready";
const handleSubmit = useCallback(
(value?: string) => {
const trimmed = (value ?? input).trim();
if (trimmed) {
sendMessage({ text: trimmed });
setInput("");
}
},
[input, sendMessage],
);
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }} className="w-full">
<PromptInput onSubmit={handleSubmit}>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask anything..."
disabled={isLoading}
/>
<PromptInputActions>
<PromptInputActionGroup />
<PromptInputActionGroup>
<PromptInputAction asChild>
<Button
type="submit"
size="icon-sm"
className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
disabled={isLoading || !input.trim()}
>
{isLoading ? (
<HugeiconsIcon icon={SquareIcon} strokeWidth={2.0} className="size-3.5 fill-current" />
) : (
<HugeiconsIcon icon={ArrowUp02Icon} strokeWidth={2.0} className="size-4" />
)}
</Button>
</PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</form>
);
}API Reference
PromptInput
The root container that wraps the textarea and action bar.
Prop
Type
PromptInputTextarea
An auto-resizing textarea wrapped in a scroll area. Accepts all standard textarea props including disabled and onKeyDown. Use onSubmit on PromptInput for Enter-to-submit; Shift+Enter inserts a new line.
Prop
Type
PromptInputActions
A flex container for action buttons. Uses justify-between to position child groups at opposite ends.
Prop
Type
PromptInputActionGroup
Groups related action buttons together with a horizontal layout.
Prop
Type
PromptInputAction
A wrapper for individual action buttons. Supports polymorphism via asChild and optional built-in tooltip rendering.
Prop
Type