# Prompt Input

> A chat composer: a growing message field, file attachments with previews, a tools menu (web search, deep research, image), and a send button that turns into stop while the answer streams.

A shadcn/ui block from the [@sortui](https://sortui.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/sortui/prompt-input-01).

- Page, with a live preview: https://shadcn.exchange/r/sortui/prompt-input-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/sortui/prompt-input-01-light-12badb5b166286cb.png
- Registry item: https://sortui.dev/r/prompt-input-01.json
- Categories: ai
- Listed: 2026-10-23, source last changed 2026-10-23

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/sortui/prompt-input-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `cn`, `lucide-react`
- Registry items: `@sortui/attachment`, `@sortui/dropdown-menu`, `@sortui/input-group`, `@sortui/tooltip`

## Files

### `registry/blocks/prompt-input-01/components/prompt-input.tsx`

```tsx
"use client"

import * as React from "react"
import {
  ArrowUpIcon,
  FileTextIcon,
  GlobeIcon,
  ImageIcon,
  MicIcon,
  PaperclipIcon,
  PlusIcon,
  SquareIcon,
  TelescopeIcon,
  XIcon,
} from "lucide-react"
import { cn } from "cn"

import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"

export type PromptTool = "search" | "research" | "image"

export type PromptSubmission = {
  text: string
  files: File[]
  tool: PromptTool | null
}

export const promptTools: {
  id: PromptTool
  label: string
  icon: React.ComponentType
}[] = [
  { id: "search", label: "Web search", icon: GlobeIcon },
  { id: "research", label: "Deep research", icon: TelescopeIcon },
  { id: "image", label: "Create image", icon: ImageIcon },
]

export function PromptInput({
  onSubmit,
  onStop,
  onDictate,
  status = "ready",
  placeholder = "Ask anything",
  autoFocus,
  className,
}: {
  onSubmit?: (submission: PromptSubmission) => void
  onStop?: () => void
  onDictate?: () => void
  status?: "ready" | "busy"
  placeholder?: string
  autoFocus?: boolean
  className?: string
}) {
  const [text, setText] = React.useState("")
  const [files, setFiles] = React.useState<PickedFile[]>([])
  const [tool, setTool] = React.useState<PromptTool | null>(null)
  const fileInput = React.useRef<HTMLInputElement>(null)

  const previews = React.useRef(new Set<string>())
  const nextId = React.useRef(0)
  React.useEffect(() => {
    const urls = previews.current
    return () => urls.forEach((url) => URL.revokeObjectURL(url))
  }, [])

  function release(items: PickedFile[]) {
    for (const { preview } of items) {
      if (!preview) continue
      URL.revokeObjectURL(preview)
      previews.current.delete(preview)
    }
  }

  const busy = status === "busy"
  const canSend = !busy && (text.trim().length > 0 || files.length > 0)
  const activeTool = promptTools.find((item) => item.id === tool)

  function submit() {
    if (!canSend) return
    onSubmit?.({
      text: text.trim(),
      files: files.map((item) => item.file),
      tool,
    })
    release(files)
    setText("")
    setFiles([])
  }

  return (
    <form
      className={cn("w-full", className)}
      onSubmit={(event) => {
        event.preventDefault()
        submit()
      }}
    >
      <InputGroup className="rounded-3xl">
        {files.length > 0 && (
          <InputGroupAddon align="block-start" className="flex-wrap gap-2">
            {files.map((item) => (
              <FileChip
                key={item.id}
                file={item.file}
                preview={item.preview}
                onRemove={() => {
                  release([item])
                  setFiles((current) =>
                    current.filter((other) => other.id !== item.id)
                  )
                }}
              />
            ))}
          </InputGroupAddon>
        )}
        <InputGroupTextarea
          aria-label="Message"
          placeholder={placeholder}
          autoFocus={autoFocus}
          value={text}
          onChange={(event) => setText(event.target.value)}
          onKeyDown={(event) => {
            if (
              event.key === "Enter" &&
              !event.shiftKey &&
              !event.nativeEvent.isComposing &&
              event.keyCode !== 229
            ) {
              event.preventDefault()
              submit()
            }
          }}
          className="max-h-52 min-h-14"
        />
        <InputGroupAddon align="block-end" className="gap-1">
          <DropdownMenu>
            <DropdownMenuTrigger
              render={
                <InputGroupButton
                  size="icon-sm"
                  variant="outline"
                  aria-label="Add files and tools"
                />
              }
            >
              <PlusIcon />
            </DropdownMenuTrigger>
            <DropdownMenuContent side="top" align="start" className="w-52">
              <DropdownMenuItem onClick={() => fileInput.current?.click()}>
                <PaperclipIcon />
                Add photos & files
              </DropdownMenuItem>
              <DropdownMenuSeparator />
              <DropdownMenuGroup>
                <DropdownMenuLabel>Tools</DropdownMenuLabel>
                {promptTools.map(({ id, label, icon: Icon }) => (
                  <DropdownMenuItem key={id} onClick={() => setTool(id)}>
                    <Icon />
                    {label}
                  </DropdownMenuItem>
                ))}
              </DropdownMenuGroup>
            </DropdownMenuContent>
          </DropdownMenu>
          {activeTool && (
            <InputGroupButton
              size="sm"
              variant="secondary"
              aria-label={`Turn off ${activeTool.label.toLowerCase()}`}
              onClick={() => setTool(null)}
            >
              <activeTool.icon />
              {activeTool.label}
              <XIcon className="text-muted-foreground" />
            </InputGroupButton>
          )}
          <div className="ml-auto flex items-center gap-1">
            <Tooltip>
              <TooltipTrigger
                render={
                  <InputGroupButton
                    size="icon-sm"
                    aria-label="Dictate"
                    onClick={onDictate}
                  />
                }
              >
                <MicIcon />
              </TooltipTrigger>
              <TooltipContent>Dictate</TooltipContent>
            </Tooltip>
            {busy ? (
              <InputGroupButton
                size="icon-sm"
                variant="default"
                aria-label="Stop generating"
                onClick={onStop}
              >
                <SquareIcon className="fill-current" />
              </InputGroupButton>
            ) : (
              <InputGroupButton
                type="submit"
                size="icon-sm"
                variant="default"
                aria-label="Send"
                disabled={!canSend}
              >
                <ArrowUpIcon />
              </InputGroupButton>
            )}
          </div>
        </InputGroupAddon>
        <input
          ref={fileInput}
          type="file"
          multiple
          hidden
          onChange={(event) => {
            const picked = Array.from(event.target.files ?? []).map((file) => {
              const preview = file.type.startsWith("image/")
                ? URL.createObjectURL(file)
                : null
              if (preview) previews.current.add(preview)
              return { id: nextId.current++, file, preview }
            })
            setFiles((current) => [...current, ...picked])
            event.target.value = ""
          }}
        />
      </InputGroup>
    </form>
  )
}

type PickedFile = { id: number; file: File; preview: string | null }

function FileChip({
  file,
  preview,
  onRemove,
}: {
  file: File
  preview: string | null
  onRemove: () => void
}) {
  return (
    <Attachment
      type={preview ? "image" : "file"}
      className={preview ? "size-13.5" : "w-56"}
    >
      <AttachmentMedia variant={preview ? "image" : "icon"}>
        {preview ? <img src={preview} alt="" /> : <FileTextIcon />}
      </AttachmentMedia>
      <AttachmentContent className={preview ? "sr-only" : undefined}>
        <AttachmentTitle>{file.name}</AttachmentTitle>
        <AttachmentDescription>{formatSize(file.size)}</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction aria-label={`Remove ${file.name}`} onClick={onRemove}>
          <XIcon />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
  )
}

function formatSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
```
