Assistant UI
componentattachmentsource: ok · hit
Attachment
Attach files from the composer and view them inside messages.
npx shadcn add "https://r.assistant-ui.com/attachment.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
dialogtooltipavatarhttps://r.assistant-ui.com/tooltip-icon-button.jsonhttps://r.assistant-ui.com/use-attachment-src.json
Files1 · source cached 10/9/2026, 12:44:38 PM
components/assistant-ui/elements/attachment.aui.tsx
"use client";
import {
type PropsWithChildren,
useState,
type FC,
isValidElement,
} from "react";
import {
XIcon,
PlusIcon,
FileText,
Loader2Icon,
AlertCircleIcon,
} from "lucide-react";
import {
AttachmentPrimitive,
ComposerPrimitive,
MessagePrimitive,
useAuiState,
useAui,
} from "@assistant-ui/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogTitle,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Avatar,
AvatarImage,
AvatarFallback,
} from "@/components/ui/avatar";
import { TooltipIconButton } from "@/components/assistant-ui/elements/tooltip-icon-button";
import { useAttachmentSrc } from "@/hooks/use-attachment-src";
import { cn } from "@/lib/utils";
type AttachmentPreviewProps = {
src: string;
name: string;
};
const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src, name }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<img
src={src}
alt={`Preview of ${name}`}
className={cn(
"block h-auto max-h-[80vh] w-auto max-w-full rounded-sm object-contain transition-opacity duration-300 motion-reduce:transition-none",
isLoaded
? "aui-attachment-preview-image-loaded opacity-100"
: "aui-attachment-preview-image-loading opacity-0",
)}
onLoad={() => setIsLoaded(true)}
/>
);
};
type AttachmentPreviewDialogProps = PropsWithChildren<{
src: string | undefined;
name: string;
}>;
const AttachmentPreviewDialog: FC<AttachmentPreviewDialogProps> = ({
children,
src,
name,
}) => {
if (!src) return children;
return (
<Dialog>
<DialogTrigger
className="aui-attachment-preview-trigger cursor-zoom-in"
asChild
>
{isValidElement(children) ? (
children
) : (
<button type="button">{children}</button>
)}
</DialogTrigger>
<DialogContent className="aui-attachment-preview-dialog-content [&>button]:bg-foreground/60 [&>button]:hover:bg-foreground/80 [&_svg]:text-background p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0!">
<DialogTitle className="aui-sr-only sr-only">
Preview {name}
</DialogTitle>
<div className="aui-attachment-preview bg-background relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden rounded-sm">
<AttachmentPreview src={src} name={name} />
</div>
</DialogContent>
</Dialog>
);
};
const AttachmentThumb: FC<{ src: string | undefined }> = ({ src }) => {
return (
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
<AvatarImage
src={src}
alt=""
className="aui-attachment-tile-image rounded-none object-cover"
/>
<AvatarFallback>
<FileText className="aui-attachment-tile-fallback-icon text-muted-foreground/80 size-6 stroke-[1.5]" />
</AvatarFallback>
</Avatar>
);
};
const AttachmentUI: FC = () => {
const aui = useAui();
const isComposer = aui.attachment.source !== "message";
const src = useAttachmentSrc();
const isImage = useAuiState((s) => s.attachment.type === "image");
const name = useAuiState((s) => s.attachment.name);
const typeLabel = useAuiState((s) => {
const type = s.attachment.type;
switch (type) {
case "image":
return "Image";
case "document":
return "Document";
case "file":
return "File";
default:
return type;
}
});
// An attachment on a submission is still being prepared, whether or not the
// adapter reports progress while it uploads.
const uploadState = useAuiState((s) =>
s.attachment.status.type === "incomplete" &&
s.attachment.status.reason === "error"
? "error"
: s.attachment.status.type === "running" ||
(s.optional.message?.submission !== undefined &&
s.attachment.status.type !== "complete")
? "uploading"
: undefined,
);
const isUploading = uploadState === "uploading";
const isError = uploadState === "error";
const errorMessage = useAuiState((s) =>
s.attachment.status.type === "incomplete" &&
s.attachment.status.reason === "error"
? (s.attachment.status.message ?? "Upload failed")
: undefined,
);
return (
<TooltipProvider>
<Tooltip>
<AttachmentPrimitive.Root
className={cn(
"aui-attachment-root relative",
isComposer &&
"animate-in fade-in-0 zoom-in-95 duration-200 motion-reduce:animate-none",
isImage &&
!isComposer &&
"aui-attachment-root-message only:*:first:size-24",
)}
>
<AttachmentPreviewDialog src={src} name={name}>
<TooltipTrigger asChild>
<div
className={cn(
"aui-attachment-tile bg-muted hover:after:bg-foreground/10 focus-visible:ring-ring/50 relative size-14 overflow-hidden rounded-[calc(var(--composer-radius,1rem)-var(--composer-padding,8px))] transition-transform outline-none after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-black/10 after:transition-colors after:ring-inset focus-visible:ring-1 motion-reduce:transition-none dark:after:ring-white/10",
src ? "cursor-zoom-in active:scale-[0.96]" : "cursor-default",
isError &&
"after:ring-destructive/60 dark:after:ring-destructive/60",
)}
role={src ? "button" : "group"}
tabIndex={0}
onKeyDown={
src
? (e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.click();
} else if (e.key === " ") {
e.preventDefault();
}
}
: undefined
}
onKeyUp={
src
? (e) => {
if (e.key === " ") e.currentTarget.click();
}
: undefined
}
aria-label={`${src ? `Preview ${name}` : `${typeLabel} attachment ${name}`}${
isError ? ", upload failed" : isUploading ? ", uploading" : ""
}`}
>
<AttachmentThumb src={src} />
{isUploading && (
<div
aria-hidden="true"
className="aui-attachment-tile-uploading bg-background/60 animate-in fade-in-0 absolute inset-0 flex items-center justify-center backdrop-blur-[2px] motion-reduce:animate-none"
>
<Loader2Icon className="text-muted-foreground size-4 animate-spin" />
</div>
)}
{isError && (
<div
aria-hidden="true"
className="aui-attachment-tile-error bg-background/70 animate-in fade-in-0 absolute inset-0 flex items-center justify-center backdrop-blur-[2px] motion-reduce:animate-none"
>
<AlertCircleIcon className="text-destructive size-4" />
</div>
)}
</div>
</TooltipTrigger>
</AttachmentPreviewDialog>
{isComposer && <AttachmentRemove name={name} />}
</AttachmentPrimitive.Root>
<TooltipContent side="top">
<AttachmentPrimitive.Name />
{errorMessage && (
<p className="aui-attachment-error-message">{errorMessage}</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const AttachmentRemove: FC<{ name: string }> = ({ name }) => {
return (
<AttachmentPrimitive.Remove asChild>
<TooltipIconButton
tooltip={`Remove ${name}`}
className="aui-attachment-tile-remove absolute end-1 top-1 size-5 rounded-full bg-black/50! text-white after:absolute after:-inset-1.5 hover:bg-black/70! hover:text-white! active:scale-[0.96] motion-reduce:transition-none"
side="top"
>
<XIcon className="aui-attachment-remove-icon size-3 stroke-[2.5]" />
</TooltipIconButton>
</AttachmentPrimitive.Remove>
);
};
export const UserMessageAttachments: FC = () => {
return (
<div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2 empty:hidden">
<MessagePrimitive.Attachments>
{() => <AttachmentUI />}
</MessagePrimitive.Attachments>
</div>
);
};
export const ComposerAttachments: FC = () => {
return (
<div className="aui-composer-attachments flex w-full flex-row items-center gap-2 overflow-x-auto empty:hidden">
<ComposerPrimitive.Attachments>
{() => <AttachmentUI />}
</ComposerPrimitive.Attachments>
</div>
);
};
export const ComposerAddAttachment: FC = () => {
return (
<ComposerPrimitive.AddAttachment asChild>
<TooltipIconButton
tooltip="Add Attachment"
side="bottom"
variant="ghost"
size="icon"
className="aui-composer-add-attachment text-muted-foreground hover:text-foreground hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30 size-7 rounded-full active:scale-[0.96] motion-reduce:transition-none"
aria-label="Add Attachment"
>
<PlusIcon className="aui-attachment-add-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.AddAttachment>
);
};