Attachments

Composable attachment UI for chat and messaging: thumbnails, type icons, or a pasted-text excerpt depending on Attachment variant, plus remove actions and optional upload progress. A controlled Attachments root owns the hidden file input and AttachmentTrigger; opt in to page drop (windowDrop, AttachmentsDropOverlay) and paste or custom flows via useAttachments and appendFiles.

import {
  Attachment,
  AttachmentList,
  type AttachmentMeta,
} from "@/components/nexus-ui/attachments";

const imgSrc =
  "https://images.unsplash.com/photo-1538428494232-9c0d8a3ab403?q=80&w=1740&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";

const items: AttachmentMeta[] = [
  {
    type: "image",
    name: "photo.jpg",
    url: imgSrc,
    mimeType: "image/jpeg",
  },
  { type: "file", name: "notes.txt", mimeType: "text/plain" },
  { type: "video", name: "clip.mp4", mimeType: "video/mp4" },
  { type: "audio", name: "song.mp3", mimeType: "audio/mpeg" },
];

function AttachmentsDefault() {
  return (
    <div className="flex flex-col items-center justify-center gap-4">
      <AttachmentList>
        {items.map((item) => (
          <Attachment
            key={`${item.name}-${item.type}-${item.mimeType}`}
            variant="compact"
            attachment={item}
          />
        ))}
      </AttachmentList>
    </div>
  );
}

export default AttachmentsDefault;

Installation

npx shadcn@latest add @nexus-ui/attachments
pnpm dlx shadcn@latest add @nexus-ui/attachments
yarn dlx shadcn@latest add @nexus-ui/attachments
bunx shadcn@latest add @nexus-ui/attachments

Copy and paste the following code into your project.

components/nexus-ui/attachments.tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import {
  Cancel01Icon,
  Image02Icon,
  File02Icon,
  Video02Icon,
  MusicNote02Icon,
  Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";

import { cn } from "@/lib/utils";

// ——— Metadata schema (single source) ———

/** Upload or message attachment metadata. */
export interface AttachmentMeta {
  type: "image" | "file" | "video" | "audio";
  name?: string;
  url?: string;
  /** Raster preview URL (e.g. PDF first page). When unset, preview uses the icon for `type`. */
  thumbnailUrl?: string;
  mimeType?: string;
  size?: number;
  width?: number;
  height?: number;
  /** Binary payload when not using `url` (browser-friendly; prefer `Blob`). */
  data?: Blob | ArrayBuffer;
  /**
   * **`"paste"`** when created from clipboard (e.g. long text → **`File`**). Use with **`Attachment`** **`variant="pasted"`**.
   */
  source?: "paste";
}

/** Files not appended by the picker: oversized, over `maxFiles`, extra when `multiple` is false, or outside `accept`. */
export type AttachmentsRejectedFiles = {
  tooLarge: File[];
  /** Within size but did not fit under `maxFiles`. */
  overMaxFiles: File[];
  /** Ignored because `multiple` is false. */
  truncatedByMultiple: File[];
  /** Did not match the root `accept` string (drop path or permissive pickers). */
  notAccepted: File[];
};

export function toAttachmentMeta(
  file: File,
  options?: { objectUrl?: string; source?: AttachmentMeta["source"] },
): AttachmentMeta {
  const mime = file.type?.toLowerCase() ?? "";
  let kind: AttachmentMeta["type"] = "file";
  if (mime.startsWith("image/")) kind = "image";
  else if (mime.startsWith("video/")) kind = "video";
  else if (mime.startsWith("audio/")) kind = "audio";

  return {
    type: kind,
    name: file.name,
    url: options?.objectUrl,
    mimeType: file.type || undefined,
    size: file.size,
    ...(options?.source != null ? { source: options.source } : {}),
  };
}

/** Optional second argument to **`appendFiles`** (from **`useAttachments`**). */
export type AppendFilesOptions = {
  /** Sets **`AttachmentMeta.source`** to **`"paste"`** for every appended item. */
  paste?: boolean;
};

/**
 * `File`s from a `DataTransfer` (paste **`clipboardData`** or drop **`dataTransfer`**).
 * Prefer **`items`** so pasted screenshots and copied images resolve reliably; falls back to **`files`**.
 */
export function filesFromDataTransfer(data: DataTransfer | null): File[] {
  if (!data) return [];
  const out: File[] = [];
  if (data.items?.length) {
    for (const item of data.items) {
      if (item.kind !== "file") continue;
      const f = item.getAsFile();
      if (f) out.push(f);
    }
  }
  if (out.length > 0) return out;
  if (data.files?.length) return Array.from(data.files);
  return [];
}

/** Best-effort match for an HTML `accept` attribute (comma tokens: `.pdf`, `image/*`, exact MIME). */
function fileMatchesAccept(file: File, accept: string): boolean {
  const trimmed = accept.trim();
  if (!trimmed || trimmed === "*/*") return true;
  const tokens = trimmed
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean);
  const type = (file.type ?? "").toLowerCase();
  const name = file.name ?? "";
  const extWithDot =
    name.lastIndexOf(".") > 0
      ? name.slice(name.lastIndexOf(".")).toLowerCase()
      : "";

  for (const token of tokens) {
    const t = token.toLowerCase();
    if (t === "*/*") return true;
    if (t.startsWith(".")) {
      if (extWithDot === t) return true;
      continue;
    }
    if (t.endsWith("/*")) {
      const prefix = t.slice(0, -1);
      if (type.startsWith(prefix)) return true;
      continue;
    }
    if (type && type === t) return true;
  }
  return false;
}

function formatBytes(bytes?: number): string | undefined {
  if (bytes == null || !Number.isFinite(bytes)) return undefined;
  const units = ["B", "KB", "MB", "GB"] as const;
  let v = bytes;
  let i = 0;
  while (v >= 1024 && i < units.length - 1) {
    v /= 1024;
    i += 1;
  }
  const rounded = i === 0 ? Math.round(v) : Math.round(v * 10) / 10;
  return `${rounded} ${units[i]}`;
}

/** Uppercased file extension for the detailed subtitle (no leading dot); `undefined` if there is no usable extension. */
function kindLabel(item: AttachmentMeta): string | undefined {
  const name = item.name?.trim();
  if (!name) return undefined;
  const dot = name.lastIndexOf(".");
  if (dot <= 0 || dot >= name.length - 1) return undefined;
  const ext = name.slice(dot + 1).toLowerCase();
  if (!ext || ext.length > 16) return undefined;
  return ext.toUpperCase();
}

function iconForAttachmentType(type: AttachmentMeta["type"]) {
  switch (type) {
    case "image":
      return Image02Icon;
    case "video":
      return Video02Icon;
    case "audio":
      return MusicNote02Icon;
    default:
      return File02Icon;
  }
}

function inferDetailedSubtitleMode(
  attachment: AttachmentMeta,
): "size" | "kind" {
  if (
    attachment.size != null &&
    Number.isFinite(attachment.size) &&
    attachment.size > 0
  ) {
    return "size";
  }
  return "kind";
}

const attachmentVariants = cva(
  "group relative cursor-default overflow-hidden rounded-[6px] border border-muted bg-secondary text-muted-foreground",
  {
    variants: {
      variant: {
        compact: "relative flex size-15 shrink-0 items-center justify-center",
        inline:
          "relative flex h-8 w-auto min-w-0 max-w-[200px] shrink-0 items-center justify-start p-1 pr-2",
        detailed:
          "relative flex h-15 w-auto min-w-[200px] max-w-[250px] shrink-0 items-center justify-start p-2 pr-3",
        pasted:
          "relative flex w-[156px] h-[144px] shrink-0 flex-col items-center justify-center overflow-hidden rounded-[6px] p-2 gap-2",
      },
    },
    defaultVariants: {
      variant: "compact",
    },
  },
);

