Skip to content

Attachments

The files staged alongside a message: <r-attachments> holds the list, previews it, validates what arrives, and owns the object URLs it creates.

Use when a composer needs to show what is about to be sent. It does not collect files — paste, drag-and-drop and a file picker are three gestures belonging to three different elements of a composer, and which of them your app offers is your decision. Call add() from whichever you wire.

Quick Start

Basic Usage

html
<r-attachments accept="image/*,.pdf" max-size="5242880" max-count="4"></r-attachments>
js
const strip = document.querySelector('r-attachments');

// A file picker
picker.addEventListener('change', () => strip.add(picker.files));

// Paste — only when the clipboard actually carries files. Intercepting every paste
// breaks pasting text, which is what the box is mostly for.
input.addEventListener('paste', (event) => {
  if (event.clipboardData?.files.length) {
    event.preventDefault();
    strip.add(event.clipboardData.files);
  }
});

// Drag and drop
dropZone.addEventListener('drop', (event) => {
  event.preventDefault();
  strip.add(event.dataTransfer.files);
});

The strip renders one row per file with a thumbnail (images), the name, the size and a remove button. count is reflected onto the host — and removed when the strip is empty, rather than set to 0 — so an empty strip can take no space:

css
r-attachments:not([count]) {
  display: none;
}

Sending

js
const body = new FormData();
for (const file of strip.files) body.append('files', file);
await fetch('/api/messages', { method: 'POST', body });
strip.clear();

files is just the File objects, in order — the shape a request body wants. attachments is the richer list (id, name, size, type, previewUrl) when you need to render your own view of the same state.

Rejection is reported, never silent

A file that vanishes because it was 3 MB over a limit nobody mentioned reads as a bug in the page. Every refusal fires an event with the file and the rule it broke:

js
const explain = {
  'too-large': 'That file is larger than 5 MB.',
  'type-not-accepted': "That file type isn't accepted here.",
  'too-many': 'You can attach at most 4 files.',
  duplicate: 'That file is already attached.',
};

strip.addEventListener('attachmentrejected', (event) => {
  toast(explain[event.detail.reason]);
});

duplicate compares name, size and modification time together — what a file manager treats as the same file. Attaching the same file twice is a slip, not an instruction.

API Reference

Properties

PropertyAttributeTypeDefaultDescription
acceptacceptstring''Comma-separated types or extensions, in the form <input accept> takes.
maxSizemax-sizenumber10 MBLargest file accepted, in bytes.
maxCountmax-countnumberMost files that may be staged at once; unlimited when unset.
attachmentsreadonly Attachment[][]The staged files, in the order they arrived.
filesFile[][]Just the files, for building a request body.
sheetsheetstring''CSS injected into the shadow root.

attachments and files are read-only views — stage files through add().

Methods

MethodReturnsDescription
add(files)Attachment[]Stages an iterable of Files; returns the ones that were accepted.
detach(id)booleanRemoves one attachment by id; false if there was no such id.
clear()voidRemoves everything and revokes its object URLs.

It is detach(id), not remove(id)

Every element already has a remove() that takes no arguments and takes itself out of the document. Shadowing it with different semantics is a trap for anyone reaching for the standard method.

Events

EventDetailDispatchDescription
attachmentschange{ attachments }bubbles, composedThe staged list changed.
attachmentrejected{ file, reason }bubbles, composedA file was refused. reason is one of too-large, type-not-accepted, too-many, duplicate.

Types

ts
interface Attachment {
  id: string; // stable for this attachment's life
  file: File;
  name: string;
  size: number;
  type: string;
  previewUrl: string | null; // object URL for images, null otherwise
}

type AttachmentRejection = 'too-large' | 'type-not-accepted' | 'too-many' | 'duplicate';

Parts

list · attachment · thumb · icon · name · size · remove

How previews work

Previews are object URLs, not data URLs. Previewing costs a reference to bytes the browser already holds; reading a 10 MB photo into a base64 string to show a 40px thumbnail costs the string. Build the data URL later, once, in whatever sends.

Every URL the element creates it also revokes — on detach, on clear, and on disconnect. Don't hold previewUrl past the attachment's life.

Accessibility

A thumbnail's alt text is the file name, not "image": four attachments all announced as "image" have told the reader nothing about which is which. Each remove button is named after its file for the same reason.

Styling

<r-attachments> exposes 17 CSS custom properties of its own, plus the semantic tokens it reads from the theme. Set one anywhere it inherits from — :root, a wrapper, or the element:

css
r-attachments {
  --ran-attachment-background: var(--ran-color-bg-subtle);
}

Parts: attachment · icon · list · name · remove · size · thumb

The full list is in style tokens; which token to reach for is the design system.

Best Practices

  • Validate on the server too. accept and max-size are a courtesy to the person attaching, not a security boundary.
  • Clear after a successful send, not before — a failed request should leave the files staged so they can be retried.
  • Explain every rejection. The event exists so the strip never silently swallows a file.

Released under the MIT License.