Skip to content

Attachments

File and image attachments with progress, failure recovery, metadata, and removal actions.

Default

A settled attachment with its file type, size, and a remove action.

Loading component preview…
import { Attachments } from "@/components/ai/attachments"


export function AttachmentDefault() {
  return (
    <Attachments
      variant="default"
      items={[
        { id: "spec", kind: "pdf", name: "agent-spec.pdf", size: "1.8 MB" },
      ]}
    />
  )
}

Uploading

An attachment in progress with a live upload bar.

Loading component preview…
import { Attachments } from "@/components/ai/attachments"


export function AttachmentUploading() {
  return (
    <Attachments
      variant="uploading"
      items={[
        { id: "spec", kind: "pdf", name: "agent-spec.pdf", size: "1.8 MB", progress: 45 },
      ]}
    />
  )
}

Failed

An attachment that did not upload, with a retry action.

Loading component preview…
import { Attachments } from "@/components/ai/attachments"


export function AttachmentFailed() {
  return (
    <Attachments
      variant="failed"
      items={[
        { id: "spec", kind: "pdf", name: "agent-spec.pdf", size: "1.8 MB" },
      ]}
    />
  )
}

Usage

import { useState } from "react"
import {
  Attachments,
  type AttachmentItem,
} from "@/components/ai/attachments"


const initialFiles: AttachmentItem[] = [
  { id: "pdf", name: "Project brief.pdf", kind: "pdf", size: "2.4 MB" },
  { id: "document", name: "Notes.docx", kind: "document", size: "128 KB" },
  { id: "image", name: "Cover.png", kind: "image", size: "840 KB" },
  { id: "file", name: "Assets.zip", kind: "file", size: "5.1 MB" },
]


export function AttachmentsExample() {
  const [files, setFiles] = useState(initialFiles)


  return (
    <Attachments
      items={files}
      onRemove={(id) => setFiles((items) => items.filter((item) => item.id !== id))}
    />
  )
}


// Use a variant to set the state for every item in a list.
export function AttachmentVariants() {
  return (
    <div className="space-y-6">
      <Attachments variant="default" items={[initialFiles[0]]} />
      <Attachments
        variant="uploading"
        items={[{ ...initialFiles[0], progress: 64 }]}
      />
      <Attachments variant="failed" items={[initialFiles[0]]} />
    </div>
  )
}


// Item status overrides the variant for mixed states.
// Supply your upload implementation; report progress from your uploader.
export function UploadExample({
  upload,
}: {
  upload: (
    item: AttachmentItem,
    onProgress: (progress: number) => void,
  ) => Promise<void>
}) {
  const [files, setFiles] = useState<AttachmentItem[]>([
    { ...initialFiles[0], status: "error" },
  ])


  function updateFile(id: string, changes: Partial<AttachmentItem>) {
    setFiles((items) => items.map((item) =>
      item.id === id ? { ...item, ...changes } : item
    ))
  }


  async function retryUpload(id: string) {
    const file = files.find((item) => item.id === id)
    if (!file) return


    updateFile(id, { status: "uploading", progress: 0 })
    try {
      await upload(file, (progress) => updateFile(id, { progress }))
      updateFile(id, { status: "ready", progress: 100 })
    } catch {
      updateFile(id, { status: "error" })
    }
  }


  return (
    <Attachments
      items={files}
      onRetry={retryUpload}
      onRemove={(id) => setFiles((items) => items.filter((item) => item.id !== id))}
    />
  )
}

Install

pnpm dlx shadcn@latest add @pastaui/attachments

Source

import { FileIcon } from '@solar-icons/react/linear/file'
import { FileTextIcon } from '@solar-icons/react/linear/file-text'
import { GalleryIcon as ImageIcon } from '@solar-icons/react/linear/gallery'
import { RestartIcon as RotateCcwIcon } from '@solar-icons/react/linear/restart'
import { CloseIcon as XIcon } from '@solar-icons/react/linear/close'
import * as React from 'react'


import { Button } from '@/components/ui/button'
import { cn } from 'cn'


interface AttachmentItem {
  id: string
  kind?: 'document' | 'file' | 'image' | 'pdf'
  name: string
  progress?: number
  size?: string
  status?: 'error' | 'ready' | 'uploading'
  url?: string
}