type AttachmentVariant = NonNullable<
  VariantProps<typeof attachmentVariants>["variant"]
>;

// ——— Context ———

/** Public context value from **`useAttachments()`** (also used internally by **`Attachments`**). */
export type AttachmentsContextValue = {
  inputRef: React.RefObject<HTMLInputElement | null>;
  /** Stable id on the hidden file input (labels, tests, or custom `aria-*`). */
  inputId: string;
  openPicker: () => void;
  /**
   * Append files with the same limits and `onFilesRejected` behavior as the native picker.
   * For drag-and-drop or paste, call this from your handler.
   */
  appendFiles: (files: File[], options?: AppendFilesOptions) => void;
  /** True while a file drag is active over the document (when `windowDrop` is enabled). */
  isDraggingFile: boolean;
  attachments: AttachmentMeta[];
  onAttachmentsChange: (next: AttachmentMeta[]) => void;
  accept?: string;
  multiple: boolean;
  maxFiles?: number;
  maxSize?: number;
  disabled: boolean;
};

const AttachmentsContext = React.createContext<AttachmentsContextValue | null>(
  null,
);

function useAttachmentsContext(component: string) {
  const ctx = React.useContext(AttachmentsContext);
  if (!ctx) {
    throw new Error(`${component} must be used within <Attachments>`);
  }
  return ctx;
}

type AttachmentItemContextValue = {
  variant: AttachmentVariant;
  attachment: AttachmentMeta;
  onRemove?: () => void;
  /** When true, remove controls are hidden (see `Attachment` `readOnly`). */
  readOnly?: boolean;
};

const AttachmentItemContext =
  React.createContext<AttachmentItemContextValue | null>(null);

function useAttachmentItemContext(component: string) {
  const ctx = React.useContext(AttachmentItemContext);
  if (!ctx) {
    throw new Error(`${component} must be used within <Attachment>`);
  }
  return ctx;
}

export type AttachmentsProps = {
  attachments: AttachmentMeta[];
  onAttachmentsChange: (attachments: AttachmentMeta[]) => void;
  accept?: string;
  /** @default true */
  multiple?: boolean;
  maxFiles?: number;
  /** Maximum file size per file in bytes */
  maxSize?: number;
  disabled?: boolean;
  /** Fires after the internal file handler (same event; `target.files` still available). */
  onFileInputChange?: React.ChangeEventHandler<HTMLInputElement>;
  /**
   * Called when some selected files are not appended (oversized, over `maxFiles`, or trimmed because `multiple` is false).
   */
  onFilesRejected?: (detail: AttachmentsRejectedFiles) => void;
  /**
   * Register `dragover` / `drop` on `document` so files can be dropped anywhere.
   * Pair with **`AttachmentsDropOverlay`** or **`useAttachments().appendFiles`** if you build a custom drop target.
   * @default false
   */
  windowDrop?: boolean;
  children?: React.ReactNode;
};

function Attachments({
  attachments,
  onAttachmentsChange,
  accept,
  multiple = true,
  maxFiles,
  maxSize,
  disabled = false,
  onFileInputChange,
  onFilesRejected,
  windowDrop = false,
  children,
}: AttachmentsProps) {
  const inputRef = React.useRef<HTMLInputElement | null>(null);
  const inputId = React.useId();
  const managedBlobUrlsRef = React.useRef<Set<string>>(new Set());
  const [isDraggingFile, setIsDraggingFile] = React.useState(false);

  React.useLayoutEffect(() => {
    const inUse = new Set<string>();
    for (const a of attachments) {
      if (a.url) inUse.add(a.url);
      if (a.thumbnailUrl) inUse.add(a.thumbnailUrl);
    }
    for (const url of [...managedBlobUrlsRef.current]) {
      if (!inUse.has(url)) {
        URL.revokeObjectURL(url);
        managedBlobUrlsRef.current.delete(url);
      }
    }
  }, [attachments]);

  React.useEffect(() => {
    return () => {
      for (const url of managedBlobUrlsRef.current) {
        URL.revokeObjectURL(url);
      }
      managedBlobUrlsRef.current.clear();
    };
  }, []);

  const openPicker = React.useCallback(() => {
    if (disabled) return;
    inputRef.current?.click();
  }, [disabled]);

  const appendFilesFromList = React.useCallback(
    (rawFiles: File[], appendOptions?: AppendFilesOptions) => {
      if (disabled || rawFiles.length === 0) return;

      let incoming = [...rawFiles];
      const notAccepted =
        accept != null && accept !== "" && accept !== "*/*"
          ? incoming.filter((f) => !fileMatchesAccept(f, accept))
          : [];
      if (accept != null && accept !== "" && accept !== "*/*") {
        incoming = incoming.filter((f) => fileMatchesAccept(f, accept));
      }

      let truncatedByMultiple: File[] = [];
      if (!multiple && incoming.length > 1) {
        truncatedByMultiple = incoming.slice(1);
        incoming = incoming.slice(0, 1);
      }

      const tooLarge =
        maxSize != null ? incoming.filter((f) => f.size > maxSize) : [];

      const withinSize =
        maxSize != null ? incoming.filter((f) => f.size <= maxSize) : incoming;

      const room =
        maxFiles != null
          ? Math.max(0, maxFiles - attachments.length)
          : Number.POSITIVE_INFINITY;

      const take =
        room === Number.POSITIVE_INFINITY
          ? withinSize
          : withinSize.slice(0, room);

      const overMaxFiles =
        room === Number.POSITIVE_INFINITY ? [] : withinSize.slice(room);

      if (
        notAccepted.length > 0 ||
        tooLarge.length > 0 ||
        overMaxFiles.length > 0 ||
        truncatedByMultiple.length > 0
      ) {
        onFilesRejected?.({
          notAccepted,
          tooLarge,
          overMaxFiles,
          truncatedByMultiple,
        });
      }

      const newMetas = take.map((file) => {
        const objectUrl = URL.createObjectURL(file);
        managedBlobUrlsRef.current.add(objectUrl);
        return toAttachmentMeta(file, {
          objectUrl,
          source: appendOptions?.paste ? "paste" : undefined,
        });
      });

      if (newMetas.length > 0) {
        onAttachmentsChange([...attachments, ...newMetas]);
      }
    },
    [
      disabled,
      accept,
      multiple,
      maxSize,
      maxFiles,
      attachments,
      onAttachmentsChange,
      onFilesRejected,
    ],
  );

  const handleInputChange = React.useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const list = e.target.files;
      if (!list?.length || disabled) {
        onFileInputChange?.(e);
        e.target.value = "";
        return;
      }

      appendFilesFromList(Array.from(list));
      onFileInputChange?.(e);
      e.target.value = "";
    },
    [disabled, appendFilesFromList, onFileInputChange],
  );

  React.useEffect(() => {
    if (disabled || !windowDrop) {
      setIsDraggingFile(false);
      return;
    }

    const hasFiles = (e: DragEvent) =>
      Boolean(
        e.dataTransfer?.types?.length &&
        [...e.dataTransfer.types].includes("Files"),
      );

    const onDragEnter = (e: DragEvent) => {
      if (!hasFiles(e)) return;
      setIsDraggingFile(true);
    };

    const onDragLeave = (e: DragEvent) => {
      if (!hasFiles(e)) return;
      const next = e.relatedTarget as Node | null;
      if (next && document.contains(next)) return;
      setIsDraggingFile(false);
    };

    const onDragOver = (e: DragEvent) => {
      if (!hasFiles(e)) return;
      e.preventDefault();
      e.dataTransfer!.dropEffect = "copy";
    };

    const onDrop = (e: DragEvent) => {
      e.preventDefault();
      setIsDraggingFile(false);
      const list = e.dataTransfer?.files;
      if (!list?.length) return;
      appendFilesFromList(Array.from(list));
    };

    document.addEventListener("dragenter", onDragEnter);
    document.addEventListener("dragleave", onDragLeave);
    document.addEventListener("dragover", onDragOver);
    document.addEventListener("drop", onDrop);
    return () => {
      document.removeEventListener("dragenter", onDragEnter);
      document.removeEventListener("dragleave", onDragLeave);
      document.removeEventListener("dragover", onDragOver);
      document.removeEventListener("drop", onDrop);
    };
  }, [disabled, windowDrop, appendFilesFromList]);

  const value = React.useMemo<AttachmentsContextValue>(
    () => ({
      inputRef,
      inputId,
      openPicker,
      appendFiles: appendFilesFromList,
      isDraggingFile,
      attachments,
      onAttachmentsChange,
      accept,
      multiple,
      maxFiles,
      maxSize,
      disabled,
    }),
    [
      inputId,
      appendFilesFromList,
      isDraggingFile,
      attachments,
      onAttachmentsChange,
      accept,
      multiple,
      maxFiles,
      maxSize,
      disabled,
      openPicker,
    ],
  );

  return (
    <AttachmentsContext.Provider value={value}>
      <input
        ref={inputRef}
        id={inputId}
        type="file"
        data-slot="attachments-input"
        className="sr-only"
        aria-hidden
        tabIndex={-1}
        accept={accept}
        multiple={multiple}
        disabled={disabled}
        onChange={handleInputChange}
      />
      {children}
    </AttachmentsContext.Provider>
  );
}

