Attachments

The attachment tray — structural slots for items, a remove affordance, a drop zone, and a file picker.

quarterly-report.pdf242 KB
meeting-notes.txt1 KB
"use client";

import { type AttachmentItem, Attachments } from "@intentface/chat/attachments";
import { File, X } from "@keyline-icons/react";
import { useState } from "react";

// A removable strip driven by local state — the parts are structural slots and
// impose no media taxonomy, so icons and layout are yours to decide.
const INITIAL: AttachmentItem[] = [
  {
    id: "1",
    filename: "quarterly-report.pdf",
    mediaType: "application/pdf",
    url: "#",
    fileSize: 248_000,
  },
  { id: "2", filename: "meeting-notes.txt", mediaType: "text/plain", url: "#", fileSize: 1_200 },
];

export const Basic = () => {
  const [items, setItems] = useState<AttachmentItem[]>(INITIAL);

  if (items.length === 0) {
    return (
      <button
        type="button"
        onClick={() => setItems(INITIAL)}
        className={`h-8 cursor-pointer rounded-full px-3.5 font-medium text-[13px] text-zinc-900 hover:from-[#fafafa] hover:to-[#f6f6f6] dark:hover:from-[#38383b] dark:hover:to-[#313134] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-100 ${RAISED}`}
      >
        Reset
      </button>
    );
  }

  return (
    <Attachments.Root className="flex w-full max-w-md flex-wrap gap-1.5">
      {items.map((item) => (
        <Attachments.Item
          key={item.id}
          className={`flex h-6 items-center gap-1.5 rounded-[7px] pr-2 pl-1.5 ${RAISED}`}
        >
          <File className="size-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
          <span className="max-w-40 truncate font-medium text-xs text-zinc-900 dark:text-zinc-100">
            {item.filename}
          </span>
          <span className="font-mono text-[11px] text-zinc-400 dark:text-zinc-500">
            {formatFileSize(item.fileSize)}
          </span>
          <Attachments.Remove
            onRemove={() => setItems((current) => current.filter((it) => it.id !== item.id))}
            filename={item.filename}
            className="-mr-1 flex size-4 cursor-pointer items-center justify-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
          >
            <X className="size-[11px]" />
          </Attachments.Remove>
        </Attachments.Item>
      ))}
    </Attachments.Root>
  );
};