interface AttachmentsProps extends React.ComponentProps<'ul'> {
  variant?: 'default' | 'uploading' | 'failed'
  items: AttachmentItem[]
  onRemove?: (id: string) => void
  onRetry?: (id: string) => void
}


function Attachments({
  className,
  items,
  onRemove,
  onRetry,
  variant = 'default',
  ...props
}: AttachmentsProps) {
  return (
    <ul
      aria-label="Attachments"
      data-slot="attachments"
      className={cn(
        'grid gap-3 font-sans text-sm font-[450] tracking-[-0.05px] text-muted-foreground sm:grid-cols-2',
        className,
      )}
      {...props}
    >
      {items.map((attachment) => {
        const item = {
          ...attachment,
          status:
            attachment.status ??
            (variant === 'failed' ? 'error' : variant === 'uploading' ? 'uploading' : 'ready'),
        }
        return (
          <li
            className="relative flex min-w-0 items-center gap-3 overflow-hidden rounded-[16px] bg-card p-3 shadow-card transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:scale-[0.97] starting:opacity-0 motion-reduce:starting:scale-100"
            key={item.id}
          >
            <AttachmentPreview item={item} />
            <div className="min-w-0 flex-1">
              <p className="truncate card-heading leading-5 text-muted-foreground">{item.name}</p>
              <p
                className={cn(
                  'mt-0.5 text-xs leading-5 font-[450] text-muted-foreground',
                  item.status === 'error' && 'text-destructive',
                )}
              >
                {item.status === 'uploading'
                  ? `${item.progress ?? 0}% uploaded`
                  : item.status === 'error'
                    ? 'Upload failed'
                    : item.size}
              </p>
            </div>
            {item.status === 'error' && (
              <Button
                aria-label={`Retry upload: ${item.name}`}
                type="button"
                onClick={() => onRetry?.(item.id)}
                size="icon-xs"
                variant="ghost"
              >
                <RotateCcwIcon />
              </Button>
            )}
            <Button
              aria-label={`Remove ${item.name}`}
              type="button"
              onClick={() => onRemove?.(item.id)}
              size="icon-xs"
              variant="ghost"
            >
              <XIcon />
            </Button>
            {item.status === 'uploading' && (
              <span className="absolute inset-x-0 bottom-0 h-0.5 bg-muted">
                <span
                  className="block h-full origin-left bg-foreground transition-[scale] duration-300 ease-linear"
                  style={{ scale: `${(item.progress ?? 0) / 100} 1` }}
                />
              </span>
            )}
          </li>
        )
      })}
    </ul>
  )
}


function AttachmentPreview({ item }: { item: AttachmentItem }) {
  if (item.kind === 'image' && item.url) {
    return (
      <img
        alt=""
        className="size-10 shrink-0 rounded-md object-cover"
        height={40}
        src={item.url}
        width={40}
      />
    )
  }


  const kind = item.kind === 'pdf' || /\.pdf$/i.test(item.name) ? 'pdf' : (item.kind ?? 'file')
  const Icon =
    kind === 'image' ? ImageIcon : kind === 'document' || kind === 'pdf' ? FileTextIcon : FileIcon
  const colors = {
    image: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
    document: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
    pdf: 'bg-red-500/10 text-red-600 dark:text-red-400',
    file: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
  }
  return (
    <span
      aria-hidden="true"
      className={cn(
        'flex size-10 shrink-0 flex-col items-center justify-center gap-0.5 rounded-md',
        colors[kind],
      )}
    >
      <Icon className="size-4" />
      {kind === 'pdf' && (
        <span className="text-[8px] leading-none font-semibold tracking-wide">PDF</span>
      )}
    </span>
  )
}


export { Attachments }
export type { AttachmentItem, AttachmentsProps }


Props

Attachments props
PropTypeDefaultDescription
variant"default" | "uploading" | "failed"= defaultSets the attachment state. Individual item status values override this variant.
itemsAttachmentItem[]= —Provides files and their transfer state.
onRemove(id: string) => void= —Removes an attachment.
onRetry(id: string) => void= —Retries a failed upload.