/** Access **`Attachments`** context: **`appendFiles`**, **`isDraggingFile`**, **`openPicker`**, controlled state, and limits. Must be used under **`Attachments`**. */
export function useAttachments(): AttachmentsContextValue {
  return useAttachmentsContext("useAttachments");
}

export type AttachmentsDropOverlayProps = Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children"
> & {
  /**
   * `fullscreen` portals to `document.body` and covers the viewport.
   * `contained` fills the nearest positioned ancestor — wrap a **`relative`** container (e.g. prompt shell).
   * @default "fullscreen"
   */
  variant?: "fullscreen" | "contained";
  children?: React.ReactNode;
};

export function AttachmentsDropOverlay({
  variant = "fullscreen",
  className,
  children,
  ...props
}: AttachmentsDropOverlayProps) {
  const { isDraggingFile, disabled, maxSize } = useAttachmentsContext(
    "AttachmentsDropOverlay",
  );
  const [mounted, setMounted] = React.useState(false);

  React.useEffect(() => {
    setMounted(true);
  }, []);

  const open = mounted && !disabled && isDraggingFile;

  React.useEffect(() => {
    if (!open || variant !== "fullscreen") return;
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = previousOverflow;
    };
  }, [open, variant]);

  if (!open) return null;

  const maxSizeLabel = maxSize != null ? formatBytes(maxSize) : undefined;

  const inner = (
    <div
      data-slot="attachments-drop-overlay"
      role="presentation"
      aria-hidden
      className={cn(
        "pointer-events-none bg-background/50 backdrop-blur-sm",
        variant === "fullscreen"
          ? "fixed inset-0 z-50 flex items-center justify-center"
          : "absolute inset-0 z-10 flex items-center justify-center rounded-[inherit]",
        className,
      )}
      {...props}
    >
      {children ?? (
        <div className="flex flex-col items-center gap-3">
          <HugeiconsIcon
            icon={Upload01Icon}
            className="size-5 text-primary"
          />

          <div className="flex flex-col items-center gap-1">
            <p className="text-sm font-[350] text-primary">
              Drop files here to add as attachment
            </p>
            {maxSizeLabel ? (
              <p className="text-xs font-[350] text-muted-foreground">
                Maximum {maxSizeLabel} per file
              </p>
            ) : null}
          </div>
        </div>
      )}
    </div>
  );

  if (variant === "fullscreen") {
    return createPortal(inner, document.body);
  }
  return inner;
}

type AttachmentTriggerProps = React.ComponentProps<"button"> & {
  asChild?: boolean;
};

function AttachmentTrigger({
  asChild = false,
  className,
  children,
  onClick,
  disabled: disabledProp,
  ...props
}: AttachmentTriggerProps) {
  const { openPicker, disabled: rootDisabled } =
    useAttachmentsContext("AttachmentTrigger");
  const disabled = Boolean(rootDisabled || disabledProp);

  const handleClick = React.useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      if (disabled) return;
      onClick?.(e);
      openPicker();
    },
    [disabled, onClick, openPicker],
  );

  const triggerClassName = cn(
    disabled && "cursor-not-allowed opacity-50",
    className,
  );

  if (asChild) {
    return (
      <Slot
        {...props}
        data-slot="attachment-trigger"
        className={triggerClassName}
        aria-disabled={disabled}
        onClick={handleClick as React.MouseEventHandler<HTMLElement>}
      >
        {children}
      </Slot>
    );
  }

  return (
    <button
      {...props}
      type="button"
      data-slot="attachment-trigger"
      className={triggerClassName}
      disabled={disabled}
      onClick={handleClick}
    >
      {children}
    </button>
  );
}

type AttachmentListProps = React.HTMLAttributes<HTMLDivElement>;

function AttachmentList({ className, role, ...props }: AttachmentListProps) {
  return (
    <div
      data-slot="attachment-list"
      role={role ?? "list"}
      className={cn(
        "flex w-full max-w-full min-w-0 flex-wrap items-end justify-center gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 [scrollbar-color:var(--scrollbar-thumb)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-track]:bg-transparent",
        className,
      )}
      {...props}
    />
  );
}

type AttachmentOverflowFadeLayerProps = React.HTMLAttributes<HTMLDivElement> & {
  variant: "inline" | "detailed";
};

function AttachmentOverflowFadeLayer({
  className,
  variant,
  ...props
}: AttachmentOverflowFadeLayerProps) {
  return (
    <div
      aria-hidden
      data-slot="attachment-overflow-fade"
      className={cn(
        "pointer-events-none absolute top-1/2 right-0 w-10 -translate-y-1/2 bg-linear-to-l from-secondary from-65% to-transparent transition-opacity group-hover:opacity-100 sm:opacity-0",
        variant === "detailed" ? "h-15" : "h-8",
        className,
      )}
      {...props}
    />
  );
}

type AttachmentProps = Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children"
> & {
  variant?: AttachmentVariant;
  /** Attachment metadata; drives preview and properties. */
  attachment: AttachmentMeta;
  /** Upload progress 0–100; shows a bottom bar when set */
  progress?: number;
  onRemove?: () => void;
  /**
   * When true, hides remove controls and the progress bar (e.g. message history).
   * @default false
   */
  readOnly?: boolean;
  /** Detailed layout: second line; inferred from `attachment.size` when omitted. */
  detailedSubtitle?: "size" | "kind";
  /**
   * When set, replaces the default layout.
   */
  children?: React.ReactNode;
  /**
   * `pasted` only: maximum characters in the preview line (remainder as ellipsis).
   * @default 220
   */
  pastedExcerptMaxChars?: number;
};

