Skip to content

Shadcn Attachments ​

useCourier is headless, so it can provide upload behavior for a Shadcn attachment interface without taking a dependency on Shadcn or dictating how attachments look. It works with Shadcn in React and with shadcn-vue in Vue, whose Attachment components share the same parts and the same state values as item.status.

This integration separates responsibilities:

  • useCourier manages uploads, progress, status, retries, and cancellation.
  • Your attachment component manages previews, layout, buttons, and styling.

Connect the upload state ​

The core wiring uses the values returned by useCourier:

tsx
const { files, addFile, retryUpload, removeFile } = useCourier({
  url: "/api/uploads",
});

Connect the attachment component's file-selection event to addFile:

tsx
function handleFilesSelected(selectedFiles: File[]) {
  selectedFiles.forEach((file) => {
    void addFile(file);
  });
}

Render each attachment from the corresponding UploadItem:

Attachment UIuseCourier value
File name and previewitem.file
Progress indicatoritem.uploadProgress
Upload stateitem.status
Retry actionretryUpload(item.id)
Remove or cancel actionremoveFile(item.id)

Display upload states ​

Use item.status to choose the appropriate attachment state:

tsx
function getAttachmentState(status: UploadItem["status"]) {
  switch (status) {
    case "uploading":
      return "Uploading";
    case "processing":
      return "Processing";
    case "done":
      return "Uploaded";
    case "error":
      return "Upload failed";
    default:
      return "Ready";
  }
}

For an error state, show a retry action using the file's tracked ID. For an active upload, removeFile aborts the request before removing the attachment from the tracked list.

Complete example ​

The following component connects useCourier to Shadcn's attachment components (shadcn-vue's, in the Vue tab). The attachment components and formatFileSize helper belong to the consuming application.

tsx
import React from "react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment";
import { Spinner } from "@/components/ui/spinner";
import { FileIcon, RefreshCwIcon, XIcon } from "lucide-react";
import { useCourier } from "use-courier";
import { formatFileSize } from "@/lib/utils";

export function FileUpload() {
  const fileInputRef = React.useRef<HTMLInputElement>(null);
  const { addFile, files, removeFile, retryUpload } = useCourier({
    url: "http://localhost:3000/upload",
    fileChunking: {
      route: "http://localhost:3000/upload/chunk",
      threshold: 100 * 1024 * 1024,
      chunkSize: 10 * 1024 * 1024,
    },
    onUploadError({ error }) {
      console.error(error.message);
    },
  });

  function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
    const selectedFiles = Array.from(event.target.files ?? []);
    selectedFiles.forEach((file) => void addFile(file));
  }

  function handleRemove(id: string) {
    removeFile(id);
    if (fileInputRef.current) fileInputRef.current.value = "";
  }

  return (
    <main>
      <p>File Upload</p>
      <input
        ref={fileInputRef}
        type="file"
        multiple
        onChange={handleFileChange}
      />

      <AttachmentGroup className="flex flex-col gap-4">
        {files.map((item) => (
          <Attachment
            key={item.id}
            state={item.status}
            className="w-full max-w-xs"
          >
            <AttachmentMedia>
              {item.status === "uploading" || item.status === "processing" ? (
                <Spinner />
              ) : (
                <FileIcon />
              )}
            </AttachmentMedia>
            <AttachmentContent>
              <AttachmentTitle>{item.file.name}</AttachmentTitle>
              <AttachmentDescription>
                {item.status === "uploading"
                  ? `Uploading - ${item.uploadProgress}%`
                  : `${item.file.type} - ${formatFileSize(item.file.size)}`}
              </AttachmentDescription>
            </AttachmentContent>
            <AttachmentActions>
              {item.status === "error" && (
                <AttachmentAction
                  aria-label="Retry upload"
                  onClick={() => void retryUpload(item.id)}
                >
                  <RefreshCwIcon />
                </AttachmentAction>
              )}
              <AttachmentAction
                aria-label={`Remove ${item.file.name}`}
                onClick={() => handleRemove(item.id)}
              >
                <XIcon />
              </AttachmentAction>
            </AttachmentActions>
          </Attachment>
        ))}
      </AttachmentGroup>
    </main>
  );
}
vue
<script setup lang="ts">
import { ref } from "vue";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment";
import { Spinner } from "@/components/ui/spinner";
import { FileIcon, RefreshCwIcon, XIcon } from "@lucide/vue";
import { useCourier } from "use-courier/vue";
import { formatFileSize } from "@/lib/utils";

const fileInput = ref<HTMLInputElement | null>(null);
const { addFile, files, removeFile, retryUpload } = useCourier({
  url: "http://localhost:3000/upload",
  fileChunking: {
    route: "http://localhost:3000/upload/chunk",
    threshold: 100 * 1024 * 1024,
    chunkSize: 10 * 1024 * 1024,
  },
  onUploadError({ error }) {
    console.error(error.message);
  },
});

function handleFileChange(event: Event) {
  const input = event.target as HTMLInputElement;
  Array.from(input.files ?? []).forEach((file) => void addFile(file));
}

function handleRemove(id: string) {
  removeFile(id);
  if (fileInput.value) fileInput.value.value = "";
}
</script>

<template>
  <main>
    <p>File Upload</p>
    <input ref="fileInput" type="file" multiple @change="handleFileChange" />

    <AttachmentGroup class="flex flex-col gap-4">
      <Attachment
        v-for="item in files"
        :key="item.id"
        :state="item.status"
        class="w-full max-w-xs"
      >
        <AttachmentMedia>
          <Spinner
            v-if="item.status === 'uploading' || item.status === 'processing'"
          />
          <FileIcon v-else />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>{{ item.file.name }}</AttachmentTitle>
          <AttachmentDescription>
            {{
              item.status === "uploading"
                ? `Uploading - ${item.uploadProgress}%`
                : `${item.file.type} - ${formatFileSize(item.file.size)}`
            }}
          </AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction
            v-if="item.status === 'error'"
            aria-label="Retry upload"
            @click="retryUpload(item.id)"
          >
            <RefreshCwIcon />
          </AttachmentAction>
          <AttachmentAction
            :aria-label="`Remove ${item.file.name}`"
            @click="handleRemove(item.id)"
          >
            <XIcon />
          </AttachmentAction>
        </AttachmentActions>
      </Attachment>
    </AttachmentGroup>
  </main>
</template>

Keep the integration optional ​

This recipe does not require Shadcn at the package level. You can use the same mapping with another attachment component or a custom upload interface. The important boundary is that the UI reads from files and sends user actions back through addFile, retryUpload, and removeFile.