Prompt Input + Attachments UI
Prompt input with file attachment chips, upload progress and removal.
promptinputattachmentsfiles
screenshot.png1.2 MBrequirements.pdf340 KB
Enter to send · Shift+Enter for newline
Props
Also takes onSubmit, onAttach, onRemove, onRetry.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
prompt-input-attachments.tsx
"use client";
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="2" 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="2" 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="2" 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="2" 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="2" 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="2" 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-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-300"
}`}
>
{file.type === "image" ? <ImageIcon /> : <FileIcon />}
<span className="max-w-36 truncate">{file.name}</span>
{/* zinc-600 / red-700: an errored attachment row is drawn on red-50,
where zinc-500 is 4.41:1 and red-600 is 4.36:1 — both under AA. */}
{file.size && <span className="text-zinc-600 dark:text-zinc-400">{file.size}</span>}
{status === "uploading" && file.progress !== undefined && (
<>
<span className="h-1 w-10 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
<span
className="block h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${file.progress}%` }}
/>
</span>
<span className="tabular-nums text-zinc-500 dark:text-zinc-400">{file.progress}%</span>
</>
)}
{status === "error" ? (
<>
<AlertIcon />
{onRetry && (
<button
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
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => onRemove(file.id)}
className="rounded p-0.5 text-zinc-400 transition-colors hover:bg-zinc-200 hover:text-zinc-700 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
>
<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}`}>
<div
className={`rounded-2xl border bg-white shadow-sm transition-colors dark:bg-zinc-900 ${
disabled
? "border-zinc-200 opacity-60 dark:border-zinc-800"
: "border-zinc-200 focus-within:border-zinc-400 dark:border-zinc-800 dark:focus-within:border-zinc-600"
}`}
>
{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="block w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 text-[15px] leading-6 text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
/>
<div className="flex items-center justify-between px-2.5 pb-2.5">
<button
type="button"
onClick={onAttach}
disabled={disabled}
aria-label="Add attachment"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
<PlusIcon />
</button>
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Enter to send · Shift+Enter for newline</span>
<button
type="button"
onClick={submit}
disabled={!canSubmit}
aria-label="Send message"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-white transition-opacity hover:opacity-80 disabled:opacity-30 dark:bg-zinc-100 dark:text-zinc-900"
>
<ArrowUpIcon />
</button>
</div>
</div>
</div>
);
}
Usage Guidelines
- 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.
Common UX Mistakes
- 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
Prompt Input
The core input for AI chat products — with attachments, model selector, tools, voice and loading states.
Prompt Input + Model Selector
Prompt input with an inline model picker and capability badges.
Streaming Message
Assistant message that renders token-by-token with a cursor and stop control.
User Message
The user's turn — right-aligned bubble with copy, edit and regenerate actions.