function Attachment({
  className,
  variant = "compact",
  attachment,
  progress,
  onRemove,
  readOnly = false,
  detailedSubtitle: detailedSubtitleProp,
  pastedExcerptMaxChars = 220,
  children,
  ...props
}: AttachmentProps) {
  const detailedSubtitle =
    variant === "detailed"
      ? (detailedSubtitleProp ?? inferDetailedSubtitleMode(attachment))
      : undefined;

  const ctxValue = React.useMemo<AttachmentItemContextValue>(
    () => ({
      variant: variant ?? "compact",
      attachment,
      onRemove,
      readOnly,
    }),
    [variant, attachment, onRemove, readOnly],
  );

  const showProgress =
    !readOnly && progress != null && Number.isFinite(progress);

  const defaultLayout =
    variant === "pasted" ? (
      <>
        <AttachmentPreview pastedExcerptMaxChars={pastedExcerptMaxChars} />

        {!readOnly ? (
          <div className="flex h-6 w-full items-center justify-between gap-2 rounded-[6px] bg-card pr-1 pl-2">
            <span className="text-xs leading-4 font-[350] text-muted-foreground uppercase">
              Pasted
            </span>
            <AttachmentRemove
              position="inline"
              className="bg-transparent text-muted-foreground hover:bg-muted dark:bg-transparent dark:hover:bg-muted"
            />
          </div>
        ) : null}
      </>
    ) : variant === "compact" ? (
      <>
        <AttachmentRemove />
        <AttachmentPreview />
      </>
    ) : variant === "detailed" ? (
      <>
        <AttachmentRemove />
        <div className="flex min-w-0 items-center gap-2">
          <AttachmentPreview />
          <AttachmentInfo>
            <AttachmentProperty as="name" />
            <AttachmentProperty
              as={detailedSubtitle === "size" ? "size" : "kind"}
            />
          </AttachmentInfo>
        </div>
      </>
    ) : (
      <>
        <AttachmentRemove />
        <div className="flex min-w-0 items-center gap-1">
          <AttachmentPreview />
          <AttachmentProperty as="name" />
        </div>
      </>
    );

  return (
    <AttachmentItemContext.Provider value={ctxValue}>
      <div
        data-slot="attachment"
        data-variant={variant}
        role="listitem"
        className={cn(attachmentVariants({ variant }), className)}
        {...props}
      >
        {variant === "inline" || variant === "detailed" ? (
          <AttachmentOverflowFadeLayer variant={variant} />
        ) : null}
        {children ?? defaultLayout}
        {showProgress && variant !== "pasted" ? (
          <AttachmentProgress value={progress} />
        ) : null}
      </div>
    </AttachmentItemContext.Provider>
  );
}

const attachmentPreviewVariants = cva(
  "flex shrink-0 items-center justify-center overflow-hidden bg-card text-muted-foreground",
  {
    variants: {
      variant: {
        compact:
          "absolute inset-0 size-full rounded-[inherit] border-0 bg-transparent",
        inline:
          "size-6 rounded-[4px] border border-input",
        detailed:
          "size-11 rounded-[6px] border border-input",
        pasted:
          "min-h-0 w-full flex-1 shrink self-stretch items-start justify-start border-0 bg-transparent p-0",
      },
    },
    defaultVariants: {
      variant: "compact",
    },
  },
);

type AttachmentPreviewProps = Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children"
> &
  Partial<VariantProps<typeof attachmentPreviewVariants>> & {
    /**
     * `pasted` only: max characters before ellipsis (from **`Attachment`** **`pastedExcerptMaxChars`**).
     * @default 220
     */
    pastedExcerptMaxChars?: number;
  };

function AttachmentPreview({
  className,
  variant: variantProp,
  pastedExcerptMaxChars = 220,
  ...props
}: AttachmentPreviewProps) {
  const { variant, attachment } = useAttachmentItemContext("AttachmentPreview");
  const v = variantProp ?? variant;
  const [pastedRawText, setPastedRawText] = React.useState("");

  React.useEffect(() => {
    if (v !== "pasted") return;

    let cancelled = false;

    const run = async () => {
      if (attachment.data instanceof Blob) {
        const t = await attachment.data.text();
        if (!cancelled) setPastedRawText(t);
        return;
      }
      if (attachment.data instanceof ArrayBuffer) {
        const t = new TextDecoder().decode(attachment.data);
        if (!cancelled) setPastedRawText(t);
        return;
      }
      const url = attachment.url;
      if (url?.startsWith("blob:") || url?.startsWith("data:")) {
        try {
          const res = await fetch(url);
          const t = await res.text();
          if (!cancelled) setPastedRawText(t);
        } catch {
          if (!cancelled) setPastedRawText("");
        }
        return;
      }
      if (!cancelled) setPastedRawText("");
    };

    void run();
    return () => {
      cancelled = true;
    };
  }, [v, attachment.data, attachment.url]);

  const pastedExcerpt = React.useMemo(() => {
    if (v !== "pasted") return "";
    const normalized = pastedRawText.replace(/\s+/g, " ").trim();
    const max = pastedExcerptMaxChars;
    if (normalized.length <= max) return normalized;
    return `${normalized.slice(0, max).trimEnd()}…`;
  }, [v, pastedRawText, pastedExcerptMaxChars]);

  const rasterSrc =
    attachment.thumbnailUrl ??
    (attachment.type === "image" && attachment.url
      ? attachment.url
      : undefined);
  const videoSrc =
    !attachment.thumbnailUrl && attachment.type === "video" && attachment.url
      ? attachment.url
      : undefined;
  const showRaster = Boolean(rasterSrc);
  const showVideo = Boolean(videoSrc);
  const inlinePlainIcon =
    v === "inline" && !showRaster && !showVideo
      ? "border-0 bg-transparent dark:bg-transparent"
      : "";

  const iconClass = v === "inline" ? "size-5" : "size-7";

  const content = (() => {
    if (v === "pasted") {
      return (
        <p
          data-slot="attachment-preview-excerpt"
          className="my-0! line-clamp-6 text-xs leading-4 font-[350] text-ring"
        >
          {pastedExcerpt.length > 0 ? pastedExcerpt : "\u00a0"}
        </p>
      );
    }
    if (rasterSrc) {
      return (
        <>
          <div className="absolute inset-0 z-0 size-full animate-pulse bg-input" />
          <img
            src={rasterSrc}
            alt=""
            className="relative z-1 size-full object-cover"
          />
        </>
      );
    }
    if (videoSrc) {
      return (
        <>
          <div className="absolute inset-0 z-0 size-full animate-pulse bg-input" />
          <video
            src={videoSrc}
            muted
            playsInline
            preload="metadata"
            aria-hidden
            className="relative z-1 size-full object-cover"
          />
        </>
      );
    }
    return (
      <HugeiconsIcon
        icon={iconForAttachmentType(attachment.type)}
        strokeWidth={1.5}
        className={iconClass}
        aria-hidden
      />
    );
  })();

  return (
    <div
      data-slot="attachment-preview"
      className={cn(
        attachmentPreviewVariants({ variant: v }),
        inlinePlainIcon,
        v !== "pasted" && "relative",
        className,
      )}
      {...props}
    >
      {content}
    </div>
  );
}

