Browse all components
Prompt Input + Attachments UI
A chat input that accepts file uploads — attachment chips with upload progress, type icons, and one-click removal.
Preview
Presets
Props
Also takes onSubmit, onAttach, onRemove, onRetry.
Agent promptClaude Code · Cursor · any agent
Add the Prompt Input + Attachments UI component from the Scrim UI registry to this project and use it in the configuration described below.
Prompt Input + Attachments UI — A chat input that accepts file uploads — attachment chips with upload progress, type icons, and one-click removal.
## 1. Install
```bash
npx shadcn@latest add @scrimui/prompt-input-attachments
```
This writes a single file to `components/ui/prompt-input-attachments.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/prompt-input-attachments to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { PromptInputAttachments } from "@/components/ui/prompt-input-attachments";
const FILES = [
{ id: "f1", name: "screenshot.png", size: "1.2 MB", type: "image", status: "done", progress: 100 },
{ id: "f2", name: "requirements.pdf", size: "340 KB", type: "file", status: "done", progress: 100 },
];
<PromptInputAttachments
placeholder="Ask anything…"
files={FILES}
onSubmit={() => {}}
onAttach={() => {}}
onRemove={() => {}}
onRetry={() => {}}
/>
```
## 3. The configuration that matters
This is the component's "Attached" state — Chips with type icons, sizes and one-click removal — the steady state.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
## 4. Wire the handlers
`onSubmit`, `onAttach`, `onRemove`, `onRetry` are stubs in the snippet above (`() => {}`). Connect them to this project's own state and data layer instead of shipping the empty functions.
Reference: https://scrimui.dev/components/prompt-input-attachmentsFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/prompt-input-attachmentsComponent source
React + Tailwind component using shadcn/ui (button, card, textarea). The install command adds these dependencies automatically.
When copying source manually, install the required primitives first:
npx shadcn@latest add button card textarea"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import * as React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type PendingFile = {
id: string;
name: string;
size?: string;
type?: "image" | "file";
status?: "uploading" | "done" | "error";
progress?: number;
};
export type PromptInputAttachmentsProps = {
files?: PendingFile[];
placeholder?: string;
disabled?: boolean;
onSubmit?: (value: string) => void;
onAttach?: () => void;
onRemove?: (id: string) => void;
onRetry?: (id: string) => void;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" {...props}>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
function ArrowUpIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" {...props}>
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
);
}
function FileIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2Z" />
<path d="M14 2v6h6" />
</svg>
);
}
function ImageIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
}
function XIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function AlertIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" {...props}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* File chip */
/* ------------------------------------------------------------------ */
function FileChip({
file,
onRemove,
onRetry,
}: {
file: PendingFile;
onRemove?: (id: string) => void;
onRetry?: (id: string) => void;
}) {
const status = file.status ?? "done";
return (
<span
className={`inline-flex max-w-full items-center gap-1.5 rounded-lg border py-1 pl-2 pr-1 text-xs ${
status === "error"
? "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300"
: "border-border bg-muted text-foreground"
}`}
>
{file.type === "image" ? <ImageIcon /> : <FileIcon />}
<span className="max-w-36 truncate">{file.name}</span>
{}
{file.size && <span className="text-muted-foreground">{file.size}</span>}
{status === "uploading" && file.progress !== undefined && (
<>
<span className="h-1 w-10 overflow-hidden rounded-full bg-muted">
<span
className="block h-full rounded-full bg-primary transition-[color,background-color,border-color,box-shadow]"
style={{ width: `${file.progress}%` }}
/>
</span>
<span className="tabular-nums text-muted-foreground">{file.progress}%</span>
</>
)}
{status === "error" ? (
<>
<AlertIcon />
{onRetry && (
<Button variant="ghost" size="sm"
type="button"
onClick={() => onRetry(file.id)}
className="rounded px-1.5 py-0.5 font-medium text-red-700 transition-colors hover:bg-red-100 dark:text-red-300 dark:hover:bg-red-900/40"
>
Retry
</Button>
)}
</>
) : (
onRemove && (
<Button variant="ghost" size="icon-sm"
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => onRemove(file.id)}
className="min-h-6 min-w-6 rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<XIcon />
</Button>
)
)}
</span>
);
}
/* ------------------------------------------------------------------ */
/* PromptInputAttachments */
/* ------------------------------------------------------------------ */
export function PromptInputAttachments({
files = [],
placeholder = "Ask anything…",
disabled = false,
onSubmit,
onAttach,
onRemove,
onRetry,
className = "",
}: PromptInputAttachmentsProps) {
const [value, setValue] = React.useState("");
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const canSubmit = value.trim().length > 0 && !disabled;
React.useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "0px";
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
}, [value]);
function submit() {
if (!canSubmit) return;
onSubmit?.(value.trim());
setValue("");
}
return (
<div className={`w-full ${className}`}>
<Card
className={`gap-0 py-0 rounded-xl border bg-card shadow-sm transition-colors ${
disabled
? "border-border opacity-60"
: "border-border focus-within:border-border"
}`}
>
{files.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 pt-3">
{files.map((f) => (
<FileChip key={f.id} file={f} onRemove={onRemove} onRetry={onRetry} />
))}
</div>
)}
<Textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder={placeholder}
disabled={disabled}
aria-label="Prompt"
className="border-0 rounded-none shadow-none focus-visible:ring-0 field-sizing-fixed dark:bg-transparent block w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 text-base sm:text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground"
/>
<div className="flex items-center justify-between px-2.5 pb-2.5">
<Button variant="ghost" size="icon-sm"
type="button"
onClick={onAttach}
disabled={disabled}
aria-label="Add attachment"
className="min-h-6 min-w-6 inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<PlusIcon />
</Button>
<span className="text-xs text-muted-foreground">Enter to send · Shift+Enter for newline</span>
<Button variant="default" size="icon-sm"
type="button"
onClick={submit}
disabled={!canSubmit}
aria-label="Send message"
className="min-h-6 min-w-6 inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground transition-opacity hover:opacity-80 disabled:opacity-30"
>
<ArrowUpIcon />
</Button>
</div>
</Card>
</div>
);
}
When to use it
- Track upload progress per file, not per batch — large files shouldn't stall small ones.
- Keep failed chips visible with retry; a silent drop makes users think their file was sent.
- Show type, name and size on every chip so users can audit what will reach the model.
- Allow removing any file at any stage, including mid-upload.
- Confirm the model's context limit before too many files are attached.
What breaks in production
- Hiding the file once upload completes — users assume it was dropped and re-upload it.
- A single indeterminate spinner for all files instead of per-file progress.
- Clearing attachments on send without showing they were included.
- Allowing duplicate uploads of the same file with no dedupe or warning.
Related Components
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.
A chat input with the model picker built in — switch models inline, with badges for what each one can do.
A prompt template editor with {{variables}} highlighted as you type, a rendered preview, and a line diff against the previous version.
Render an AI reply token by token like ChatGPT's typing effect — blinking cursor, stop button, and no layout jump as the text grows.