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-inputCopy 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";
type PromptInputContextValue = {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
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>(
() => ({ textareaRef, onSubmit }),
[onSubmit],
);
return (
<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 dark:border-border/50 bg-card dark:bg-input/30",
className,
)}
onClick={handleClick}
{...props}
/>
</PromptInputContext.Provider>
);
}
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 textareaRef = context?.textareaRef;
const onSubmit = context?.onSubmit;
const mergedRef = textareaRef
? mergeRefs<HTMLTextAreaElement>(textareaRef, ref)
: 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={mergedRef}
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;
};
function PromptInputAction({
asChild = false,
...props
}: PromptInputActionProps) {
const Comp = asChild ? Slot : "div";
return <Comp {...props} />;
}
export {
PromptInput,
PromptInputTextarea,
PromptInputActions,
PromptInputActionGroup,
PromptInputAction,
};
export default PromptInput;
Update the 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.
"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,
Mic02Icon,
PlusSignIcon,
} 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>
<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>
<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>
<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 Tooltips
Action buttons wrapped in shadcn tooltips for descriptive hover labels.
"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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
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 PromptInputWithTooltips() {
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 (
<TooltipProvider delayDuration={200}>
<PromptInput onSubmit={doSubmit}>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="How can I help you today?"
disabled={isLoading}
/>
<PromptInputActions>
<PromptInputActionGroup>
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent
className="rounded-full bg-primary text-primary-foreground"
arrowClassName="fill-primary bg-primary"
>
Attach file
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<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={Image01Icon} strokeWidth={2.0} className="size-4" />
</Button>
</PromptInputAction>
</TooltipTrigger>
<TooltipContent
className="rounded-full bg-primary text-primary-foreground"
arrowClassName="fill-primary bg-primary"
>
Upload image
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<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={Mic02Icon} strokeWidth={2.0} className="size-4" />
</Button>
</PromptInputAction>
</TooltipTrigger>
<TooltipContent
className="rounded-full bg-primary text-primary-foreground"
arrowClassName="fill-primary bg-primary"
>
Voice input
</TooltipContent>
</Tooltip>
</PromptInputActionGroup>
<PromptInputActionGroup>
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent
className="rounded-full bg-primary text-primary-foreground"
arrowClassName="fill-primary bg-primary"
>
Send message
</TooltipContent>
</Tooltip>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</TooltipProvider>
);
}
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.
Prop
Type