const removeButtonVariants = cva(
  "z-10 flex size-4.5 cursor-pointer items-center justify-center rounded-full bg-secondary text-muted-foreground sm:opacity-0 transition-all group-hover:opacity-100 hover:bg-border hover:text-primary active:scale-97",
  {
    variants: {
      position: {
        corner: "absolute top-1 right-1",
        "center-end": "absolute top-1/2 right-1 -translate-y-1/2",
        /** Inline footer (e.g. **`variant="pasted"`**): always visible. */
        inline: "relative shrink-0 opacity-100 sm:opacity-100",
      },
    },
    defaultVariants: {
      position: "corner",
    },
  },
);

type AttachmentRemoveProps = React.ComponentProps<"button"> &
  VariantProps<typeof removeButtonVariants> & {
    asChild?: boolean;
  };

function AttachmentRemove({
  className,
  asChild = false,
  position: positionProp,
  children,
  type: _type,
  onClick,
  "aria-label": ariaLabelProp,
  ...props
}: AttachmentRemoveProps) {
  const { variant, attachment, onRemove, readOnly } =
    useAttachmentItemContext("AttachmentRemove");
  const position =
    positionProp ?? (variant === "inline" ? "center-end" : "corner");

  const ariaLabel =
    ariaLabelProp ?? `Remove ${attachment.name ?? "attachment"}`;

  if (readOnly) {
    return null;
  }

  const handleClick = React.useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      onClick?.(e);
      onRemove?.();
    },
    [onClick, onRemove],
  );

  if (asChild) {
    return (
      <Slot
        className={cn(removeButtonVariants({ position }), className)}
        aria-label={ariaLabel}
        onClick={handleClick as React.MouseEventHandler<HTMLElement>}
        {...props}
      >
        {children}
      </Slot>
    );
  }

  return (
    <button
      type="button"
      data-slot="attachment-remove"
      className={cn(removeButtonVariants({ position }), className)}
      aria-label={ariaLabel}
      onClick={handleClick}
      {...props}
    >
      {children ?? (
        <HugeiconsIcon
          icon={Cancel01Icon}
          strokeWidth={2.5}
          className="size-3"
          aria-hidden
        />
      )}
    </button>
  );
}

type AttachmentInfoProps = React.HTMLAttributes<HTMLDivElement>;

function AttachmentInfo({ className, ...props }: AttachmentInfoProps) {
  return (
    <div
      data-slot="attachment-info"
      className={cn("flex min-w-0 flex-col gap-0", className)}
      {...props}
    />
  );
}

type AttachmentPropertyAs = "name" | "size" | "kind";

type AttachmentPropertyProps = Omit<
  React.HTMLAttributes<HTMLParagraphElement>,
  "children"
> & {
  as: AttachmentPropertyAs;
};

function AttachmentProperty({
  as: mode,
  className,
  ...props
}: AttachmentPropertyProps) {
  const { attachment } = useAttachmentItemContext("AttachmentProperty");
  let text: string;
  if (mode === "name") {
    text = attachment.name ?? "";
  } else if (mode === "size") {
    text = formatBytes(attachment.size) ?? "—";
  } else {
    text = kindLabel(attachment) ?? "—";
  }

  const isTitle = mode === "name";
  return (
    <p
      data-slot="attachment-property"
      data-as={mode}
      className={cn(
        isTitle
          ? "my-0! truncate text-sm leading-6 font-[450] text-primary"
          : "my-0! text-xs font-[350] text-muted-foreground",
        className,
      )}
      {...props}
    >
      {text}
    </p>
  );
}

type AttachmentProgressProps = React.HTMLAttributes<HTMLDivElement> & {
  /** 0–100 */
  value: number;
};

function AttachmentProgress({
  className,
  value,
  ...props
}: AttachmentProgressProps) {
  const clamped = Math.min(100, Math.max(0, value));
  return (
    <div
      data-slot="attachment-progress"
      className={cn(
        "pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-border/90",
        className,
      )}
      {...props}
    >
      <div
        className="h-full bg-foreground transition-[width] duration-200 dark:bg-primary"
        style={{ width: `${clamped}%` }}
      />
    </div>
  );
}

Attachments.displayName = "Attachments";
AttachmentsDropOverlay.displayName = "AttachmentsDropOverlay";
AttachmentTrigger.displayName = "AttachmentTrigger";
AttachmentList.displayName = "AttachmentList";
Attachment.displayName = "Attachment";
AttachmentPreview.displayName = "AttachmentPreview";
AttachmentRemove.displayName = "AttachmentRemove";
AttachmentInfo.displayName = "AttachmentInfo";
AttachmentProperty.displayName = "AttachmentProperty";
AttachmentProgress.displayName = "AttachmentProgress";

export {
  Attachments,
  AttachmentTrigger,
  AttachmentList,
  Attachment,
  AttachmentPreview,
  AttachmentRemove,
  AttachmentInfo,
  AttachmentProperty,
  AttachmentProgress,
};

export default Attachments;

Update the import paths to match your project setup.

Usage

import {
  Attachments,
  AttachmentsDropOverlay,
  AttachmentTrigger,
  AttachmentList,
  Attachment,
  useAttachments,
  type AttachmentsContextValue,
} from "@/components/nexus-ui/attachments";
<Attachments
  attachments={attachments}
  onAttachmentsChange={setAttachments}
  accept="image/*"
  multiple
>
  <AttachmentTrigger asChild>
    <button type="button">Add files</button>
  </AttachmentTrigger>
  <AttachmentList>
    {attachments.map((a) => (
      <Attachment
        key={`${a.name}-${a.size}`}
        variant="inline"
        attachment={a}
        onRemove={() =>
          setAttachments((prev) => prev.filter((x) => x !== a))
        }
      />
    ))}
  </AttachmentList>
</Attachments>

AttachmentTrigger and any other component that calls the file picker must be rendered inside Attachments so they receive context from the same provider as the hidden input.

The module also exports filesFromDataTransfer, toAttachmentMeta, AppendFilesOptions, and useAttachments for paste/drop and advanced wiring (see With Prompt Input and the useAttachments section).

Examples

Detailed variant

Wider tile with thumbnail, file name, and a second line: formatted size when attachment.size is set, otherwise a kind label from the file extension (uppercased, e.g. PDF, XLSX).

Skyline.png

1.2 MB

Marketing-Plan.pdf

PDF

Report on Design.docx

87.1 KB

DEMO.pptx

PPTX

import {
  Attachment,
  AttachmentList,
  type AttachmentMeta,
} from "@/components/nexus-ui/attachments";

const imgSrc =
  "https://images.unsplash.com/photo-1538428494232-9c0d8a3ab403?q=80&w=1740&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";

