Prompt Input

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-input
pnpm dlx shadcn@latest add @nexus-ui/prompt-input
yarn dlx shadcn@latest add @nexus-ui/prompt-input
bunx shadcn@latest add @nexus-ui/prompt-input

Copy and paste the following code into your project.

components/nexus-ui/prompt-input.tsx
"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/openai

Create your chat API route

app/api/chat/route.ts
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