const formatFileSize = (bytes?: number) => {
  if (!bytes) return "";
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

// The raised surface shared by the reset button and each attachment chip.
const RAISED =
  "bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]";

Usage guidelines

  • Pending-file tray — the strip above the composer input, showing attachment chips with a remove affordance.
  • Model included — accept matching and blob-URL lifecycle ship in the package; the tray's layout and motion are yours.
  • No media taxonomy — Attachments.Item is a structural slot; read the item's mediaType and decide what an image, a PDF, or a file looks like.
  • Drop + pick — a Dropzone overlay (in place, or portalled elsewhere via portalSelector) plus a Trigger file picker.
  • Get started — see Quick start to add the package.

Anatomy

tsx
{items.length > 0 && (
  <Attachments.Root>
    {items.map((item) => (
      <Attachments.Item key={item.id}>
        <span>{item.filename}</span>
        <Attachments.Remove onRemove={() => remove(item.id)} filename={item.filename} />
      </Attachments.Item>
    ))}
  </Attachments.Root>
)}

With a drop zone and a picker trigger:

tsx
<>
  <Attachments.Dropzone visible={isDragging} portalSelector="#app-shell" />
  <Attachments.Root>
    {items.map((item) => (
      <Attachments.Item key={item.id}>
        <span>{item.filename}</span>
        <Attachments.Remove onRemove={() => remove(item.id)} filename={item.filename} />
      </Attachments.Item>
    ))}
  </Attachments.Root>
  <Attachments.Trigger onClick={openFileDialog} />
</>

Examples

Dropping, picking and rejecting files

The whole intake path in one tray. The package ships the mechanics — matchesAccept and toAttachmentItem are plain functions — and none of the policy: what counts as too large, and what the message says, are yours.

Validation emits a code rather than copy, which is why the wording lives in one map in the demo and can be localised there.

Images and PDFs, up to 2 MB. Try a .txt to see a rejection.
"use client";

import {
  type AttachmentErrorCode,
  type AttachmentItem,
  Attachments,
  matchesAccept,
  revokeAttachmentUrl,
  toAttachmentItem,
} from "@intentface/chat/attachments";
import { X } from "@keyline-icons/react";
import { type DragEvent, useEffect, useRef, useState } from "react";

const ACCEPT = "image/*,.pdf";
const MAX_BYTES = 2 * 1024 * 1024;

/*
 * The whole intake path: drop a file on the panel, or pick one with the
 * button, and watch a rejected file land in the error slot.
 *
 * The package ships the mechanics and none of the policy. `matchesAccept` and
 * `toAttachmentItem` are plain functions you call where you like; what counts
 * as too large, and what the message says when something is rejected, are
 * decided here. Validation emits a *code*, never copy, so the wording below is
 * ours to localise.
 */
export const Dropzone = () => {
  const [items, setItems] = useState<AttachmentItem[]>([]);
  const [dragging, setDragging] = useState(false);
  const [error, setError] = useState<AttachmentErrorCode | null>(null);
  const input = useRef<HTMLInputElement>(null);
  const depth = useRef(0);

  // Blob URLs outlive the component unless something revokes them: a removed
  // item's on removal, and whatever is still minted when the demo unmounts.
  const minted = useRef(new Set<AttachmentItem>());
  useEffect(() => {
    const live = minted.current;
    return () => live.forEach(revokeAttachmentUrl);
  }, []);

  const add = (files: FileList | null) => {
    if (!files?.length) return;
    setError(null);

    for (const file of Array.from(files)) {
      if (!matchesAccept(file, ACCEPT)) return setError("accept");
      if (file.size > MAX_BYTES) return setError("max_file_size");
      const item = toAttachmentItem(file);
      minted.current.add(item);
      setItems((current) => [...current, item]);
    }
  };

  // dragenter/dragleave fire for every child element, so a bare boolean
  // flickers as the pointer crosses the tray. Counting depth does not.
  const onDragEnter = (event: DragEvent) => {
    event.preventDefault();
    depth.current += 1;
    setDragging(true);
  };
  const onDragLeave = () => {
    depth.current -= 1;
    if (depth.current <= 0) setDragging(false);
  };
  const onDrop = (event: DragEvent) => {
    event.preventDefault();
    depth.current = 0;
    setDragging(false);
    add(event.dataTransfer.files);
  };

  // A drop target is a pointer-only convenience, not a control. Giving it a role
  // would announce an affordance no keyboard user can reach; the picker button
  // below is the accessible route to the same thing.
  return (
    // biome-ignore lint/a11y/noStaticElementInteractions: drop target, not a control
    <div
      onDragEnter={onDragEnter}
      onDragOver={(event) => event.preventDefault()}
      onDragLeave={onDragLeave}
      onDrop={onDrop}
      className="relative flex w-full max-w-lg flex-col gap-3 rounded-xl border border-zinc-950/15 border-dashed bg-zinc-950/[0.02] p-3 has-data-[visible]:border-[#0169cc]/60 has-data-[visible]:bg-[#0169cc]/5 dark:border-white/15 dark:bg-white/[0.03] dark:has-data-[visible]:border-[#4c9bea]/60 dark:has-data-[visible]:bg-[#4c9bea]/10"
    >
      <Attachments.Dropzone
        visible={dragging}
        className="pointer-events-none absolute inset-0 z-10 hidden place-items-center rounded-[11px] bg-white/80 font-medium text-[#0169cc] text-[13px] data-[visible]:grid dark:bg-zinc-900/80 dark:text-[#4c9bea]"
      >
        Drop to attach
      </Attachments.Dropzone>

      {items.length > 0 && (
        <Attachments.Root className="flex flex-wrap gap-1.5">
          {items.map((item) => (
            <Attachments.Item
              key={item.id}
              className={`group/item flex h-6 items-center gap-1.5 rounded-[7px] px-2 font-medium text-xs text-zinc-900 dark:text-zinc-100 ${RAISED}`}
            >
              <span className="max-w-40 truncate">{item.filename}</span>
              <Attachments.Remove
                filename={item.filename}
                onRemove={() => {
                  revokeAttachmentUrl(item);
                  minted.current.delete(item);
                  setItems((current) => current.filter((candidate) => candidate.id !== item.id));
                }}
                className="-mr-1 grid size-4 cursor-pointer place-items-center rounded-full text-zinc-400 opacity-0 transition-opacity hover:text-zinc-900 focus-visible:opacity-100 group-hover/item:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-500 dark:hover:text-zinc-100"
              >
                <X className="size-[11px]" />
              </Attachments.Remove>
            </Attachments.Item>
          ))}
        </Attachments.Root>
      )}

      <div className="flex items-center gap-3">
        <Attachments.Trigger
          onClick={() => input.current?.click()}
          className={`h-8 shrink-0 cursor-pointer rounded-full px-3.5 font-medium text-[13px] text-zinc-900 hover:from-[#fafafa] hover:to-[#f6f6f6] dark:hover:from-[#38383b] dark:hover:to-[#313134] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0169cc]/60 dark:text-zinc-100 ${RAISED}`}
        >
          Add attachment
        </Attachments.Trigger>

        {/* The package owns no file input; this one is ours. */}
        <input
          ref={input}
          type="file"
          multiple
          accept={ACCEPT}
          onChange={(event) => {
            add(event.target.files);
            event.target.value = "";
          }}
          className="hidden"
        />

        <span className="text-xs text-zinc-500 dark:text-zinc-400">
          Images and PDFs, up to 2 MB. Try a .txt to see a rejection.
        </span>
      </div>

      {/* A live region: whatever appears inside announces immediately. */}
      <Attachments.Error className="text-red-600 text-xs empty:hidden dark:text-red-400">
        {error === null ? null : MESSAGES[error]}
      </Attachments.Error>
    </div>
  );
};

/** Codes in, copy out — the only place wording lives. */
const MESSAGES: Record<AttachmentErrorCode, string> = {
  accept: "That file type is not accepted. Images and PDFs only.",
  max_file_size: "That file is larger than 2 MB.",
  max_files: "Too many files at once.",
};

// The raised surface shared by each attachment chip and the picker button.
const RAISED =
  "bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]";

API reference

Every part accepts className, style, and render (see Styling) and emits a bespoke part attribute (data-<part>) unless noted.

Attachments

The tray container. Mount it only when there are items to show; it renders no layout of its own. Renders a <div> element.

Attachments.Item

One attachment, as a structural slot with no media taxonomy of its own. Read the item's mediaType and decide what an image or a PDF looks like. Renders a <div> element.

AttributeDescription
data-attachments-item
The chip.

Attachments.Remove

The removal affordance. Named "Remove attachment" by default, or Remove {filename} when filename is set, so a row of them does not announce identically. Renders a <button> element.

PropTypeDefaultDetails
onRemove() => void(required)
filenamestring—

Attachments.Dropzone

The drop overlay. portalSelector only moves where it renders (e.g. over an app shell region); where files can be dropped is Composer.Attachments' globalDrop, not this part. Renders a <div> element.

PropTypeDefaultDetails
visiblebooleanfalse
keepMountedbooleanfalse
portalSelectorstring—
AttributeDescription
data-attachments-dropzone
The overlay.
data-visible
Present while visible is true.

Attachments.Error

The validation slot, as a live region: whatever appears inside announces immediately. Validation emits an AttachmentErrorCode — "accept", "max_file_size" or "max_files" — never copy, so the message is yours to write and localise. Renders a <span> element with role="alert".

Attachments.Trigger

The file-picker button, named "Add attachment" by default. The package owns no file input; wire this to your own. Renders a <button> element.

Utilities

@intentface/chat/attachments exports the generic mechanics: toAttachmentItem (the default blob ingestion), matchesAccept, and revokeAttachmentUrl.

Everything above that is yours: the accept and size policy, the media taxonomy that decides what an image or a PDF looks like, and the adapter that turns submitted items into whatever your transport expects — AI SDK file parts, signed uploads, or anything else. The package imposes none of it.