const items: AttachmentMeta[] = [
  {
    type: "image",
    name: "Skyline.png",
    url: imgSrc,
    mimeType: "image/png",
    size: 1_240_000,
  },
  {
    type: "file",
    name: "Marketing-Plan.pdf",
    mimeType: "application/pdf",
  },
  {
    type: "file",
    name: "Report on Design.docx",
    mimeType:
      "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    size: 89_200,
  },
  {
    type: "file",
    name: "DEMO.pptx",
    mimeType:
      "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  },
];

function AttachmentsVariantDetailed() {
  return (
    <div className="flex flex-col items-center justify-center gap-4">
      <AttachmentList>
        {items.map((item) => (
          <Attachment
            key={`${item.name}-${item.mimeType}`}
            variant="detailed"
            attachment={item}
          />
        ))}
      </AttachmentList>
    </div>
  );
}

export default AttachmentsVariantDetailed;

Inline variant

Compact horizontal chip with thumbnail and file name. A hover fade sits over the trailing edge so long names can share space with the remove control.

Skyline.png

Marketing-Plan.pdf

Report on Design.docx

DEMO_SLIDES.pptx

import {
  Attachment,
  AttachmentList,
  type AttachmentMeta,
} from "@/components/nexus-ui/attachments";

const imgSrc =
  "https://images.unsplash.com/photo-1538428494232-9c0d8a3ab403?q=80&w=1740&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";

const items: AttachmentMeta[] = [
  {
    type: "image",
    name: "Skyline.png",
    url: imgSrc,
    mimeType: "image/png",
  },
  {
    type: "file",
    name: "Marketing-Plan.pdf",
    mimeType: "application/pdf",
  },
  {
    type: "file",
    name: "Report on Design.docx",
    mimeType:
      "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  },
  {
    type: "file",
    name: "DEMO_SLIDES.pptx",
    mimeType:
      "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  },
];

function AttachmentsVariantInline() {
  return (
    <div className="flex flex-col items-center justify-center gap-4">
      <AttachmentList>
        {items.map((item) => (
          <Attachment
            key={`${item.name}-${item.mimeType}`}
            variant="inline"
            attachment={item}
          />
        ))}
      </AttachmentList>
    </div>
  );
}

export default AttachmentsVariantInline;

Pasted text variant

For large pasted plain text (e.g. from appendFiles([file], { paste: true }) after pasting into your prompt), use variant="pasted" with AttachmentMeta.source === "paste". The tile shows a short excerpt and a Pasted footer with remove.

 

Pasted
"use client";

import * as React from "react";

import {
  Attachment,
  AttachmentList,
  type AttachmentMeta,
} from "@/components/nexus-ui/attachments";

const SAMPLE_TEXT = [
  "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vulputate libero et velit interdum, ac aliquet odio mattis.",
  "Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Curabitur tempus urna at turpis condimentum lobortis.",
  "Ut commodo efficitur neque. Ut diam quam, semper iaculis condimentum ac, vestibulum eu nisl. Integer ac erat auctor, faucibus magna sed, tempus metus.",
].join(" ");

function AttachmentsVariantPasted() {
  const blobUrlRef = React.useRef<string | null>(null);
  const [items, setItems] = React.useState<AttachmentMeta[]>(() => {
    const blob = new Blob([SAMPLE_TEXT], { type: "text/plain" });
    const url = URL.createObjectURL(blob);
    blobUrlRef.current = url;
    return [
      {
        type: "file",
        name: "pasted-text.txt",
        url,
        mimeType: "text/plain",
        size: blob.size,
        source: "paste",
      },
    ];
  });

  React.useEffect(
    () => () => {
      if (blobUrlRef.current) {
        URL.revokeObjectURL(blobUrlRef.current);
        blobUrlRef.current = null;
      }
    },
    [],
  );

  const remove = React.useCallback(() => {
    if (blobUrlRef.current) {
      URL.revokeObjectURL(blobUrlRef.current);
      blobUrlRef.current = null;
    }
    setItems([]);
  }, []);

  return (
    <div className="flex flex-col items-center justify-center gap-4">
      <AttachmentList>
        {items.map((item) => (
          <Attachment
            key={`${item.url ?? ""}-${item.name}`}
            variant="pasted"
            attachment={item}
            onRemove={remove}
          />
        ))}
      </AttachmentList>
    </div>
  );
}

export default AttachmentsVariantPasted;

Upload progress

Pass progress (0100) on Attachment to show a thin bar along the bottom of the tile for compact, inline, and detailed. The pasted variant does not render that bar (omit progress or it is ignored). Omit progress when the upload finishes.

dataset.csv

2.3 MB

import {
  Attachment,
  AttachmentList,
  type AttachmentMeta,
} from "@/components/nexus-ui/attachments";

const item: AttachmentMeta = {
  type: "file",
  name: "dataset.csv",
  mimeType: "text/csv",
  size: 2_400_000,
};

function AttachmentsWithProgress() {
  return (
    <div className="flex flex-col items-center justify-center gap-4">
      <AttachmentList>
        <Attachment variant="detailed" attachment={item} progress={62} />
      </AttachmentList>
    </div>
  );
}

export default AttachmentsWithProgress;

With Prompt Input

Wrap Prompt Input with Attachments (not the other way around) so AttachmentTrigger, the list, and the textarea stay in one Attachments tree. Enable windowDrop for page-level drag-and-drop (same validation as the picker: accept, maxFiles, maxSize, onFilesRejected). This example turns that on and adds AttachmentsDropOverlay (default fullscreen) for drag feedback—use variant="contained" inside a relative shell for in-box chrome only. Pasting images uses filesFromDataTransfer. Long text over a threshold becomes a text/plain file with appendFiles([file], { paste: true }) so source: "paste" is set and the pasted tile is used (see with-prompt-input.tsx).

"use client";

import * as React from "react";
import {
  ArrowUp02Icon,
  PlusSignIcon,
  SquareIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";

import {
  Attachment,
  AttachmentList,
  Attachments,
  AttachmentsDropOverlay,
  AttachmentTrigger,
  filesFromDataTransfer,
  useAttachments,
  type AttachmentMeta,
} from "@/components/nexus-ui/attachments";
import PromptInput, {
  PromptInputAction,
  PromptInputActionGroup,
  PromptInputActions,
  PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import { Button } from "@/components/ui/button";

function attachmentKey(a: AttachmentMeta) {
  return `${a.name ?? ""}-${a.size ?? ""}-${a.mimeType ?? ""}-${a.source ?? ""}-${a.url ?? ""}`;
}

type InputStatus = "idle" | "loading" | "error" | "submitted";

const maxAttachmentSize = 500 * 1024 * 1024;

/** Plain-text paste longer than this becomes a **`source: "paste"`** attachment (`variant="pasted"`). */
const pasteTextAsFileMinChars = 2000;

/** Must render under **`Attachments`** — uses **`appendFiles`** from context. */
function PromptInputTextareaWithPaste(
  props: React.ComponentProps<typeof PromptInputTextarea>,
) {
  const { appendFiles, disabled } = useAttachments();
  const { onPaste, ...textareaProps } = props;

  const handlePaste = React.useCallback(
    (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
      onPaste?.(e);
      if (e.defaultPrevented || disabled) return;

      const imageFiles = filesFromDataTransfer(e.clipboardData).filter((f) =>
        f.type.startsWith("image/"),
      );
      if (imageFiles.length > 0) {
        e.preventDefault();
        appendFiles(imageFiles);
        return;
      }

      const text = e.clipboardData.getData("text/plain");
      if (text.length >= pasteTextAsFileMinChars) {
        e.preventDefault();
        appendFiles(
          [new File([text], "pasted-text.txt", { type: "text/plain" })],
          { paste: true },
        );
      }
    },
    [appendFiles, disabled, onPaste],
  );

  return (
    <PromptInputTextarea {...textareaProps} onPaste={handlePaste} />
  );
}

function AttachmentsWithPromptInput() {
  const [message, setMessage] = React.useState("");
  const [attachments, setAttachments] = React.useState<AttachmentMeta[]>([]);
  const [status, setStatus] = React.useState<InputStatus>("idle");
  /** Per-attachment demo progress; only keys for newly added files are set. */
  const [progressByKey, setProgressByKey] = React.useState<
    Record<string, number>
  >({});

  const syncAttachments = React.useCallback((next: AttachmentMeta[]) => {
    setAttachments((prev) => {
      for (const a of prev) {
        const u = a.url;
        if (u?.startsWith("blob:") && !next.some((n) => n.url === u)) {
          URL.revokeObjectURL(u);
        }
      }
      return next;
    });
  }, []);

  const attachmentsRef = React.useRef(attachments);
  attachmentsRef.current = attachments;

  const intervalsRef = React.useRef<Map<string, number>>(new Map());
  const timeoutsRef = React.useRef<Map<string, number>>(new Map());
  const prevAttachmentKeysRef = React.useRef<Set<string>>(new Set());

  React.useEffect(() => {
    const current = new Set(attachments.map(attachmentKey));

    const clearKeyTimers = (key: string) => {
      const intId = intervalsRef.current.get(key);
      if (intId != null) {
        clearInterval(intId);
        intervalsRef.current.delete(key);
      }
      const toId = timeoutsRef.current.get(key);
      if (toId != null) {
        clearTimeout(toId);
        timeoutsRef.current.delete(key);
      }
    };

    for (const key of [
      ...intervalsRef.current.keys(),
      ...timeoutsRef.current.keys(),
    ]) {
      if (!current.has(key)) clearKeyTimers(key);
    }

    setProgressByKey((p) => {
      const next = { ...p };
      let changed = false;
      for (const k of Object.keys(next)) {
        if (!current.has(k)) {
          delete next[k];
          changed = true;
        }
      }
      return changed ? next : p;
    });

    const newKeys = [...current].filter(
      (k) => !prevAttachmentKeysRef.current.has(k),
    );

    for (const key of newKeys) {
      const added = attachments.find((a) => attachmentKey(a) === key);
      if (added?.source === "paste") continue;

      clearKeyTimers(key);

      setProgressByKey((p) => ({ ...p, [key]: 0 }));

      const t0 = Date.now();
      const duration = 2000;
      const intId = window.setInterval(() => {
        if (!attachmentsRef.current.some((a) => attachmentKey(a) === key)) {
          clearKeyTimers(key);
          return;
        }
        const pct = Math.min(
          100,
          Math.round(((Date.now() - t0) / duration) * 100),
        );
        setProgressByKey((prev) => ({ ...prev, [key]: pct }));

        if (pct >= 100) {
          clearInterval(intId);
          intervalsRef.current.delete(key);
          setProgressByKey((prev) => ({ ...prev, [key]: 100 }));

          const toId = window.setTimeout(() => {
            timeoutsRef.current.delete(key);
            setProgressByKey((prev) => {
              if (!(key in prev)) return prev;
              const { [key]: _, ...rest } = prev;
              return rest;
            });
          }, 300);
          timeoutsRef.current.set(key, toId);
        }
      }, 50);
      intervalsRef.current.set(key, intId);
    }

    prevAttachmentKeysRef.current = current;
  }, [attachments]);

  React.useEffect(
    () => () => {
      for (const id of intervalsRef.current.values()) {
        clearInterval(id);
      }
      for (const id of timeoutsRef.current.values()) {
        clearTimeout(id);
      }
      intervalsRef.current.clear();
      timeoutsRef.current.clear();
    },
    [],
  );

  React.useEffect(
    () => () => {
      for (const a of attachmentsRef.current) {
        if (a.url?.startsWith("blob:")) URL.revokeObjectURL(a.url);
      }
    },
    [],
  );

  const removeAttachment = React.useCallback(
    (item: AttachmentMeta) => {
      syncAttachments(
        attachmentsRef.current.filter(
          (a) => attachmentKey(a) !== attachmentKey(item),
        ),
      );
    },
    [syncAttachments],
  );

  const handleSubmit = React.useCallback(
    (value: string) => {
      if (status === "loading") return;
      const trimmed = value.trim();
      if (!trimmed && attachmentsRef.current.length === 0) return;
      setMessage("");
      syncAttachments([]);
      setStatus("loading");
      window.setTimeout(() => {
        setStatus("submitted");
        window.setTimeout(() => setStatus("idle"), 800);
      }, 2500);
    },
    [syncAttachments, status],
  );

  const isLoading = status === "loading";
  const canSend = message.trim().length > 0 || attachments.length > 0;

  return (
    <div className="mx-auto w-full max-w-xl">
      {/* Attachments outer: easy to wrap a full chat column for page-level drag-and-drop */}
      <Attachments
        attachments={attachments}
        onAttachmentsChange={syncAttachments}
        accept="*/*"
        multiple
        maxSize={maxAttachmentSize}
        windowDrop
      >
        <AttachmentsDropOverlay />
        <PromptInput onSubmit={handleSubmit}>
          {attachments.length > 0 ? (
            <AttachmentList className="min-h-0 flex-nowrap justify-start overflow-x-auto overflow-y-hidden px-4 pt-4 [scrollbar-color:var(--scrollbar-thumb)_transparent] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-track]:bg-transparent">
              {attachments.map((item) => {
                const key = attachmentKey(item);
                const progress = progressByKey[key];
                const isPasted = item.source === "paste";
                return (
                  <Attachment
                    key={key}
                    variant={isPasted ? "pasted" : "detailed"}
                    attachment={item}
                    progress={isPasted ? undefined : progress}
                    onRemove={() => removeAttachment(item)}
                  />
                );
              })}
            </AttachmentList>
          ) : null}
          <PromptInputTextareaWithPaste
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            placeholder="Message with attachments…"
            disabled={isLoading}
          />
          <PromptInputActions>
            <PromptInputActionGroup>
              <PromptInputAction>
                <AttachmentTrigger asChild>
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon-sm"
                    className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
                  >
                    <HugeiconsIcon
                      icon={PlusSignIcon}
                      strokeWidth={2.0}
                      className="size-4"
                    />
                  </Button>
                </AttachmentTrigger>
              </PromptInputAction>
            </PromptInputActionGroup>
            <PromptInputActionGroup>
              <PromptInputAction asChild>
                <Button
                  type="button"
                  size="icon-sm"
                  className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
                  disabled={!isLoading && !canSend}
                  onClick={() => handleSubmit(message)}
                >
                  {isLoading ? (
                    <HugeiconsIcon
                      icon={SquareIcon}
                      strokeWidth={2.0}
                      className="size-3.5 fill-current"
                    />
                  ) : (
                    <HugeiconsIcon
                      icon={ArrowUp02Icon}
                      strokeWidth={2.0}
                      className="size-4"
                    />
                  )}
                </Button>
              </PromptInputAction>
            </PromptInputActionGroup>
          </PromptInputActions>
        </PromptInput>
      </Attachments>
    </div>
  );
}

export default AttachmentsWithPromptInput;

Vercel AI SDK Integration

Use Attachments with the Vercel AI SDK and useChat: sendMessage accepts a files argument (FileList or an array of FileUIPart objects). The SDK turns them into user message parts for multimodal models.

See Prompt Input for a minimal chat API route. The same route works when messages include file parts—convertToModelMessages includes those parts in the model request.

Install the AI SDK

npm install ai @ai-sdk/react @ai-sdk/openai

Create your chat API route

Use the route from the Prompt Input docs, or ensure your handler calls streamText (or generateText) with messages: await convertToModelMessages(messages) so file parts are forwarded to the provider.

Wire Prompt Input, Attachments, and sendMessage

Build FileUIPart values from your AttachmentMeta list and pass them as files (URLs can be data URLs, HTTPS URLs, or blob URLs your app can read; for production, prefer stable URLs after upload). Put Attachments around PromptInput so AttachmentTrigger stays in context and you can later wrap the same Attachments subtree with a chat-wide drop zone if needed.

"use client";

import { useCallback, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type FileUIPart } from "ai";
import { Button } from "@/components/ui/button";
import PromptInput, {
  PromptInputAction,
  PromptInputActionGroup,
  PromptInputActions,
  PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import {
  Attachments,
  Attachment,
  AttachmentList,
  AttachmentTrigger,
  type AttachmentMeta,
} from "@/components/nexus-ui/attachments";
import { ArrowUp02Icon, PlusSignIcon, SquareIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";

function attachmentKey(a: AttachmentMeta) {
  return `${a.name ?? ""}-${a.size ?? ""}-${a.mimeType ?? ""}-${a.source ?? ""}-${a.url ?? ""}`;
}

function toFileParts(items: AttachmentMeta[]): FileUIPart[] {
  return items
    .filter((a) => a.url)
    .map((a) => ({
      type: "file" as const,
      url: a.url!,
      mediaType: a.mimeType ?? "application/octet-stream",
      filename: a.name,
    }));
}

export default function ChatWithAttachments() {
  const { sendMessage, status } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });
  const [input, setInput] = useState("");
  const [attachments, setAttachments] = useState<AttachmentMeta[]>([]);
  const isLoading = status !== "ready";

  const handleSubmit = useCallback(
    (value?: string) => {
      const trimmed = (value ?? input).trim();
      const files = toFileParts(attachments);
      if (!trimmed && files.length === 0) return;
      sendMessage({
        text: trimmed,
        ...(files.length ? { files } : {}),
      });
      setInput("");
      setAttachments([]);
    },
    [attachments, input, sendMessage],
  );

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        handleSubmit();
      }}
      className="w-full"
    >
      <Attachments
        attachments={attachments}
        onAttachmentsChange={setAttachments}
        accept="image/*"
        multiple
        disabled={isLoading}
      >
        <PromptInput onSubmit={handleSubmit}>
          {attachments.length > 0 ? (
            <AttachmentList className="px-3 pt-3">
              {attachments.map((item) => (
                <Attachment
                  key={attachmentKey(item)}
                  variant="inline"
                  attachment={item}
                  onRemove={() =>
                    setAttachments((prev) =>
                      prev.filter((x) => attachmentKey(x) !== attachmentKey(item)),
                    )
                  }
                />
              ))}
            </AttachmentList>
          ) : null}
          <PromptInputTextarea
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Message with attachments…"
            disabled={isLoading}
          />
          <PromptInputActions>
            <PromptInputActionGroup>
              <PromptInputAction>
                <AttachmentTrigger asChild>
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon-sm"
                    className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
                    disabled={isLoading}
                  >
                    <HugeiconsIcon icon={PlusSignIcon} strokeWidth={2.0} className="size-4" />
                  </Button>
                </AttachmentTrigger>
              </PromptInputAction>
            </PromptInputActionGroup>
            <PromptInputActionGroup>
              <PromptInputAction asChild>
                <Button
                  type="submit"
                  size="icon-sm"
                  className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
                  disabled={isLoading || !input.trim()}
                >
                  {isLoading ? (
                    <HugeiconsIcon icon={SquareIcon} strokeWidth={2.0} className="size-3.5 fill-current" />
                  ) : (
                    <HugeiconsIcon icon={ArrowUp02Icon} strokeWidth={2.0} className="size-4" />
                  )}
                </Button>
              </PromptInputAction>
            </PromptInputActionGroup>
          </PromptInputActions>
        </PromptInput>
      </Attachments>
    </form>
  );
}

The AI SDK attachments guide also covers FileList and automatic conversion for image/* and text/* when you pass a native file input.

API Reference

Attachments

Controlled root: holds AttachmentMeta[], wires onAttachmentsChange, renders a screen-reader-only input type="file", optionally registers document drag-and-drop when windowDrop is true (opt-in), and exposes context for AttachmentTrigger, appendFiles, and isDraggingFile. Renders the input first, then children. Must wrap every AttachmentTrigger that opens its picker.

Object URLs created by this picker (URL.createObjectURL for every File chosen) are tracked and URL.revokeObjectURL when an attachment leaves the list or when Attachments unmounts. Blob URLs you attach yourself (outside this flow) are not revoked by the component. Only image and video tiles use url for built-in previews; other types keep the file icon.

Prop

Type

export type AttachmentsRejectedFiles = {
  notAccepted: File[];
  tooLarge: File[];
  overMaxFiles: File[];
  truncatedByMultiple: File[];
};

AttachmentsDropOverlay

Optional visual layer when isDraggingFile is true (set when windowDrop is enabled and a file drag is over the document). variant="fullscreen" (default) portals to document.body and covers the viewport; variant="contained" uses absolute inset-0 — place inside a relative wrapper (e.g. prompt shell). pointer-events-none so drops still reach document. Default content is short copy; override with children. Must be rendered inside Attachments.

Prop

Type

Also extends React.HTMLAttributes<HTMLDivElement> (for example style, id) except children is typed explicitly above.

AttachmentTrigger

Button (or slotted child) that opens the Attachments file dialog. Extends standard button props; supports asChild for composing with Button.

Prop

Type

AttachmentList

Horizontal, scrollable row for attachment tiles. Sets role="list" by default. Extends React.HTMLAttributes<HTMLDivElement>.

Prop

Type

Attachment

One attachment tile. Chooses a default layout from variant unless children is provided. Renders AttachmentProgress when progress is a finite number except for variant="pasted", which keeps the tile progress-free.

Prop

Type

AttachmentPreview

Preview region inside an Attachment: raster image when thumbnailUrl or an image url exists, otherwise the type icon for attachment.type. For variant="pasted", the preview is a multi-line text excerpt (from blob / data / url content) instead of an icon. Reads variant and attachment from context.

Prop

Type

AttachmentRemove

Remove control for the current attachment. Default aria-label uses attachment.name. Merges onClick with onRemove from context. Extends button props; supports asChild.

Prop

Type

AttachmentInfo

Column wrapper for title and subtitle text in the detailed layout. Pure layout; extends React.HTMLAttributes<HTMLDivElement>.

Prop

Type

AttachmentProperty

Renders a single line of text from attachment: file name, formatted size, or a kind label from the filename extension (uppercased). The as prop selects which field to show.

Prop

Type

AttachmentProgress

Thin horizontal progress bar along the bottom edge of a tile for compact, inline, and detailed. Usually passed via Attachment progress. Not used in the default pasted layout. value is clamped to 0–100.

Prop

Type

AttachmentMeta

Metadata object for one attachment (not a React component). Used with Attachments, Attachment, and when mapping to AI SDK FileUIPart values.

export interface AttachmentMeta {
  type: "image" | "file" | "video" | "audio";
  name?: string;
  url?: string;
  /** Raster preview URL (e.g. PDF first page). When unset, preview uses the icon for `type`. */
  thumbnailUrl?: string;
  mimeType?: string;
  size?: number;
  width?: number;
  height?: number;
  data?: Blob | ArrayBuffer;
  source?: "paste";
}

useAttachments

Returns the full Attachments context (same source as internal primitives). Use isDraggingFile for custom drag chrome when windowDrop is on, appendFiles for custom drop targets (or your own onDrop handlers), openPicker / inputRef for advanced wiring. Throws if used outside Attachments.

export type AppendFilesOptions = {
  paste?: boolean;
};

export type AttachmentsContextValue = {
  inputRef: React.RefObject<HTMLInputElement | null>;
  inputId: string;
  openPicker: () => void;
  appendFiles: (files: File[], options?: AppendFilesOptions) => void;
  isDraggingFile: boolean;
  attachments: AttachmentMeta[];
  onAttachmentsChange: (next: AttachmentMeta[]) => void;
  accept?: string;
  multiple: boolean;
  maxFiles?: number;
  maxSize?: number;
  disabled: boolean;
};

export function useAttachments(): AttachmentsContextValue;