logoassistant-ui

Attachment

Overview

The Attachment components let the user attach files and view the attachments.

Sample Attachment

Note: These components provide the UI for attachments, but you also need to configure attachment adapters in your runtime to handle file uploads and processing. See the Attachments Guide for complete setup instructions.

Getting Started

Add attachment

npx shadcn@latest add @assistant-ui/attachment

Main Component

npm install @assistant-ui/react zustand
yarn add @assistant-ui/react zustand
pnpm add @assistant-ui/react zustand
bun add @assistant-ui/react zustand
xpm add @assistant-ui/react zustand
"use client";import { PropsWithChildren, useEffect, useState, type FC } from "react";import Image from "next/image";import { XIcon, PlusIcon, FileText } from "lucide-react";import {  AttachmentPrimitive,  ComposerPrimitive,  MessagePrimitive,  useAssistantState,  useAssistantApi,} from "@assistant-ui/react";import { useShallow } from "zustand/shallow";import {  Tooltip,  TooltipContent,  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/tooltip-icon-button";import { cn } from "@/lib/utils";const useFileSrc = (file: File | undefined) => {  const [src, setSrc] = useState<string | undefined>(undefined);  useEffect(() => {    if (!file) {      setSrc(undefined);      return;    }    const objectUrl = URL.createObjectURL(file);    setSrc(objectUrl);    return () => {      URL.revokeObjectURL(objectUrl);    };  }, [file]);  return src;};const useAttachmentSrc = () => {  const { file, src } = useAssistantState(    useShallow(({ attachment }): { file?: File; src?: string } => {      if (attachment.type !== "image") return {};      if (attachment.file) return { file: attachment.file };      const src = attachment.content?.filter((c) => c.type === "image")[0]        ?.image;      if (!src) return {};      return { src };    }),  );  return useFileSrc(file) ?? src;};type AttachmentPreviewProps = {  src: string;};const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => {  const [isLoaded, setIsLoaded] = useState(false);  return (    <Image      src={src}      alt="Image Preview"      width={1}      height={1}      className={        isLoaded          ? "aui-attachment-preview-image-loaded block h-auto max-h-[80vh] w-auto max-w-full object-contain"          : "aui-attachment-preview-image-loading hidden"      }      onLoadingComplete={() => setIsLoaded(true)}      priority={false}    />  );};const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {  const src = useAttachmentSrc();  if (!src) return children;  return (    <Dialog>      <DialogTrigger        className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"        asChild      >        {children}      </DialogTrigger>      <DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">        <DialogTitle className="aui-sr-only sr-only">          Image Attachment Preview        </DialogTitle>        <div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">          <AttachmentPreview src={src} />        </div>      </DialogContent>    </Dialog>  );};const AttachmentThumb: FC = () => {  const isImage = useAssistantState(    ({ attachment }) => attachment.type === "image",  );  const src = useAttachmentSrc();  return (    <Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">      <AvatarImage        src={src}        alt="Attachment preview"        className="aui-attachment-tile-image object-cover"      />      <AvatarFallback delayMs={isImage ? 200 : 0}>        <FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />      </AvatarFallback>    </Avatar>  );};const AttachmentUI: FC = () => {  const api = useAssistantApi();  const isComposer = api.attachment.source === "composer";  const isImage = useAssistantState(    ({ attachment }) => attachment.type === "image",  );  const typeLabel = useAssistantState(({ attachment }) => {    const type = attachment.type;    switch (type) {      case "image":        return "Image";      case "document":        return "Document";      case "file":        return "File";      default:        const _exhaustiveCheck: never = type;        throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`);    }  });  return (    <Tooltip>      <AttachmentPrimitive.Root        className={cn(          "aui-attachment-root relative",          isImage &&            "aui-attachment-root-composer only:[&>#attachment-tile]:size-24",        )}      >        <AttachmentPreviewDialog>          <TooltipTrigger asChild>            <div              className={cn(                "aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",                isComposer &&                  "aui-attachment-tile-composer border-foreground/20",              )}              role="button"              id="attachment-tile"              aria-label={`${typeLabel} attachment`}            >              <AttachmentThumb />            </div>          </TooltipTrigger>        </AttachmentPreviewDialog>        {isComposer && <AttachmentRemove />}      </AttachmentPrimitive.Root>      <TooltipContent side="top">        <AttachmentPrimitive.Name />      </TooltipContent>    </Tooltip>  );};const AttachmentRemove: FC = () => {  return (    <AttachmentPrimitive.Remove asChild>      <TooltipIconButton        tooltip="Remove file"        className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"        side="top"      >        <XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" />      </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">      <MessagePrimitive.Attachments components={{ Attachment: AttachmentUI }} />    </div>  );};export const ComposerAttachments: FC = () => {  return (    <div className="aui-composer-attachments mb-2 flex w-full flex-row items-center gap-2 overflow-x-auto px-1.5 pt-0.5 pb-1 empty:hidden">      <ComposerPrimitive.Attachments        components={{ Attachment: AttachmentUI }}      />    </div>  );};export const ComposerAddAttachment: FC = () => {  return (    <ComposerPrimitive.AddAttachment asChild>      <TooltipIconButton        tooltip="Add Attachment"        side="bottom"        variant="ghost"        size="icon"        className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"        aria-label="Add Attachment"      >        <PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />      </TooltipIconButton>    </ComposerPrimitive.AddAttachment>  );};

assistant-ui dependencies

"use client";import { ComponentPropsWithRef, forwardRef } from "react";import { Slottable } from "@radix-ui/react-slot";import {  Tooltip,  TooltipContent,  TooltipTrigger,} from "@/components/ui/tooltip";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {  tooltip: string;  side?: "top" | "bottom" | "left" | "right";};export const TooltipIconButton = forwardRef<  HTMLButtonElement,  TooltipIconButtonProps>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {  return (    <Tooltip>      <TooltipTrigger asChild>        <Button          variant="ghost"          size="icon"          {...rest}          className={cn("aui-button-icon size-6 p-1", className)}          ref={ref}        >          <Slottable>{children}</Slottable>          <span className="aui-sr-only sr-only">{tooltip}</span>        </Button>      </TooltipTrigger>      <TooltipContent side={side}>{tooltip}</TooltipContent>    </Tooltip>  );});TooltipIconButton.displayName = "TooltipIconButton";

shadcn/ui dependencies

npm install @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
yarn add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
pnpm add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
bun add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
xpm add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
"use client";import * as React from "react";import * as DialogPrimitive from "@radix-ui/react-dialog";import { XIcon } from "lucide-react";import { cn } from "@/lib/utils";function Dialog({  ...props}: React.ComponentProps<typeof DialogPrimitive.Root>) {  return <DialogPrimitive.Root data-slot="dialog" {...props} />;}function DialogTrigger({  ...props}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;}function DialogPortal({  ...props}: React.ComponentProps<typeof DialogPrimitive.Portal>) {  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;}function DialogClose({  ...props}: React.ComponentProps<typeof DialogPrimitive.Close>) {  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;}function DialogOverlay({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {  return (    <DialogPrimitive.Overlay      data-slot="dialog-overlay"      className={cn(        "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",        className,      )}      {...props}    />  );}function DialogContent({  className,  children,  ...props}: React.ComponentProps<typeof DialogPrimitive.Content>) {  return (    <DialogPortal data-slot="dialog-portal">      <DialogOverlay />      <DialogPrimitive.Content        data-slot="dialog-content"        className={cn(          "data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",          className,        )}        {...props}      >        {children}        <DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0">          <XIcon />          <span className="sr-only">Close</span>        </DialogPrimitive.Close>      </DialogPrimitive.Content>    </DialogPortal>  );}function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="dialog-header"      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}      {...props}    />  );}function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="dialog-footer"      className={cn(        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",        className,      )}      {...props}    />  );}function DialogTitle({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Title>) {  return (    <DialogPrimitive.Title      data-slot="dialog-title"      className={cn("font-semibold text-lg leading-none", className)}      {...props}    />  );}function DialogDescription({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Description>) {  return (    <DialogPrimitive.Description      data-slot="dialog-description"      className={cn("text-muted-foreground text-sm", className)}      {...props}    />  );}export {  Dialog,  DialogClose,  DialogContent,  DialogDescription,  DialogFooter,  DialogHeader,  DialogOverlay,  DialogPortal,  DialogTitle,  DialogTrigger,};
"use client";import * as React from "react";import * as TooltipPrimitive from "@radix-ui/react-tooltip";import { cn } from "@/lib/utils";function TooltipProvider({  delayDuration = 0,  ...props}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {  return (    <TooltipPrimitive.Provider      data-slot="tooltip-provider"      delayDuration={delayDuration}      {...props}    />  );}function Tooltip({  ...props}: React.ComponentProps<typeof TooltipPrimitive.Root>) {  return (    <TooltipProvider>      <TooltipPrimitive.Root data-slot="tooltip" {...props} />    </TooltipProvider>  );}function TooltipTrigger({  ...props}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;}function TooltipContent({  className,  sideOffset = 0,  children,  ...props}: React.ComponentProps<typeof TooltipPrimitive.Content>) {  return (    <TooltipPrimitive.Portal>      <TooltipPrimitive.Content        data-slot="tooltip-content"        sideOffset={sideOffset}        className={cn(          "fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",          className,        )}        {...props}      >        {children}        <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />      </TooltipPrimitive.Content>    </TooltipPrimitive.Portal>  );}export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
"use client";import * as React from "react";import * as AvatarPrimitive from "@radix-ui/react-avatar";import { cn } from "@/lib/utils";function Avatar({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Root>) {  return (    <AvatarPrimitive.Root      data-slot="avatar"      className={cn(        "relative flex size-8 shrink-0 overflow-hidden rounded-full",        className,      )}      {...props}    />  );}function AvatarImage({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Image>) {  return (    <AvatarPrimitive.Image      data-slot="avatar-image"      className={cn("aspect-square size-full", className)}      {...props}    />  );}function AvatarFallback({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {  return (    <AvatarPrimitive.Fallback      data-slot="avatar-fallback"      className={cn(        "flex size-full items-center justify-center rounded-full bg-muted",        className,      )}      {...props}    />  );}export { Avatar, AvatarImage, AvatarFallback };
import * as React from "react";import { Slot } from "@radix-ui/react-slot";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const buttonVariants = cva(  "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm no-underline outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",  {    variants: {      variant: {        default:          "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",        destructive:          "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",        outline:          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",        secondary:          "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",        ghost:          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",        link: "text-primary underline-offset-4 hover:underline",      },      size: {        default: "h-9 px-4 py-2 has-[>svg]:px-3",        sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",        icon: "size-9",      },    },    defaultVariants: {      variant: "default",      size: "default",    },  },);function Button({  className,  variant,  size,  asChild = false,  ...props}: React.ComponentProps<"button"> &  VariantProps<typeof buttonVariants> & {    asChild?: boolean;  }) {  const Comp = asChild ? Slot : "button";  return (    <Comp      data-slot="button"      className={cn(buttonVariants({ variant, size, className }))}      {...props}    />  );}export { Button, buttonVariants };

This adds a /components/assistant-ui/attachment.tsx file to your project, which you can adjust as needed.

Use in your application

/components/assistant-ui/thread.tsx
import {
  ComposerAttachments,
  ComposerAddAttachment,
} from "@/components/assistant-ui/attachment";

const Composer: FC = () => {
  return (
    <ComposerPrimitive.Root className="...">
      <ComposerAttachments />
      <ComposerAddAttachment />

      <ComposerPrimitive.Input
        autoFocus
        placeholder="Write a message..."
        rows={1}
        className="..."
      />
      <ComposerAction />
    </ComposerPrimitive.Root>
  );
};
/components/assistant-ui/thread.tsx
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";

const UserMessage: FC = () => {
  return (
    <MessagePrimitive.Root className="...">
      <UserActionBar />

      <UserMessageAttachments />

      <div className="...">
        <MessagePrimitive.Parts />
      </div>

      <BranchPicker className="..." />
    </MessagePrimitive.Root>
  );
};