import * as React from "react";
import {
ArrowUpIcon,
MicrophoneIcon,
PaperClipIcon,
} from "@heroicons-animated/react";
import { Button } from "@/components/ui/button";
import {
PromptInput,
PromptInputAction,
PromptInputActionGroup,
PromptInputActions,
PromptInputTextarea,
} from "@/components/pandacoderz-ui/prompt-input";
export default function PromptInputDemo() {
const [value, setValue] = React.useState("");
return (
<div className="w-full max-w-xl">
<PromptInput
onSubmit={(text) => {
if (!text.trim()) return;
setValue("");
}}
>
<PromptInputTextarea
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<PromptInputActions>
<PromptInputActionGroup>
<PromptInputAction asChild tooltip="Attach files">
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
aria-label="Attach files"
>
<PaperClipIcon size={16} className="flex" />
</Button>
</PromptInputAction>
<PromptInputAction asChild tooltip="Voice input">
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
aria-label="Voice input"
>
<MicrophoneIcon size={16} className="flex" />
</Button>
</PromptInputAction>
</PromptInputActionGroup>
<PromptInputActionGroup>
<PromptInputAction
asChild
tooltip={{ content: "Send message", shortcut: "↵" }}
>
<Button
size="icon-sm"
className="rounded-full"
aria-label="Send"
disabled={!value.trim()}
>
<ArrowUpIcon size={16} className="flex" />
</Button>
</PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/prompt-input.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/prompt-input.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/prompt-input.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/prompt-input.jsonInstall the dependencies:
npm install radix-uiAdd the shadcn primitives it builds on:
npx shadcn@latest add textarea scroll-area tooltip kbdCopy the source into your project:
"use client";
import * as React from "react";
import { Slot } from "radix-ui";
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"
data-slot="prompt-input"
className={cn(
"relative flex h-auto w-full cursor-text flex-col gap-0 overflow-hidden rounded-3xl border border-border bg-card shadow-xs transition-[border-color,box-shadow] focus-within:border-ring/60 focus-within:ring-[3px] focus-within:ring-ring/15 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}
data-slot="prompt-input-textarea"
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-foreground 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"
data-slot="prompt-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
data-slot="prompt-input-action-group"
className={cn("flex items-center 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.Root : "div";
const { content, side, shortcut } =
typeof tooltip === "string" ? { content: tooltip } : (tooltip ?? {});
if (!content) {
return <Comp data-slot="prompt-input-action" {...props} />;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Comp data-slot="prompt-input-action" {...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,
};import type * as React from "react";
export function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref) {
(ref as React.RefObject<T | null>).current = value;
}
});
};
}Usage
import {
PromptInput,
PromptInputAction,
PromptInputActionGroup,
PromptInputActions,
PromptInputTextarea,
} from "@/components/pandacoderz-ui/prompt-input";<PromptInput onSubmit={(text) => send(text)}>
<PromptInputTextarea value={value} onChange={(e) => setValue(e.target.value)} />
<PromptInputActions>
<PromptInputActionGroup>{/* left actions */}</PromptInputActionGroup>
<PromptInputActionGroup>{/* right actions */}</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>Enter submits, Shift+Enter inserts a newline. Clicking anywhere on the container that isn’t a control focuses the textarea.
API Reference
PromptInput
| Prop | Type | Description |
|---|---|---|
onSubmit |
(value: string) => void |
Called on Enter without Shift. |
className |
string |
Extra classes for the container. |
PromptInputTextarea
Accepts all textarea props. Defaults to a placeholder of “How can I help you today?” and grows up to 10 rows before scrolling.
PromptInputAction
| Prop | Type | Description |
|---|---|---|
asChild |
boolean |
Merge into the child element instead of rendering a wrapper. |
tooltip |
string | { content, side, shortcut } |
Tooltip content, optional placement, and keyboard hint. |